// 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 #include #include #include #include #include #include #include #include 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; bool ConsoleTab::s_line_accents_enabled = true; bool ConsoleTab::s_line_text_color_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() { // Called on RPC worker threads. std::localtime shares a process-wide static tm, so a private // mutex here can't stop another thread's localtime call from clobbering it between the call and // the copy. Use the reentrant variant into a local tm instead (matches the codebase pattern). std::time_t now = std::time(nullptr); std::tm localTime{}; #ifdef _WIN32 localtime_s(&localTime, &now); #else localtime_r(&now, &localTime); #endif 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 namespace { // ── WCAG contrast helpers — keep console text high-contrast while following each theme's palette. ── inline float srgbToLinear(float c8) { float c = c8 / 255.0f; return c <= 0.04045f ? c / 12.92f : std::pow((c + 0.055f) / 1.055f, 2.4f); } inline float relLuminance(float r, float g, float b) { return 0.2126f * srgbToLinear(r) + 0.7152f * srgbToLinear(g) + 0.0722f * srgbToLinear(b); } inline float contrastRatio(float lumA, float lumB) { float hi = std::max(lumA, lumB), lo = std::min(lumA, lumB); return (hi + 0.05f) / (lo + 0.05f); } // Nudge `fg` toward black (light surface) or white (dark surface), preserving hue + alpha, until its // WCAG contrast against `bg` reaches `minRatio`. Colors already passing come back essentially unchanged. inline ImU32 EnsureContrast(ImU32 fg, ImU32 bg, float minRatio) { const float bgL = relLuminance((float)((bg >> IM_COL32_R_SHIFT) & 0xFF), (float)((bg >> IM_COL32_G_SHIFT) & 0xFF), (float)((bg >> IM_COL32_B_SHIFT) & 0xFF)); float r = (float)((fg >> IM_COL32_R_SHIFT) & 0xFF); float g = (float)((fg >> IM_COL32_G_SHIFT) & 0xFF); float b = (float)((fg >> IM_COL32_B_SHIFT) & 0xFF); const int a = (int)((fg >> IM_COL32_A_SHIFT) & 0xFF); const bool darken = bgL > 0.5f; // light surface -> darken the text; dark surface -> lighten it for (int i = 0; i < 40 && contrastRatio(relLuminance(r, g, b), bgL) < minRatio; ++i) { if (darken) { r *= 0.90f; g *= 0.90f; b *= 0.90f; } else { r += (255.0f - r) * 0.12f; g += (255.0f - g) * 0.12f; b += (255.0f - b) * 0.12f; } } return IM_COL32((int)(r + 0.5f), (int)(g + 0.5f), (int)(b + 0.5f), a); } // Floor a dynamically-derived channel color to a readable contrast on light terminals; no-op on dark. inline ImU32 FloorLight(ImU32 c) { return material::IsLightTheme() ? EnsureContrast(c, IM_COL32(255, 255, 255, 255), 4.5f) : c; } } // namespace void ConsoleTab::refreshColors() { using namespace material; auto& S = schema::UI(); // Derive light/dark from the SAME live background-luminance predicate the terminal surface uses // (the overlay at line ~274 is chosen by IsLightTheme()). Using the stored IsDarkTheme() flag here // let the two disagree — baking dark-theme (pale) text onto the light near-white console surface. bool dark = !IsLightTheme(); // Per-theme channel defaults. ImU32 defCmd, defRes, defErr, defDmn, defInf, defRpc; if (dark) { // Dark terminal: keep the authored light-on-dark palette (schema overrides honored below). defCmd = IM_COL32(191, 209, 229, 255); defRes = IM_COL32(200, 200, 200, 255); defErr = IM_COL32(246, 71, 64, 255); defDmn = IM_COL32(160, 160, 160, 180); defInf = IM_COL32(191, 209, 229, 255); defRpc = IM_COL32(120, 180, 255, 210); } else { // Light terminal: follow THIS theme's own palette (Marble slate/taupe, Dune sand, Light blue, …) // so the console reads on-theme — each color nudged toward black until it clears a WCAG contrast // floor on the near-white console surface. High-contrast AND theme-colored. const ImU32 W = IM_COL32(255, 255, 255, 255); // overlay is white(205); target pure white = safe floor defCmd = EnsureContrast(Primary(), W, 4.5f); // command echo — theme primary defRes = EnsureContrast(OnSurface(), W, 7.0f); // result body — theme text (already dark) defErr = EnsureContrast(Error(), W, 4.5f); // errors — theme error defDmn = WithAlpha(EnsureContrast(OnSurfaceMedium(), W, 4.5f), 235); // node log — dimmer, still legible defInf = EnsureContrast(Primary(), W, 4.5f); // info / app log — theme primary defRpc = EnsureContrast(Secondary(), W, 4.5f); // rpc trace — theme secondary } // Schema console colors are authored for the dark terminal (light-on-dark); honor them only in dark // themes. Light themes always use the palette-derived, contrast-floored defaults above. 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"); 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 { COLOR_COMMAND = defCmd; COLOR_RESULT = defRes; COLOR_ERROR = defErr; COLOR_DAEMON = defDmn; COLOR_INFO = defInf; COLOR_RPC = defRpc; } } ImU32 ConsoleTab::channelTextColor(ConsoleChannel channel) const { using namespace material; // Monochrome mode: collapse every channel (and JSON syntax role) to the neutral result-body // color. COLOR_RESULT is theme-correct (contrast-floored on light terminals in refreshColors), // so text stays readable. The left accent bars are gated separately and stay independent. if (!s_line_text_color_enabled) return COLOR_RESULT; switch (channel) { // COLOR_* channels are already palette-derived + contrast-floored in refreshColors(). The // roles below are computed live from the theme palette, so floor them to a readable contrast on // light terminals here (FloorLight is a no-op on dark themes, preserving the authored look). case ConsoleChannel::Command: return COLOR_COMMAND; case ConsoleChannel::Info: return COLOR_INFO; case ConsoleChannel::Success: return FloorLight(WithAlpha(Success(), 255)); case ConsoleChannel::Warning: return FloorLight(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 FloorLight(WithAlpha(Secondary(), 255)); case ConsoleChannel::JsonString: return FloorLight(WithAlpha(Success(), 255)); case ConsoleChannel::JsonNumber: return FloorLight(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 lock(s_rpc_trace_console_mutex); s_rpc_trace_console = this; } rpc::RPCClient::setTraceCallback([](const std::string& source, const std::string& method) { // Dereference under the lock, not after releasing it: ~ConsoleTab clears // s_rpc_trace_console under the same lock, so holding it here blocks destruction until the // call returns — closing the shutdown use-after-free window (this fires on RPC worker threads). std::lock_guard lock(s_rpc_trace_console_mutex); if (s_rpc_trace_console) s_rpc_trace_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 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 whenever the theme changes. Keyed on the schema generation // (bumped on every theme/skin load) rather than a dark/light toggle: the constructor's refreshColors() // can run before the theme is applied (s_isDarkTheme still defaults true), and a toggle-only trigger // would never correct that on startup. Line colors are derived from each line's channel at draw time. { static uint32_t s_lastGen = ~0u; uint32_t gen = schema::UI().generation(); if (gen != s_lastGen) { refreshColors(); s_lastGen = gen; } } // Run a command the RPC-reference modal's "Insert & run" queued (the modal has no executor, so it // defers to here). Done before drain so the echoed "> cmd" line surfaces this frame. if (!pending_submit_.empty()) { std::string cmd; cmd.swap(pending_submit_); submitConsoleCommand(exec, cmd); } // 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(); // Compute the filtered visible-line set once per frame, BEFORE the toolbar — so its " matches" // label reflects the current frame (not last frame's stale count). renderOutput reuses the result. computeVisibleLines(has_text_filter_, filter_lower_); // Main console layout // NoScrollWithMouse: the inner ConsoleOutput owns wheel scrolling (via ApplySmoothScroll). Without // this, a wheel over the output would scroll BOTH the output (smooth-scroll) and this outer container // (ImGui forwards the NoScrollWithMouse child's wheel to its scrollable ancestor) — a double-scroll. // Safe because the output panel is sized to fill the remaining height, so this outer never overflows. ImGui::BeginChild("ConsoleContainer", ImVec2(0, 0), false, ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); // 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(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(ConsoleCommandExecutor* exec) { if (!show_commands_popup_) { return; } // Need a backend that offers a reference table. If the console hasn't built its executor yet // (popup can't have been opened normally) or the backend has none, just dismiss. if (!exec || !exec->commandReference()) { show_commands_popup_ = false; return; } renderCommandsPopup(*exec); } 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(); // also drops the stale visible_indices_ + selection (see ConsoleTab::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 — shown whenever the backend offers a reference table (full-node // JSON-RPC commands, or the lite backend's own verbs). if (exec.commandReference()) { if (TactileButton(TR("console_commands"), ImVec2(0, 0), schema::UI().resolveFont("button"))) { command_search_[0] = '\0'; // fresh search each open (dismiss paths don't all reset it) show_commands_popup_ = true; } if (ImGui::IsItemHovered()) { material::Tooltip("%s", exec.hasRpcReference() ? TR("console_show_rpc_ref") : TR("console_show_backend_ref")); } ImGui::SameLine(); } // Line count ImGui::TextDisabled(TR("console_line_count"), model_.size()); ImGui::SameLine(); ImGui::Spacing(); ImGui::SameLine(); // Output filter input drawFilterInput(); // Color-accent toggle: hide/show the per-line left accent bars (appearance, grouped with zoom). ImGui::SameLine(); ImGui::Spacing(); ImGui::SameLine(); { float btnSz = ImGui::GetFrameHeight(); // Capture the flag BEFORE the button can flip it — the push/pop guards must use the SAME value, // or a click leaves the colour stack unbalanced (ImGui then draws a red error rect on the window). const bool dim = !s_line_accents_enabled; const char* icon = s_line_accents_enabled ? ICON_MD_FORMAT_COLOR_FILL : ICON_MD_FORMAT_COLOR_RESET; if (dim) ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(OnSurfaceDisabled())); if (TactileButton(icon, ImVec2(btnSz, btnSz), Type().iconMed())) s_line_accents_enabled = !s_line_accents_enabled; if (dim) ImGui::PopStyleColor(); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("console_toggle_accents")); } // Text-color toggle: colored per-channel text vs. monochrome (grouped with the accent toggle). ImGui::SameLine(); { float btnSz = ImGui::GetFrameHeight(); const bool dim = !s_line_text_color_enabled; // capture before the click flips it (balanced push/pop) if (dim) ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(OnSurfaceDisabled())); if (TactileButton(ICON_MD_FORMAT_COLOR_TEXT, ImVec2(btnSz, btnSz), Type().iconMed())) s_line_text_color_enabled = !s_line_text_color_enabled; if (dim) ImGui::PopStyleColor(); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("console_toggle_text_color")); } // 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.0f * Layout::dpiScale(); 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.0f * Layout::dpiScale(), 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; // Reserve room for EVERY trailing same-line control drawn AFTER this filter on the toolbar // row: the two icon toggles (accent-fill + text-color) and the two zoom buttons, plus the // group spacers between them. Otherwise the filter eats the row and the trailing controls // run off-window (worst at 1024px / font_scale 1.5). All four are GetFrameHeight() wide. float trailingBtnSpace = ImGui::GetFrameHeight() * 4.0f + Layout::spacingSm() * 7.0f; float filterAvail = ImGui::GetContentRegionAvail().x - trailingBtnSpace; float filterMaxW = schema::UI().drawElement("tabs.console", "filter-max-width").size * Layout::dpiScale(); float filterW = std::min(filterMaxW, 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(text.size()); auto isTok = [](unsigned char c) { return std::isalnum(c) != 0; }; int at = col; if (at >= n || (at >= 0 && !isTok(static_cast(text[at])))) at--; if (at < 0 || at >= n || !isTok(static_cast(text[at]))) return {}; int s = at, e = at; while (s > 0 && isTok(static_cast(text[s - 1]))) s--; while (e + 1 < n && isTok(static_cast(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). // Raw logical px from the schema; scale it so the inter-line gap grows at font_scale 1.5 // (it is added to the already-DPI-scaled GetTextLineHeight in BuildConsoleLayout — scale the // gap only, never the line height). float interLineGap = S.drawElement("tabs.console", "output").getFloat("line-spacing", 0.0f) * Layout::dpiScale(); 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(); // visible_indices_ / has_text_filter_ / filter_lower_ were already computed once at the top of // render() (before the toolbar). screenToTextPos maps through visible_indices_, so it's ready. // 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(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) && // The RPC reference is a BeginOverlayDialog (not an ImGui popup), so it // isn't caught above; suppress the output's text cursor + selection under it. !show_commands_popup_); 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(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(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(std::tolower(c)); }); } bool has_filter = hasTextFilter || !outputFilter.daemonMessagesEnabled || !outputFilter.rpcTraceEnabled || !outputFilter.appMessagesEnabled || outputFilter.errorsOnly; // Folding is only honored in the unfiltered view; the filtered view is flat. Record it so the // fold triangles aren't drawn/clickable while filtering (else clicks would silently mutate the // collapsed flag with no visible effect and the glyph would disagree with the rendered block). folding_active_ = !has_filter; visible_indices_.clear(); const int n = static_cast(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(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). Ignore clicks in the // left gutter (< output_origin_.x) — that strip holds the accent bar + fold triangles, so a fold // toggle there shouldn't begin (and thus clear) a text selection. if (mouseInOutput && io.MouseClicked[0] && mousePos.x >= output_origin_.x) { 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(model_.size()), static_cast(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(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(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(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(layout_.segments.size()); k++) scanRowOrdinal += static_cast(layout_.segments[k].size()); int last_rendered_vi = first_visible - 1; for (int vi = first_visible; vi < visible_count; vi++) { if (vi < static_cast(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. // Suppressed when the toolbar's color-accent toggle is off (cleaner monochrome gutter). if (s_line_accents_enabled && 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(c) * spaceW; dl->AddLine(ImVec2(gx, lineOrigin.y), ImVec2(gx, lineOrigin.y + totalH), IM_COL32(255, 255, 255, 20), 1.0f * Layout::dpiScale()); } } } // 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. Only shown in the // unfiltered view, where folding actually applies (see folding_active_ in computeVisibleLines). if (folding_active_ && line.foldSpan > 0) { // Size from the (DPI/density-scaled) gutter width, not the zoomed font, and center it in // the gutter band [origin-padX, origin) so the glyph and its clickable cell stay aligned. float sz = std::min(fontSize * 0.30f, padX * 0.34f); float cx = output_origin_.x - padX * 0.5f; 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. // Guard against a click that is actually dismissing the ConsoleContextMenu (or any // popup) — the same popup guard the text-selection path uses (see mouse_in_output). ImVec2 mp = ImGui::GetIO().MousePos; if (ImGui::IsMouseClicked(ImGuiMouseButton_Left) && !ImGui::IsPopupOpen("", ImGuiPopupFlags_AnyPopup) && 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(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(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(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(pos)).x; float xe = font->CalcTextSizeA(fontSize, FLT_MAX, 0, segStart, segStart + static_cast(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(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(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(model_.size()), static_cast(model_.back().text.size())); } } ImGui::Separator(); if (ImGui::MenuItem(TR("console_clear_console"))) { // View-only clear — route through clear() so the stale visible_indices_/selection are dropped too // (a bare model_.clear() mid-frame would leave renderOutput() indexing the emptied model_ → crash). 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; // The box geometry is hand-drawn absolute px, so scale it by dpiScale() (the font metrics below // are already scaled — left alone). Without this the pill renders native-size on a HiDPI display. float dp = Layout::dpiScale(); float indicW = 140.0f * dp; float indicH = 24.0f * dp; 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 * dp; ImVec2 iMin(ix, iy); ImVec2 iMax(ix + indicW, iy + indicH); dlInd->AddRectFilled(iMin, iMax, IM_COL32(40, 40, 40, 220), 12.0f * dp); dlInd->AddRect(iMin, iMax, IM_COL32(255, 218, 0, 120), 12.0f * dp, 0, 1.0f * dp); 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(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(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(schema::UI().drawElement("tabs.console", "bg-darken-alpha").sizeOr(110.0f)); const bool barLight = IsLightTheme(); // Match the output panel: near-white terminal surface on light skins, dark on dark, with a // 1px glass-rim outline along the bar's own edges (so the outline hugs the input, not inset). dlIn->AddRectFilled(inMin, inMax, barLight ? IM_COL32(255, 255, 255, 205) : IM_COL32(0, 0, 0, darkA), Layout::glassRounding()); dlIn->AddRect(inMin, inMax, barLight ? IM_COL32(0, 0, 0, 45) : IM_COL32(255, 255, 255, 35), Layout::glassRounding(), 0, 1.0f); } // 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(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; }; // Transparent frame with a subtle hover/active tint (the outline lives on the terminal bar above, // hugging the input's edges). Colors are theme-aware so they read on the light-skin console too. const bool inputLight = material::IsLightTheme(); ImGui::PushStyleColor(ImGuiCol_FrameBg, IM_COL32(0, 0, 0, 0)); ImGui::PushStyleColor(ImGuiCol_FrameBgHovered, inputLight ? IM_COL32(0, 0, 0, 10) : IM_COL32(255, 255, 255, 12)); ImGui::PushStyleColor(ImGuiCol_FrameBgActive, inputLight ? IM_COL32(0, 0, 0, 16) : 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(); // Blinking terminal caret at the end of the current input. Shown when the field isn't actively // being edited (ImGui draws its own caret while focused), so the console always reads as a live // prompt. Monospace font => caret X = frame-padding + charWidth * length. if (!busy && !ImGui::IsItemActive()) { if (std::fmod(ImGui::GetTime(), 1.06) < 0.53) { ImFont* mf = Type().mono(); float charW = mf->CalcTextSizeA(mf->LegacySize, FLT_MAX, 0, "M").x; ImVec2 itMin = ImGui::GetItemRectMin(); ImVec2 itMax = ImGui::GetItemRectMax(); float caretW = std::max(2.0f, 2.0f * Layout::dpiScale()); float caretX = itMin.x + ImGui::GetStyle().FramePadding.x + charW * (float)strlen(input_buffer_); caretX = std::min(caretX, itMax.x - ImGui::GetStyle().FramePadding.x - caretW); float pad = (itMax.y - itMin.y) * 0.18f; ImU32 caretCol = inputLight ? IM_COL32(0, 0, 0, 210) : IM_COL32(255, 255, 255, 210); dlIn->AddRectFilled(ImVec2(caretX, itMin.y + pad), ImVec2(caretX + caretW, itMax.y - pad), caretCol); } } ImGui::PopStyleColor(3); ImGui::PopItemWidth(); ImGui::PopFont(); // Auto-focus on input — after submitting a command (reclaim), or once when the Console tab is opened // (focus_input_pending_, set by requestInputFocus() and gated on the console_auto_focus setting). // Skip while a command is running: SetKeyboardFocusHere can't focus the disabled field anyway. if ((reclaim_focus || focus_input_pending_) && !busy) { ImGui::SetKeyboardFocusHere(-1); } focus_input_pending_ = false; } bool ConsoleTab::submitConsoleCommand(ConsoleCommandExecutor& exec, const std::string& cmd) { if (cmd.empty()) return false; // Redact secret-bearing commands (walletpassphrase, z_importkey, …) before they reach the visible // log and the recall history. The real `cmd` below is still executed unredacted. const std::string display = RedactConsoleCommand(cmd); addLine("> " + display, ConsoleChannel::Command); AppendConsoleHistory(command_history_, display, 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(std::tolower(c)); }); } // 'stop' shuts down the node — require a confirming second 'stop'; any other command clears the // pending state. Member (not a function-local static) so clear() can also cancel it — otherwise a // toolbar/context-menu clear between the two 'stop's would leave a stale arm and skip the warning. if (first != "stop") stop_confirm_pending_ = false; 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 (first == "stop" && exec.hasRpcReference()) { // Full-node 'stop' shuts down the daemon (destructive) — gate behind a confirming second // 'stop'. Lite has no node: `stop` isn't a backend verb, so it falls through below and the // backend reports it as unknown — no misleading "shut down the node" warning or dead gate. if (!stop_confirm_pending_) { stop_confirm_pending_ = true; addLine(TR("console_stop_confirm_node"), ConsoleChannel::Warning); } else { stop_confirm_pending_ = false; if (!exec.isReady()) addLine(TR("console_not_connected"), ConsoleChannel::Error); else exec.submit(cmd); } } else if (!exec.isReady()) { // Full node connects to a daemon; the lite backend opens a wallet — word the error per variant. addLine(exec.hasRpcReference() ? TR("console_not_connected") : TR("console_not_connected_lite"), 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. static bool lcContains(const char* s, const std::string& needleLower) { std::string v(s); std::transform(v.begin(), v.end(), v.begin(), ::tolower); return v.find(needleLower) != std::string::npos; } static bool lcStartsWith(const char* s, const std::string& needleLower) { std::string v(s); std::transform(v.begin(), v.end(), v.begin(), ::tolower); return v.rfind(needleLower, 0) == 0; } // Search relevance of a command for a lowercased query. -1 = no match; higher = better. Keywords // let novices find a command by intent ("balance" -> getbalance) even without the exact name. int consoleCommandRank(const ConsoleCommandEntry& cmd, const std::string& q) { if (q.empty()) return 0; if (lcStartsWith(cmd.name, q)) return 100; if (lcContains(cmd.name, q)) return 60; if (cmd.keywords[0] && lcContains(cmd.keywords, q)) return 40; if (lcContains(cmd.desc, q)) return 25; if (cmd.details[0] && lcContains(cmd.details, q)) return 15; if (lcContains(cmd.params, q)) return 10; return -1; } // Split a parameter template into top-level tokens, respecting quote/bracket nesting so a space // inside "..." or [{...}] doesn't split (e.g. `"address" [{"a":1}]` -> {`"address"`, `[{"a":1}]`}). std::vector splitParamTemplate(const char* params) { std::vector out; std::string tok; int depth = 0; char q = 0; for (const char* p = params; *p; ++p) { char c = *p; if (q) { tok += c; if (c == q) q = 0; continue; } if (c == '"' || c == '\'') { q = c; tok += c; continue; } if (c == '[' || c == '{') { depth++; tok += c; continue; } if (c == ']' || c == '}') { if (depth > 0) depth--; tok += c; continue; } if (c == ' ' && depth == 0) { if (!tok.empty()) { out.push_back(tok); tok.clear(); } continue; } tok += c; } if (!tok.empty()) out.push_back(tok); return out; } // A parsed parameter for the builder form. type in {string, number, json}; `raw` is the original // template token (used as a placeholder when a required field is left empty). struct ConsoleParamSpec { std::string label; std::string type; bool optional = false; std::string raw; }; // Best-effort parse of a param template into fillable fields. The templates are human-readable, not // a strict schema, so heuristics: [word] / ["word"] = an optional scalar; a [ / { with JSON content // (a brace or comma) = a JSON field the user pastes; otherwise a required string/number. std::vector parseParamSpecs(const char* params) { std::vector out; for (const std::string& t : splitParamTemplate(params)) { ConsoleParamSpec p; p.raw = t; std::string inner = t; if (inner.size() >= 2 && inner.front() == '[' && inner.back() == ']') { std::string body = inner.substr(1, inner.size() - 2); bool looksJson = body.find('{') != std::string::npos || body.find(',') != std::string::npos || (!body.empty() && body.front() == '['); if (!looksJson) { p.optional = true; inner = body; } // optional scalar wrapper } char c0 = inner.empty() ? 0 : inner.front(); if (c0 == '"' || c0 == '\'') { p.type = "string"; if (inner.size() >= 2 && inner.back() == c0) inner = inner.substr(1, inner.size() - 2); p.label = inner; } else if (c0 == '{' || c0 == '[') { p.type = "json"; p.label = "json"; } else { p.type = "number"; p.label = inner; } out.push_back(p); } return out; } // Translate a command-category name from the static reference tables (English) for display. The // per-command descriptions stay in English (technical RPC docs); only the 7 category labels are i18n'd. const char* consoleCategoryLabel(const char* name) { if (!std::strcmp(name, "Control")) return TR("console_cat_control"); if (!std::strcmp(name, "Network")) return TR("console_cat_network"); if (!std::strcmp(name, "Blockchain")) return TR("console_cat_blockchain"); if (!std::strcmp(name, "Mining")) return TR("console_cat_mining"); if (!std::strcmp(name, "Wallet")) return TR("console_cat_wallet"); if (!std::strcmp(name, "Raw Transactions")) return TR("console_cat_raw_transactions"); if (!std::strcmp(name, "Utility")) return TR("console_cat_utility"); // Lite backend reference categories. if (!std::strcmp(name, "Sync")) return TR("console_cat_sync"); if (!std::strcmp(name, "Send")) return TR("console_cat_send"); if (!std::strcmp(name, "Keys & Security")) return TR("console_cat_keys"); if (!std::strcmp(name, "Advanced")) return TR("console_cat_advanced"); return name; } } // namespace void ConsoleTab::insertCommandToInput(const ConsoleCommandEntry& cmd) { // Fill the console input with the command (+ its param template) and close the modal — the user // reviews/edits it and presses Enter to run. 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'; } command_search_[0] = '\0'; run_confirm_cmd_ = nullptr; show_commands_popup_ = false; } void ConsoleTab::renderCommandDetail(const ConsoleCommandEntry& cmd, const char* catLabel, bool jsonArgs) { using namespace material; float dp = Layout::dpiScale(); ImFont* mono = Type().mono(); // Heading: command name, then category + a safety badge for consequential commands. ImGui::PushFont(Type().subtitle1()); ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(Primary()), "%s", cmd.name); ImGui::PopFont(); ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(OnSurfaceDisabled()), "%s", catLabel); if (cmd.destructive) { ImGui::SameLine(0, Layout::spacingMd()); ImVec4 warn = ImGui::ColorConvertU32ToFloat4(Warning()); ImGui::PushFont(Type().iconSmall()); ImGui::TextColored(warn, ICON_MD_WARNING); ImGui::PopFont(); ImGui::SameLine(0, Layout::spacingXs()); ImGui::TextColored(warn, "%s", TR("console_ref_destructive")); } ImGui::Dummy(ImVec2(0, Layout::spacingSm())); // Plain-language explanation (falls back to the terse summary when not enriched). ImGui::PushTextWrapPos(0.0f); ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(OnSurface()), "%s", cmd.details[0] ? cmd.details : cmd.desc); ImGui::PopTextWrapPos(); ImGui::Dummy(ImVec2(0, Layout::spacingMd())); // Parameters — an editable form that assembles the command. Reset the fields whenever the // selected command changes so values don't carry across commands. if (cmd_param_owner_ != &cmd) { for (auto& b : cmd_param_bufs_) b[0] = '\0'; cmd_param_owner_ = &cmd; } std::vector specs = parseParamSpecs(cmd.params); ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(OnSurfaceMedium()), "%s", TR("console_ref_parameters")); if (specs.empty()) { ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(OnSurfaceDisabled()), "%s", TR("console_ref_no_params")); } else { float labelW = 110.0f * dp; ImGui::Indent(Layout::spacingSm()); for (size_t k = 0; k < specs.size() && k < 6; k++) { const ConsoleParamSpec& s = specs[k]; ImGui::PushID((int)k); ImGui::PushFont(mono); ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(s.optional ? OnSurfaceMedium() : OnSurface()), "%s", s.label.c_str()); ImGui::PopFont(); ImGui::SameLine(labelW); ImGui::SetNextItemWidth(-1); // Lite backend args are bare freeform tokens, so its param types (string/number) don't // apply — show a neutral "value" hint there instead of a misleading "number". std::string typeHint = jsonArgs ? s.type : std::string(TR("console_ref_value")); std::string hint = s.optional ? (typeHint + " \xC2\xB7 " + std::string(TR("console_ref_optional"))) : typeHint; ImGui::InputTextWithHint("##pv", hint.c_str(), cmd_param_bufs_[k], sizeof(cmd_param_bufs_[k])); ImGui::PopID(); } ImGui::Unindent(Layout::spacingSm()); } // Assemble the command from the filled fields. A required field left empty keeps its placeholder // token (so Insert still shows what's needed) and marks the command incomplete (Insert & run // disabled). String values are auto-quoted; trailing empty optionals are omitted. auto trimStr = [](const std::string& s) -> std::string { size_t a = s.find_first_not_of(" \t"); if (a == std::string::npos) return std::string(); return s.substr(a, s.find_last_not_of(" \t") - a + 1); }; // Include fields up to the last one that is filled OR required; trailing empty optionals are // dropped. An empty field that must still be included (a blank required field, or a gap before a // later filled field — positional args can't skip a middle slot) keeps its placeholder and marks // the command incomplete, so Insert & run stays disabled and no typed value is silently lost. int lastNeeded = -1; for (size_t k = 0; k < specs.size() && k < 6; k++) if (!trimStr(cmd_param_bufs_[k]).empty() || !specs[k].optional) lastNeeded = (int)k; bool complete = specs.size() <= 6; std::string built = cmd.name; for (int k = 0; k <= lastNeeded; k++) { std::string val = trimStr(cmd_param_bufs_[k]); if (val.empty()) { built += " " + specs[k].raw; complete = false; } else { // JSON-RPC (full node) auto-quotes string args; the lite backend takes bare tokens, so // leave the value exactly as typed there (quoting would break its address/key parsing). if (jsonArgs && specs[k].type == "string" && val.front() != '"' && val.front() != '\'' && val.front() != '[' && val.front() != '{') val = "\"" + val + "\""; built += " " + val; } } // Live "Builds" preview when the command takes parameters. if (!specs.empty()) { ImGui::Dummy(ImVec2(0, Layout::spacingSm())); ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(OnSurfaceMedium()), "%s", TR("console_ref_builds")); ImGui::PushFont(mono); ImGui::PushTextWrapPos(0.0f); ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(FloorLight(IM_COL32(150, 200, 150, 255))), "%s", built.c_str()); ImGui::PopTextWrapPos(); ImGui::PopFont(); } ImGui::Dummy(ImVec2(0, Layout::spacingMd())); // Example (curated commands only) — reference alongside the builder. if (cmd.example[0]) { ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(OnSurfaceMedium()), "%s", TR("console_ref_example")); ImGui::PushFont(mono); ImGui::PushTextWrapPos(0.0f); ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(FloorLight(IM_COL32(150, 200, 150, 255))), "%s", cmd.example); ImGui::PopTextWrapPos(); ImGui::PopFont(); ImGui::Dummy(ImVec2(0, Layout::spacingMd())); } // Actions (or the destructive run confirmation) — Insert/run use the assembled command. ImGui::Dummy(ImVec2(0, Layout::spacingSm())); if (run_confirm_cmd_ == &cmd) { ImVec4 warn = ImGui::ColorConvertU32ToFloat4(Warning()); ImGui::PushFont(Type().iconSmall()); ImGui::TextColored(warn, ICON_MD_WARNING); ImGui::PopFont(); ImGui::SameLine(0, Layout::spacingXs()); ImGui::TextColored(warn, TR("console_ref_run_confirm"), cmd.name); ImGui::Dummy(ImVec2(0, Layout::spacingXs())); float cbw = 120.0f * dp; if (material::TactileButton(TR("console_ref_cancel"), ImVec2(cbw, 0))) run_confirm_cmd_ = nullptr; ImGui::SameLine(); if (material::TactileButton(TR("console_ref_run"), ImVec2(cbw, 0))) { pending_submit_ = built; command_search_[0] = '\0'; run_confirm_cmd_ = nullptr; show_commands_popup_ = false; } } else { if (material::TactileButton(TR("console_ref_insert"), ImVec2(200.0f * dp, 0))) { snprintf(input_buffer_, sizeof(input_buffer_), "%s", built.c_str()); command_search_[0] = '\0'; run_confirm_cmd_ = nullptr; show_commands_popup_ = false; } // Insert & run: enabled once every required field is filled (a template with unfilled // placeholders would just error). ImGui::SameLine(); ImGui::BeginDisabled(!complete); if (material::TactileButton(TR("console_ref_insert_run"), ImVec2(140.0f * dp, 0))) { if (cmd.destructive) { run_confirm_cmd_ = &cmd; } else { pending_submit_ = built; command_search_[0] = '\0'; show_commands_popup_ = false; } } ImGui::EndDisabled(); } } void ConsoleTab::renderCommandsPopup(ConsoleCommandExecutor& exec) { using namespace material; float dp = Layout::dpiScale(); // Full node speaks JSON-RPC (quote string args); the lite backend takes bare tokens. const bool jsonArgs = exec.hasRpcReference(); material::OverlayDialogSpec ov; ov.title = jsonArgs ? TR("console_rpc_reference") : TR("console_backend_reference"); ov.p_open = &show_commands_popup_; ov.style = material::OverlayStyle::BlurFloat; // floating content on the blur, plain heading ov.cardWidth = 960.0f; // wide enough for two panes ov.cardHeight = ImGui::GetMainViewport()->Size.y * 0.74f / dp; // fixed; both panes fill it ov.idSuffix = "cmdref"; if (!material::BeginOverlayDialog(ov)) return; // Esc dismisses (a first Esc cancels a pending run-confirm). Not built into the overlay. if (ImGui::IsKeyPressed(ImGuiKey_Escape)) { if (run_confirm_cmd_) run_confirm_cmd_ = nullptr; else show_commands_popup_ = false; } // Search box — auto-focus on open so the user can type immediately. Enter inserts the selected // command's template; reset the param-builder fields on open. if (ImGui::IsWindowAppearing()) { ImGui::SetKeyboardFocusHere(); cmd_param_owner_ = nullptr; } ImGui::SetNextItemWidth(-1); bool searchEnter = ImGui::InputTextWithHint("##CmdSearch", TR("console_ref_search_hint"), command_search_, sizeof(command_search_), ImGuiInputTextFlags_EnterReturnsTrue); bool searchChanged = ImGui::IsItemEdited(); bool searchActive = ImGui::IsItemActive(); ImGui::Dummy(ImVec2(0, Layout::spacingXs())); std::string q(command_search_); std::transform(q.begin(), q.end(), q.begin(), ::tolower); const bool searching = !q.empty(); const auto& categories = *exec.commandReference(); // non-null: guarded by renderCommandsPopupModal // Flat display order of (cat,idx): ranked when searching, category order when browsing. Drives // keyboard nav + auto-selection; the browse view still renders grouped headers below. std::vector> order; if (searching) { std::vector>> scored; // (score, (cat,idx)) for (int c = 0; c < (int)categories.size(); c++) for (int i = 0; i < categories[c].count; i++) { int s = consoleCommandRank(categories[c].commands[i], q); if (s >= 0) scored.push_back({s, {c, i}}); } std::stable_sort(scored.begin(), scored.end(), [](const auto& a, const auto& b) { return a.first > b.first; }); for (auto& s : scored) order.push_back(s.second); } else { for (int c = 0; c < (int)categories.size(); c++) for (int i = 0; i < categories[c].count; i++) order.push_back({c, i}); } // Keep a valid selection: reset to the top when the query changes or the current selection falls // out of the visible set, so the detail pane is always populated. int selPos = -1; for (int k = 0; k < (int)order.size(); k++) if (order[k].first == cmd_sel_cat_ && order[k].second == cmd_sel_idx_) { selPos = k; break; } if (searchChanged || selPos < 0) { if (!order.empty()) { cmd_sel_cat_ = order[0].first; cmd_sel_idx_ = order[0].second; selPos = 0; } else { cmd_sel_cat_ = cmd_sel_idx_ = -1; } } // Keyboard nav from the SEARCH box only (so typing in a param field doesn't move the selection): // Up/Down move, Enter inserts the selected command's template. Disabled while a run-confirm shows. if (!run_confirm_cmd_ && searchActive && !order.empty() && selPos >= 0) { if (ImGui::IsKeyPressed(ImGuiKey_DownArrow) && selPos + 1 < (int)order.size()) selPos++; else if (ImGui::IsKeyPressed(ImGuiKey_UpArrow) && selPos > 0) selPos--; cmd_sel_cat_ = order[selPos].first; cmd_sel_idx_ = order[selPos].second; } if (searchEnter && !run_confirm_cmd_ && cmd_sel_cat_ >= 0) insertCommandToInput(categories[cmd_sel_cat_].commands[cmd_sel_idx_]); // Two-pane body sized above the footer. float footerH = ImGui::GetFrameHeightWithSpacing() + Layout::spacingXs(); float bodyH = std::max(120.0f, ImGui::GetContentRegionAvail().y - footerH); float contentW = ImGui::GetContentRegionAvail().x; float gap = Layout::spacingLg(); float masterW = std::min(std::max(contentW * 0.34f, 280.0f * dp), 380.0f * dp); float detailW = contentW - masterW - gap; ImFont* mono = Type().mono(); // The panes are theme-adaptive glass (light on light skins), so floor the link-blue to a readable // contrast on light surfaces (FloorLight is a no-op on dark themes). ImU32 nameCol = FloorLight(IM_COL32(100, 180, 255, 255)); float rowH = std::max(20.0f * dp, mono->LegacySize + 6.0f * dp); // One master row: mono command name + a warning dot for consequential commands, with a soft // rounded selection/hover fill (Material, not the default sharp Selectable highlight). Click // selects (drives the detail pane); double-click inserts. auto drawRow = [&](int c, int i) { const ConsoleCommandEntry& cmd = categories[c].commands[i]; bool sel = (cmd_sel_cat_ == c && cmd_sel_idx_ == i); ImGui::PushID(c * 1000 + i); ImVec2 rmn = ImGui::GetCursorScreenPos(); ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0, 0, 0, 0)); // we draw our own fill ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ImVec4(0, 0, 0, 0)); ImGui::PushStyleColor(ImGuiCol_HeaderActive, ImVec4(0, 0, 0, 0)); bool clicked = ImGui::Selectable("##cmdrow", sel, ImGuiSelectableFlags_SpanAvailWidth, ImVec2(0, rowH)); ImGui::PopStyleColor(3); bool hov = ImGui::IsItemHovered(); if (clicked) { cmd_sel_cat_ = c; cmd_sel_idx_ = i; } if (hov) { ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) insertCommandToInput(cmd); } ImDrawList* dl = ImGui::GetWindowDrawList(); float rowW = ImGui::GetItemRectSize().x; if (sel || hov) dl->AddRectFilled(ImVec2(rmn.x, rmn.y + 1.0f * dp), ImVec2(rmn.x + rowW, rmn.y + rowH - 1.0f * dp), sel ? WithAlpha(Primary(), 52) : WithAlpha(OnSurface(), 16), 6.0f * dp); float tx = rmn.x + Layout::spacingSm(); if (cmd.destructive) { float r = 3.0f * dp; dl->AddCircleFilled(ImVec2(tx + r, rmn.y + rowH * 0.5f), r, Warning()); tx += r * 2.0f + Layout::spacingXs(); } dl->AddText(mono, mono->LegacySize, ImVec2(tx, rmn.y + (rowH - mono->LegacySize) * 0.5f), sel ? OnSurface() : nameCol, cmd.name); ImGui::PopID(); }; // Both panes sit on soft Material glass surfaces (no hard 1px child border) with inner padding. GlassPanelSpec paneGlass; paneGlass.rounding = 14.0f * dp; paneGlass.fillAlpha = 30; paneGlass.borderAlpha = 30; // MASTER pane. { ImVec2 mMin = ImGui::GetCursorScreenPos(); DrawGlassPanel(ImGui::GetWindowDrawList(), mMin, ImVec2(mMin.x + masterW, mMin.y + bodyH), paneGlass); } ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(Layout::spacingMd(), Layout::spacingSm())); ImGui::BeginChild("##cmdMaster", ImVec2(masterW, bodyH), ImGuiChildFlags_AlwaysUseWindowPadding); { if (order.empty()) { Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("console_ref_no_match")); } else if (searching) { for (auto& p : order) drawRow(p.first, p.second); } else { for (int c = 0; c < (int)categories.size(); c++) { // Subtle accent-labelled section header (no heavy filled bar). ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0, 0, 0, 0)); ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ImGui::ColorConvertU32ToFloat4(WithAlpha(OnSurface(), 14))); ImGui::PushStyleColor(ImGuiCol_HeaderActive, ImGui::ColorConvertU32ToFloat4(WithAlpha(OnSurface(), 20))); ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(Primary())); bool open = ImGui::CollapsingHeader(consoleCategoryLabel(categories[c].name), ImGuiTreeNodeFlags_DefaultOpen); ImGui::PopStyleColor(4); if (open) for (int i = 0; i < categories[c].count; i++) drawRow(c, i); } } } ImGui::EndChild(); ImGui::PopStyleVar(); ImGui::SameLine(0, gap); // DETAIL pane. { ImVec2 dMin = ImGui::GetCursorScreenPos(); DrawGlassPanel(ImGui::GetWindowDrawList(), dMin, ImVec2(dMin.x + detailW, dMin.y + bodyH), paneGlass); } ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(Layout::spacingLg(), Layout::spacingMd())); ImGui::BeginChild("##cmdDetail", ImVec2(detailW, bodyH), ImGuiChildFlags_AlwaysUseWindowPadding); if (cmd_sel_cat_ >= 0 && cmd_sel_cat_ < (int)categories.size() && cmd_sel_idx_ >= 0 && cmd_sel_idx_ < categories[cmd_sel_cat_].count) { renderCommandDetail(categories[cmd_sel_cat_].commands[cmd_sel_idx_], consoleCategoryLabel(categories[cmd_sel_cat_].name), jsonArgs); } else { ImVec2 av = ImGui::GetContentRegionAvail(); ImGui::SetCursorPosY(av.y * 0.4f); Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("console_ref_select_hint")); } ImGui::EndChild(); ImGui::PopStyleVar(); // Footer. ImGui::Dummy(ImVec2(0, Layout::spacingXs())); if (material::TactileButton(TR("console_close"), ImVec2(-1, 0))) { command_search_[0] = '\0'; run_confirm_cmd_ = nullptr; show_commands_popup_ = false; } material::EndOverlayDialog(); } void ConsoleTab::addFormattedResult(const std::string& result, bool is_error) { const std::vector 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 texts; texts.reserve(resultLines.size()); for (const auto& rl : resultLines) texts.push_back(rl.text); const std::vector 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(dr.popped)); // Track new output that arrived while the user is scrolled up (for the "N new lines" indicator). // Count only the newly-added lines that pass the active filter, so the indicator isn't inflated // by lines the current filter / errors-only view hides — the user wouldn't see those on jumping // to the bottom. (The new lines are the last dr.added entries; eviction is from the front.) ConsoleOutputFilter f{filter_text_, s_daemon_messages_enabled, s_errors_only_enabled, s_rpc_trace_enabled, s_app_messages_enabled}; const int n = static_cast(model_.size()); int visibleAdded = 0; for (int i = std::max(0, n - static_cast(dr.added)); i < n; i++) if (consoleLinePassesFilter(model_[i].text, model_[i].channel, f)) ++visibleAdded; scroll_.onLinesAdded(visibleAdded); } 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(); // visible_indices_ was computed at the top of render() (line 311), BEFORE the toolbar's Clear button // ran; those indices now point past the emptied model_. renderOutput() (this same frame, after the // toolbar) indexes model_[visible_indices_[vi]] — so drop them (and the selection, which also holds // line indices) here to avoid an out-of-bounds crash. computeVisibleLines() rebuilds them next frame. visible_indices_.clear(); selection_.clear(); stop_confirm_pending_ = false; // a pending 'stop' confirmation is cancelled by clearing addLine(TR("console_cleared"), ConsoleChannel::Info); } } // namespace ui } // namespace dragonx