Files
ObsidianDragon/src/ui/windows/console_tab.cpp
DanS 1c5bc0d4d6 fix(theme): per-skin legibility polish from the sweep review
Address the high-severity, systemic legibility issues found in the per-skin
screenshot review (verified across all 9 skins):

- Status pills (history): "Confirmed" text was 55%-alpha (dim); make it full
  opacity, and give status/shield pills a visible tinted fill (a48) + 1px border
  (a90) so they stop vanishing on dark-red / near-white / gradient skins.
- Console light theming: light skins now use a near-white terminal surface, and
  the log text/JsonBrace colors switch to the dark defaults in light themes (the
  schema colors are dark-terminal-authored — honor them only in dark themes) so
  text isn't pale-on-white.
- Dark-skin muted text: bump --on-surface-medium 0.85 / --on-surface-disabled
  0.58 on dragonx/dark/color-pop-dark/obsidian (obsidian addresses were ~1.1:1).
- Disabled Send button: the disabled fill was hardcoded white (invisible on
  light skins) — use a dark overlay in light themes.
- Light-skin inputs: stronger frame fill + border color and a 1px frame border
  for light themes so fields (and buttons) have defined boundaries on white/
  pastel surfaces.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 23:33:48 -05:00

1561 lines
68 KiB
C++

// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// console_tab.cpp — Interactive RPC console with command history,
// tab completion, daemon log display, and color-coded output.
#include "console_tab.h"
#include "console_command_executor.h"
#include "console_command_reference.h"
#include "console_input_model.h"
#include "console_output_model.h"
#include "console_tab_helpers.h"
#include "../material/colors.h"
#include "../material/type.h"
#include "../material/draw_helpers.h"
#include "../notifications.h"
#include "../schema/ui_schema.h"
#include "../layout.h"
#include "../effects/imgui_acrylic.h"
#include "../material/color_theme.h"
#include "../theme.h"
#include "../../embedded/IconsMaterialDesign.h"
#include "../../util/i18n.h"
#include <imgui.h>
#include <cctype>
#include <cstring>
#include <sstream>
#include <algorithm>
#include <cmath>
#include <mutex>
#include <ctime>
#include <unordered_set>
namespace dragonx {
namespace ui {
// Static color definitions — defaults; overridden from ui.toml in constructor
ImU32 ConsoleTab::COLOR_COMMAND = IM_COL32(191, 209, 229, 255);
ImU32 ConsoleTab::COLOR_RESULT = IM_COL32(200, 200, 200, 255);
ImU32 ConsoleTab::COLOR_ERROR = IM_COL32(246, 71, 64, 255);
ImU32 ConsoleTab::COLOR_DAEMON = IM_COL32(160, 160, 160, 180);
ImU32 ConsoleTab::COLOR_INFO = IM_COL32(191, 209, 229, 255);
ImU32 ConsoleTab::COLOR_RPC = IM_COL32(120, 180, 255, 210);
bool ConsoleTab::s_scanline_enabled = true;
float ConsoleTab::s_console_zoom = 1.0f;
bool ConsoleTab::s_daemon_messages_enabled = true;
bool ConsoleTab::s_errors_only_enabled = false;
bool ConsoleTab::s_rpc_trace_enabled = false;
bool ConsoleTab::s_app_messages_enabled = true;
namespace {
std::mutex s_rpc_trace_console_mutex;
ConsoleTab* s_rpc_trace_console = nullptr;
std::string rpcTraceTimestamp()
{
std::time_t now = std::time(nullptr);
std::tm localTime{};
static std::mutex timeMutex;
{
std::lock_guard<std::mutex> lock(timeMutex);
if (const std::tm* current = std::localtime(&now)) {
localTime = *current;
}
}
char buffer[16];
std::strftime(buffer, sizeof(buffer), "%H:%M:%S", &localTime);
return buffer;
}
// Result-body channels (plain command output + JSON syntax roles) — the lines that carry
// no left accent bar and get the JSON indent guides.
bool isResultBodyChannel(ConsoleChannel ch)
{
switch (ch) {
case ConsoleChannel::None:
case ConsoleChannel::JsonKey:
case ConsoleChannel::JsonString:
case ConsoleChannel::JsonNumber:
case ConsoleChannel::JsonBrace:
return true;
default:
return false;
}
}
} // namespace
void ConsoleTab::refreshColors()
{
auto& S = schema::UI();
bool dark = material::IsDarkTheme();
// Try schema overrides first, then use sensible per-theme defaults
if (S.isLoaded()) {
auto cmd = S.drawElement("console", "color-command");
auto res = S.drawElement("console", "color-result");
auto err = S.drawElement("console", "color-error");
auto dmn = S.drawElement("console", "color-daemon");
auto inf = S.drawElement("console", "color-info");
auto rpc = S.drawElement("console", "color-rpc");
ImU32 defCmd = dark ? IM_COL32(191, 209, 229, 255) : IM_COL32(21, 101, 192, 255);
ImU32 defRes = dark ? IM_COL32(200, 200, 200, 255) : IM_COL32(50, 50, 50, 255);
ImU32 defErr = dark ? IM_COL32(246, 71, 64, 255) : IM_COL32(198, 40, 40, 255);
ImU32 defDmn = dark ? IM_COL32(160, 160, 160, 180) : IM_COL32(90, 90, 90, 200);
ImU32 defInf = dark ? IM_COL32(191, 209, 229, 255) : IM_COL32(21, 101, 192, 255);
ImU32 defRpc = dark ? IM_COL32(120, 180, 255, 210) : IM_COL32(25, 118, 210, 220);
// The schema console colors are authored for the dark terminal (light-on-dark); honor them only
// in dark themes. Light themes always use the dark-text defaults so log text stays legible on the
// now near-white console surface (otherwise the schema's light-blue text sat pale-on-white ~1.2:1).
COLOR_COMMAND = (dark && !cmd.color.empty()) ? S.resolveColor(cmd.color, defCmd) : defCmd;
COLOR_RESULT = (dark && !res.color.empty()) ? S.resolveColor(res.color, defRes) : defRes;
COLOR_ERROR = (dark && !err.color.empty()) ? S.resolveColor(err.color, defErr) : defErr;
COLOR_DAEMON = (dark && !dmn.color.empty()) ? S.resolveColor(dmn.color, defDmn) : defDmn;
COLOR_INFO = (dark && !inf.color.empty()) ? S.resolveColor(inf.color, defInf) : defInf;
COLOR_RPC = (dark && !rpc.color.empty()) ? S.resolveColor(rpc.color, defRpc) : defRpc;
} else {
// No schema — use hardcoded defaults per theme
COLOR_COMMAND = dark ? IM_COL32(191, 209, 229, 255) : IM_COL32(21, 101, 192, 255);
COLOR_RESULT = dark ? IM_COL32(200, 200, 200, 255) : IM_COL32(50, 50, 50, 255);
COLOR_ERROR = dark ? IM_COL32(246, 71, 64, 255) : IM_COL32(198, 40, 40, 255);
COLOR_DAEMON = dark ? IM_COL32(160, 160, 160, 180) : IM_COL32(90, 90, 90, 200);
COLOR_INFO = dark ? IM_COL32(191, 209, 229, 255) : IM_COL32(21, 101, 192, 255);
COLOR_RPC = dark ? IM_COL32(120, 180, 255, 210) : IM_COL32(25, 118, 210, 220);
}
}
ImU32 ConsoleTab::channelTextColor(ConsoleChannel channel) const
{
using namespace material;
switch (channel) {
case ConsoleChannel::Command: return COLOR_COMMAND;
case ConsoleChannel::Info: return COLOR_INFO;
case ConsoleChannel::Success: return WithAlpha(Success(), 255);
case ConsoleChannel::Warning: return Warning();
case ConsoleChannel::Error: return COLOR_ERROR;
case ConsoleChannel::Rpc: return COLOR_RPC;
case ConsoleChannel::Daemon: return COLOR_DAEMON;
case ConsoleChannel::Xmrig: return COLOR_DAEMON;
case ConsoleChannel::App: return COLOR_INFO;
// JSON syntax roles — highlight against the plain-result body.
case ConsoleChannel::JsonKey: return WithAlpha(Secondary(), 255);
case ConsoleChannel::JsonString: return WithAlpha(Success(), 255);
case ConsoleChannel::JsonNumber: return WithAlpha(Warning(), 255);
case ConsoleChannel::JsonBrace: return IsLightTheme() ? IM_COL32(90, 90, 90, 180) : IM_COL32(200, 200, 200, 150);
case ConsoleChannel::None:
default: return COLOR_RESULT;
}
}
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()
{
{
std::lock_guard<std::mutex> lock(s_rpc_trace_console_mutex);
s_rpc_trace_console = this;
}
rpc::RPCClient::setTraceCallback([](const std::string& source, const std::string& method) {
ConsoleTab* console = nullptr;
{
std::lock_guard<std::mutex> lock(s_rpc_trace_console_mutex);
console = s_rpc_trace_console;
}
if (console) console->addRpcTraceLine(source, method);
});
rpc::RPCClient::setTraceEnabled(s_rpc_trace_enabled);
// Load console colors from ui.toml schema (uses current theme)
refreshColors();
// Add welcome message
addLine(TR("console_welcome"), ConsoleChannel::Info);
addLine(TR("console_type_help"), ConsoleChannel::Info);
addLine("", ConsoleChannel::None);
}
ConsoleTab::~ConsoleTab()
{
rpc::RPCClient::setTraceEnabled(false);
rpc::RPCClient::setTraceCallback(nullptr);
std::lock_guard<std::mutex> lock(s_rpc_trace_console_mutex);
if (s_rpc_trace_console == this) s_rpc_trace_console = nullptr;
}
void ConsoleTab::render(ConsoleCommandExecutor& exec)
{
using namespace material;
// Refresh the console theme colors on a dark/light switch. Line colors are derived
// from each line's channel at draw time, so no per-line remap is needed.
{
static bool s_lastDark = IsDarkTheme();
bool nowDark = IsDarkTheme();
if (nowDark != s_lastDark) {
refreshColors();
s_lastDark = nowDark;
}
}
// Pull passive log lines (daemon/xmrig output, or the lite diagnostics ring) and any
// completed command results from the backend executor.
exec.pollLogLines([this](const std::string& l, ConsoleChannel c) { addLine(l, c); });
{
std::string result;
bool isError = false;
while (exec.pollResult(result, isError)) addFormattedResult(result, isError);
}
// Drain the ingest queue into the visible model once per frame, after this frame's own
// ingests above and any that arrived from worker threads. From here on lines_ (via
// model_) and the selection/scroll state are touched only on this (main) thread.
drainModel();
// Main console layout
ImGui::BeginChild("ConsoleContainer", ImVec2(0, 0), false,
ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollbar);
// Optional status header (lite: sync + last error) above the toolbar.
renderStatusHeader(exec);
// Toolbar
renderToolbar(exec);
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// Output area (scrollable) — glass panel background
float availHeight = ImGui::GetContentRegionAvail().y;
float input_height = ComputeConsoleInputHeight(
ImGui::GetFrameHeightWithSpacing(),
ImGui::GetStyle().ItemSpacing.y,
Layout::spacingSm(),
Layout::spacingXs(),
schema::UI().drawElement("tabs.console", "input-cursor-offset").size);
float outputH = ComputeConsoleOutputHeight(
availHeight,
input_height,
schema::UI().drawElement("tabs.console", "output-min-height").size,
schema::UI().drawElement("tabs.console", "output-min-height-ratio").size);
ImDrawList* dlOut = ImGui::GetWindowDrawList();
ImVec2 outPanelMin = ImGui::GetCursorScreenPos();
ImVec2 outPanelMax(outPanelMin.x + ImGui::GetContentRegionAvail().x, outPanelMin.y + outputH);
GlassPanelSpec outGlass;
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));
// Light skins get a near-white terminal surface (not a black darken) so the dark, theme-aware
// log text stays legible instead of sitting dark-on-dark-grey.
ImU32 termOverlay = IsLightTheme() ? IM_COL32(255, 255, 255, 205) : IM_COL32(0, 0, 0, darkA);
dlOut->AddRectFilled(outPanelMin, outPanelMax, termOverlay, outGlass.rounding);
}
int consoleParentVtx = dlOut->VtxBuffer.Size;
ImGui::BeginChild("ConsoleOutput", ImVec2(0, outputH), false,
ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollWithMouse);
ApplySmoothScroll();
ImDrawList* consoleChildDL = ImGui::GetWindowDrawList();
int consoleChildVtx = consoleChildDL->VtxBuffer.Size;
float consoleScrollY = ImGui::GetScrollY();
float consoleScrollMaxY = ImGui::GetScrollMaxY();
// 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);
ImGui::PopFont();
ImGui::EndChild();
// Auto-toggle auto-scroll based on scroll position:
// At the bottom → re-enable; scrolled up → already disabled by wheel handler.
// After wheel-up, wait for the cooldown so smooth-scroll can animate
// away from the bottom before we check position again.
scroll_.tickCooldown(ImGui::GetIO().DeltaTime);
{
float tolerance = Type().mono()->LegacySize * 1.5f;
bool atBottom = consoleScrollMaxY > 0.0f &&
consoleScrollY >= consoleScrollMaxY - tolerance;
scroll_.considerReenable(atBottom);
}
// CSS-style clipping mask
// 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().mono()->LegacySize * 3.0f, outputH * 0.18f);
float effectiveScrollMax = scroll_.autoScroll() ? consoleScrollMaxY
: std::max(consoleScrollMaxY, consoleScrollY + 10.0f);
ApplyScrollEdgeMask(dlOut, consoleParentVtx, consoleChildDL, consoleChildVtx,
outPanelMin.y, outPanelMax.y, fadeZone, consoleScrollY, effectiveScrollMax);
}
// 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().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);
if (textLineH >= 1.0f && (lightAlpha > 0 || darkAlpha > 0)) {
ImU32 lightCol = IM_COL32(255, 255, 255, lightAlpha);
ImU32 darkCol = IM_COL32(0, 0, 0, darkAlpha);
for (const auto& row : scanline_rows_) {
float yTop = std::max(row.yTop, outPanelMin.y);
float yBot = std::min(row.yBot, outPanelMax.y);
if (yTop < yBot) {
dlOut->AddRectFilled(ImVec2(outPanelMin.x, yTop), ImVec2(outPanelMax.x, yBot),
(row.rowIndex % 2 == 0) ? lightCol : darkCol);
}
}
// Continue the banding into any empty space below the last text row (the bottom
// fade padding, or when the content is shorter than the panel) so the zebra fills
// the whole panel instead of stopping short.
if (!scanline_rows_.empty()) {
const auto& last = scanline_rows_.back();
int parity = (last.rowIndex + 1) & 1;
for (float y = last.yBot; y < outPanelMax.y; y += textLineH) {
float yTop = std::max(y, outPanelMin.y);
float yBot = std::min(y + textLineH, outPanelMax.y);
if (yTop < yBot)
dlOut->AddRectFilled(ImVec2(outPanelMin.x, yTop), ImVec2(outPanelMax.x, yBot),
(parity == 0) ? lightCol : darkCol);
parity ^= 1;
}
}
}
float panelH = outPanelMax.y - outPanelMin.y;
float scanSpeed = schema::UI().drawElement("tabs.console", "scanline-speed").sizeOr(40.0f);
float rawScanH = schema::UI().drawElement("tabs.console", "scanline-height").sizeOr(textLineH * 2.0f);
int scanAlpha = std::clamp((int)schema::UI().drawElement("tabs.console", "scanline-alpha").sizeOr(8.0f), 0, 255);
if (panelH > 1.0f && textLineH >= 1.0f && scanSpeed > 0.0f && scanAlpha > 0) {
float scanLines = std::max(1.0f, std::round(rawScanH / textLineH));
float scanH = scanLines * textLineH;
float t = (float)std::fmod(ImGui::GetTime() * scanSpeed, (double)(panelH + scanH));
float scanY = outPanelMin.y + t - scanH;
float yTop = std::max(scanY, outPanelMin.y);
float yBot = std::min(scanY + scanH, outPanelMax.y);
if (yTop < yBot) {
float mid = (yTop + yBot) * 0.5f;
ImU32 clear = IM_COL32(255, 255, 255, 0);
ImU32 peak = IM_COL32(255, 255, 255, scanAlpha);
dlOut->AddRectFilledMultiColor(
ImVec2(outPanelMin.x, yTop), ImVec2(outPanelMax.x, mid),
clear, clear, peak, peak);
dlOut->AddRectFilledMultiColor(
ImVec2(outPanelMin.x, mid), ImVec2(outPanelMax.x, yBot),
peak, peak, clear, clear);
}
}
}
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// Input area
renderInput(exec);
ImGui::EndChild();
}
void ConsoleTab::renderCommandsPopupModal()
{
if (!show_commands_popup_) {
return;
}
renderCommandsPopup();
}
void ConsoleTab::renderToolbar(ConsoleCommandExecutor& exec)
{
using namespace material;
ImDrawList* dl = ImGui::GetWindowDrawList();
// Glass panel for toolbar
float toolbarH = ImGui::GetFrameHeightWithSpacing() + Layout::spacingMd();
ImVec2 tbMin = ImGui::GetCursorScreenPos();
ImVec2 tbMax(tbMin.x + ImGui::GetContentRegionAvail().x, tbMin.y + toolbarH);
GlassPanelSpec tbGlass;
tbGlass.rounding = Layout::glassRounding();
tbGlass.fillAlpha = 12;
DrawGlassPanel(dl, tbMin, tbMax, tbGlass);
ImGui::SetCursorPosY(ImGui::GetCursorPosY() + (toolbarH - ImGui::GetFrameHeight()) * 0.5f);
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + Layout::spacingMd());
// Backend status with colored dot (daemon state / lite connection).
drawToolbarStatus(exec);
ImGui::SameLine();
ImGui::Spacing();
ImGui::SameLine();
// Auto-scroll toggle
bool autoScroll = scroll_.autoScroll();
if (ImGui::Checkbox(TR("console_auto_scroll"), &autoScroll)) {
scroll_.setAutoScroll(autoScroll);
}
ImGui::SameLine();
ImGui::Spacing();
ImGui::SameLine();
// 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
if (TactileButton(TR("console_clear"), ImVec2(0, 0), schema::UI().resolveFont("button"))) {
clear();
selection_.clear();
}
ImGui::SameLine();
// Copy button — material styled
if (TactileButton(TR("copy"), ImVec2(0, 0), schema::UI().resolveFont("button"))) {
if (selection_.active()) {
std::string selected = selectedText();
if (!selected.empty()) {
ImGui::SetClipboardText(selected.c_str());
}
} else {
// Copy all output if nothing selected
std::string all;
for (const auto& line : model_.lines()) {
all += line.text + "\n";
}
if (!all.empty()) {
ImGui::SetClipboardText(all.c_str());
}
}
}
if (ImGui::IsItemHovered()) {
material::Tooltip("%s", selection_.active() ? TR("console_copy_selected") : TR("console_copy_all"));
}
ImGui::SameLine();
// Commands reference button (full-node RPC reference only)
if (exec.hasRpcReference()) {
if (TactileButton(TR("console_commands"), ImVec2(0, 0), schema::UI().resolveFont("button"))) {
show_commands_popup_ = true;
}
if (ImGui::IsItemHovered()) {
material::Tooltip("%s", TR("console_show_rpc_ref"));
}
ImGui::SameLine();
}
// Line count
ImGui::TextDisabled(TR("console_line_count"), model_.size());
ImGui::SameLine();
ImGui::Spacing();
ImGui::SameLine();
// Output filter input
drawFilterInput();
// Zoom +/- buttons (right side of toolbar)
ImGui::SameLine();
ImGui::Spacing();
ImGui::SameLine();
drawZoomControls();
}
void ConsoleTab::drawToolbarStatus(ConsoleCommandExecutor& exec)
{
using namespace material;
ImDrawList* dl = ImGui::GetWindowDrawList();
ConsoleStatusLine st = exec.toolbarStatus();
if (!st.text.empty()) {
ImVec2 cp = ImGui::GetCursorScreenPos();
float dotR = schema::UI().drawElement("tabs.console", "status-dot-radius-base").size + schema::UI().drawElement("tabs.console", "status-dot-radius-scale").size * Layout::hScale();
float dotY = cp.y + ImGui::GetTextLineHeight() * 0.5f;
float dotX = cp.x + dotR + 2;
if (st.pulse) {
float a = schema::UI().drawElement("animations", "pulse-base-glow").size + schema::UI().drawElement("animations", "pulse-amp-glow").size * (float)std::sin((double)ImGui::GetTime() * schema::UI().drawElement("animations", "pulse-speed-fast").size);
ImU32 pCol = (st.color & 0x00FFFFFF) | ((ImU32)(255 * a) << 24);
dl->AddCircleFilled(ImVec2(dotX, dotY), dotR, pCol);
} else {
dl->AddCircleFilled(ImVec2(dotX, dotY), dotR, st.color);
}
ImGui::Dummy(ImVec2(dotR * 2 + 6, 0));
ImGui::SameLine();
Type().textColored(TypeStyle::Caption, st.color, st.text.c_str());
} else {
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("console_no_daemon"));
}
}
void ConsoleTab::drawLogFilterToggles(const ConsoleLogFilterCaps& caps)
{
// 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"));
}
// Trailing spacer so the next toolbar item (Clear) stays on the same line.
if (drawnAny) { ImGui::SameLine(); ImGui::Spacing(); ImGui::SameLine(); }
}
void ConsoleTab::drawFilterInput()
{
using namespace material;
float zoomBtnSpace = ImGui::GetFrameHeight() * 2.0f + Layout::spacingSm() * 3.0f;
float filterAvail = ImGui::GetContentRegionAvail().x - zoomBtnSpace;
float filterW = std::min(schema::UI().drawElement("tabs.console", "filter-max-width").size, filterAvail * schema::UI().drawElement("tabs.console", "filter-width-ratio").size);
ImGui::SetNextItemWidth(filterW);
ImGui::InputTextWithHint("##ConsoleFilter", TR("console_filter_hint"), filter_text_, sizeof(filter_text_));
if (filter_text_[0] != '\0') {
ImGui::SameLine(0, Layout::spacingSm());
std::string mc = std::to_string(filter_match_count_) + " " + TR("console_matches");
Type().textColored(TypeStyle::Caption,
filter_match_count_ > 0 ? OnSurfaceMedium() : Error(), mc.c_str());
}
}
void ConsoleTab::drawZoomControls()
{
using namespace material;
auto& S = schema::UI();
float zoomStep = S.drawElement("tabs.console", "zoom-step").sizeOr(0.1f);
float zoomMin = S.drawElement("tabs.console", "zoom-min").sizeOr(0.5f);
float zoomMax = S.drawElement("tabs.console", "zoom-max").sizeOr(3.0f);
float btnSz = ImGui::GetFrameHeight();
if (TactileButton(ICON_MD_REMOVE, ImVec2(btnSz, btnSz), Type().iconMed())) {
s_console_zoom = std::max(zoomMin, s_console_zoom - zoomStep);
}
if (ImGui::IsItemHovered()) {
material::Tooltip(TR("console_zoom_out"), s_console_zoom * 100.0f);
}
ImGui::SameLine();
if (TactileButton(ICON_MD_ADD, ImVec2(btnSz, btnSz), Type().iconMed())) {
s_console_zoom = std::min(zoomMax, s_console_zoom + zoomStep);
}
if (ImGui::IsItemHovered()) {
material::Tooltip(TR("console_zoom_in"), s_console_zoom * 100.0f);
}
}
namespace {
// Extract a hash/address-like token (a long alphanumeric run, >= 16 chars) around byte
// offset `col` in `text`, for the right-click "Copy value" action. Returns "" if none.
std::string extractCopyableToken(const std::string& text, int col)
{
const int n = static_cast<int>(text.size());
auto isTok = [](unsigned char c) { return std::isalnum(c) != 0; };
int at = col;
if (at >= n || (at >= 0 && !isTok(static_cast<unsigned char>(text[at])))) at--;
if (at < 0 || at >= n || !isTok(static_cast<unsigned char>(text[at]))) return {};
int s = at, e = at;
while (s > 0 && isTok(static_cast<unsigned char>(text[s - 1]))) s--;
while (e + 1 < n && isTok(static_cast<unsigned char>(text[e + 1]))) e++;
std::string tok = text.substr(s, e - s + 1);
return tok.size() >= 16 ? tok : std::string(); // txids/blockhashes/addresses are long
}
// Production ConsoleTextMeasure backed by the active ImGui font (used for word-wrap +
// per-character positioning). The pure layout module is measured against this in prod and
// a fixed-width stub in tests.
struct ImFontConsoleMeasure : ConsoleTextMeasure {
ImFont* font;
float fontSize;
ImFontConsoleMeasure(ImFont* f, float s) : font(f), fontSize(s) {}
float width(const char* begin, const char* end) const override {
return font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, begin, end).x;
}
const char* wrapPosition(const char* begin, const char* end, float wrapWidth) const override {
return font->CalcWordWrapPositionA(fontSize / font->LegacySize, begin, end, wrapWidth);
}
};
} // namespace
void ConsoleTab::renderOutput()
{
using namespace material;
auto& S = schema::UI();
// No lock: render() already drained the ingest queue this frame, and the visible model
// (model_) plus all selection/scroll state are touched only on this (main) thread.
// Zero item spacing so Dummy items advance the cursor by exactly their
// height. The inter-line gap is added explicitly to layout_.heights
// so that layout_.cumulativeY stays perfectly in sync with actual
// cursor positions (avoiding selection-offset drift).
float interLineGap = S.drawElement("tabs.console", "output").getFloat("line-spacing", 0.0f);
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0, 0));
// Inner padding for glass panel
float padX = Layout::spacingMd();
float padY = Layout::spacingSm();
ImGui::SetCursorPosY(ImGui::GetCursorPosY() + padY);
ImGui::Indent(padX);
float line_height = ImGui::GetTextLineHeight();
output_line_height_ = line_height; // store for scanline alignment
output_origin_ = ImGui::GetCursorScreenPos();
output_scroll_y_ = ImGui::GetScrollY();
scanline_rows_.clear();
// Build the filtered visible-line index list BEFORE mouse handling (screenToTextPos maps
// through visible_indices_). Also yields the text-filter state used for highlighting.
bool has_text_filter = false;
std::string filter_lower;
computeVisibleLines(has_text_filter, filter_lower);
// Calculate wrapped heights AND build sub-row segments for each visible line. Each
// segment records which bytes of the source text appear on that visual row, so
// hit-testing and selection highlight can map screen positions to exact char offsets.
float wrap_width = ClampConsoleWrapWidth(ImGui::GetContentRegionAvail().x, padX);
ImFontConsoleMeasure measure(ImGui::GetFont(), ImGui::GetFontSize());
layout_ = BuildConsoleLayout(
static_cast<int>(visible_indices_.size()),
[this](int vi) -> const std::string& { return model_[visible_indices_[vi]].text; },
wrap_width, line_height, interLineGap, measure);
// Mouse/keyboard interaction (wheel-up detach, selection drag, Ctrl+C/A). Raw IO bypasses
// the child window's event consumption.
ImVec2 mouse_pos = ImGui::GetIO().MousePos;
ImVec2 win_min = ImGui::GetWindowPos();
ImVec2 win_max = ImVec2(win_min.x + ImGui::GetWindowWidth(),
win_min.y + ImGui::GetWindowHeight());
bool mouse_in_output = (mouse_pos.x >= win_min.x && mouse_pos.x < win_max.x &&
mouse_pos.y >= win_min.y && mouse_pos.y < win_max.y &&
!ImGui::IsPopupOpen("", ImGuiPopupFlags_AnyPopup));
handleOutputInteraction(mouse_pos, mouse_in_output);
// Draw the visible lines: accent bars, JSON indent guides, selection + filter highlight,
// scanline capture, and the text itself.
drawVisibleLines(padX, line_height, has_text_filter, filter_lower);
ImGui::Unindent(padX);
ImGui::PopStyleVar();
// Bottom padding keeps the last line above the fade-out zone.
// Always present so that scrollMaxY stays stable when auto-scroll
// 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().mono()->LegacySize * 3.0f,
ImGui::GetWindowHeight() * 0.18f);
ImGui::Dummy(ImVec2(0, fadeZone));
}
// Auto-scroll - when enabled, always scroll to bottom of content
// This ensures daemon output stays visible and scrolled to bottom
if (scroll_.autoScroll()) {
ImGui::SetScrollHereY(1.0f);
scroll_.resetNewLines();
}
// Filter indicator (text filter only — daemon toggle is already visible in toolbar)
if (has_text_filter) {
char filterBuf[128];
snprintf(filterBuf, sizeof(filterBuf), TR("console_showing_lines"),
static_cast<int>(visible_indices_.size()), model_.size());
ImVec2 indicatorPos = ImGui::GetCursorScreenPos();
ImGui::GetWindowDrawList()->AddText(indicatorPos,
WithAlpha(Warning(), 180), filterBuf);
ImGui::Dummy(ImVec2(0, ImGui::GetTextLineHeight()));
}
// Capture the hash/address under the cursor on right-click, for "Copy value".
if (ImGui::IsMouseClicked(ImGuiMouseButton_Right)) {
ConsoleTextPos tp = screenToTextPos(ImGui::GetMousePos());
context_token_.clear();
if (tp.line >= 0 && tp.line < static_cast<int>(model_.size()))
context_token_ = extractCopyableToken(model_[tp.line].text, tp.col);
}
drawOutputContextMenu();
drawNewOutputIndicator();
}
// ── renderOutput sub-steps ───────────────────────────────────────────────────
void ConsoleTab::computeVisibleLines(bool& hasTextFilter, std::string& filterLower)
{
ConsoleOutputFilter outputFilter{filter_text_, s_daemon_messages_enabled,
s_errors_only_enabled, s_rpc_trace_enabled,
s_app_messages_enabled};
hasTextFilter = !outputFilter.text.empty();
// Lowercased needle for in-line match highlighting.
if (hasTextFilter) {
filterLower = std::string(filter_text_);
std::transform(filterLower.begin(), filterLower.end(), filterLower.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
}
bool has_filter = hasTextFilter || !outputFilter.daemonMessagesEnabled ||
!outputFilter.rpcTraceEnabled || !outputFilter.appMessagesEnabled ||
outputFilter.errorsOnly;
visible_indices_.clear();
const int n = static_cast<int>(model_.size());
if (has_filter) {
// Filtered view is flat — folding is bypassed so every match is reachable.
for (int i = 0; i < n; i++) {
if (!consoleLinePassesFilter(model_[i].text, model_[i].channel, outputFilter)) continue;
visible_indices_.push_back(i);
}
} else {
// No filter — show all lines, but hide the interior of any collapsed JSON block
// (its opener stays visible; the opener + foldSpan lines through the closer are skipped).
for (int i = 0; i < n; ) {
visible_indices_.push_back(i);
const ConsoleModelLine& line = model_[i];
if (line.foldSpan > 0 && line.collapsed) i += line.foldSpan + 1; // skip inner + closer
else i++;
}
}
filter_match_count_ = hasTextFilter ? static_cast<int>(visible_indices_.size()) : 0;
}
void ConsoleTab::handleOutputInteraction(ImVec2 mousePos, bool mouseInOutput)
{
ImGuiIO& io = ImGui::GetIO();
// Disable auto-scroll when the user scrolls up (wheel). Scrolling back down to the very
// bottom re-enables it; that position check happens after EndChild() in render(), and is
// skipped on the wheel-up frame (the scroll position hasn't caught up yet).
if (mouseInOutput && io.MouseWheel > 0.0f) {
scroll_.onUserScrolledUp();
}
// Text-selection cursor while hovering.
if (mouseInOutput) {
ImGui::SetMouseCursor(ImGuiMouseCursor_TextInput);
}
// Selection drag lifecycle (continues even if the mouse leaves the window).
if (mouseInOutput && io.MouseClicked[0]) {
selection_.beginDrag(screenToTextPos(mousePos));
}
if (selection_.dragging() && io.MouseDown[0]) {
selection_.updateDrag(screenToTextPos(mousePos));
}
if (selection_.dragging() && io.MouseReleased[0]) {
selection_.endDrag(screenToTextPos(mousePos));
}
// Ctrl+C / Ctrl+A
if (mouseInOutput || selection_.active()) {
if (io.KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_C)) {
std::string selected = selectedText();
if (!selected.empty()) ImGui::SetClipboardText(selected.c_str());
}
if (io.KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_A) && !model_.empty()) {
selection_.selectAll(static_cast<int>(model_.size()),
static_cast<int>(model_.back().text.size()));
}
}
}
void ConsoleTab::drawVisibleLines(float padX, float lineHeight, bool hasTextFilter,
const std::string& filterLower)
{
using namespace material;
ImFont* font = ImGui::GetFont();
float fontSize = ImGui::GetFontSize();
const int visible_count = static_cast<int>(visible_indices_.size());
// Ordered selection bounds (only consulted when selection_.active()).
ConsoleTextPos sel_start_pos = selection_.rangeStart();
ConsoleTextPos sel_end_pos = selection_.rangeEnd();
// Cull to the visible viewport rows for cheap scrolling over large buffers.
float scroll_y = ImGui::GetScrollY();
float window_height = ImGui::GetWindowHeight();
float visible_top = scroll_y;
float visible_bottom = scroll_y + window_height;
// Find first visible line using binary search.
int first_visible = 0;
if (!layout_.cumulativeY.empty()) {
int lo = 0, hi = static_cast<int>(layout_.cumulativeY.size()) - 1;
while (lo < hi) {
int mid = (lo + hi) / 2;
float line_bottom = layout_.cumulativeY[mid] + layout_.heights[mid];
if (line_bottom < visible_top) lo = mid + 1;
else hi = mid;
}
first_visible = lo;
}
// Invisible spacer for lines before the first visible one (keeps scroll correct).
if (first_visible > 0 && first_visible < static_cast<int>(layout_.cumulativeY.size())) {
ImGui::Dummy(ImVec2(0, layout_.cumulativeY[first_visible]));
}
ImDrawList* dl = ImGui::GetWindowDrawList();
ImU32 selColor = WithAlpha(Secondary(), 80);
// A fold triangle clicked this frame; applied after the loop so we don't mutate the model
// mid-draw. -1 = none.
int pendingFoldToggle = -1;
// CRT scanline parity is a true per-visual-row counter (each drawn sub-row = one row).
// Deriving it from cumulativeY/lineHeight drifted (cumulativeY includes the inter-line
// gap) and skipped rows, breaking the alternation. Seed it with the sub-row ordinal of
// the first on-screen line so the shading stays stable across scrolling.
int scanRowOrdinal = 0;
for (int k = 0; k < first_visible && k < static_cast<int>(layout_.segments.size()); k++)
scanRowOrdinal += static_cast<int>(layout_.segments[k].size());
int last_rendered_vi = first_visible - 1;
for (int vi = first_visible; vi < visible_count; vi++) {
if (vi < static_cast<int>(layout_.cumulativeY.size()) &&
layout_.cumulativeY[vi] > visible_bottom) {
break;
}
last_rendered_vi = vi;
int i = visible_indices_[vi];
const auto& line = model_[i];
const auto& segs = layout_.segments[vi];
ImVec2 lineOrigin = ImGui::GetCursorScreenPos();
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. Same color that tints the toolbar toggle.
if (line.channel != ConsoleChannel::None) {
ImU32 barCol = channelAccentColor(line.channel);
if (barCol != 0) {
float barW = 3.0f * Layout::hScale();
float barX = output_origin_.x - padX + 3.0f * Layout::hScale();
dl->AddRectFilled(ImVec2(barX, lineOrigin.y + 1.0f),
ImVec2(barX + barW, lineOrigin.y + totalH - 1.0f),
barCol, barW * 0.5f);
}
}
// JSON indent guides — faint vertical lines per 2-space nesting level, on result
// body lines only (plain result + JSON syntax roles). Draw-only, like the bar.
if (isResultBodyChannel(line.channel)) {
size_t leading = 0;
while (leading < line.text.size() && line.text[leading] == ' ') ++leading;
if (leading >= 2) {
float spaceW = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, " ").x;
for (size_t c = 2; c < leading; c += 2) {
float gx = lineOrigin.x + static_cast<float>(c) * spaceW;
dl->AddLine(ImVec2(gx, lineOrigin.y), ImVec2(gx, lineOrigin.y + totalH),
IM_COL32(255, 255, 255, 20), 1.0f);
}
}
}
// JSON fold toggle — a small triangle in the left gutter for block-opener lines.
// Openers carry no accent bar (result/JSON channels), so the gutter is free.
if (line.foldSpan > 0) {
float sz = fontSize * 0.30f;
float cx = output_origin_.x - padX + sz + 1.0f * Layout::hScale();
float cy = lineOrigin.y + lineHeight * 0.5f;
ImU32 triCol = WithAlpha(OnSurfaceMedium(), 210);
if (line.collapsed) {
dl->AddTriangleFilled(ImVec2(cx - sz * 0.5f, cy - sz), ImVec2(cx - sz * 0.5f, cy + sz),
ImVec2(cx + sz * 0.7f, cy), triCol); // ▶ collapsed
} else {
dl->AddTriangleFilled(ImVec2(cx - sz, cy - sz * 0.5f), ImVec2(cx + sz, cy - sz * 0.5f),
ImVec2(cx, cy + sz * 0.7f), triCol); // ▼ expanded
}
// Click anywhere in the gutter cell for this line's first row toggles the fold.
ImVec2 mp = ImGui::GetIO().MousePos;
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left) &&
mp.x >= output_origin_.x - padX && mp.x < output_origin_.x &&
mp.y >= lineOrigin.y && mp.y < lineOrigin.y + lineHeight) {
pendingFoldToggle = i;
}
}
// Determine byte-level selection range for this line
int selByteStart = 0, selByteEnd = 0;
bool lineSelected = false;
if (selection_.active() && i >= sel_start_pos.line && i <= sel_end_pos.line) {
lineSelected = true;
selByteStart = (i == sel_start_pos.line) ? sel_start_pos.col : 0;
selByteEnd = (i == sel_end_pos.line) ? sel_end_pos.col
: static_cast<int>(line.text.size());
}
for (const auto& seg : segs) {
float rowY = lineOrigin.y + seg.yOffset;
const char* segStart = line.text.c_str() + seg.byteStart;
const char* segEnd = line.text.c_str() + seg.byteEnd;
if (s_scanline_enabled && lineHeight > 0.0f) {
scanline_rows_.push_back({rowY, rowY + seg.height, scanRowOrdinal});
}
++scanRowOrdinal; // one per visual sub-row, so parity always alternates
// Selection highlight for this sub-row
if (lineSelected && selByteStart < seg.byteEnd && selByteEnd > seg.byteStart) {
int hlStart = std::max(selByteStart, seg.byteStart) - seg.byteStart;
int hlEnd = std::min(selByteEnd, seg.byteEnd) - seg.byteStart;
int segLen = seg.byteEnd - seg.byteStart;
float xStart = 0.0f;
if (hlStart > 0) {
xStart = font->CalcTextSizeA(fontSize, FLT_MAX, 0,
segStart, segStart + hlStart).x;
}
float xEnd = font->CalcTextSizeA(fontSize, FLT_MAX, 0,
segStart, segStart + hlEnd).x;
// Extend to window edge when selection reaches end of segment
if (hlEnd >= segLen && selByteEnd >= static_cast<int>(line.text.size())) {
xEnd = std::max(xEnd + 8.0f, ImGui::GetWindowWidth());
}
dl->AddRectFilled(
ImVec2(lineOrigin.x + xStart, rowY),
ImVec2(lineOrigin.x + xEnd, rowY + seg.height),
selColor);
}
// Filter match highlight (case-insensitive) within this segment.
if (hasTextFilter && !filterLower.empty() && seg.byteStart < seg.byteEnd) {
std::string segLower(segStart, segEnd);
std::transform(segLower.begin(), segLower.end(), segLower.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
size_t pos = 0;
while ((pos = segLower.find(filterLower, pos)) != std::string::npos) {
float xs = font->CalcTextSizeA(fontSize, FLT_MAX, 0, segStart, segStart + static_cast<int>(pos)).x;
float xe = font->CalcTextSizeA(fontSize, FLT_MAX, 0, segStart,
segStart + static_cast<int>(pos + filterLower.size())).x;
dl->AddRectFilled(ImVec2(lineOrigin.x + xs, rowY),
ImVec2(lineOrigin.x + xe, rowY + seg.height),
IM_COL32(255, 210, 0, 70));
pos += filterLower.size();
}
}
// Render text segment
if (seg.byteStart < seg.byteEnd) {
dl->AddText(font, fontSize,
ImVec2(lineOrigin.x, rowY),
channelTextColor(line.channel), segStart, segEnd);
}
}
// Collapsed JSON block: append a dim " ... }" summary after the opener's text so the
// fold reads as e.g. "outputs": [ ... ]. Anchor it on the opener's LAST visual row
// and that row's measured width so it stays put even if the opener wraps.
if (line.foldSpan > 0 && line.collapsed && !segs.empty()) {
char closeCh = '}';
for (size_t k = line.text.size(); k-- > 0; ) {
if (line.text[k] != ' ' && line.text[k] != '\t') {
closeCh = (line.text[k] == '[') ? ']' : '}';
break;
}
}
const auto& lastSeg = segs.back();
const char* lsStart = line.text.c_str() + lastSeg.byteStart;
const char* lsEnd = line.text.c_str() + lastSeg.byteEnd;
float lastRowW = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, lsStart, lsEnd).x;
char summary[8];
snprintf(summary, sizeof(summary), " ... %c", closeCh);
dl->AddText(font, fontSize,
ImVec2(lineOrigin.x + lastRowW, lineOrigin.y + lastSeg.yOffset),
WithAlpha(OnSurfaceDisabled(), 220), summary);
}
// Advance ImGui cursor by the total wrapped height of this line
ImGui::Dummy(ImVec2(0, totalH));
}
// Apply a fold toggle requested this frame (takes visible effect next frame).
if (pendingFoldToggle >= 0) model_.toggleCollapsed(static_cast<std::size_t>(pendingFoldToggle));
// Add spacer for lines after last visible (to maintain correct content height)
if (last_rendered_vi >= 0 && last_rendered_vi < visible_count - 1) {
float rendered_height = (last_rendered_vi < static_cast<int>(layout_.cumulativeY.size()))
? layout_.cumulativeY[last_rendered_vi] + layout_.heights[last_rendered_vi]
: 0.0f;
float remaining_height = layout_.totalHeight - rendered_height;
if (remaining_height > 0) {
ImGui::Dummy(ImVec2(0, remaining_height));
}
}
}
void ConsoleTab::drawOutputContextMenu()
{
if (!ImGui::BeginPopupContextWindow("ConsoleContextMenu")) return;
if (!context_token_.empty()) {
std::string shortTok = context_token_.size() > 22
? context_token_.substr(0, 12) + "\xE2\x80\xA6" + context_token_.substr(context_token_.size() - 6)
: context_token_;
std::string label = std::string(TR("console_copy_value")) + " \"" + shortTok + "\"";
if (ImGui::MenuItem(label.c_str())) {
ImGui::SetClipboardText(context_token_.c_str());
}
ImGui::Separator();
}
if (ImGui::MenuItem(TR("copy"), "Ctrl+C", false, selection_.active())) {
std::string selected = selectedText();
if (!selected.empty()) {
ImGui::SetClipboardText(selected.c_str());
}
}
if (ImGui::MenuItem(TR("console_select_all"), "Ctrl+A")) {
if (!model_.empty()) {
selection_.selectAll(static_cast<int>(model_.size()),
static_cast<int>(model_.back().text.size()));
}
}
ImGui::Separator();
if (ImGui::MenuItem(TR("console_clear_console"))) {
// View-only clear (main thread) — drop the visible lines and the selection.
model_.clear();
selection_.clear();
}
ImGui::EndPopup();
}
void ConsoleTab::drawNewOutputIndicator()
{
using namespace material;
// "New output" indicator when the user is scrolled up and new lines arrived.
if (scroll_.autoScroll() || scroll_.newLines() <= 0) return;
float indicW = 140.0f;
float indicH = 24.0f;
ImDrawList* dlInd = ImGui::GetWindowDrawList();
ImVec2 wMin = ImGui::GetWindowPos();
ImVec2 wSize = ImGui::GetWindowSize();
float ix = wMin.x + (wSize.x - indicW) * 0.5f;
float iy = wMin.y + wSize.y - indicH - 8.0f;
ImVec2 iMin(ix, iy);
ImVec2 iMax(ix + indicW, iy + indicH);
dlInd->AddRectFilled(iMin, iMax, IM_COL32(40, 40, 40, 220), 12.0f);
dlInd->AddRect(iMin, iMax, IM_COL32(255, 218, 0, 120), 12.0f);
char buf[48];
snprintf(buf, sizeof(buf), TR("console_new_lines"), scroll_.newLines());
ImFont* capFont = Type().caption();
if (!capFont) capFont = ImGui::GetFont();
ImFont* icoFont = Type().iconSmall();
if (!icoFont) icoFont = capFont;
// Measure icon + text to center them together
ImVec2 icoSz = icoFont->CalcTextSizeA(icoFont->LegacySize, FLT_MAX, 0, ICON_MD_ARROW_DOWNWARD);
ImVec2 txtSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf);
float totalW = icoSz.x + txtSz.x;
float startX = ix + (indicW - totalW) * 0.5f;
float icoY = iy + (indicH - icoSz.y) * 0.5f;
float txtY = iy + (indicH - txtSz.y) * 0.5f;
ImU32 col = IM_COL32(255, 218, 0, 255);
dlInd->AddText(icoFont, icoFont->LegacySize, ImVec2(startX, icoY), col, ICON_MD_ARROW_DOWNWARD);
dlInd->AddText(capFont, capFont->LegacySize, ImVec2(startX + icoSz.x, txtY), col, buf);
// Click to jump to bottom
ImGui::SetCursorScreenPos(iMin);
if (ImGui::InvisibleButton("##scrollToBottom", ImVec2(indicW, indicH))) {
scroll_.jumpToBottom();
}
}
ConsoleTextPos ConsoleTab::screenToTextPos(ImVec2 screen_pos) const
{
if (visible_indices_.empty()) return {0, 0};
ImFontConsoleMeasure measure(ImGui::GetFont(), ImGui::GetFontSize());
ConsoleHit hit = HitTestConsoleLayout(
layout_, static_cast<int>(visible_indices_.size()),
[this](int vi) -> const std::string& { return model_[visible_indices_[vi]].text; },
screen_pos.x - output_origin_.x, screen_pos.y - output_origin_.y, measure);
ConsoleTextPos pos;
pos.line = visible_indices_[hit.visibleRow];
pos.col = hit.col;
return pos;
}
std::string ConsoleTab::selectedText() const
{
return selection_.extract(
static_cast<int>(model_.size()),
[this](int i) -> const std::string& { return model_[i].text; },
visible_indices_);
}
void ConsoleTab::renderInput(ConsoleCommandExecutor& exec)
{
using namespace material;
// Glass panel for input area
ImDrawList* dlIn = ImGui::GetWindowDrawList();
float inputPanelH = ImGui::GetFrameHeightWithSpacing() + Layout::spacingSm() + Layout::spacingXs();
ImVec2 inMin = ImGui::GetCursorScreenPos();
ImVec2 inMax(inMin.x + ImGui::GetContentRegionAvail().x, inMin.y + inputPanelH);
// 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();
ImGui::SetCursorPosY(ImGui::GetCursorPosY() + (inputPanelH - inputFrameH) * 0.5f);
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + Layout::spacingMd());
// 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;
bool reclaim_focus = false;
auto callback = [](ImGuiInputTextCallbackData* data) -> int {
ConsoleTab* console = static_cast<ConsoleTab*>(data->UserData);
if (data->EventFlag == ImGuiInputTextFlags_CallbackHistory) {
// Handle history navigation
if (console->command_history_.empty()) return 0;
int prev_index = console->history_index_;
console->history_index_ = NavigateConsoleHistoryIndex(
console->history_index_,
console->command_history_.size(),
data->EventKey == ImGuiKey_UpArrow);
if (prev_index != console->history_index_) {
std::string history = ConsoleHistoryEntry(console->command_history_, console->history_index_);
data->DeleteChars(0, data->BufTextLen);
data->InsertChars(0, history.c_str());
}
}
else if (data->EventFlag == ImGuiInputTextFlags_CallbackCompletion) {
std::string input(data->Buf);
if (!input.empty()) {
auto completion = CompleteConsoleCommand(input);
if (completion.matches.size() == 1) {
// Single match — complete it
data->DeleteChars(0, data->BufTextLen);
data->InsertChars(0, completion.matches.front().c_str());
} else if (completion.matches.size() > 1) {
// Multiple matches — show list in console and complete common prefix
console->addLine(TR("console_completions"), ConsoleChannel::Info);
for (const auto& line : FormatConsoleCompletionLines(completion.matches)) {
console->addLine(line, ConsoleChannel::None);
}
// Complete to longest common prefix
if (completion.commonPrefix.length() > input.length()) {
data->DeleteChars(0, data->BufTextLen);
data->InsertChars(0, completion.commonPrefix.c_str());
}
}
}
}
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)) {
std::string cmd(input_buffer_);
// Trim surrounding whitespace.
size_t tb = cmd.find_first_not_of(" \t");
if (tb == std::string::npos) cmd.clear(); else cmd = cmd.substr(tb);
while (!cmd.empty() && (cmd.back() == ' ' || cmd.back() == '\t')) cmd.pop_back();
if (submitConsoleCommand(exec, cmd)) {
input_buffer_[0] = '\0';
reclaim_focus = true;
}
}
if (busy) ImGui::EndDisabled();
ImGui::PopStyleColor(3);
ImGui::PopItemWidth();
ImGui::PopFont();
// Auto-focus on input
if (reclaim_focus) {
ImGui::SetKeyboardFocusHere(-1);
}
}
bool ConsoleTab::submitConsoleCommand(ConsoleCommandExecutor& exec, const std::string& cmd)
{
if (cmd.empty()) return false;
addLine("> " + cmd, ConsoleChannel::Command);
AppendConsoleHistory(command_history_, cmd, 100);
history_index_ = -1;
// First token, lowercased, for built-in interception.
std::string first;
{
size_t fb = cmd.find_first_not_of(" \t");
size_t fe = cmd.find_first_of(" \t", fb);
first = cmd.substr(fb, fe == std::string::npos ? std::string::npos : fe - fb);
std::transform(first.begin(), first.end(), first.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
}
auto add = [this](const std::string& l, ConsoleChannel c) { addLine(l, c); };
if (first == "clear" || first == "cls") {
// View-only clear — NEVER forwarded (the lite backend's `clear` wipes tx history).
clear();
selection_.clear();
} else if (first == "help") {
exec.printHelp(add);
} else if (first == "quit" || first == "exit") {
addLine(TR("console_quit_note"), ConsoleChannel::Info);
} else if (!exec.isReady()) {
addLine(TR("console_not_connected"), ConsoleChannel::Error);
} else {
exec.submit(cmd);
}
return true;
}
namespace {
// True if any of a command's name/desc/params contains `filterLower` (already lowercased).
// An empty filter matches everything.
bool consoleCommandMatchesFilter(const char* name, const char* desc, const char* params,
const std::string& filterLower)
{
if (filterLower.empty()) return true;
auto contains = [&filterLower](const char* s) {
std::string v(s);
std::transform(v.begin(), v.end(), v.begin(), ::tolower);
return v.find(filterLower) != std::string::npos;
};
return contains(name) || contains(desc) || contains(params);
}
// Draw a command's parameter string into the current table cell, dimming optional [params].
void drawConsoleCommandParams(const char* params)
{
using namespace material;
const char* p = params;
bool first = true;
while (*p) {
const char* bracketStart = strchr(p, '[');
if (bracketStart) {
// Draw the required part before the bracket.
if (bracketStart > p) {
if (!first) ImGui::SameLine(0, 0);
std::string req(p, bracketStart);
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(OnSurfaceDisabled()), "%s", req.c_str());
first = false;
}
const char* bracketEnd = strchr(bracketStart, ']');
if (bracketEnd) {
if (!first) ImGui::SameLine(0, 0);
std::string opt(bracketStart, bracketEnd + 1);
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(OnSurfaceMedium()), "%s", opt.c_str());
first = false;
p = bracketEnd + 1;
} else {
if (!first) ImGui::SameLine(0, 0);
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(OnSurfaceDisabled()), "%s", bracketStart);
first = false;
break;
}
} else {
if (!first) ImGui::SameLine(0, 0);
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(OnSurfaceDisabled()), "%s", p);
first = false;
break;
}
}
}
} // namespace
void ConsoleTab::renderCommandsPopup()
{
using namespace material;
float popW = std::min(schema::UI().drawElement("tabs.console", "popup-max-width").size, ImGui::GetMainViewport()->Size.x * schema::UI().drawElement("tabs.console", "popup-width-ratio").size);
material::OverlayDialogSpec ov;
ov.title = TR("console_rpc_reference"); ov.p_open = &show_commands_popup_;
ov.style = material::OverlayStyle::BlurFloat; // floating content on the blur, plain heading
ov.cardWidth = popW; ov.cardBottomViewportRatio = 0.94f; // keep authored width
if (!material::BeginOverlayDialog(ov)) {
return;
}
Type().text(TypeStyle::H6, TR("console_rpc_reference"));
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// Search filter
static char cmdFilter[128] = {0};
ImGui::SetNextItemWidth(-1);
ImGui::InputTextWithHint("##CmdSearch", TR("console_search_commands"), cmdFilter, sizeof(cmdFilter));
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
const auto& categories = consoleCommandCategories();
std::string filter(cmdFilter);
std::transform(filter.begin(), filter.end(), filter.begin(), ::tolower);
ImGui::BeginChild("CmdListScroll", ImVec2(0, -ImGui::GetFrameHeightWithSpacing() - Layout::spacingXs()),
false);
ImGui::PushFont(Type().caption());
float cmdMinWidth = schema::UI().drawElement("tabs.console", "cmd-min-width").sizeOr(500.0f);
float popupInnerW = ImGui::GetContentRegionAvail().x;
bool showParams = popupInnerW >= cmdMinWidth;
int catIdx = 0;
for (const auto& cat : categories) {
// Count matching commands in this category
int matchCount = 0;
if (filter.empty()) {
matchCount = cat.count;
} else {
for (int i = 0; i < cat.count; i++) {
if (consoleCommandMatchesFilter(cat.commands[i].name, cat.commands[i].desc,
cat.commands[i].params, filter)) {
matchCount++;
}
}
}
if (matchCount == 0) { catIdx++; continue; }
// Default-open only the first category (Control); collapse the rest
ImGuiTreeNodeFlags headerFlags = (catIdx == 0) ? ImGuiTreeNodeFlags_DefaultOpen : 0;
// When filtering, open all matching categories
if (!filter.empty()) headerFlags = ImGuiTreeNodeFlags_DefaultOpen;
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(Primary()));
// Show match count badge when filtering
char headerLabel[128];
if (!filter.empty()) {
snprintf(headerLabel, sizeof(headerLabel), "%s (%d)", cat.name, matchCount);
} else {
snprintf(headerLabel, sizeof(headerLabel), "%s", cat.name);
}
bool open = ImGui::CollapsingHeader(headerLabel, headerFlags);
ImGui::PopStyleColor();
catIdx++;
if (open) {
float nameColW = schema::UI().drawElement("tabs.console", "cmd-name-col-width").size * Layout::hScale();
float paramsColW = schema::UI().drawElement("tabs.console", "cmd-params-col-width").size * Layout::hScale();
int numCols = showParams ? 3 : 2;
if (ImGui::BeginTable("##cmds", numCols, ImGuiTableFlags_None)) {
ImGui::TableSetupColumn("Name", ImGuiTableColumnFlags_WidthFixed, nameColW);
if (showParams)
ImGui::TableSetupColumn("Parameters", ImGuiTableColumnFlags_WidthFixed, paramsColW);
ImGui::TableSetupColumn("Desc", ImGuiTableColumnFlags_WidthStretch);
for (int i = 0; i < cat.count; i++) {
const auto& cmd = cat.commands[i];
if (!consoleCommandMatchesFilter(cmd.name, cmd.desc, cmd.params, filter))
continue;
ImGui::TableNextRow();
ImGui::TableNextColumn();
ImGui::PushStyleColor(ImGuiCol_Text,
ImGui::ColorConvertU32ToFloat4(IM_COL32(100, 180, 255, 255)));
ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0, 0, 0, 0));
ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ImVec4(0.2f, 0.4f, 0.6f, 0.3f));
char selId[128];
snprintf(selId, sizeof(selId), "%s##cmdRef", cmd.name);
if (ImGui::Selectable(selId, false)) {
if (cmd.params[0] != '\0') {
snprintf(input_buffer_, sizeof(input_buffer_), "%s %s", cmd.name, cmd.params);
} else {
strncpy(input_buffer_, cmd.name, sizeof(input_buffer_) - 1);
input_buffer_[sizeof(input_buffer_) - 1] = '\0';
}
ImGui::CloseCurrentPopup();
}
if (ImGui::IsItemHovered()) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
if (cmd.params[0] != '\0')
material::Tooltip(TR("console_click_insert_params"), cmd.name, cmd.params);
else
material::Tooltip(TR("console_click_insert"), cmd.name);
}
ImGui::PopStyleColor(3);
if (showParams) {
ImGui::TableNextColumn();
drawConsoleCommandParams(cmd.params);
}
ImGui::TableNextColumn();
ImGui::TextColored(
ImGui::ColorConvertU32ToFloat4(OnSurfaceMedium()),
"%s", cmd.desc);
}
ImGui::EndTable();
}
ImGui::Spacing();
}
}
ImGui::PopFont();
ImGui::EndChild();
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
// Close button
if (material::TactileButton(TR("console_close"), ImVec2(-1, 0))) {
cmdFilter[0] = '\0';
show_commands_popup_ = false;
}
material::EndOverlayDialog();
}
void ConsoleTab::addFormattedResult(const std::string& result, bool is_error)
{
const std::vector<ConsoleResultLine> resultLines = FormatConsoleRpcResultLines(result, is_error);
// Precompute JSON fold spans over the whole block so each opener line carries the offset
// to its matching closer (the spans are relative, so they stay valid as the model's line
// cap evicts from the front). This block is ingested atomically on the main thread.
std::vector<std::string> texts;
texts.reserve(resultLines.size());
for (const auto& rl : resultLines) texts.push_back(rl.text);
const std::vector<int> foldSpans = ComputeConsoleFoldSpans(texts);
for (std::size_t i = 0; i < resultLines.size(); ++i) {
ConsoleChannel channel = ConsoleChannel::None;
switch (resultLines[i].role) {
case ConsoleResultLineRole::Error: channel = ConsoleChannel::Error; break;
case ConsoleResultLineRole::JsonKey: channel = ConsoleChannel::JsonKey; break;
case ConsoleResultLineRole::JsonString: channel = ConsoleChannel::JsonString; break;
case ConsoleResultLineRole::JsonNumber: channel = ConsoleChannel::JsonNumber; break;
case ConsoleResultLineRole::JsonBrace: channel = ConsoleChannel::JsonBrace; break;
case ConsoleResultLineRole::Result: break;
}
model_.ingest(resultLines[i].text, channel, foldSpans[i]);
}
}
void ConsoleTab::renderStatusHeader(ConsoleCommandExecutor& exec)
{
using namespace material;
auto lines = exec.statusLines();
for (const auto& sl : lines)
Type().textColored(TypeStyle::Caption, sl.color, sl.text.c_str());
if (!lines.empty()) ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
}
void ConsoleTab::addLine(const std::string& line, ConsoleChannel channel)
{
// The channel is the canonical semantic key — text color and the left accent bar are
// both derived from it at draw time (channelTextColor / the bar switch). Sources emit
// clean text (no "[daemon]"/"[xmrig]"/"[app]"/"[rpc]" prefix); the bar carries origin.
//
// ingest() is thread-safe, so this is callable from the RPC-trace / app-logger worker
// threads. The line becomes visible on the next render() drain (main thread), where the
// line-cap eviction and selection/scroll bookkeeping happen — none of the UI-only state
// is touched here, so there is no cross-thread access to it.
model_.ingest(line, channel);
}
void ConsoleTab::drainModel()
{
const ConsoleModel::DrainResult dr = model_.drain();
if (dr.added == 0) return;
// Lines evicted from the front by the cap shift every index down — move the selection
// with them so the highlight stays on the text the user selected.
selection_.shiftForEviction(static_cast<int>(dr.popped));
// Track new output that arrived while the user is scrolled up (for the indicator).
scroll_.onLinesAdded(static_cast<int>(dr.added));
}
void ConsoleTab::addRpcTraceLine(const std::string& source, const std::string& method)
{
// The Rpc channel supplies the accent bar + color; no "[rpc] " text prefix needed.
addLine("[" + rpcTraceTimestamp() + "] [" + source + "] " + method, ConsoleChannel::Rpc);
}
void ConsoleTab::clear()
{
// View-only clear (main thread). The executor keeps its own log cursors, so new output
// still appends. The "cleared" line is ingested and appears on the next frame's drain.
model_.clear();
addLine(TR("console_cleared"), ConsoleChannel::Info);
}
} // namespace ui
} // namespace dragonx