refactor(console): phase 5 — decompose renderOutput into focused sub-steps

renderOutput() was a ~410-line method doing filter-building, mouse/keyboard
interaction, the per-line draw loop, the new-output pill, and the right-click
context menu all inline. Split it into a thin orchestrator that calls five
single-purpose private methods (pure code motion, no behavior change):

  - computeVisibleLines()      -> the filter -> visible_indices_ + match count
  - handleOutputInteraction()  -> wheel-up, selection drag lifecycle, Ctrl+C/A
  - drawVisibleLines()         -> accent bars, JSON indent guides, selection +
                                  filter highlight, scanline capture, text
  - drawOutputContextMenu()    -> the right-click menu
  - drawNewOutputIndicator()   -> the jump-to-bottom pill

Ordering and every operation are preserved exactly (interaction still runs
after layout build and before the draw; selection bounds are recomputed post-
interaction; scanline_rows_ cleared before the draw pushes to it). Full-node +
lite build clean; ctest green; source hygiene clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 19:16:36 -05:00
parent 18c1de87db
commit 57291ef28c
2 changed files with 218 additions and 199 deletions

View File

@@ -599,19 +599,95 @@ void ConsoleTab::renderOutput()
output_scroll_y_ = ImGui::GetScrollY(); output_scroll_y_ = ImGui::GetScrollY();
scanline_rows_.clear(); scanline_rows_.clear();
// Build filtered line index list BEFORE mouse handling (so screenToTextPos works) // 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().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 (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, ConsoleOutputFilter outputFilter{filter_text_, s_daemon_messages_enabled,
s_errors_only_enabled, s_rpc_trace_enabled, s_errors_only_enabled, s_rpc_trace_enabled,
s_app_messages_enabled}; s_app_messages_enabled};
bool has_text_filter = !outputFilter.text.empty(); hasTextFilter = !outputFilter.text.empty();
// Lowercased needle for in-line match highlighting. // Lowercased needle for in-line match highlighting.
std::string filter_lower; if (hasTextFilter) {
if (has_text_filter) { filterLower = std::string(filter_text_);
filter_lower = std::string(filter_text_); std::transform(filterLower.begin(), filterLower.end(), filterLower.begin(),
std::transform(filter_lower.begin(), filter_lower.end(), filter_lower.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); }); [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
} }
bool has_filter = has_text_filter || !outputFilter.daemonMessagesEnabled || bool has_filter = hasTextFilter || !outputFilter.daemonMessagesEnabled ||
!outputFilter.rpcTraceEnabled || !outputFilter.appMessagesEnabled || !outputFilter.rpcTraceEnabled || !outputFilter.appMessagesEnabled ||
outputFilter.errorsOnly; outputFilter.errorsOnly;
visible_indices_.clear(); visible_indices_.clear();
@@ -622,120 +698,88 @@ void ConsoleTab::renderOutput()
} }
} else { } else {
// No filter - all lines are visible // No filter - all lines are visible
for (int i = 0; i < static_cast<int>(model_.size()); i++) { for (int i = 0; i < static_cast<int>(model_.size()); i++)
visible_indices_.push_back(i); visible_indices_.push_back(i);
}
} }
int visible_count = static_cast<int>(visible_indices_.size()); filter_match_count_ = hasTextFilter ? static_cast<int>(visible_indices_.size()) : 0;
filter_match_count_ = has_text_filter ? visible_count : 0; }
// Calculate wrapped heights AND build sub-row segments for each visible line. void ConsoleTab::handleOutputInteraction(ImVec2 mousePos, bool mouseInOutput)
// Each segment records which bytes of the source text appear on that visual {
// row, so hit-testing and selection highlight can map screen positions to
// exact character offsets.
float wrap_width = ClampConsoleWrapWidth(ImGui::GetContentRegionAvail().x, padX);
ImFont* font = ImGui::GetFont();
float fontSize = ImGui::GetFontSize();
ImFontConsoleMeasure measure(font, fontSize);
layout_ = BuildConsoleLayout(
visible_count,
[this](int vi) -> const std::string& { return model_[visible_indices_[vi]].text; },
wrap_width, line_height, interLineGap, measure);
// Use raw IO for mouse handling to bypass child window event consumption
ImGuiIO& io = ImGui::GetIO(); ImGuiIO& io = ImGui::GetIO();
ImVec2 mouse_pos = io.MousePos;
// Disable auto-scroll when the user scrolls up (wheel). Scrolling back down to the very
// Manual hit test: is mouse within this child window? // bottom re-enables it; that position check happens after EndChild() in render(), and is
ImVec2 win_min = ImGui::GetWindowPos(); // skipped on the wheel-up frame (the scroll position hasn't caught up yet).
ImVec2 win_max = ImVec2(win_min.x + ImGui::GetWindowWidth(), if (mouseInOutput && io.MouseWheel > 0.0f) {
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) {
scroll_.onUserScrolledUp(); scroll_.onUserScrolledUp();
} }
// Scrolling down to the very bottom re-enables auto-scroll. // Text-selection cursor while hovering.
// Actual position check happens after EndChild() using captured if (mouseInOutput) {
// 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); ImGui::SetMouseCursor(ImGuiMouseCursor_TextInput);
} }
// Selection drag lifecycle (continues even if the mouse leaves the window).
// Mouse press - start selection (use raw IO mouse state) if (mouseInOutput && io.MouseClicked[0]) {
if (mouse_in_output && io.MouseClicked[0]) { selection_.beginDrag(screenToTextPos(mousePos));
selection_.beginDrag(screenToTextPos(mouse_pos));
} }
// Mouse drag - extend selection (continue even if mouse leaves the window)
if (selection_.dragging() && io.MouseDown[0]) { if (selection_.dragging() && io.MouseDown[0]) {
selection_.updateDrag(screenToTextPos(mouse_pos)); selection_.updateDrag(screenToTextPos(mousePos));
} }
// Mouse release - finalize selection
if (selection_.dragging() && io.MouseReleased[0]) { if (selection_.dragging() && io.MouseReleased[0]) {
selection_.endDrag(screenToTextPos(mouse_pos)); selection_.endDrag(screenToTextPos(mousePos));
} }
// Ctrl+C / Ctrl+A // Ctrl+C / Ctrl+A
if (mouse_in_output || selection_.active()) { if (mouseInOutput || selection_.active()) {
if (io.KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_C)) { if (io.KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_C)) {
std::string selected = selectedText(); std::string selected = selectedText();
if (!selected.empty()) { if (!selected.empty()) ImGui::SetClipboardText(selected.c_str());
ImGui::SetClipboardText(selected.c_str());
}
} }
if (io.KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_A) && !model_.empty()) { if (io.KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_A) && !model_.empty()) {
selection_.selectAll(static_cast<int>(model_.size()), selection_.selectAll(static_cast<int>(model_.size()),
static_cast<int>(model_.back().text.size())); static_cast<int>(model_.back().text.size()));
} }
} }
}
// Get selection bounds (ordered) 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_start_pos = selection_.rangeStart();
ConsoleTextPos sel_end_pos = selection_.rangeEnd(); ConsoleTextPos sel_end_pos = selection_.rangeEnd();
// Render lines with selection highlighting. // Cull to the visible viewport rows for cheap scrolling over large buffers.
// 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 scroll_y = ImGui::GetScrollY();
float window_height = ImGui::GetWindowHeight(); float window_height = ImGui::GetWindowHeight();
float visible_top = scroll_y; float visible_top = scroll_y;
float visible_bottom = scroll_y + window_height; float visible_bottom = scroll_y + window_height;
// Find first visible line using binary search // Find first visible line using binary search.
int first_visible = 0; int first_visible = 0;
if (!layout_.cumulativeY.empty()) { if (!layout_.cumulativeY.empty()) {
int lo = 0, hi = static_cast<int>(layout_.cumulativeY.size()) - 1; int lo = 0, hi = static_cast<int>(layout_.cumulativeY.size()) - 1;
while (lo < hi) { while (lo < hi) {
int mid = (lo + hi) / 2; int mid = (lo + hi) / 2;
float line_bottom = layout_.cumulativeY[mid] + layout_.heights[mid]; float line_bottom = layout_.cumulativeY[mid] + layout_.heights[mid];
if (line_bottom < visible_top) { if (line_bottom < visible_top) lo = mid + 1;
lo = mid + 1; else hi = mid;
} else {
hi = mid;
}
} }
first_visible = lo; first_visible = lo;
} }
// Add invisible spacer for lines before first visible (for correct scroll) // Invisible spacer for lines before the first visible one (keeps scroll correct).
if (first_visible > 0 && first_visible < static_cast<int>(layout_.cumulativeY.size())) { if (first_visible > 0 && first_visible < static_cast<int>(layout_.cumulativeY.size())) {
ImGui::Dummy(ImVec2(0, layout_.cumulativeY[first_visible])); ImGui::Dummy(ImVec2(0, layout_.cumulativeY[first_visible]));
} }
ImDrawList* dl = ImGui::GetWindowDrawList(); ImDrawList* dl = ImGui::GetWindowDrawList();
ImU32 selColor = WithAlpha(Secondary(), 80); ImU32 selColor = WithAlpha(Secondary(), 80);
// Render visible lines
int last_rendered_vi = first_visible - 1; int last_rendered_vi = first_visible - 1;
for (int vi = first_visible; vi < visible_count; vi++) { for (int vi = first_visible; vi < visible_count; vi++) {
if (vi < static_cast<int>(layout_.cumulativeY.size()) && if (vi < static_cast<int>(layout_.cumulativeY.size()) &&
@@ -743,7 +787,7 @@ void ConsoleTab::renderOutput()
break; break;
} }
last_rendered_vi = vi; last_rendered_vi = vi;
int i = visible_indices_[vi]; int i = visible_indices_[vi];
const auto& line = model_[i]; const auto& line = model_[i];
const auto& segs = layout_.segments[vi]; const auto& segs = layout_.segments[vi];
@@ -799,23 +843,23 @@ void ConsoleTab::renderOutput()
selByteEnd = (i == sel_end_pos.line) ? sel_end_pos.col selByteEnd = (i == sel_end_pos.line) ? sel_end_pos.col
: static_cast<int>(line.text.size()); : static_cast<int>(line.text.size());
} }
for (const auto& seg : segs) { for (const auto& seg : segs) {
float rowY = lineOrigin.y + seg.yOffset; float rowY = lineOrigin.y + seg.yOffset;
const char* segStart = line.text.c_str() + seg.byteStart; const char* segStart = line.text.c_str() + seg.byteStart;
const char* segEnd = line.text.c_str() + seg.byteEnd; const char* segEnd = line.text.c_str() + seg.byteEnd;
if (s_scanline_enabled && line_height > 0.0f) { if (s_scanline_enabled && lineHeight > 0.0f) {
int rowIndex = static_cast<int>(std::floor((layout_.cumulativeY[vi] + seg.yOffset) / line_height + 0.5f)); int rowIndex = static_cast<int>(std::floor((layout_.cumulativeY[vi] + seg.yOffset) / lineHeight + 0.5f));
scanline_rows_.push_back({rowY, rowY + seg.height, rowIndex}); scanline_rows_.push_back({rowY, rowY + seg.height, rowIndex});
} }
// Selection highlight for this sub-row // Selection highlight for this sub-row
if (lineSelected && selByteStart < seg.byteEnd && selByteEnd > seg.byteStart) { if (lineSelected && selByteStart < seg.byteEnd && selByteEnd > seg.byteStart) {
int hlStart = std::max(selByteStart, seg.byteStart) - seg.byteStart; int hlStart = std::max(selByteStart, seg.byteStart) - seg.byteStart;
int hlEnd = std::min(selByteEnd, seg.byteEnd) - seg.byteStart; int hlEnd = std::min(selByteEnd, seg.byteEnd) - seg.byteStart;
int segLen = seg.byteEnd - seg.byteStart; int segLen = seg.byteEnd - seg.byteStart;
float xStart = 0.0f; float xStart = 0.0f;
if (hlStart > 0) { if (hlStart > 0) {
xStart = font->CalcTextSizeA(fontSize, FLT_MAX, 0, xStart = font->CalcTextSizeA(fontSize, FLT_MAX, 0,
@@ -827,27 +871,27 @@ void ConsoleTab::renderOutput()
if (hlEnd >= segLen && selByteEnd >= static_cast<int>(line.text.size())) { if (hlEnd >= segLen && selByteEnd >= static_cast<int>(line.text.size())) {
xEnd = std::max(xEnd + 8.0f, ImGui::GetWindowWidth()); xEnd = std::max(xEnd + 8.0f, ImGui::GetWindowWidth());
} }
dl->AddRectFilled( dl->AddRectFilled(
ImVec2(lineOrigin.x + xStart, rowY), ImVec2(lineOrigin.x + xStart, rowY),
ImVec2(lineOrigin.x + xEnd, rowY + seg.height), ImVec2(lineOrigin.x + xEnd, rowY + seg.height),
selColor); selColor);
} }
// Filter match highlight (case-insensitive) within this segment. // Filter match highlight (case-insensitive) within this segment.
if (has_text_filter && !filter_lower.empty() && seg.byteStart < seg.byteEnd) { if (hasTextFilter && !filterLower.empty() && seg.byteStart < seg.byteEnd) {
std::string segLower(segStart, segEnd); std::string segLower(segStart, segEnd);
std::transform(segLower.begin(), segLower.end(), segLower.begin(), std::transform(segLower.begin(), segLower.end(), segLower.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); }); [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
size_t pos = 0; size_t pos = 0;
while ((pos = segLower.find(filter_lower, pos)) != std::string::npos) { 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 xs = font->CalcTextSizeA(fontSize, FLT_MAX, 0, segStart, segStart + static_cast<int>(pos)).x;
float xe = font->CalcTextSizeA(fontSize, FLT_MAX, 0, segStart, float xe = font->CalcTextSizeA(fontSize, FLT_MAX, 0, segStart,
segStart + static_cast<int>(pos + filter_lower.size())).x; segStart + static_cast<int>(pos + filterLower.size())).x;
dl->AddRectFilled(ImVec2(lineOrigin.x + xs, rowY), dl->AddRectFilled(ImVec2(lineOrigin.x + xs, rowY),
ImVec2(lineOrigin.x + xe, rowY + seg.height), ImVec2(lineOrigin.x + xe, rowY + seg.height),
IM_COL32(255, 210, 0, 70)); IM_COL32(255, 210, 0, 70));
pos += filter_lower.size(); pos += filterLower.size();
} }
} }
@@ -858,11 +902,11 @@ void ConsoleTab::renderOutput()
channelTextColor(line.channel), segStart, segEnd); channelTextColor(line.channel), segStart, segEnd);
} }
} }
// Advance ImGui cursor by the total wrapped height of this line // Advance ImGui cursor by the total wrapped height of this line
ImGui::Dummy(ImVec2(0, totalH)); ImGui::Dummy(ImVec2(0, totalH));
} }
// Add spacer for lines after last visible (to maintain correct content height) // Add spacer for lines after last visible (to maintain correct content height)
if (last_rendered_vi >= 0 && last_rendered_vi < visible_count - 1) { if (last_rendered_vi >= 0 && last_rendered_vi < visible_count - 1) {
float rendered_height = (last_rendered_vi < static_cast<int>(layout_.cumulativeY.size())) float rendered_height = (last_rendered_vi < static_cast<int>(layout_.cumulativeY.size()))
@@ -873,118 +917,85 @@ void ConsoleTab::renderOutput()
ImGui::Dummy(ImVec2(0, remaining_height)); ImGui::Dummy(ImVec2(0, remaining_height));
} }
} }
}
ImGui::Unindent(padX);
ImGui::PopStyleVar();
// Bottom padding keeps the last line above the fade-out zone. void ConsoleTab::drawOutputContextMenu()
// Always present so that scrollMaxY stays stable when auto-scroll {
// toggles — otherwise the geometry shift clamps the user back to if (!ImGui::BeginPopupContextWindow("ConsoleContextMenu")) return;
// 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 if (!context_token_.empty()) {
// This ensures daemon output stays visible and scrolled to bottom std::string shortTok = context_token_.size() > 22
if (scroll_.autoScroll()) { ? context_token_.substr(0, 12) + "\xE2\x80\xA6" + context_token_.substr(context_token_.size() - 6)
ImGui::SetScrollHereY(1.0f); : context_token_;
scroll_.resetNewLines(); std::string label = std::string(TR("console_copy_value")) + " \"" + shortTok + "\"";
} if (ImGui::MenuItem(label.c_str())) {
ImGui::SetClipboardText(context_token_.c_str());
// Filter indicator (text filter only — daemon toggle is already visible in toolbar)
if (has_text_filter) {
char filterBuf[128];
snprintf(filterBuf, sizeof(filterBuf), TR("console_showing_lines"),
visible_count, model_.size());
ImVec2 indicatorPos = ImGui::GetCursorScreenPos();
ImGui::GetWindowDrawList()->AddText(indicatorPos,
WithAlpha(Warning(), 180), filterBuf);
ImGui::Dummy(ImVec2(0, ImGui::GetTextLineHeight()));
}
// Capture the hash/address under the cursor on right-click, for "Copy value".
if (ImGui::IsMouseClicked(ImGuiMouseButton_Right)) {
ConsoleTextPos tp = screenToTextPos(ImGui::GetMousePos());
context_token_.clear();
if (tp.line >= 0 && tp.line < static_cast<int>(model_.size()))
context_token_ = extractCopyableToken(model_[tp.line].text, tp.col);
}
// Right-click context menu
if (ImGui::BeginPopupContextWindow("ConsoleContextMenu")) {
if (!context_token_.empty()) {
std::string shortTok = context_token_.size() > 22
? context_token_.substr(0, 12) + "\xE2\x80\xA6" + context_token_.substr(context_token_.size() - 6)
: context_token_;
std::string label = std::string(TR("console_copy_value")) + " \"" + shortTok + "\"";
if (ImGui::MenuItem(label.c_str())) {
ImGui::SetClipboardText(context_token_.c_str());
}
ImGui::Separator();
}
if (ImGui::MenuItem(TR("copy"), "Ctrl+C", false, selection_.active())) {
std::string selected = selectedText();
if (!selected.empty()) {
ImGui::SetClipboardText(selected.c_str());
}
}
if (ImGui::MenuItem(TR("console_select_all"), "Ctrl+A")) {
if (!model_.empty()) {
selection_.selectAll(static_cast<int>(model_.size()),
static_cast<int>(model_.back().text.size()));
}
} }
ImGui::Separator(); 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();
} }
if (ImGui::MenuItem(TR("copy"), "Ctrl+C", false, selection_.active())) {
// "New output" indicator when user is scrolled up and new lines arrived std::string selected = selectedText();
if (!scroll_.autoScroll() && scroll_.newLines() > 0) { if (!selected.empty()) {
float indicW = 140.0f; ImGui::SetClipboardText(selected.c_str());
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();
} }
} }
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 ConsoleTextPos ConsoleTab::screenToTextPos(ImVec2 screen_pos) const

View File

@@ -101,9 +101,17 @@ private:
void addFormattedResult(const std::string& result, bool is_error); void addFormattedResult(const std::string& result, bool is_error);
void renderStatusHeader(ConsoleCommandExecutor& exec); void renderStatusHeader(ConsoleCommandExecutor& exec);
void renderToolbar(ConsoleCommandExecutor& exec); void renderToolbar(ConsoleCommandExecutor& exec);
void renderOutput();
void renderInput(ConsoleCommandExecutor& exec); void renderInput(ConsoleCommandExecutor& exec);
void renderCommandsPopup(); void renderCommandsPopup();
// renderOutput() orchestrates the scrollable output area; these are its sub-steps:
void renderOutput();
void computeVisibleLines(bool& hasTextFilter, std::string& filterLower); // -> visible_indices_
void handleOutputInteraction(ImVec2 mousePos, bool mouseInOutput); // wheel/selection/keys
void drawVisibleLines(float padX, float lineHeight, bool hasTextFilter,
const std::string& filterLower); // bars/guides/highlight/text
void drawNewOutputIndicator(); // jump-to-bottom pill
void drawOutputContextMenu(); // right-click menu
// Map a screen point to a raw {line, col} position via this frame's wrapped layout. // Map a screen point to a raw {line, col} position via this frame's wrapped layout.
// (View-side because it needs layout_ + output_origin_; feeds the selection controller.) // (View-side because it needs layout_ + output_origin_; feeds the selection controller.)