Skip to content

Commit fb87a8b

Browse files
committed
qml: render debug log entries with a virtualized list
Replace the Flickable and Repeater output with a ListView. Qt creates items only for visible log entries instead of creating the entire displayed history at once. Keep the same row and pixel offset under the user's eyes when entries are inserted at the top, old cached entries are removed, or older entries are loaded at the bottom. Calculate line numbers from the current row index and leave enough room for four-digit numbers. Add QML and functional tests for virtualization, stable scrolling, live updates, and loading older entries.
1 parent de76d84 commit fb87a8b

7 files changed

Lines changed: 876 additions & 189 deletions

File tree

qml/components/DebugLogOutputView.qml

Lines changed: 315 additions & 140 deletions
Large diffs are not rendered by default.

qml/pages/settings/SettingsDebugLog.qml

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,11 @@ Page {
132132
color: Theme.color.neutral9
133133
placeholderTextColor: Theme.color.neutral5
134134
placeholderText: qsTr("Search...")
135+
// The page is unloaded whenever another Settings section is
136+
// selected, while the C++ model intentionally retains its
137+
// filter. Mirror that retained value on re-entry so the field
138+
// and the rows cannot disagree.
139+
text: debugLogModel.filter
135140
verticalAlignment: TextInput.AlignVCenter
136141
selectByMouse: true
137142
Accessible.name: qsTr("Search debug log")
@@ -201,11 +206,13 @@ Page {
201206
topPadding: 10
202207
accessibleName: qsTr("Debug log entries")
203208
autoScrollToBottom: false
204-
// DebugLogModel renders newest-first at the top, so y > 0 means
205-
// "scrolled away from the newest entries" — which is exactly when
206-
// the "N new entries" pill should be offered.
207-
onScrolled: function(y) {
208-
root.userIsScrolled = (y > 0)
209+
// DebugLogModel renders newest-first at the top, so leaving the
210+
// beginning is exactly when the "N new entries" pill applies.
211+
onScrolled: function() {
212+
// A ListView's content origin is not guaranteed to be zero,
213+
// particularly with variable-height rows and incremental model
214+
// changes. Its boundary state is the authoritative answer.
215+
root.userIsScrolled = !logView.atTop
209216
if (logView.atTop && root.pendingNewLines > 0) {
210217
root.pendingNewLines = 0
211218
}
@@ -220,6 +227,7 @@ Page {
220227
Layout.preferredHeight: 36
221228

222229
TextButton {
230+
objectName: "debugLogLoadMoreButton"
223231
anchors.centerIn: parent
224232
text: qsTr("Load more")
225233
textSize: 13

test/functional/qml_test_debug_log.py

Lines changed: 127 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66
77
Walks through Settings → Debug Log and verifies:
88
1. The viewer page loads with a visible search bar and log list.
9-
2. The log list is non-empty (entries were actually loaded).
9+
2. The initial load is capped while older entries remain available.
1010
3. Typing in the search field filters the list; clearing it restores
1111
the full count.
12+
4. Auto-refresh replaces the capped tail without growing it unboundedly.
13+
5. The list virtualizes offscreen rows and can load older rows on demand.
1214
1315
This test requires the binary to be built with -DENABLE_TEST_AUTOMATION=ON.
1416
"""
@@ -22,7 +24,11 @@
2224
import tempfile
2325

2426
from qml_test_harness import GUI_STARTUP_TIMEOUT, dump_qml_tree, find_gui_binary, setup_datadir
25-
from qml_driver import QmlDriver, QmlDriverError
27+
from qml_driver import QmlDriver
28+
29+
30+
INITIAL_LOAD_LIMIT = 1000
31+
SEEDED_HISTORY_LINES = 1200
2632

2733

2834
def assert_close(actual, expected, label, tolerance=1):
@@ -43,6 +49,19 @@ def __init__(self):
4349
self.process = None
4450
self.driver = None
4551
self.datadir = setup_datadir(self.tmpdir)
52+
self._seed_debug_log_history()
53+
54+
def _seed_debug_log_history(self):
55+
"""Create enough history to exercise the initial cap and Load more."""
56+
network_dir = os.path.join(self.datadir, "regtest")
57+
os.makedirs(network_dir, exist_ok=True)
58+
log_path = os.path.join(network_dir, "debug.log")
59+
with open(log_path, "w", encoding="utf-8") as f:
60+
for i in range(SEEDED_HISTORY_LINES):
61+
f.write(
62+
"2026-01-01T00:00:00Z "
63+
f"test-automation seeded-history marker {i}\n"
64+
)
4665

4766
def start(self):
4867
env = dict(os.environ)
@@ -114,10 +133,15 @@ def test_viewer_visibility(gui):
114133

115134

116135
def test_log_has_entries(gui):
117-
"""Verify that log entries were actually loaded into the list view."""
136+
"""Verify that the first snapshot is capped at 1,000 rows."""
118137
print("\n── test_log_has_entries ──────────────────────────────────────────")
119-
count = gui.get_property("debugLogListView", "count")
120-
assert count > 0, f"Expected log entries to be loaded, got count={count}"
138+
count = gui.wait_for_property(
139+
"debugLogListView", "count", INITIAL_LOAD_LIMIT, timeout_ms=5000
140+
)
141+
assert count == INITIAL_LOAD_LIMIT, (
142+
f"Expected the initial load to be capped at {INITIAL_LOAD_LIMIT}, "
143+
f"got count={count}"
144+
)
121145
print(f" PASSED: debugLogListView.count = {count}")
122146
return count
123147

@@ -130,7 +154,6 @@ def test_search_layout_matches_design(gui):
130154
gui.wait_for_property("debugLogSearchDivider", "height", 1, timeout_ms=3000)
131155
gui.wait_for_property("debugLogListView", "topPadding", 10, timeout_ms=3000)
132156
gui.wait_for_property("debugLogListView_row_0", "visible", True, timeout_ms=3000)
133-
gui.wait_for_property("debugLogListView_row_0", "y", 10, timeout_ms=3000)
134157
gui.wait_for_property("debugLogListView_lineNumber_0", "visible", True, timeout_ms=3000)
135158
gui.wait_for_property("debugLogListView_date_0", "visible", True, timeout_ms=3000)
136159

@@ -147,6 +170,13 @@ def test_search_layout_matches_design(gui):
147170
divider_y = gui.get_property("debugLogSearchDivider", "y")
148171
divider_height = gui.get_property("debugLogSearchDivider", "height")
149172
list_y = gui.get_property("debugLogListView", "y")
173+
first_row_viewport_y = (
174+
gui.get_property("debugLogListView_row_0", "y")
175+
- gui.get_property("debugLogListView", "contentY")
176+
)
177+
effective_line_number_width = gui.get_property(
178+
"debugLogListView", "effectiveLineNumberWidth"
179+
)
150180
page_width = gui.get_property("settingsDebugLog", "width")
151181
content_width = gui.get_property("debugLogContentLayout", "width")
152182
expected_content_width = max(0, min(page_width - 40, 600))
@@ -197,20 +227,24 @@ def test_search_layout_matches_design(gui):
197227
"debug log entry command/content spacing")
198228
assert_close(gui.get_property("debugLogListView", "lineNumberWidth"), 20,
199229
"debug log line number slot width")
200-
assert_close(gui.get_property("debugLogListView", "effectiveLineNumberWidth"), 20,
201-
"debug log effective line number slot width")
230+
assert effective_line_number_width >= 20, (
231+
"Expected the effective line-number slot to honor its 20px minimum"
232+
)
202233
assert_close(gui.get_property("debugLogListView", "fontPixelSize"), 12,
203234
"debug log entry font size")
204235
assert_close(gui.get_property("debugLogListView", "textLineHeight"), 17,
205236
"debug log entry line height")
206-
assert_close(gui.get_property("debugLogListView_row_0", "y"), 10,
207-
"first debug log row y")
237+
assert_close(first_row_viewport_y, 10,
238+
"first debug log row viewport y")
208239
assert_close(gui.get_property("debugLogListView_row_0", "spacing"), 10,
209240
"first debug log row column gap")
210241
assert_close(gui.get_property("debugLogListView_entryContent_0", "spacing"), 2,
211242
"first debug log entry internal gap")
212-
assert_close(gui.get_property("debugLogListView_lineNumber_0", "width"), 20,
213-
"first debug log line number width")
243+
assert_close(
244+
gui.get_property("debugLogListView_lineNumber_0", "width"),
245+
effective_line_number_width,
246+
"first debug log line number width",
247+
)
214248
first_text_object = (
215249
"debugLogListView_message_0"
216250
if first_row_has_command
@@ -236,46 +270,56 @@ def test_refresh_button(gui, original_count):
236270

237271

238272
def test_auto_refresh(gui, datadir, current_count):
239-
"""Appending to debug.log triggers auto-refresh and grows the entry count."""
273+
"""Appending refreshes the capped tail without exceeding its load limit."""
240274
print("\n── test_auto_refresh ─────────────────────────────────────────────")
241275
log_path = os.path.join(datadir, "regtest", "debug.log")
242276
ts = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
243277
marker_body = "test-automation auto-refresh marker"
244278
marker = f"{ts} {marker_body}"
245279
with open(log_path, "a", encoding="utf-8") as f:
246280
f.write(marker + "\n")
247-
try:
248-
new_count = gui.wait_for_property(
249-
"debugLogListView", "count", lambda c: c > current_count, timeout_ms=5000
250-
)
251-
except QmlDriverError:
252-
gui.click("debugLogRefreshButton")
253-
new_count = gui.wait_for_property(
254-
"debugLogListView", "count", lambda c: c > current_count, timeout_ms=3000
255-
)
256-
assert new_count > current_count, (
257-
f"Expected count to grow beyond {current_count} after appending to debug.log, "
258-
f"got {new_count}"
259-
)
260-
print(f" PASSED: count grew from {current_count} to {new_count} after file append")
261281

282+
# A capped model can insert the new row and remove one old row without
283+
# changing count. Filter for the unique marker to observe the actual data
284+
# update rather than treating row-count growth as the refresh signal.
262285
gui.set_text("debugLogSearchField", marker_body)
263-
gui.wait_for_property("debugLogListView", "count", lambda c: c >= 1, timeout_ms=3000)
286+
gui.wait_for_property("debugLogListView", "count", 1, timeout_ms=5000)
287+
gui.wait_for_property(
288+
"debugLogListView_commandlessMessage_0",
289+
"text",
290+
marker_body,
291+
timeout_ms=3000,
292+
)
293+
264294
gui.wait_for_property("debugLogListView_command_0", "visible", False, timeout_ms=3000)
265295
gui.wait_for_property("debugLogListView_commandlessMessage_0", "visible", True, timeout_ms=3000)
266-
gui.wait_for_property("debugLogListView_commandlessMessage_0", "text", marker_body, timeout_ms=3000)
267296
gui.wait_for_property("debugLogListView_message_0", "visible", False, timeout_ms=3000)
297+
gui.invoke("debugLogListView_commandlessMessage_0", "selectAll")
298+
gui.wait_for_property(
299+
"debugLogListView_commandlessMessage_0",
300+
"selectedText",
301+
marker_body,
302+
timeout_ms=3000,
303+
)
268304
print(" PASSED: commandless log entry renders inline with the date")
269305

270306
gui.set_text("debugLogSearchField", "")
271307
restored_count = gui.wait_for_property(
272-
"debugLogListView", "count", lambda c: c >= new_count, timeout_ms=3000
308+
"debugLogListView", "count", current_count, timeout_ms=3000
309+
)
310+
assert restored_count == INITIAL_LOAD_LIMIT, (
311+
f"Expected auto-refresh to retain the {INITIAL_LOAD_LIMIT}-row cap, "
312+
f"got {restored_count}"
313+
)
314+
print(
315+
f" PASSED: appended entry loaded and count remained capped at "
316+
f"{restored_count}"
273317
)
274318
return restored_count
275319

276320

277321
def test_four_digit_line_numbers_are_not_clipped(gui, datadir, current_count):
278-
"""Verify the line-number column expands once visible numbers reach four digits."""
322+
"""Verify virtualized offscreen rows and the four-digit number column."""
279323
print("\n── test_four_digit_line_numbers_are_not_clipped ────────────────")
280324
target_count = 1000
281325
needed = max(0, target_count - current_count)
@@ -293,20 +337,63 @@ def test_four_digit_line_numbers_are_not_clipped(gui, datadir, current_count):
293337
f"Expected at least {target_count} lines after append, got {count}"
294338
)
295339

296-
gui.wait_for_property(
297-
"debugLogListView_lineNumber_999", "text", str(target_count), timeout_ms=3000
298-
)
299-
line_number_width = gui.get_property("debugLogListView_lineNumber_999", "width")
300-
line_number_implicit_width = gui.get_property(
301-
"debugLogListView_lineNumber_999", "implicitWidth"
340+
last_index = count - 1
341+
last_line_number = f"debugLogListView_lineNumber_{last_index}"
342+
gui.invoke("debugLogListView", "scrollToTop")
343+
gui.wait_for_property("debugLogListView", "atTop", True, timeout_ms=3000)
344+
assert not gui.object_exists(last_line_number), (
345+
f"Expected offscreen row {last_index} not to be instantiated at the top"
302346
)
347+
348+
gui.invoke("debugLogListView", "scrollToBottom")
349+
gui.wait_for_property("debugLogListView", "atBottom", True, timeout_ms=3000)
350+
gui.wait_for_property(last_line_number, "text", str(count), timeout_ms=3000)
351+
line_number_width = gui.get_property(last_line_number, "width")
352+
line_number_implicit_width = gui.get_property(last_line_number, "implicitWidth")
303353
assert line_number_width >= line_number_implicit_width, (
304354
f"Expected four-digit line number width {line_number_width} to fit "
305355
f"implicit width {line_number_implicit_width}"
306356
)
307357
assert gui.get_property("debugLogListView", "effectiveLineNumberWidth") > 20, \
308358
"Expected effective line-number slot to expand beyond the design minimum"
309-
print(" PASSED: four-digit line numbers fit expanded line-number slot")
359+
print(" PASSED: offscreen rows are virtualized and four-digit line numbers fit")
360+
361+
362+
def test_load_more_at_bottom(gui, current_count):
363+
"""The bottom affordance appends older rows without moving the viewport."""
364+
print("\n── test_load_more_at_bottom ───────────────────────")
365+
gui.invoke("debugLogListView", "scrollToBottom")
366+
gui.wait_for_property("debugLogListView", "atBottom", True, timeout_ms=3000)
367+
gui.wait_for_property("debugLogLoadMoreButton", "visible", True, timeout_ms=3000)
368+
content_y_before = gui.get_property("debugLogListView", "contentY")
369+
370+
gui.click("debugLogLoadMoreButton")
371+
expanded_count = gui.wait_for_property(
372+
"debugLogListView", "count", lambda c: c > current_count, timeout_ms=5000
373+
)
374+
content_y_after = gui.get_property("debugLogListView", "contentY")
375+
assert_close(
376+
content_y_after,
377+
content_y_before,
378+
"load-more viewport anchor",
379+
tolerance=2,
380+
)
381+
gui.wait_for_property("debugLogLoadMoreButton", "visible", False, timeout_ms=3000)
382+
383+
oldest_index = expanded_count - 1
384+
oldest_message = f"debugLogListView_commandlessMessage_{oldest_index}"
385+
gui.invoke("debugLogListView", "scrollToBottom")
386+
gui.wait_for_property(
387+
oldest_message,
388+
"text",
389+
"test-automation seeded-history marker 0",
390+
timeout_ms=3000,
391+
)
392+
print(
393+
f" PASSED: loaded {expanded_count - current_count} older rows without "
394+
"moving the viewport, including the oldest seeded row"
395+
)
396+
return expanded_count
310397

311398

312399
def test_close_settings(gui):
@@ -363,9 +450,10 @@ def run_tests():
363450
total = test_log_has_entries(gui)
364451
test_search_layout_matches_design(gui)
365452
test_search_filter(gui, total)
366-
total = test_refresh_button(gui, total)
367453
total = test_auto_refresh(gui, harness.datadir, total)
454+
total = test_refresh_button(gui, total)
368455
test_four_digit_line_numbers_are_not_clipped(gui, harness.datadir, total)
456+
total = test_load_more_at_bottom(gui, total)
369457
test_close_settings(gui)
370458

371459
print("\n" + "=" * 50)

test/qml/bitcoin_qmltests.qrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
<file>tst_createpassword.qml</file>
1515
<file>tst_createtypeselector.qml</file>
1616
<file>tst_createwalletwizard.qml</file>
17+
<file>tst_debuglogoutputview.qml</file>
1718
<file>tst_desktopwallets.qml</file>
1819
<file>tst_displaysettings.qml</file>
1920
<file>tst_dropdownbutton.qml</file>

0 commit comments

Comments
 (0)