From 57291ef28c951ea9367cdb24e8ca38e6b2c5e90a Mon Sep 17 00:00:00 2001 From: DanS Date: Wed, 1 Jul 2026 19:16:36 -0500 Subject: [PATCH] =?UTF-8?q?refactor(console):=20phase=205=20=E2=80=94=20de?= =?UTF-8?q?compose=20renderOutput=20into=20focused=20sub-steps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/ui/windows/console_tab.cpp | 407 +++++++++++++++++---------------- src/ui/windows/console_tab.h | 10 +- 2 files changed, 218 insertions(+), 199 deletions(-) diff --git a/src/ui/windows/console_tab.cpp b/src/ui/windows/console_tab.cpp index 131d214..e5444d6 100644 --- a/src/ui/windows/console_tab.cpp +++ b/src/ui/windows/console_tab.cpp @@ -599,19 +599,95 @@ void ConsoleTab::renderOutput() output_scroll_y_ = ImGui::GetScrollY(); 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(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(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(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}; - bool has_text_filter = !outputFilter.text.empty(); + hasTextFilter = !outputFilter.text.empty(); // Lowercased needle for in-line match highlighting. - std::string filter_lower; - if (has_text_filter) { - filter_lower = std::string(filter_text_); - std::transform(filter_lower.begin(), filter_lower.end(), filter_lower.begin(), + if (hasTextFilter) { + filterLower = std::string(filter_text_); + std::transform(filterLower.begin(), filterLower.end(), filterLower.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); } - bool has_filter = has_text_filter || !outputFilter.daemonMessagesEnabled || + bool has_filter = hasTextFilter || !outputFilter.daemonMessagesEnabled || !outputFilter.rpcTraceEnabled || !outputFilter.appMessagesEnabled || outputFilter.errorsOnly; visible_indices_.clear(); @@ -622,120 +698,88 @@ void ConsoleTab::renderOutput() } } else { // No filter - all lines are visible - for (int i = 0; i < static_cast(model_.size()); i++) { + for (int i = 0; i < static_cast(model_.size()); i++) visible_indices_.push_back(i); - } } - int visible_count = static_cast(visible_indices_.size()); - filter_match_count_ = has_text_filter ? visible_count : 0; - - // Calculate wrapped heights AND build sub-row segments for each visible line. - // Each segment records which bytes of the source text appear on that visual - // row, so hit-testing and selection highlight can map screen positions to - // exact character offsets. - float wrap_width = ClampConsoleWrapWidth(ImGui::GetContentRegionAvail().x, padX); - - ImFont* font = ImGui::GetFont(); - float fontSize = ImGui::GetFontSize(); - - ImFontConsoleMeasure measure(font, fontSize); - layout_ = BuildConsoleLayout( - visible_count, - [this](int vi) -> const std::string& { return model_[visible_indices_[vi]].text; }, - wrap_width, line_height, interLineGap, measure); - - // Use raw IO for mouse handling to bypass child window event consumption + filter_match_count_ = hasTextFilter ? static_cast(visible_indices_.size()) : 0; +} + +void ConsoleTab::handleOutputInteraction(ImVec2 mousePos, bool mouseInOutput) +{ 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) { + + // 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(); } - // 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) { + // Text-selection cursor while hovering. + if (mouseInOutput) { ImGui::SetMouseCursor(ImGuiMouseCursor_TextInput); } - - // Mouse press - start selection (use raw IO mouse state) - if (mouse_in_output && io.MouseClicked[0]) { - selection_.beginDrag(screenToTextPos(mouse_pos)); + // Selection drag lifecycle (continues even if the mouse leaves the window). + if (mouseInOutput && io.MouseClicked[0]) { + selection_.beginDrag(screenToTextPos(mousePos)); } - - // Mouse drag - extend selection (continue even if mouse leaves the window) 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]) { - selection_.endDrag(screenToTextPos(mouse_pos)); + selection_.endDrag(screenToTextPos(mousePos)); } - // Ctrl+C / Ctrl+A - if (mouse_in_output || selection_.active()) { + if (mouseInOutput || selection_.active()) { if (io.KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_C)) { std::string selected = selectedText(); - if (!selected.empty()) { - ImGui::SetClipboardText(selected.c_str()); - } + if (!selected.empty()) ImGui::SetClipboardText(selected.c_str()); } if (io.KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_A) && !model_.empty()) { selection_.selectAll(static_cast(model_.size()), static_cast(model_.back().text.size())); } } +} - // Get selection bounds (ordered) +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(visible_indices_.size()); + + // Ordered selection bounds (only consulted when selection_.active()). ConsoleTextPos sel_start_pos = selection_.rangeStart(); ConsoleTextPos sel_end_pos = selection_.rangeEnd(); - - // Render lines with selection highlighting. - // Each line is split into pre-computed wrap segments rendered individually - // via AddText so that hit-testing and highlights map 1:1 to visual positions. + + // 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 + + // Find first visible line using binary search. int first_visible = 0; if (!layout_.cumulativeY.empty()) { int lo = 0, hi = static_cast(layout_.cumulativeY.size()) - 1; while (lo < hi) { int mid = (lo + hi) / 2; float line_bottom = layout_.cumulativeY[mid] + layout_.heights[mid]; - if (line_bottom < visible_top) { - lo = mid + 1; - } else { - hi = mid; - } + 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) + + // Invisible spacer for lines before the first visible one (keeps scroll correct). if (first_visible > 0 && first_visible < static_cast(layout_.cumulativeY.size())) { ImGui::Dummy(ImVec2(0, layout_.cumulativeY[first_visible])); } - + ImDrawList* dl = ImGui::GetWindowDrawList(); ImU32 selColor = WithAlpha(Secondary(), 80); - - // Render visible lines + int last_rendered_vi = first_visible - 1; for (int vi = first_visible; vi < visible_count; vi++) { if (vi < static_cast(layout_.cumulativeY.size()) && @@ -743,7 +787,7 @@ void ConsoleTab::renderOutput() break; } last_rendered_vi = vi; - + int i = visible_indices_[vi]; const auto& line = model_[i]; const auto& segs = layout_.segments[vi]; @@ -799,23 +843,23 @@ void ConsoleTab::renderOutput() selByteEnd = (i == sel_end_pos.line) ? sel_end_pos.col : static_cast(line.text.size()); } - + for (const auto& seg : segs) { float rowY = lineOrigin.y + seg.yOffset; const char* segStart = line.text.c_str() + seg.byteStart; const char* segEnd = line.text.c_str() + seg.byteEnd; - if (s_scanline_enabled && line_height > 0.0f) { - int rowIndex = static_cast(std::floor((layout_.cumulativeY[vi] + seg.yOffset) / line_height + 0.5f)); + if (s_scanline_enabled && lineHeight > 0.0f) { + int rowIndex = static_cast(std::floor((layout_.cumulativeY[vi] + seg.yOffset) / lineHeight + 0.5f)); scanline_rows_.push_back({rowY, rowY + seg.height, rowIndex}); } - + // Selection highlight for this sub-row if (lineSelected && selByteStart < seg.byteEnd && selByteEnd > seg.byteStart) { int hlStart = std::max(selByteStart, seg.byteStart) - seg.byteStart; int hlEnd = std::min(selByteEnd, seg.byteEnd) - seg.byteStart; int segLen = seg.byteEnd - seg.byteStart; - + float xStart = 0.0f; if (hlStart > 0) { xStart = font->CalcTextSizeA(fontSize, FLT_MAX, 0, @@ -827,27 +871,27 @@ void ConsoleTab::renderOutput() if (hlEnd >= segLen && selByteEnd >= static_cast(line.text.size())) { xEnd = std::max(xEnd + 8.0f, ImGui::GetWindowWidth()); } - + dl->AddRectFilled( ImVec2(lineOrigin.x + xStart, rowY), ImVec2(lineOrigin.x + xEnd, rowY + seg.height), selColor); } - + // Filter match highlight (case-insensitive) within this segment. - if (has_text_filter && !filter_lower.empty() && seg.byteStart < seg.byteEnd) { + 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(std::tolower(c)); }); 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(pos)).x; float xe = font->CalcTextSizeA(fontSize, FLT_MAX, 0, segStart, - segStart + static_cast(pos + filter_lower.size())).x; + segStart + static_cast(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 += filter_lower.size(); + pos += filterLower.size(); } } @@ -858,11 +902,11 @@ void ConsoleTab::renderOutput() channelTextColor(line.channel), segStart, segEnd); } } - + // Advance ImGui cursor by the total wrapped height of this line ImGui::Dummy(ImVec2(0, totalH)); } - + // Add spacer for lines after last visible (to maintain correct content height) if (last_rendered_vi >= 0 && last_rendered_vi < visible_count - 1) { float rendered_height = (last_rendered_vi < static_cast(layout_.cumulativeY.size())) @@ -873,118 +917,85 @@ void ConsoleTab::renderOutput() 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)); - } +void ConsoleTab::drawOutputContextMenu() +{ + if (!ImGui::BeginPopupContextWindow("ConsoleContextMenu")) return; - // 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"), - visible_count, model_.size()); - ImVec2 indicatorPos = ImGui::GetCursorScreenPos(); - ImGui::GetWindowDrawList()->AddText(indicatorPos, - WithAlpha(Warning(), 180), filterBuf); - ImGui::Dummy(ImVec2(0, ImGui::GetTextLineHeight())); - } - - // Capture the hash/address under the cursor on right-click, for "Copy value". - if (ImGui::IsMouseClicked(ImGuiMouseButton_Right)) { - ConsoleTextPos tp = screenToTextPos(ImGui::GetMousePos()); - context_token_.clear(); - if (tp.line >= 0 && tp.line < static_cast(model_.size())) - context_token_ = extractCopyableToken(model_[tp.line].text, tp.col); - } - - // Right-click context menu - if (ImGui::BeginPopupContextWindow("ConsoleContextMenu")) { - if (!context_token_.empty()) { - std::string shortTok = context_token_.size() > 22 - ? context_token_.substr(0, 12) + "\xE2\x80\xA6" + context_token_.substr(context_token_.size() - 6) - : context_token_; - std::string label = std::string(TR("console_copy_value")) + " \"" + shortTok + "\""; - if (ImGui::MenuItem(label.c_str())) { - ImGui::SetClipboardText(context_token_.c_str()); - } - ImGui::Separator(); - } - if (ImGui::MenuItem(TR("copy"), "Ctrl+C", false, selection_.active())) { - std::string selected = selectedText(); - if (!selected.empty()) { - ImGui::SetClipboardText(selected.c_str()); - } - } - if (ImGui::MenuItem(TR("console_select_all"), "Ctrl+A")) { - if (!model_.empty()) { - selection_.selectAll(static_cast(model_.size()), - static_cast(model_.back().text.size())); - } + 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("console_clear_console"))) { - // View-only clear (main thread) — drop the visible lines and the selection. - model_.clear(); - selection_.clear(); - } - ImGui::EndPopup(); } - - // "New output" indicator when user is scrolled up and new lines arrived - if (!scroll_.autoScroll() && scroll_.newLines() > 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"), 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("copy"), "Ctrl+C", false, selection_.active())) { + std::string selected = selectedText(); + if (!selected.empty()) { + ImGui::SetClipboardText(selected.c_str()); } } + if (ImGui::MenuItem(TR("console_select_all"), "Ctrl+A")) { + if (!model_.empty()) { + selection_.selectAll(static_cast(model_.size()), + static_cast(model_.back().text.size())); + } + } + ImGui::Separator(); + if (ImGui::MenuItem(TR("console_clear_console"))) { + // View-only clear (main thread) — drop the visible lines and the selection. + model_.clear(); + selection_.clear(); + } + ImGui::EndPopup(); +} + +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 diff --git a/src/ui/windows/console_tab.h b/src/ui/windows/console_tab.h index 01fbcb1..654fc33 100644 --- a/src/ui/windows/console_tab.h +++ b/src/ui/windows/console_tab.h @@ -101,9 +101,17 @@ private: void addFormattedResult(const std::string& result, bool is_error); void renderStatusHeader(ConsoleCommandExecutor& exec); void renderToolbar(ConsoleCommandExecutor& exec); - void renderOutput(); void renderInput(ConsoleCommandExecutor& exec); 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. // (View-side because it needs layout_ + output_origin_; feeds the selection controller.)