// 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; 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 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); COLOR_COMMAND = !cmd.color.empty() ? S.resolveColor(cmd.color, defCmd) : defCmd; COLOR_RESULT = !res.color.empty() ? S.resolveColor(res.color, defRes) : defRes; COLOR_ERROR = !err.color.empty() ? S.resolveColor(err.color, defErr) : defErr; COLOR_DAEMON = !dmn.color.empty() ? S.resolveColor(dmn.color, defDmn) : defDmn; COLOR_INFO = !inf.color.empty() ? S.resolveColor(inf.color, defInf) : defInf; COLOR_RPC = !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 IM_COL32(200, 200, 200, 150); case ConsoleChannel::None: default: return COLOR_RESULT; } } 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) { ConsoleTab* console = nullptr; { std::lock_guard 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 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); 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(); // Use smaller font for console output ImGui::PushFont(Type().caption()); 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. if (scroll_up_cooldown_ > 0.0f) scroll_up_cooldown_ -= ImGui::GetIO().DeltaTime; if (!auto_scroll_ && scroll_up_cooldown_ <= 0.0f && consoleScrollMaxY > 0.0f) { float tolerance = Type().caption()->LegacySize * 1.5f; if (consoleScrollY >= consoleScrollMaxY - tolerance) { auto_scroll_ = true; new_lines_since_scroll_ = 0; } } // 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().caption()->LegacySize * 3.0f, outputH * 0.18f); float effectiveScrollMax = auto_scroll_ ? 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().caption()->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); } } } 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). { 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")); } } ImGui::SameLine(); ImGui::Spacing(); ImGui::SameLine(); // Auto-scroll toggle if (ImGui::Checkbox(TR("console_auto_scroll"), &auto_scroll_)) { if (auto_scroll_) { new_lines_since_scroll_ = 0; } } ImGui::SameLine(); ImGui::Spacing(); ImGui::SameLine(); // Log filters (daemon/errors/rpc-trace/app) apply only to a daemon log source. if (exec.hasLogFilters()) { // Daemon messages toggle ImGui::Checkbox(TR("console_daemon"), &s_daemon_messages_enabled); if (ImGui::IsItemHovered()) { material::Tooltip("%s", TR("console_show_daemon_output")); } ImGui::SameLine(); ImGui::Spacing(); ImGui::SameLine(); // Errors-only toggle ImGui::Checkbox(TR("console_errors"), &s_errors_only_enabled); if (ImGui::IsItemHovered()) { material::Tooltip("%s", TR("console_show_errors_only")); } ImGui::SameLine(); ImGui::Spacing(); ImGui::SameLine(); // App RPC trace toggle — captures method/source only, never results or params if (ImGui::Checkbox(TR("console_rpc_trace"), &s_rpc_trace_enabled)) { rpc::RPCClient::setTraceEnabled(s_rpc_trace_enabled); } if (ImGui::IsItemHovered()) { material::Tooltip("%s", TR("console_show_rpc_trace")); } ImGui::SameLine(); ImGui::Spacing(); ImGui::SameLine(); // App messages toggle — "[app] ..." wallet log lines ImGui::Checkbox(TR("console_app"), &s_app_messages_enabled); if (ImGui::IsItemHovered()) { material::Tooltip("%s", TR("console_show_app_output")); } ImGui::SameLine(); ImGui::Spacing(); ImGui::SameLine(); } // exec.hasLogFilters() // 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 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()); } // Zoom +/- buttons (right side of toolbar) ImGui::SameLine(); ImGui::Spacing(); ImGui::SameLine(); { 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). 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 filtered line index list BEFORE mouse handling (so screenToTextPos works) ConsoleOutputFilter outputFilter{filter_text_, s_daemon_messages_enabled, s_errors_only_enabled, s_rpc_trace_enabled, s_app_messages_enabled}; bool has_text_filter = !outputFilter.text.empty(); // Lowercased needle for in-line match highlighting. std::string filter_lower; if (has_text_filter) { filter_lower = std::string(filter_text_); std::transform(filter_lower.begin(), filter_lower.end(), filter_lower.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); } bool has_filter = has_text_filter || !outputFilter.daemonMessagesEnabled || !outputFilter.rpcTraceEnabled || !outputFilter.appMessagesEnabled || outputFilter.errorsOnly; visible_indices_.clear(); if (has_filter) { for (int i = 0; i < static_cast(model_.size()); i++) { if (!consoleLinePassesFilter(model_[i].text, model_[i].channel, outputFilter)) continue; visible_indices_.push_back(i); } } else { // No filter - all lines are visible for (int i = 0; i < static_cast(model_.size()); i++) { visible_indices_.push_back(i); } } int visible_count = static_cast(visible_indices_.size()); filter_match_count_ = has_text_filter ? visible_count : 0; // 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 character offsets. float wrap_width = ClampConsoleWrapWidth(ImGui::GetContentRegionAvail().x, padX); ImFont* font = ImGui::GetFont(); float fontSize = ImGui::GetFontSize(); ImFontConsoleMeasure measure(font, fontSize); layout_ = BuildConsoleLayout( visible_count, [this](int vi) -> const std::string& { return model_[visible_indices_[vi]].text; }, wrap_width, line_height, interLineGap, measure); // Use raw IO for mouse handling to bypass child window event consumption ImGuiIO& io = ImGui::GetIO(); ImVec2 mouse_pos = io.MousePos; // Manual hit test: is mouse within this child window? 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)); // Disable auto-scroll when user scrolls up (wheel scroll) if (mouse_in_output && io.MouseWheel > 0.0f) { auto_scroll_ = false; scroll_up_cooldown_ = 0.5f; // give smooth-scroll time to animate away } // Scrolling down to the very bottom re-enables auto-scroll. // Actual position check happens after EndChild() using captured // scroll values, but is skipped on the frame where wheel-up // was detected (scroll position hasn't caught up yet). // Set cursor to text selection when hovering if (mouse_in_output) { ImGui::SetMouseCursor(ImGuiMouseCursor_TextInput); } // Mouse press - start selection (use raw IO mouse state) if (mouse_in_output && io.MouseClicked[0]) { selection_.beginDrag(screenToTextPos(mouse_pos)); } // Mouse drag - extend selection (continue even if mouse leaves the window) if (selection_.dragging() && io.MouseDown[0]) { selection_.updateDrag(screenToTextPos(mouse_pos)); } // Mouse release - finalize selection if (selection_.dragging() && io.MouseReleased[0]) { selection_.endDrag(screenToTextPos(mouse_pos)); } // Ctrl+C / Ctrl+A if (mouse_in_output || 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())); } } // Get selection bounds (ordered) ConsoleTextPos sel_start_pos = selection_.rangeStart(); ConsoleTextPos sel_end_pos = selection_.rangeEnd(); // Render lines with selection highlighting. // Each line is split into pre-computed wrap segments rendered individually // via AddText so that hit-testing and highlights map 1:1 to visual positions. 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; } // Add invisible spacer for lines before first visible (for correct scroll) 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); // Render visible lines 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. if (line.channel != ConsoleChannel::None) { ImU32 barCol = 0; switch (line.channel) { case ConsoleChannel::Command: barCol = Primary(); break; case ConsoleChannel::Error: barCol = Error(); break; case ConsoleChannel::Warning: barCol = Warning(); break; case ConsoleChannel::Success: barCol = Success(); break; case ConsoleChannel::Rpc: barCol = Secondary(); break; case ConsoleChannel::Daemon: barCol = IM_COL32(90, 130, 190, 210); break; // node log — blue case ConsoleChannel::Xmrig: barCol = Warning(); break; // mining — amber case ConsoleChannel::App: barCol = IM_COL32(120, 190, 160, 210); break; // app — teal case ConsoleChannel::Info: barCol = OnSurfaceDisabled(); break; default: break; // result / JSON roles get no bar } 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); } } } // 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 && line_height > 0.0f) { int rowIndex = static_cast(std::floor((layout_.cumulativeY[vi] + seg.yOffset) / line_height + 0.5f)); scanline_rows_.push_back({rowY, rowY + seg.height, rowIndex}); } // 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 (has_text_filter && !filter_lower.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(filter_lower, 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 + filter_lower.size())).x; dl->AddRectFilled(ImVec2(lineOrigin.x + xs, rowY), ImVec2(lineOrigin.x + xe, rowY + seg.height), IM_COL32(255, 210, 0, 70)); pos += filter_lower.size(); } } // Render text segment if (seg.byteStart < seg.byteEnd) { dl->AddText(font, fontSize, ImVec2(lineOrigin.x, rowY), channelTextColor(line.channel), segStart, segEnd); } } // Advance ImGui cursor by the total wrapped height of this line ImGui::Dummy(ImVec2(0, totalH)); } // 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)); } } 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().caption()->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 (auto_scroll_) { ImGui::SetScrollHereY(1.0f); new_lines_since_scroll_ = 0; } // 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"), visible_count, 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); } // Right-click context menu if (ImGui::BeginPopupContextWindow("ConsoleContextMenu")) { 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 (main thread) — drop the visible lines and the selection. model_.clear(); selection_.clear(); } ImGui::EndPopup(); } // "New output" indicator when user is scrolled up and new lines arrived if (!auto_scroll_ && new_lines_since_scroll_ > 0) { 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"), new_lines_since_scroll_); 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))) { auto_scroll_ = true; new_lines_since_scroll_ = 0; } } } 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); GlassPanelSpec inGlass; inGlass.rounding = Layout::glassRounding(); inGlass.fillAlpha = 12; material::DrawGlassPanel(dlIn, inMin, inMax, inGlass); // 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 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; }; 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 (!cmd.empty()) { 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(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); } input_buffer_[0] = '\0'; reclaim_focus = true; } } if (busy) ImGui::EndDisabled(); ImGui::PopItemWidth(); // Auto-focus on input if (reclaim_focus) { ImGui::SetKeyboardFocusHere(-1); } } 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); if (!material::BeginOverlayDialog(TR("console_rpc_reference"), &show_commands_popup_, popW, 0.94f)) { 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++) { std::string name(cat.commands[i].name); std::string desc(cat.commands[i].desc); std::string params(cat.commands[i].params); std::transform(name.begin(), name.end(), name.begin(), ::tolower); std::transform(desc.begin(), desc.end(), desc.begin(), ::tolower); std::transform(params.begin(), params.end(), params.begin(), ::tolower); if (name.find(filter) != std::string::npos || desc.find(filter) != std::string::npos || params.find(filter) != std::string::npos) { 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]; std::string nameLower(cmd.name); std::string descLower(cmd.desc); std::string paramsLower(cmd.params); std::transform(nameLower.begin(), nameLower.end(), nameLower.begin(), ::tolower); std::transform(descLower.begin(), descLower.end(), descLower.begin(), ::tolower); std::transform(paramsLower.begin(), paramsLower.end(), paramsLower.begin(), ::tolower); if (!filter.empty() && nameLower.find(filter) == std::string::npos && descLower.find(filter) == std::string::npos && paramsLower.find(filter) == std::string::npos) { 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(); // Style optional params [param] in dimmed text const char* p = cmd.params; bool first = true; while (*p) { const char* bracketStart = strchr(p, '['); if (bracketStart) { // Draw required part before 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; } // Find matching ] 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; } } } 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 (ImGui::Button(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) { for (const auto& resultLine : FormatConsoleRpcResultLines(result, is_error)) { ConsoleChannel channel = ConsoleChannel::None; switch (resultLine.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; } addLine(resultLine.text, channel); } } 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 indicator). if (!auto_scroll_) new_lines_since_scroll_ += static_cast(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