// DragonX Wallet - ImGui Edition // Copyright 2024-2026 The Hush Developers // Released under the GPLv3 #include "console_tab.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 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); 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; 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"); 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); 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; } 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); } } ConsoleTab::ConsoleTab() { // Load console colors from ui.toml schema (uses current theme) refreshColors(); // Add welcome message addLine(TR("console_welcome"), COLOR_INFO); addLine(TR("console_type_help"), COLOR_INFO); addLine("", COLOR_RESULT); } void ConsoleTab::render(daemon::EmbeddedDaemon* daemon, rpc::RPCClient* rpc, rpc::RPCWorker* worker, daemon::XmrigManager* xmrig) { using namespace material; // Refresh console colors when dark/light theme changes { static bool s_lastDark = IsDarkTheme(); bool nowDark = IsDarkTheme(); if (nowDark != s_lastDark) { // Save old colors to remap existing lines ImU32 oldCmd = COLOR_COMMAND, oldRes = COLOR_RESULT; ImU32 oldErr = COLOR_ERROR, oldDmn = COLOR_DAEMON; ImU32 oldInf = COLOR_INFO; refreshColors(); // Remap stored line colors from old to new { std::lock_guard lock(lines_mutex_); for (auto& line : lines_) { if (line.color == oldCmd) line.color = COLOR_COMMAND; else if (line.color == oldRes) line.color = COLOR_RESULT; else if (line.color == oldErr) line.color = COLOR_ERROR; else if (line.color == oldDmn) line.color = COLOR_DAEMON; else if (line.color == oldInf) line.color = COLOR_INFO; } } s_lastDark = nowDark; } } // Check for daemon state changes if (daemon) { auto current_state = daemon->getState(); // Show message when daemon starts if (current_state == daemon::EmbeddedDaemon::State::Starting && last_daemon_state_ == daemon::EmbeddedDaemon::State::Stopped) { addLine("", COLOR_RESULT); addLine(TR("console_starting_node"), COLOR_INFO); addLine(TR("console_capturing_output"), COLOR_INFO); addLine("", COLOR_RESULT); shown_startup_message_ = true; } else if (current_state == daemon::EmbeddedDaemon::State::Running && last_daemon_state_ != daemon::EmbeddedDaemon::State::Running) { addLine(TR("console_daemon_started"), COLOR_INFO); } else if (current_state == daemon::EmbeddedDaemon::State::Stopped && last_daemon_state_ == daemon::EmbeddedDaemon::State::Running) { addLine("", COLOR_RESULT); addLine(TR("console_daemon_stopped"), COLOR_INFO); } else if (current_state == daemon::EmbeddedDaemon::State::Error) { addLine(std::string(TR("console_daemon_error")) + daemon->getLastError() + " ===", COLOR_ERROR); } last_daemon_state_ = current_state; } // Track RPC connection state and show a message when connected if (rpc) { bool connected_now = rpc->isConnected(); if (connected_now && !last_rpc_connected_) { addLine(TR("console_connected"), COLOR_INFO); } else if (!connected_now && last_rpc_connected_) { addLine(TR("console_disconnected"), COLOR_ERROR); } last_rpc_connected_ = connected_now; } // Check for new daemon output — always capture so toggle works as a live filter if (daemon) { std::string new_output = daemon->getOutputSince(last_daemon_output_size_); if (!new_output.empty()) { // Split by newlines and add each line std::istringstream stream(new_output); std::string line; while (std::getline(stream, line)) { if (!line.empty()) { // Color based on content: [ERROR] -> red, [WARN] -> warning color ImU32 lineColor = COLOR_DAEMON; if (line.find("[ERROR]") != std::string::npos || line.find("error:") != std::string::npos || line.find("Error:") != std::string::npos) { lineColor = COLOR_ERROR; } addLine("[daemon] " + line, lineColor); } } } } // Check for new xmrig output (pool mining) if (xmrig && xmrig->isRunning()) { std::string new_output = xmrig->getOutputSince(last_xmrig_output_size_); if (!new_output.empty()) { std::istringstream stream(new_output); std::string line; while (std::getline(stream, line)) { if (!line.empty()) { // Color xmrig output - errors in red, accepted shares in green ImU32 lineColor = COLOR_DAEMON; if (line.find("error") != std::string::npos || line.find("ERROR") != std::string::npos || line.find("failed") != std::string::npos) { lineColor = COLOR_ERROR; } else if (line.find("accepted") != std::string::npos) { lineColor = COLOR_INFO; } addLine("[xmrig] " + line, lineColor); } } } } else if (!xmrig || !xmrig->isRunning()) { // Reset offset when xmrig stops so we get fresh output next time if (last_xmrig_output_size_ != 0) { last_xmrig_output_size_ = 0; } } // Main console layout ImGui::BeginChild("ConsoleContainer", ImVec2(0, 0), false, ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollbar); // Toolbar renderToolbar(daemon); ImGui::Dummy(ImVec2(0, Layout::spacingSm())); // Output area (scrollable) — glass panel background float frameH = ImGui::GetFrameHeightWithSpacing(); float itemSp = ImGui::GetStyle().ItemSpacing.y; float input_height = (Layout::spacingSm() + itemSp) // Dummy(0,sm) + spacing + frameH + Layout::spacingSm() + Layout::spacingXs() + schema::UI().drawElement("tabs.console", "input-cursor-offset").size; // input glass panel + cursor offset float outputH = ImGui::GetContentRegionAvail().y - input_height; float availHeight = ImGui::GetContentRegionAvail().y; if (outputH < std::max(schema::UI().drawElement("tabs.console", "output-min-height").size, availHeight * schema::UI().drawElement("tabs.console", "output-min-height-ratio").size)) outputH = std::max(schema::UI().drawElement("tabs.console", "output-min-height").size, availHeight * 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 panelH = outPanelMax.y - outPanelMin.y; // --- Text-aligned horizontal scanlines --- // Stride matches the actual text line height so each band sits between lines. float textLineH = output_line_height_; if (textLineH <= 1.0f) textLineH = Type().caption()->LegacySize * s_console_zoom + 2.0f; // fallback float bandH = schema::UI().drawElement("tabs.console", "scanline-gap").sizeOr(2.0f); int lineAlpha = (int)schema::UI().drawElement("tabs.console", "scanline-line-alpha").sizeOr(18.0f); // Glow fringe parameters (soft gradient above/below each band) float glowSpread = schema::UI().drawElement("tabs.console", "scanline-glow-spread").sizeOr(0.0f); float glowIntensity = schema::UI().drawElement("tabs.console", "scanline-glow-intensity").sizeOr(0.6f); int glowRGB = (int)schema::UI().drawElement("tabs.console", "scanline-glow-color").sizeOr(255.0f); bool drawGlow = glowSpread > 0.0f && glowIntensity > 0.0f && lineAlpha > 0; int glowAlpha = drawGlow ? std::min(255, (int)(lineAlpha * glowIntensity)) : 0; ImU32 glowPeak = IM_COL32(glowRGB, glowRGB, glowRGB, glowAlpha); ImU32 glowClear = IM_COL32(glowRGB, glowRGB, glowRGB, 0); if (textLineH >= 1.0f && lineAlpha > 0) { ImU32 lineCol = IM_COL32(255, 255, 255, lineAlpha); float stride = textLineH; // one text line per scanline period // Align with text: account for inner padding and scroll position float padY = Layout::spacingSm(); float scrollFrac = std::fmod(consoleScrollY, stride); float startY = outPanelMin.y + padY - scrollFrac; // Ensure first band starts above the visible area while (startY > outPanelMin.y) startY -= stride; for (float y = startY; y < outPanelMax.y; y += stride) { // Place the dark band at the bottom edge of each text line period float bandTop = y + stride - bandH; float bandBot = y + stride; float yTop = std::max(bandTop, outPanelMin.y); float yBot = std::min(bandBot, outPanelMax.y); if (yTop < yBot) { // Glow fringes (gradient tapers away from band) if (drawGlow) { // Above fringe: transparent at top, glowPeak at bottom float gTop = std::max(yTop - glowSpread, outPanelMin.y); if (gTop < yTop) { dlOut->AddRectFilledMultiColor( ImVec2(outPanelMin.x, gTop), ImVec2(outPanelMax.x, yTop), glowClear, glowClear, glowPeak, glowPeak); } // Below fringe: glowPeak at top, transparent at bottom float gBot = std::min(yBot + glowSpread, outPanelMax.y); if (yBot < gBot) { dlOut->AddRectFilledMultiColor( ImVec2(outPanelMin.x, yBot), ImVec2(outPanelMax.x, gBot), glowPeak, glowPeak, glowClear, glowClear); } } // Opaque scanline band (drawn on top of glow) dlOut->AddRectFilled(ImVec2(outPanelMin.x, yTop), ImVec2(outPanelMax.x, yBot), lineCol); } } } // --- Animated sweep band (brighter moving highlight) --- float scanSpeed = schema::UI().drawElement("tabs.console", "scanline-speed").sizeOr(40.0f); float scanH = schema::UI().drawElement("tabs.console", "scanline-height").sizeOr(30.0f); int scanAlpha = (int)schema::UI().drawElement("tabs.console", "scanline-alpha").sizeOr(12.0f); 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(rpc, worker); ImGui::EndChild(); } void ConsoleTab::renderCommandsPopupModal() { if (!show_commands_popup_) { return; } renderCommandsPopup(); } void ConsoleTab::renderToolbar(daemon::EmbeddedDaemon* daemon) { 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()); // Daemon status with colored dot if (daemon) { auto state = daemon->getState(); const char* status_text = TR("console_status_unknown"); ImU32 dotCol = IM_COL32(150, 150, 150, 255); bool pulse = false; switch (state) { case daemon::EmbeddedDaemon::State::Stopped: status_text = TR("console_status_stopped"); dotCol = IM_COL32(150, 150, 150, 255); break; case daemon::EmbeddedDaemon::State::Starting: status_text = TR("console_status_starting"); dotCol = Warning(); pulse = true; break; case daemon::EmbeddedDaemon::State::Running: status_text = TR("console_status_running"); dotCol = Success(); break; case daemon::EmbeddedDaemon::State::Stopping: status_text = TR("console_status_stopping"); dotCol = Warning(); pulse = true; break; case daemon::EmbeddedDaemon::State::Error: status_text = TR("console_status_error"); dotCol = Error(); break; } 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 (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 = (dotCol & 0x00FFFFFF) | ((ImU32)(255 * a) << 24); dl->AddCircleFilled(ImVec2(dotX, dotY), dotR, pCol); } else { dl->AddCircleFilled(ImVec2(dotX, dotY), dotR, dotCol); } ImGui::Dummy(ImVec2(dotR * 2 + 6, 0)); ImGui::SameLine(); Type().textColored(TypeStyle::Caption, dotCol, status_text); } 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_) { scroll_to_bottom_ = true; new_lines_since_scroll_ = 0; } } ImGui::SameLine(); ImGui::Spacing(); ImGui::SameLine(); // Daemon messages toggle { static bool s_prev_daemon_enabled = true; ImGui::Checkbox(TR("console_daemon"), &s_daemon_messages_enabled); // When toggling daemon filter while auto-scroll is active, scroll to bottom if (s_prev_daemon_enabled != s_daemon_messages_enabled && auto_scroll_) { scroll_to_bottom_ = true; } s_prev_daemon_enabled = s_daemon_messages_enabled; } if (ImGui::IsItemHovered()) { ImGui::SetTooltip("%s", TR("console_show_daemon_output")); } ImGui::SameLine(); ImGui::Spacing(); ImGui::SameLine(); // Errors-only toggle { static bool s_prev_errors_only = false; ImGui::Checkbox(TR("console_errors"), &s_errors_only_enabled); // When toggling errors filter while auto-scroll is active, scroll to bottom if (s_prev_errors_only != s_errors_only_enabled && auto_scroll_) { scroll_to_bottom_ = true; } s_prev_errors_only = s_errors_only_enabled; } if (ImGui::IsItemHovered()) { ImGui::SetTooltip("%s", TR("console_show_errors_only")); } ImGui::SameLine(); ImGui::Spacing(); ImGui::SameLine(); // Clear button if (TactileButton(TR("console_clear"), ImVec2(0, 0), schema::UI().resolveFont("button"))) { clear(); clearSelection(); } ImGui::SameLine(); // Copy button — material styled if (TactileButton(TR("copy"), ImVec2(0, 0), schema::UI().resolveFont("button"))) { std::lock_guard lock(lines_mutex_); if (has_selection_) { std::string selected = getSelectedText(); if (!selected.empty()) { ImGui::SetClipboardText(selected.c_str()); } } else { // Copy all output if nothing selected std::string all; for (const auto& line : lines_) { all += line.text + "\n"; } if (!all.empty()) { ImGui::SetClipboardText(all.c_str()); } } } if (ImGui::IsItemHovered()) { ImGui::SetTooltip("%s", has_selection_ ? TR("console_copy_selected") : TR("console_copy_all")); } ImGui::SameLine(); // Commands reference button if (TactileButton(TR("console_commands"), ImVec2(0, 0), schema::UI().resolveFont("button"))) { show_commands_popup_ = true; } if (ImGui::IsItemHovered()) { ImGui::SetTooltip("%s", TR("console_show_rpc_ref")); } ImGui::SameLine(); // Line count { std::lock_guard lock(lines_mutex_); ImGui::TextDisabled(TR("console_line_count"), lines_.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_)); // 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()) { ImGui::SetTooltip(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()) { ImGui::SetTooltip(TR("console_zoom_in"), s_console_zoom * 100.0f); } } } void ConsoleTab::renderOutput() { using namespace material; auto& S = schema::UI(); std::lock_guard lock(lines_mutex_); // Zero item spacing so Dummy items advance the cursor by exactly their // height. The inter-line gap is added explicitly to wrapped_heights_ // so that cumulative_y_offsets_ 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(); // Build filtered line index list BEFORE mouse handling (so screenToTextPos works) std::string filter_str(filter_text_); bool has_text_filter = !filter_str.empty(); bool hide_daemon = !s_daemon_messages_enabled; bool errors_only = s_errors_only_enabled; bool has_filter = has_text_filter || hide_daemon || errors_only; visible_indices_.clear(); if (has_filter) { std::string filter_lower; if (has_text_filter) { filter_lower = filter_str; std::transform(filter_lower.begin(), filter_lower.end(), filter_lower.begin(), ::tolower); } for (int i = 0; i < static_cast(lines_.size()); i++) { // Skip daemon lines when daemon toggle is off if (hide_daemon && lines_[i].color == COLOR_DAEMON) continue; // When errors-only is enabled, skip non-error lines if (errors_only && lines_[i].color != COLOR_ERROR) continue; if (has_text_filter) { std::string lower = lines_[i].text; std::transform(lower.begin(), lower.end(), lower.begin(), ::tolower); if (lower.find(filter_lower) == std::string::npos) continue; } visible_indices_.push_back(i); } } else { // No filter - all lines are visible for (int i = 0; i < static_cast(lines_.size()); i++) { visible_indices_.push_back(i); } } int visible_count = static_cast(visible_indices_.size()); // 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 = ImGui::GetContentRegionAvail().x - padX * 2; if (wrap_width < 50.0f) wrap_width = 50.0f; ImFont* font = ImGui::GetFont(); float fontSize = ImGui::GetFontSize(); wrapped_heights_.resize(visible_count); cumulative_y_offsets_.resize(visible_count); visible_wrap_segments_.resize(visible_count); total_wrapped_height_ = 0.0f; cached_wrap_width_ = wrap_width; for (int vi = 0; vi < visible_count; vi++) { int i = visible_indices_[vi]; const std::string& text = lines_[i].text; auto& segs = visible_wrap_segments_[vi]; segs.clear(); if (text.empty()) { segs.push_back({0, 0, 0.0f, line_height}); cumulative_y_offsets_[vi] = total_wrapped_height_; wrapped_heights_[vi] = line_height + interLineGap; total_wrapped_height_ += wrapped_heights_[vi]; continue; } // Walk the text using ImFont::CalcWordWrapPositionA to find // exactly where ImGui would break each visual row. const char* textStart = text.c_str(); const char* textEnd = textStart + text.size(); const char* cur = textStart; float segY = 0.0f; while (cur < textEnd) { const char* wrapPos = font->CalcWordWrapPositionA( fontSize / font->LegacySize, cur, textEnd, wrap_width); // Ensure forward progress (at least one character) if (wrapPos <= cur) wrapPos = cur + 1; // Skip a leading newline character that ends the previous segment if (*cur == '\n') { cur++; continue; } int byteStart = static_cast(cur - textStart); int byteEnd = static_cast(wrapPos - textStart); // Trim trailing newline from this segment if (byteEnd > byteStart && text[byteEnd - 1] == '\n') byteEnd--; segs.push_back({byteStart, byteEnd, segY, line_height}); segY += line_height; cur = wrapPos; } if (segs.empty()) { segs.push_back({0, 0, 0.0f, line_height}); segY = line_height; } cumulative_y_offsets_[vi] = total_wrapped_height_; wrapped_heights_[vi] = segY + interLineGap; total_wrapped_height_ += wrapped_heights_[vi]; } // 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]) { sel_anchor_ = screenToTextPos(mouse_pos, line_height); sel_end_ = sel_anchor_; is_selecting_ = true; has_selection_ = false; } // Mouse drag - extend selection (continue even if mouse leaves the window) if (is_selecting_ && io.MouseDown[0]) { TextPos new_end = screenToTextPos(mouse_pos, line_height); sel_end_ = new_end; // Consider it a real selection once the position changes if (sel_end_.line != sel_anchor_.line || sel_end_.col != sel_anchor_.col) { has_selection_ = true; } } // Mouse release - finalize selection if (is_selecting_ && io.MouseReleased[0]) { sel_end_ = screenToTextPos(mouse_pos, line_height); is_selecting_ = false; } // Ctrl+C / Ctrl+A if (mouse_in_output || has_selection_) { if (io.KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_C)) { std::string selected = getSelectedText(); if (!selected.empty()) { ImGui::SetClipboardText(selected.c_str()); } } if (io.KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_A)) { // Select all if (!lines_.empty()) { sel_anchor_ = {0, 0}; sel_end_ = {static_cast(lines_.size()) - 1, static_cast(lines_.back().text.size())}; has_selection_ = true; } } } // Get selection bounds (ordered) TextPos sel_start_pos = selectionStart(); TextPos sel_end_pos = selectionEnd(); // 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 (!cumulative_y_offsets_.empty()) { int lo = 0, hi = static_cast(cumulative_y_offsets_.size()) - 1; while (lo < hi) { int mid = (lo + hi) / 2; float line_bottom = cumulative_y_offsets_[mid] + wrapped_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(cumulative_y_offsets_.size())) { ImGui::Dummy(ImVec2(0, cumulative_y_offsets_[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(cumulative_y_offsets_.size()) && cumulative_y_offsets_[vi] > visible_bottom) { break; } last_rendered_vi = vi; int i = visible_indices_[vi]; const auto& line = lines_[i]; const auto& segs = visible_wrap_segments_[vi]; ImVec2 lineOrigin = ImGui::GetCursorScreenPos(); float totalH = wrapped_heights_[vi]; // Determine byte-level selection range for this line int selByteStart = 0, selByteEnd = 0; bool lineSelected = false; if (has_selection_ && 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; // 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); } // Render text segment if (seg.byteStart < seg.byteEnd) { dl->AddText(font, fontSize, ImVec2(lineOrigin.x, rowY), line.color, 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(cumulative_y_offsets_.size())) ? cumulative_y_offsets_[last_rendered_vi] + wrapped_heights_[last_rendered_vi] : 0.0f; float remaining_height = total_wrapped_height_ - 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); scroll_to_bottom_ = false; 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, lines_.size()); ImVec2 indicatorPos = ImGui::GetCursorScreenPos(); ImGui::GetWindowDrawList()->AddText(indicatorPos, WithAlpha(Warning(), 180), filterBuf); ImGui::Dummy(ImVec2(0, ImGui::GetTextLineHeight())); } // Right-click context menu if (ImGui::BeginPopupContextWindow("ConsoleContextMenu")) { if (ImGui::MenuItem(TR("copy"), "Ctrl+C", false, has_selection_)) { std::string selected = getSelectedText(); if (!selected.empty()) { ImGui::SetClipboardText(selected.c_str()); } } if (ImGui::MenuItem(TR("console_select_all"), "Ctrl+A")) { if (!lines_.empty()) { sel_anchor_ = {0, 0}; sel_end_ = {static_cast(lines_.size()) - 1, static_cast(lines_.back().text.size())}; has_selection_ = true; } } ImGui::Separator(); if (ImGui::MenuItem(TR("console_clear_console"))) { // Can't call clear() here (already holding lock), just mark for clearing lines_.clear(); has_selection_ = false; } 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_, new_lines_since_scroll_ != 1 ? "s" : ""); 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; scroll_to_bottom_ = true; new_lines_since_scroll_ = 0; } } } ConsoleTab::TextPos ConsoleTab::screenToTextPos(ImVec2 screen_pos, float line_height) const { TextPos pos; if (visible_indices_.empty()) { return {0, 0}; } float relative_y = screen_pos.y - output_origin_.y; // Binary search for the visible line that contains this Y position int visible_line = 0; if (!cumulative_y_offsets_.empty()) { int lo = 0, hi = static_cast(cumulative_y_offsets_.size()) - 1; while (lo < hi) { int mid = (lo + hi + 1) / 2; if (cumulative_y_offsets_[mid] <= relative_y) { lo = mid; } else { hi = mid - 1; } } visible_line = lo; } // Clamp visible line if (visible_line < 0) visible_line = 0; if (visible_line >= static_cast(visible_indices_.size())) { visible_line = static_cast(visible_indices_.size()) - 1; pos.line = visible_indices_[visible_line]; pos.col = static_cast(lines_[pos.line].text.size()); return pos; } pos.line = visible_indices_[visible_line]; const std::string& text = lines_[pos.line].text; if (text.empty()) { pos.col = 0; return pos; } // Find which sub-row (wrap segment) the mouse Y falls into const auto& segs = visible_wrap_segments_[visible_line]; float lineRelY = relative_y - cumulative_y_offsets_[visible_line]; int segIdx = 0; for (int s = 0; s < static_cast(segs.size()); s++) { if (lineRelY >= segs[s].yOffset) segIdx = s; } const auto& seg = segs[segIdx]; // Calculate column within this segment from X position float relative_x = screen_pos.x - output_origin_.x; if (relative_x <= 0.0f) { pos.col = seg.byteStart; return pos; } ImFont* font = ImGui::GetFont(); float fontSize = ImGui::GetFontSize(); const char* segStart = text.c_str() + seg.byteStart; const char* segEnd = text.c_str() + seg.byteEnd; int segLen = seg.byteEnd - seg.byteStart; // Walk characters within this segment for accurate positioning pos.col = seg.byteEnd; // default: past end of segment for (int c = 0; c < segLen; c++) { float wCur = font->CalcTextSizeA(fontSize, FLT_MAX, 0, segStart, segStart + c + 1).x; float wPrev = (c > 0) ? font->CalcTextSizeA(fontSize, FLT_MAX, 0, segStart, segStart + c).x : 0.0f; float charMid = (wPrev + wCur) * 0.5f; if (relative_x < charMid) { pos.col = seg.byteStart + c; return pos; } } return pos; } bool ConsoleTab::isPosBeforeOrEqual(const TextPos& a, const TextPos& b) const { if (a.line < b.line) return true; if (a.line > b.line) return false; return a.col <= b.col; } ConsoleTab::TextPos ConsoleTab::selectionStart() const { return isPosBeforeOrEqual(sel_anchor_, sel_end_) ? sel_anchor_ : sel_end_; } ConsoleTab::TextPos ConsoleTab::selectionEnd() const { return isPosBeforeOrEqual(sel_anchor_, sel_end_) ? sel_end_ : sel_anchor_; } std::string ConsoleTab::getSelectedText() const { if (!has_selection_) return ""; TextPos start = selectionStart(); TextPos end = selectionEnd(); if (start.line < 0 || start.line >= static_cast(lines_.size())) return ""; // Build a set of visible line indices for quick lookup // (Only copy visible lines when filtering is active) std::unordered_set visible_set(visible_indices_.begin(), visible_indices_.end()); bool has_filter = !visible_indices_.empty() && visible_indices_.size() < lines_.size(); std::string result; bool first_line = true; for (int i = start.line; i <= end.line && i < static_cast(lines_.size()); i++) { // Skip lines that aren't visible when filtering if (has_filter && visible_set.find(i) == visible_set.end()) { continue; } const std::string& text = lines_[i].text; int col_start = 0; int col_end = static_cast(text.size()); if (i == start.line) col_start = std::min(start.col, static_cast(text.size())); if (i == end.line) col_end = std::min(end.col, static_cast(text.size())); // Add newline between lines (but not before first) if (!first_line) { result += "\n"; } first_line = false; if (col_start < col_end) { result += text.substr(col_start, col_end - col_start); } } return result; } void ConsoleTab::clearSelection() { has_selection_ = false; is_selecting_ = false; sel_anchor_ = {-1, 0}; sel_end_ = {-1, 0}; } void ConsoleTab::renderInput(rpc::RPCClient* rpc, rpc::RPCWorker* worker) { 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_; if (data->EventKey == ImGuiKey_UpArrow) { if (console->history_index_ < 0) { console->history_index_ = static_cast(console->command_history_.size()) - 1; } else if (console->history_index_ > 0) { console->history_index_--; } } else if (data->EventKey == ImGuiKey_DownArrow) { if (console->history_index_ >= 0) { console->history_index_++; if (console->history_index_ >= static_cast(console->command_history_.size())) { console->history_index_ = -1; } } } if (prev_index != console->history_index_) { const char* history_str = (console->history_index_ >= 0) ? console->command_history_[console->history_index_].c_str() : ""; data->DeleteChars(0, data->BufTextLen); data->InsertChars(0, history_str); } } else if (data->EventFlag == ImGuiInputTextFlags_CallbackCompletion) { // Tab completion for common RPC commands static const char* commands[] = { // Control "help", "getinfo", "stop", // Network "getnetworkinfo", "getpeerinfo", "getconnectioncount", "addnode", "setban", "listbanned", "clearbanned", "ping", // Blockchain "getblockchaininfo", "getblockcount", "getbestblockhash", "getblock", "getblockhash", "getblockheader", "getdifficulty", "getrawmempool", "gettxout", "coinsupply", "getchaintips", // Mining "getmininginfo", "setgenerate", "getgenerate", "getnetworkhashps", "getblocksubsidy", // Wallet "getbalance", "z_gettotalbalance", "z_getbalances", "getnewaddress", "z_getnewaddress", "listaddresses", "z_listaddresses", "sendtoaddress", "z_sendmany", "listtransactions", "listunspent", "z_listunspent", "z_getoperationstatus", "z_getoperationresult", "getwalletinfo", "backupwallet", "dumpprivkey", "importprivkey", "z_exportkey", "z_importkey", "signmessage", "settxfee", // Raw Transactions "createrawtransaction", "decoderawtransaction", "getrawtransaction", "sendrawtransaction", "signrawtransaction", // Utility "validateaddress", "z_validateaddress", "estimatefee", // Built-in "clear" }; std::string input(data->Buf); if (!input.empty()) { // Collect all matches std::vector matches; for (const char* cmd : commands) { if (strncmp(cmd, input.c_str(), input.length()) == 0) { matches.push_back(cmd); } } if (matches.size() == 1) { // Single match — complete it data->DeleteChars(0, data->BufTextLen); data->InsertChars(0, matches[0]); } else if (matches.size() > 1) { // Multiple matches — show list in console and complete common prefix console->addLine(TR("console_completions"), ConsoleTab::COLOR_INFO); std::string line = " "; for (size_t m = 0; m < matches.size(); m++) { if (m > 0) line += " "; line += matches[m]; if (line.length() > 60) { console->addLine(line, ConsoleTab::COLOR_RESULT); line = " "; } } if (line.length() > 2) { console->addLine(line, ConsoleTab::COLOR_RESULT); } // Complete to longest common prefix std::string prefix = matches[0]; for (size_t m = 1; m < matches.size(); m++) { size_t len = 0; while (len < prefix.length() && len < strlen(matches[m]) && prefix[len] == matches[m][len]) len++; prefix = prefix.substr(0, len); } if (prefix.length() > input.length()) { data->DeleteChars(0, data->BufTextLen); data->InsertChars(0, prefix.c_str()); } } } } return 0; }; if (ImGui::InputText("##ConsoleInput", input_buffer_, sizeof(input_buffer_), flags, callback, this)) { std::string cmd(input_buffer_); if (!cmd.empty()) { executeCommand(cmd, rpc, worker); input_buffer_[0] = '\0'; reclaim_focus = true; } } 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())); // Command entries struct CmdEntry { const char* name; const char* desc; const char* params; }; static const CmdEntry controlCmds[] = { {"help", "List all commands, or get help for a specified command", "[\"command\"]"}, {"getinfo", "Get general info about the node", ""}, {"stop", "Stop the daemon", ""}, }; static const CmdEntry networkCmds[] = { {"getnetworkinfo", "Return P2P network state info", ""}, {"getpeerinfo", "Get data about each connected peer", ""}, {"getconnectioncount", "Get number of peer connections", ""}, {"getnettotals", "Get network traffic statistics", ""}, {"addnode", "Add, remove, or connect to a node", "\"node\" \"add|remove|onetry\""}, {"setban", "Add or remove an IP/subnet from the ban list", "\"ip\" \"add|remove\" [bantime] [absolute]"}, {"listbanned", "List all banned IPs/subnets", ""}, {"clearbanned", "Clear all banned IPs", ""}, {"ping", "Ping all peers to measure round-trip time", ""}, }; static const CmdEntry blockchainCmds[] = { {"getblockchaininfo", "Get current blockchain state", ""}, {"getblockcount", "Get number of blocks in longest chain", ""}, {"getbestblockhash", "Get hash of the tip block", ""}, {"getblock", "Get block data for a given hash or height", "\"hash|height\" [verbosity]"}, {"getblockhash", "Get block hash at a given height", "height"}, {"getblockheader", "Get block header for a given hash", "\"hash\" [verbose]"}, {"getdifficulty", "Get proof-of-work difficulty", ""}, {"getrawmempool", "Get all txids in mempool", "[verbose]"}, {"getmempoolinfo", "Get mempool state info", ""}, {"gettxout", "Get details about an unspent output", "\"txid\" n [includemempool]"}, {"coinsupply", "Get coin supply information", "[height]"}, {"getchaintips", "Get all known chain tips", ""}, {"getchaintxstats", "Get chain transaction statistics", "[nblocks] [\"blockhash\"]"}, {"verifychain", "Verify the blockchain database", "[checklevel] [numblocks]"}, {"kvsearch", "Search the blockchain key-value store", "\"key\""}, {"kvupdate", "Update a key-value pair on-chain", "\"key\" \"value\" days"}, }; static const CmdEntry miningCmds[] = { {"getmininginfo", "Get mining-related information", ""}, {"setgenerate", "Turn mining on or off (true/false [threads])", "generate [genproclimit]"}, {"getgenerate", "Check if the node is mining", ""}, {"getnetworkhashps", "Get estimated network hash rate", "[blocks] [height]"}, {"getblocksubsidy", "Get block reward at a given height", "[height]"}, {"getblocktemplate", "Get block template for mining", "[\"jsonrequestobject\"]"}, {"submitblock", "Submit a mined block to the network", "\"hexdata\""}, }; static const CmdEntry walletCmds[] = { {"getbalance", "Get wallet transparent balance", "[\"account\"] [minconf]"}, {"z_gettotalbalance", "Get total transparent + shielded balance", "[minconf]"}, {"z_getbalances", "Get all balances (transparent + shielded)", ""}, {"getnewaddress", "Generate a new transparent address", ""}, {"z_getnewaddress", "Generate a new shielded address", "[\"type\"]"}, {"listaddresses", "List all transparent addresses", ""}, {"z_listaddresses", "List all z-addresses", ""}, {"sendtoaddress", "Send to a specific address", "\"address\" amount"}, {"z_sendmany", "Send to multiple z/t-addresses with shielded support", "\"fromaddress\" [{\"address\":\"...\",\"amount\":...}]"}, {"z_shieldcoinbase", "Shield transparent coinbase funds to a z-address", "\"fromaddress\" \"tozaddress\" [fee] [limit]"}, {"z_mergetoaddress", "Merge multiple UTXOs/notes to one address", "[\"fromaddress\",...] \"toaddress\" [fee] [limit]"}, {"listtransactions", "List recent wallet transactions", "[\"account\"] [count] [from]"}, {"listunspent", "List unspent transaction outputs", "[minconf] [maxconf]"}, {"z_listunspent", "List unspent shielded notes", "[minconf] [maxconf]"}, {"z_getoperationstatus", "Get status of async z operations", "[\"operationid\",...]"}, {"z_getoperationresult", "Get result of completed z operations", "[\"operationid\",...]"}, {"z_listoperationids", "List all async z operation IDs", ""}, {"getwalletinfo", "Get wallet state info", ""}, {"backupwallet", "Back up wallet to a file", "\"destination\""}, {"dumpprivkey", "Dump private key for an address", "\"address\""}, {"importprivkey", "Import a private key into the wallet", "\"privkey\" [\"label\"] [rescan]"}, {"dumpwallet", "Dump all wallet keys to a file", "\"filename\""}, {"importwallet", "Import wallet from a dump file", "\"filename\""}, {"z_exportkey", "Export spending key for a z-address", "\"zaddr\""}, {"z_importkey", "Import a z-address spending key", "\"zkey\" [rescan] [startheight]"}, {"z_exportviewingkey", "Export viewing key for a z-address", "\"zaddr\""}, {"z_importviewingkey", "Import a z-address viewing key", "\"vkey\" [rescan] [startheight]"}, {"z_exportwallet", "Export all wallet keys (including z-keys) to file", "\"filename\""}, {"signmessage", "Sign a message with an address key", "\"address\" \"message\""}, {"settxfee", "Set the transaction fee per kB", "amount"}, {"walletpassphrase", "Unlock the wallet with passphrase", "\"passphrase\" timeout"}, {"walletlock", "Lock the wallet", ""}, {"encryptwallet", "Encrypt the wallet with a passphrase", "\"passphrase\""}, }; static const CmdEntry rawTxCmds[] = { {"createrawtransaction", "Create a raw transaction spending given inputs", "[{\"txid\":\"...\",\"vout\":n},...] {\"address\":amount,...}"}, {"decoderawtransaction", "Decode raw transaction hex string", "\"hexstring\""}, {"decodescript", "Decode a hex-encoded script", "\"hex\""}, {"getrawtransaction", "Get raw transaction data by txid", "\"txid\" [verbose]"}, {"sendrawtransaction", "Submit raw transaction to the network", "\"hexstring\" [allowhighfees]"}, {"signrawtransaction", "Sign a raw transaction with private keys", "\"hexstring\""}, {"fundrawtransaction", "Add inputs to meet output value", "\"hexstring\""}, }; static const CmdEntry utilCmds[] = { {"validateaddress", "Validate a transparent address", "\"address\""}, {"z_validateaddress", "Validate a z-address", "\"zaddr\""}, {"estimatefee", "Estimate fee for a transaction", "nblocks"}, {"verifymessage", "Verify a signed message", "\"address\" \"signature\" \"message\""}, {"createmultisig", "Create a multisig address", "nrequired [\"key\",...]"}, {"invalidateblock", "Mark a block as invalid", "\"hash\""}, {"reconsiderblock", "Reconsider a previously invalidated block", "\"hash\""}, }; struct CmdCategory { const char* name; const CmdEntry* commands; int count; }; static const CmdCategory categories[] = { {"Control", controlCmds, IM_ARRAYSIZE(controlCmds)}, {"Network", networkCmds, IM_ARRAYSIZE(networkCmds)}, {"Blockchain", blockchainCmds, IM_ARRAYSIZE(blockchainCmds)}, {"Mining", miningCmds, IM_ARRAYSIZE(miningCmds)}, {"Wallet", walletCmds, IM_ARRAYSIZE(walletCmds)}, {"Raw Transactions", rawTxCmds, IM_ARRAYSIZE(rawTxCmds)}, {"Utility", utilCmds, IM_ARRAYSIZE(utilCmds)}, }; 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') ImGui::SetTooltip(TR("console_click_insert_params"), cmd.name, cmd.params); else ImGui::SetTooltip(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::executeCommand(const std::string& cmd, rpc::RPCClient* rpc, rpc::RPCWorker* worker) { using namespace material; // Add to history (avoid duplicates) if (command_history_.empty() || command_history_.back() != cmd) { command_history_.push_back(cmd); if (command_history_.size() > 100) { command_history_.erase(command_history_.begin()); } } history_index_ = -1; // Echo command addLine("> " + cmd, COLOR_COMMAND); // Handle built-in commands if (cmd == "clear") { clear(); return; } if (cmd == "help") { addLine(TR("console_available_commands"), COLOR_INFO); addLine(TR("console_help_clear"), COLOR_RESULT); addLine(TR("console_help_help"), COLOR_RESULT); addLine("", COLOR_RESULT); addLine(TR("console_common_rpc"), COLOR_INFO); addLine(TR("console_help_getinfo"), COLOR_RESULT); addLine(TR("console_help_getbalance"), COLOR_RESULT); addLine(TR("console_help_gettotalbalance"), COLOR_RESULT); addLine(TR("console_help_getblockcount"), COLOR_RESULT); addLine(TR("console_help_getpeerinfo"), COLOR_RESULT); addLine(TR("console_help_setgenerate"), COLOR_RESULT); addLine(TR("console_help_getmininginfo"), COLOR_RESULT); addLine(TR("console_help_stop"), COLOR_RESULT); addLine("", COLOR_RESULT); addLine(TR("console_click_commands"), COLOR_INFO); addLine(TR("console_tab_completion"), COLOR_INFO); return; } // Execute RPC command if (!rpc || !rpc->isConnected()) { addLine(TR("console_not_connected"), COLOR_ERROR); return; } // Parse command and arguments (shell-like: handles quotes and JSON brackets) std::vector args; { size_t i = 0; size_t len = cmd.size(); while (i < len) { // Skip whitespace while (i < len && (cmd[i] == ' ' || cmd[i] == '\t')) i++; if (i >= len) break; std::string tok; if (cmd[i] == '"' || cmd[i] == '\'') { // Quoted string — collect until matching close quote char quote = cmd[i++]; while (i < len && cmd[i] != quote) tok += cmd[i++]; if (i < len) i++; // skip closing quote } else if (cmd[i] == '[' || cmd[i] == '{') { // JSON array/object — collect until matching bracket char open = cmd[i]; char close = (open == '[') ? ']' : '}'; int depth = 0; while (i < len) { if (cmd[i] == open) depth++; else if (cmd[i] == close) depth--; tok += cmd[i++]; if (depth == 0) break; } } else { // Unquoted token — collect until whitespace while (i < len && cmd[i] != ' ' && cmd[i] != '\t') tok += cmd[i++]; } if (!tok.empty()) args.push_back(tok); } } if (args.empty()) return; std::string method = args[0]; nlohmann::json params = nlohmann::json::array(); // Convert remaining args to JSON params for (size_t i = 1; i < args.size(); i++) { const std::string& arg = args[i]; // Try to parse as JSON first (handles objects, arrays, etc.) if (!arg.empty() && (arg[0] == '{' || arg[0] == '[')) { auto parsed = nlohmann::json::parse(arg, nullptr, false); if (!parsed.is_discarded()) { params.push_back(parsed); continue; } } // Try to parse as number or bool if (arg == "true") { params.push_back(true); } else if (arg == "false") { params.push_back(false); } else { try { if (arg.find('.') != std::string::npos) { params.push_back(std::stod(arg)); } else { params.push_back(std::stoll(arg)); } } catch (...) { // Keep as string params.push_back(arg); } } } // Execute RPC call on worker thread to avoid blocking UI if (worker) { // Capture 'this' for addLine calls in MainCb (runs on main thread, ConsoleTab outlives callbacks) auto self = this; worker->post([rpc, method, params, self]() -> rpc::RPCWorker::MainCb { std::string result_str; bool is_error = false; try { result_str = rpc->callRaw(method, params); if (result_str == "null") { result_str = "(no result)"; } } catch (const std::exception& e) { result_str = e.what(); is_error = true; } return [result_str, is_error, self]() { // Process results on main thread where ImGui colors are available using namespace material; if (is_error) { self->addLine("Error: " + result_str, COLOR_ERROR); return; } bool is_json = false; if (!result_str.empty()) { char first = result_str[0]; is_json = (first == '{' || first == '['); } ImU32 json_key_col = WithAlpha(Secondary(), 255); ImU32 json_str_col = WithAlpha(Success(), 255); ImU32 json_num_col = WithAlpha(Warning(), 255); ImU32 json_brace_col = IM_COL32(200, 200, 200, 150); std::istringstream stream(result_str); std::string line; while (std::getline(stream, line)) { if (is_json && !line.empty()) { std::string trimmed = line; size_t first = trimmed.find_first_not_of(" \t"); if (first != std::string::npos) trimmed = trimmed.substr(first); ImU32 lineCol = COLOR_RESULT; if (trimmed[0] == '{' || trimmed[0] == '}' || trimmed[0] == '[' || trimmed[0] == ']') { lineCol = json_brace_col; } else if (trimmed[0] == '\"') { size_t colon = trimmed.find("\": "); if (colon != std::string::npos || trimmed.find("\":") != std::string::npos) { lineCol = json_key_col; } else { lineCol = json_str_col; } } else if (std::isdigit(trimmed[0]) || trimmed[0] == '-') { lineCol = json_num_col; } else if (trimmed == "true," || trimmed == "false," || trimmed == "true" || trimmed == "false" || trimmed == "null," || trimmed == "null") { lineCol = json_num_col; } self->addLine(line, lineCol); } else { self->addLine(line, COLOR_RESULT); } } }; }); } else { // Fallback: synchronous execution if no worker available try { std::string result_str = rpc->callRaw(method, params); if (result_str == "null") result_str = "(no result)"; std::istringstream stream(result_str); std::string line; while (std::getline(stream, line)) { addLine(line, COLOR_RESULT); } } catch (const std::exception& e) { addLine("Error: " + std::string(e.what()), COLOR_ERROR); } } } void ConsoleTab::addLine(const std::string& line, ImU32 color) { std::lock_guard lock(lines_mutex_); lines_.push_back({line, color}); // Limit buffer size — adjust selection indices when lines are removed // from the front so the highlight stays on the text the user selected. int popped = 0; while (lines_.size() > 10000) { lines_.pop_front(); popped++; } if (popped > 0 && has_selection_) { sel_anchor_.line -= popped; sel_end_.line -= popped; if (sel_anchor_.line < 0 && sel_end_.line < 0) { // Entire selection was in the removed range has_selection_ = false; is_selecting_ = false; } else { if (sel_anchor_.line < 0) { sel_anchor_.line = 0; sel_anchor_.col = 0; } if (sel_end_.line < 0) { sel_end_.line = 0; sel_end_.col = 0; } } } // Track new output while user is scrolled up if (!auto_scroll_) { new_lines_since_scroll_++; } scroll_to_bottom_ = auto_scroll_; } void ConsoleTab::addCommandResult(const std::string& cmd, const std::string& result, bool is_error) { addLine("> " + cmd, COLOR_COMMAND); std::istringstream stream(result); std::string line; while (std::getline(stream, line)) { addLine(line, is_error ? COLOR_ERROR : COLOR_RESULT); } } void ConsoleTab::clear() { { std::lock_guard lock(lines_mutex_); lines_.clear(); last_daemon_output_size_ = 0; last_xmrig_output_size_ = 0; } // addLine() takes the lock itself, so call it outside the locked scope addLine(TR("console_cleared"), COLOR_INFO); } } // namespace ui } // namespace dragonx