stan 3 nedēļas atpakaļ
vecāks
revīzija
23ea83aa0e

+ 4 - 5
.gitignore

@@ -1,11 +1,10 @@
-build/
-.vs/
-.idea/
-.vscode/
+/build/
+/.vs/
+/.idea/
+/.vscode/
 *.user
 *.suo
 *.userosscache
 *.sln.docstates
 *.aps
-logs/
 *.log

+ 130 - 0
Sunrise/src/core/ui/modules/logs/filters/logs_filter_controls.cpp

@@ -0,0 +1,130 @@
+#include "logs_filter_controls.h"
+
+#include <algorithm>
+#include <array>
+#include <cstddef>
+#include <imgui.h>
+#include <string_view>
+
+#include "../../../components/filter/ui_filter_component.h"
+#include "../../../scaling/dpi/ui_dpi_scaling.h"
+
+namespace sunrise::core::ui::modules::logs::internal::filters {
+namespace {
+
+/** One null byte lets Dear ImGui edit the full text-filter buffer. */
+constexpr std::size_t kInputTerminatorBytes = 1;
+/** 140 pixels fit every channel name without crowding the row. */
+constexpr float kChannelFilterWidth = 140.0F;
+/** 110 pixels fit every severity name without crowding the row. */
+constexpr float kLevelFilterWidth = 110.0F;
+
+struct ChannelChoice {
+    const char* label;
+    bool filtered;
+    log::Channel channel;
+};
+
+struct LevelChoice {
+    const char* label;
+    bool filtered;
+    log::Level level;
+};
+
+/** Ordered choices map the channel combo index to one exact logger channel. */
+constexpr std::array<ChannelChoice, 6> kChannelChoices{{
+    {"All channels", false, log::Channel::core},
+    {"Core", true, log::Channel::core},
+    {"Client", true, log::Channel::client},
+    {"State", true, log::Channel::state},
+    {"Server", true, log::Channel::server},
+    {"Middleware", true, log::Channel::middleware},
+}};
+/** Ordered choices map the level combo index to one exact emitted severity. */
+constexpr std::array<LevelChoice, 5> kLevelChoices{{
+    {"All levels", false, log::Level::error},
+    {"Error", true, log::Level::error},
+    {"Warn", true, log::Level::warn},
+    {"Info", true, log::Level::info},
+    {"Debug", true, log::Level::debug},
+}};
+
+struct SelectionState {
+    std::size_t channelChoice{};
+    std::size_t levelChoice{};
+    std::array<char, log::view::kTextFilterCapacity + kInputTerminatorBytes> text{};
+};
+
+SelectionState g_selection;
+
+/** @return Current query text from the fixed edit buffer. */
+[[nodiscard]] std::string_view text_query() noexcept {
+    const auto end = std::find(g_selection.text.begin(), g_selection.text.end(), '\0');
+    return {g_selection.text.data(), static_cast<std::size_t>(end - g_selection.text.begin())};
+}
+
+} // namespace
+
+/** Draws the exact-channel selector and stores the selection. */
+void draw_channel() noexcept {
+    ImGui::SetNextItemWidth(scaling::dpi::pixels(kChannelFilterWidth));
+    if (!ImGui::BeginCombo("##log_channel", kChannelChoices[g_selection.channelChoice].label)) {
+        return;
+    }
+    for (std::size_t index = 0; index < kChannelChoices.size(); ++index) {
+        const bool selected = index == g_selection.channelChoice;
+        if (ImGui::Selectable(kChannelChoices[index].label, selected)) {
+            g_selection.channelChoice = index;
+        }
+        if (selected) {
+            ImGui::SetItemDefaultFocus();
+        }
+    }
+    ImGui::EndCombo();
+}
+
+/** Draws the exact-level selector and stores the selection. */
+void draw_level() noexcept {
+    ImGui::SetNextItemWidth(scaling::dpi::pixels(kLevelFilterWidth));
+    if (!ImGui::BeginCombo("##log_level", kLevelChoices[g_selection.levelChoice].label)) {
+        return;
+    }
+    for (std::size_t index = 0; index < kLevelChoices.size(); ++index) {
+        const bool selected = index == g_selection.levelChoice;
+        if (ImGui::Selectable(kLevelChoices[index].label, selected)) {
+            g_selection.levelChoice = index;
+        }
+        if (selected) {
+            ImGui::SetItemDefaultFocus();
+        }
+    }
+    ImGui::EndCombo();
+}
+
+/** Draws the text query input and stores the query. */
+void draw_text() noexcept {
+    (void)components::filter::input(
+        "log_text", "Filter text", g_selection.text.data(), g_selection.text.size());
+}
+
+/** @return A filter built from the current selections. */
+log::view::Filter current() noexcept {
+    log::view::Filter filter;
+    const ChannelChoice& channelChoice = kChannelChoices[g_selection.channelChoice];
+    if (channelChoice.filtered) {
+        filter.channel = channelChoice.channel;
+    }
+    const LevelChoice& levelChoice = kLevelChoices[g_selection.levelChoice];
+    if (levelChoice.filtered) {
+        filter.level = levelChoice.level;
+    }
+    (void)log::view::set_text(filter, text_query());
+    return filter;
+}
+
+/** Clears every selection at a UI lifecycle boundary. */
+void reset() noexcept {
+    g_selection = {};
+}
+
+} // namespace sunrise::core::ui::modules::logs::internal::filters

+ 22 - 0
Sunrise/src/core/ui/modules/logs/filters/logs_filter_controls.h

@@ -0,0 +1,22 @@
+#pragma once
+
+#include "../../../../logging/view/log_snapshot_view.h"
+
+namespace sunrise::core::ui::modules::logs::internal::filters {
+
+/** Draws the exact-channel selector and stores the selection. */
+void draw_channel() noexcept;
+
+/** Draws the exact-level selector and stores the selection. */
+void draw_level() noexcept;
+
+/** Draws the text query input and stores the query. */
+void draw_text() noexcept;
+
+/** @return A filter built from the current selections. */
+[[nodiscard]] log::view::Filter current() noexcept;
+
+/** Clears every selection at a UI lifecycle boundary. */
+void reset() noexcept;
+
+} // namespace sunrise::core::ui::modules::logs::internal::filters

+ 64 - 0
Sunrise/src/core/ui/modules/logs/internal.h

@@ -0,0 +1,64 @@
+#pragma once
+
+#include <Windows.h>
+
+#include <string_view>
+
+#include "../../../logging/view/log_snapshot_view.h"
+
+namespace sunrise::core::ui::modules::logs::internal {
+
+/** Draws the Logs page inside the active Core UI frame. */
+void draw() noexcept;
+
+/** Clears local filter and copy-result state between UI lifecycles. */
+void reset() noexcept;
+
+/** State of the explicit clipboard action. */
+enum class CopyStatus : unsigned char {
+    idle,
+    pending,
+    copied,
+    failed,
+};
+
+/**
+ * Checks whether one status label fits in the space left after the copy button.
+ * @param availableWidth Width left on the current row in framebuffer pixels.
+ * @param spacing Required gap before the status label in framebuffer pixels.
+ * @param statusWidth Drawn status-label width in framebuffer pixels.
+ * @return True when the gap and the whole label fit on the current row.
+ */
+[[nodiscard]] constexpr bool
+copy_status_fits_inline(float availableWidth, float spacing, float statusWidth) noexcept {
+    return availableWidth >= spacing + statusWidth;
+}
+
+/** Clipboard bridge supplied by the Win32 dispatch or by a test. */
+using CopyAction = bool (*)(HWND owner, std::string_view payload) noexcept;
+
+/**
+ * Queues all visible structured log lines without calling Win32 from the render lock.
+ * @param visible Borrowed selection whose source snapshot stays alive for the call.
+ * @return True when this payload becomes the one pending copy request.
+ */
+[[nodiscard]] bool request_copy(const log::view::Result& visible) noexcept;
+
+/** @return State of the last explicit clipboard request, read under the lock. */
+[[nodiscard]] CopyStatus copy_status() noexcept;
+
+/**
+ * Runs one pending payload through a caller-supplied bridge, after render locks unwind.
+ * @param owner Valid game or test window that becomes the Win32 clipboard owner.
+ * @param action Non-null clipboard bridge.
+ * @return The bridge result, or false when no valid request can be sent.
+ */
+[[nodiscard]] bool dispatch_pending_copy(HWND owner, CopyAction action) noexcept;
+
+/** Runs one pending payload through the Win32 clipboard bridge. */
+void dispatch_pending_copy(HWND owner) noexcept;
+
+/** Cancels pending work and clears the kept clipboard bytes at a UI lifecycle boundary. */
+void cancel_pending_copy() noexcept;
+
+} // namespace sunrise::core::ui::modules::logs::internal

+ 19 - 0
Sunrise/src/core/ui/modules/logs/logs.h

@@ -0,0 +1,19 @@
+#pragma once
+
+#include <Windows.h>
+
+namespace sunrise::core::ui::modules::logs {
+
+/** @return True when the Core Logs page owns its registry slot. */
+[[nodiscard]] bool initialize() noexcept;
+
+/** Removes the Core Logs page and clears its local filter state. */
+void shutdown() noexcept;
+
+/**
+ * Dispatches one user-requested clipboard copy after presentation locks unwind.
+ * @param owner Active game output window that becomes the clipboard owner.
+ */
+void dispatch_pending_copy(HWND owner) noexcept;
+
+} // namespace sunrise::core::ui::modules::logs

+ 198 - 0
Sunrise/src/core/ui/modules/logs/logs_clipboard_writer.cpp

@@ -0,0 +1,198 @@
+#include <Windows.h>
+
+#include <array>
+#include <cstring>
+#include <string_view>
+
+#include "internal.h"
+
+namespace sunrise::core::ui::modules::logs::internal {
+namespace {
+
+/** CRLF gives copied lines the native Windows text-file separator. */
+constexpr std::string_view kLineEnding = "\r\n";
+/** One trailing null byte is required by the CF_TEXT clipboard contract. */
+constexpr std::size_t kClipboardTerminatorBytes = 1;
+/** CF_TEXT matches the structured logger's ASCII event grammar without conversion. */
+constexpr UINT kClipboardFormat = CF_TEXT;
+/** GMEM_MOVEABLE is required when clipboard ownership crosses the Win32 API boundary. */
+constexpr UINT kClipboardMemoryFlags = GMEM_MOVEABLE;
+/** The ring limit caps one whole clipboard payload, separators and null included. */
+constexpr std::size_t kMaximumClipboardBytes =
+    log::snapshot::kEntryCapacity * (log::kLineCapacity + kLineEnding.size())
+    + kClipboardTerminatorBytes;
+
+struct ClipboardState {
+    SRWLOCK lock{SRWLOCK_INIT};
+    std::array<char, kMaximumClipboardBytes> payload{};
+    std::size_t payloadBytes{};
+    CopyStatus status{CopyStatus::idle};
+    bool dispatching{};
+    bool cancelDispatchResult{};
+};
+
+ClipboardState g_clipboard;
+
+/**
+ * Measures the whole CF_TEXT payload without letting the size overflow.
+ * @param visible Borrowed selection whose source snapshot stays alive for the call.
+ * @param bytes Receives the payload size, including the trailing null byte.
+ * @return True when every visible line fits the fixed clipboard limit.
+ */
+[[nodiscard]] bool measure_payload(const log::view::Result& visible, std::size_t& bytes) noexcept {
+    bytes = kClipboardTerminatorBytes;
+    for (const log::snapshot::Entry* entry : visible.entries()) {
+        const std::size_t lineBytes = entry->text().size() + kLineEnding.size();
+        if (lineBytes > kMaximumClipboardBytes - bytes) {
+            return false;
+        }
+        bytes += lineBytes;
+    }
+    return true;
+}
+
+/**
+ * Writes all visible lines to one locked movable-memory payload.
+ * @param visible Borrowed selection whose source snapshot stays alive for the call.
+ * @param destination Locked clipboard memory of the measured size.
+ */
+void write_payload(const log::view::Result& visible, char* destination) noexcept {
+    std::size_t offset = 0;
+    for (const log::snapshot::Entry* entry : visible.entries()) {
+        const std::string_view text = entry->text();
+        if (!text.empty()) {
+            std::memcpy(destination + offset, text.data(), text.size());
+            offset += text.size();
+        }
+        std::memcpy(destination + offset, kLineEnding.data(), kLineEnding.size());
+        offset += kLineEnding.size();
+    }
+    destination[offset] = '\0';
+}
+
+/** Clears every kept line byte. The clipboard lock must be held for writing. */
+void clear_payload_locked() noexcept {
+    SecureZeroMemory(g_clipboard.payload.data(), g_clipboard.payload.size());
+    g_clipboard.payloadBytes = 0;
+}
+
+/**
+ * Replaces the Win32 clipboard through a real window owner.
+ * @param owner Valid output window receiving clipboard ownership.
+ * @param payload Null-terminated CF_TEXT bytes, kept alive for the whole call.
+ * @return True only when Win32 accepts ownership of the movable payload.
+ */
+[[nodiscard]] bool copy_with_windows(HWND owner, std::string_view payload) noexcept {
+    if (owner == nullptr || IsWindow(owner) == FALSE || payload.empty() || payload.back() != '\0') {
+        return false;
+    }
+
+    HGLOBAL memory = GlobalAlloc(kClipboardMemoryFlags, static_cast<SIZE_T>(payload.size()));
+    if (memory == nullptr) {
+        return false;
+    }
+    auto* destination = static_cast<char*>(GlobalLock(memory));
+    if (destination == nullptr) {
+        GlobalFree(memory);
+        return false;
+    }
+    std::memcpy(destination, payload.data(), payload.size());
+    GlobalUnlock(memory);
+
+    if (!OpenClipboard(owner)) {
+        GlobalFree(memory);
+        return false;
+    }
+    bool transferred = false;
+    if (EmptyClipboard()) {
+        transferred = SetClipboardData(kClipboardFormat, memory) != nullptr;
+    }
+    CloseClipboard();
+    if (!transferred) {
+        GlobalFree(memory);
+    }
+    return transferred;
+}
+
+} // namespace
+
+/** Queues all visible structured log lines without calling Win32 from the render lock. */
+bool request_copy(const log::view::Result& visible) noexcept {
+    std::size_t payloadBytes = 0;
+    if (!measure_payload(visible, payloadBytes) || payloadBytes > kMaximumClipboardBytes) {
+        return false;
+    }
+
+    AcquireSRWLockExclusive(&g_clipboard.lock);
+    if (g_clipboard.dispatching) {
+        ReleaseSRWLockExclusive(&g_clipboard.lock);
+        return false;
+    }
+    clear_payload_locked();
+    write_payload(visible, g_clipboard.payload.data());
+    g_clipboard.payloadBytes = payloadBytes;
+    g_clipboard.status = CopyStatus::pending;
+    g_clipboard.cancelDispatchResult = false;
+    ReleaseSRWLockExclusive(&g_clipboard.lock);
+    return true;
+}
+
+/** @return State of the last explicit clipboard request, read under the lock. */
+CopyStatus copy_status() noexcept {
+    AcquireSRWLockShared(&g_clipboard.lock);
+    const CopyStatus status = g_clipboard.status;
+    ReleaseSRWLockShared(&g_clipboard.lock);
+    return status;
+}
+
+/** Runs one pending payload through a caller-supplied bridge, after render locks unwind. */
+bool dispatch_pending_copy(HWND owner, CopyAction action) noexcept {
+    AcquireSRWLockExclusive(&g_clipboard.lock);
+    if (g_clipboard.status != CopyStatus::pending || g_clipboard.dispatching) {
+        ReleaseSRWLockExclusive(&g_clipboard.lock);
+        return false;
+    }
+    if (owner == nullptr || IsWindow(owner) == FALSE || action == nullptr) {
+        clear_payload_locked();
+        g_clipboard.status = CopyStatus::failed;
+        ReleaseSRWLockExclusive(&g_clipboard.lock);
+        return false;
+    }
+
+    g_clipboard.dispatching = true;
+    g_clipboard.cancelDispatchResult = false;
+    const std::string_view payload(g_clipboard.payload.data(), g_clipboard.payloadBytes);
+    ReleaseSRWLockExclusive(&g_clipboard.lock);
+
+    const bool transferred = action(owner, payload);
+
+    AcquireSRWLockExclusive(&g_clipboard.lock);
+    if (!g_clipboard.cancelDispatchResult) {
+        g_clipboard.status = transferred ? CopyStatus::copied : CopyStatus::failed;
+    }
+    g_clipboard.dispatching = false;
+    g_clipboard.cancelDispatchResult = false;
+    clear_payload_locked();
+    ReleaseSRWLockExclusive(&g_clipboard.lock);
+    return transferred;
+}
+
+/** Runs one pending payload through the Win32 clipboard bridge. */
+void dispatch_pending_copy(HWND owner) noexcept {
+    (void)dispatch_pending_copy(owner, &copy_with_windows);
+}
+
+/** Cancels pending work and clears the kept clipboard bytes at a UI lifecycle boundary. */
+void cancel_pending_copy() noexcept {
+    AcquireSRWLockExclusive(&g_clipboard.lock);
+    g_clipboard.status = CopyStatus::idle;
+    if (g_clipboard.dispatching) {
+        // The bridge owns the buffer until it returns; only its stale result is dropped.
+        g_clipboard.cancelDispatchResult = true;
+    } else {
+        clear_payload_locked();
+    }
+    ReleaseSRWLockExclusive(&g_clipboard.lock);
+}
+
+} // namespace sunrise::core::ui::modules::logs::internal

+ 38 - 0
Sunrise/src/core/ui/modules/logs/logs_module_runtime.cpp

@@ -0,0 +1,38 @@
+#include <Windows.h>
+
+#include <string_view>
+
+#include "../registry/ui_module_registry.h"
+#include "../ui_module_descriptor.h"
+#include "internal.h"
+#include "logs.h"
+
+namespace sunrise::core::ui::modules::logs {
+namespace {
+
+/** Namespaced stable ID keeps this page distinct from feature modules. */
+constexpr std::string_view kStableId = "core.logs";
+/** Menu label for the process-wide log view. */
+constexpr std::string_view kDisplayName = "Logs";
+
+registry::PageRegistration g_page;
+
+} // namespace
+
+/** @return True when the Core Logs page owns its registry slot. */
+bool initialize() noexcept {
+    // The filter state is cleared under the slot lock, so a re-register never draws stale filters.
+    return g_page.acquire(Owner::core, kStableId, kDisplayName, &internal::draw, &internal::reset);
+}
+
+/** Removes the Core Logs page and clears its local filter state. */
+void shutdown() noexcept {
+    g_page.release(&internal::reset);
+}
+
+/** @param owner Active game output window that becomes the clipboard owner. */
+void dispatch_pending_copy(HWND owner) noexcept {
+    internal::dispatch_pending_copy(owner);
+}
+
+} // namespace sunrise::core::ui::modules::logs

+ 165 - 0
Sunrise/src/core/ui/modules/logs/logs_panel_render.cpp

@@ -0,0 +1,165 @@
+#include <cstdint>
+#include <imgui.h>
+#include <string_view>
+
+#include "../../../logging/snapshot/snapshot.h"
+#include "../../components/toggle/ui_toggle_component.h"
+#include "../../scaling/dpi/ui_dpi_scaling.h"
+#include "filters/logs_filter_controls.h"
+#include "internal.h"
+
+namespace sunrise::core::ui::modules::logs::internal {
+namespace {
+
+/** 500 authored pixels keep all three filters on one toolbar row. */
+constexpr float kInlineFilterRowWidth = 500.0F;
+/** 260 authored pixels fit both fixed-width selectors and their gap. */
+constexpr float kInlineSelectorRowWidth = 260.0F;
+/** 160 authored pixels fit the auto-scroll toggle. */
+constexpr float kAutoScrollWidth = 160.0F;
+/** 400 authored pixels fit both stats with a full-width replaced counter. */
+constexpr float kInlineStatsRowWidth = 400.0F;
+/** Fixed action label, also used to measure the row width. */
+constexpr char kCopyVisibleLabel[] = "Copy Visible";
+/** Shown until the window thread runs the copy. */
+constexpr char kCopyPendingLabel[] = "Copy queued.";
+/** Shown until the next request or a reset. */
+constexpr char kCopyCompleteLabel[] = "Copied visible lines.";
+/** Shown until the next request or a reset. */
+constexpr char kCopyFailedLabel[] = "Clipboard copy failed.";
+/** Zero size lets the log child take all the remaining space. */
+constexpr ImVec2 kAutomaticChildSize{0.0F, 0.0F};
+/** 1 scrolls to the bottom edge of the last line. */
+constexpr float kScrollBottom = 1.0F;
+/** One line per record; overflow scrolls sideways. */
+constexpr ImGuiWindowFlags kLogWindowFlags =
+    ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_HorizontalScrollbar;
+/** A border separates the controls from the stored events. */
+constexpr ImGuiChildFlags kLogChildFlags = ImGuiChildFlags_Borders;
+
+/** User setting: follow the newest record instead of holding the current view. */
+bool g_autoScroll{true};
+/** Recorded count from the last drawn frame. A change in it means the log grew. */
+std::uint64_t g_recordedCount{};
+
+/**
+ * Counts every stored event, including ones the ring replaced. The stored count alone stops
+ * rising once the ring wraps.
+ * @param visible Selection carrying its source snapshot counters.
+ * @return Total that only rises, and only when a new record is stored.
+ */
+[[nodiscard]] std::uint64_t recorded_count(const log::view::Result& visible) noexcept {
+    return visible.overwritten_count() + static_cast<std::uint64_t>(visible.source_count());
+}
+
+/** @param visible Filtered entries copied on click. */
+void draw_copy_button(const log::view::Result& visible) noexcept {
+    const float rowMaximumX = ImGui::GetCursorScreenPos().x + ImGui::GetContentRegionAvail().x;
+    if (ImGui::Button(kCopyVisibleLabel)) {
+        (void)request_copy(visible);
+    }
+    const CopyStatus status = copy_status();
+    const char* statusLabel = nullptr;
+    if (status == CopyStatus::pending) {
+        statusLabel = kCopyPendingLabel;
+    } else if (status == CopyStatus::copied) {
+        statusLabel = kCopyCompleteLabel;
+    } else if (status == CopyStatus::failed) {
+        statusLabel = kCopyFailedLabel;
+    }
+    if (statusLabel == nullptr) {
+        return;
+    }
+
+    const float availableWidth = rowMaximumX - ImGui::GetItemRectMax().x;
+    const ImGuiStyle& style = ImGui::GetStyle();
+    if (copy_status_fits_inline(
+            availableWidth, style.ItemSpacing.x, ImGui::CalcTextSize(statusLabel).x)) {
+        ImGui::SameLine();
+    }
+    ImGui::TextDisabled("%s", statusLabel);
+}
+
+/**
+ * Draws the stored records and moves the view to the newest one when asked.
+ * @param visible Filtered entries, one line each.
+ * @param follow True to put the newest line at the bottom of the view this frame.
+ */
+void draw_log_entries(const log::view::Result& visible, bool follow) noexcept {
+    if (ImGui::BeginChild("##log_entries", kAutomaticChildSize, kLogChildFlags, kLogWindowFlags)) {
+        if (visible.entries().empty()) {
+            ImGui::TextDisabled("No retained events match these filters.");
+        } else {
+            for (const log::snapshot::Entry* entry : visible.entries()) {
+                const std::string_view text = entry->text();
+                ImGui::TextUnformatted(text.data(), text.data() + text.size());
+            }
+            // Only on a frame that added a record. Pinning every frame drags the view back down
+            // while the user reads older lines.
+            if (follow) {
+                ImGui::SetScrollHereY(kScrollBottom);
+            }
+        }
+    }
+    ImGui::EndChild();
+}
+
+} // namespace
+
+/** Draws the Logs page inside the active Core UI frame. */
+void draw() noexcept {
+    ImGui::TextDisabled("Filter the bounded in-memory history or copy its visible lines.");
+    ImGui::Separator();
+    const float availableWidth = ImGui::GetContentRegionAvail().x;
+    const bool inlineSelectors = availableWidth >= scaling::dpi::pixels(kInlineSelectorRowWidth);
+    const bool inlineFilters = availableWidth >= scaling::dpi::pixels(kInlineFilterRowWidth);
+    filters::draw_channel();
+    if (inlineSelectors) {
+        ImGui::SameLine();
+    }
+    filters::draw_level();
+    if (inlineFilters) {
+        ImGui::SameLine();
+    }
+    filters::draw_text();
+
+    const log::snapshot::Snapshot retained = log::snapshot::take();
+    const log::view::Result visible = log::view::select(retained, filters::current());
+    const std::uint64_t recorded = recorded_count(visible);
+    const bool logGrew = recorded != g_recordedCount;
+    g_recordedCount = recorded;
+    const bool inlineStats =
+        ImGui::GetContentRegionAvail().x >= scaling::dpi::pixels(kInlineStatsRowWidth);
+    ImGui::Text("Visible: %zu / %zu", visible.entries().size(), visible.source_count());
+    if (inlineStats) {
+        ImGui::SameLine();
+    }
+    ImGui::TextDisabled("Replaced: %llu",
+                        static_cast<unsigned long long>(visible.overwritten_count()));
+    const float autoScrollWidth = scaling::dpi::pixels(kAutoScrollWidth);
+    const float copyButtonWidth =
+        ImGui::CalcTextSize(kCopyVisibleLabel).x + (ImGui::GetStyle().FramePadding.x * 2.0F);
+    const bool inlineActions =
+        ImGui::GetContentRegionAvail().x
+        >= autoScrollWidth + ImGui::GetStyle().ItemSpacing.x + copyButtonWidth;
+    // Switching the toggle back on catches the view up without waiting for the next record.
+    const bool followTurnedOn =
+        components::toggle::control("Auto-scroll##log_auto_scroll", g_autoScroll, autoScrollWidth)
+        && g_autoScroll;
+    if (inlineActions) {
+        ImGui::SameLine();
+    }
+    draw_copy_button(visible);
+    ImGui::Separator();
+    draw_log_entries(visible, g_autoScroll && (logGrew || followTurnedOn));
+}
+
+/** Clears local filter and copy-result state between UI lifecycles. */
+void reset() noexcept {
+    cancel_pending_copy();
+    filters::reset();
+    g_autoScroll = true;
+    g_recordedCount = 0;
+}
+
+} // namespace sunrise::core::ui::modules::logs::internal