- Replace all hardcoded English strings with TR() translation keys across every tab, dialog, and component (~20 UI files) - Expand all 8 language files (de, es, fr, ja, ko, pt, ru, zh) with complete translations (~37k lines added) - Improve i18n loader with exe-relative path fallback and English base fallback for missing keys - Add pool-side hashrate polling via pool stats API in xmrig_manager - Introduce Layout::beginFrame() per-frame caching and refresh balance layout config only on schema generation change - Offload daemon output parsing to worker thread - Add CJK subset fallback font for Chinese/Japanese/Korean glyphs
1859 lines
80 KiB
C++
1859 lines
80 KiB
C++
// 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 <imgui.h>
|
|
#include <cstring>
|
|
#include <sstream>
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
#include <unordered_set>
|
|
|
|
namespace dragonx {
|
|
namespace ui {
|
|
|
|
// Static color definitions — defaults; overridden from ui.toml in constructor
|
|
ImU32 ConsoleTab::COLOR_COMMAND = IM_COL32(191, 209, 229, 255);
|
|
ImU32 ConsoleTab::COLOR_RESULT = IM_COL32(200, 200, 200, 255);
|
|
ImU32 ConsoleTab::COLOR_ERROR = IM_COL32(246, 71, 64, 255);
|
|
ImU32 ConsoleTab::COLOR_DAEMON = IM_COL32(160, 160, 160, 180);
|
|
ImU32 ConsoleTab::COLOR_INFO = IM_COL32(191, 209, 229, 255);
|
|
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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<int>(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<int>(lines_.size()); i++) {
|
|
visible_indices_.push_back(i);
|
|
}
|
|
}
|
|
int visible_count = static_cast<int>(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<int>(cur - textStart);
|
|
int byteEnd = static_cast<int>(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<int>(lines_.size()) - 1,
|
|
static_cast<int>(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<int>(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<int>(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<int>(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<int>(line.text.size());
|
|
}
|
|
|
|
for (const auto& seg : segs) {
|
|
float rowY = lineOrigin.y + seg.yOffset;
|
|
const char* segStart = line.text.c_str() + seg.byteStart;
|
|
const char* segEnd = line.text.c_str() + seg.byteEnd;
|
|
|
|
// Selection highlight for this sub-row
|
|
if (lineSelected && selByteStart < seg.byteEnd && selByteEnd > seg.byteStart) {
|
|
int hlStart = std::max(selByteStart, seg.byteStart) - seg.byteStart;
|
|
int hlEnd = std::min(selByteEnd, seg.byteEnd) - seg.byteStart;
|
|
int segLen = seg.byteEnd - seg.byteStart;
|
|
|
|
float xStart = 0.0f;
|
|
if (hlStart > 0) {
|
|
xStart = font->CalcTextSizeA(fontSize, FLT_MAX, 0,
|
|
segStart, segStart + hlStart).x;
|
|
}
|
|
float xEnd = font->CalcTextSizeA(fontSize, FLT_MAX, 0,
|
|
segStart, segStart + hlEnd).x;
|
|
// Extend to window edge when selection reaches end of segment
|
|
if (hlEnd >= segLen && selByteEnd >= static_cast<int>(line.text.size())) {
|
|
xEnd = std::max(xEnd + 8.0f, ImGui::GetWindowWidth());
|
|
}
|
|
|
|
dl->AddRectFilled(
|
|
ImVec2(lineOrigin.x + xStart, rowY),
|
|
ImVec2(lineOrigin.x + xEnd, rowY + seg.height),
|
|
selColor);
|
|
}
|
|
|
|
// 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<int>(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<int>(lines_.size()) - 1,
|
|
static_cast<int>(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<int>(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<int>(visible_indices_.size())) {
|
|
visible_line = static_cast<int>(visible_indices_.size()) - 1;
|
|
pos.line = visible_indices_[visible_line];
|
|
pos.col = static_cast<int>(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<int>(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<int>(lines_.size())) return "";
|
|
|
|
// Build a set of visible line indices for quick lookup
|
|
// (Only copy visible lines when filtering is active)
|
|
std::unordered_set<int> 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<int>(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<int>(text.size());
|
|
|
|
if (i == start.line) col_start = std::min(start.col, static_cast<int>(text.size()));
|
|
if (i == end.line) col_end = std::min(end.col, static_cast<int>(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<ConsoleTab*>(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<int>(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<int>(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<const char*> 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<std::string> 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<std::mutex> 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<std::mutex> 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
|