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