### Bug Summary

File: | builds/wireshark/wireshark/ui/qt/lua_debugger/lua_debugger_dialog.cpp
Warning: | line 8333, column 25 Value stored to 'modeStr' during its initialization is never read

### Annotated Source Code

Press '?' to see keyboard shortcuts

Show analyzer invocation
clang -cc1 -cc1 -triple x86_64-pc-linux-gnu -analyze -disable-free -clear-ast-before-backend -disable-llvm-verifier -discard-value-names -main-file-name lua_debugger_dialog.cpp -analyzer-checker=core -analyzer-checker=apiModeling -analyzer-checker=unix -analyzer-checker=deadcode -analyzer-checker=cplusplus -analyzer-checker=security.insecureAPI.UncheckedReturn -analyzer-checker=security.insecureAPI.getpw -analyzer-checker=security.insecureAPI.gets -analyzer-checker=security.insecureAPI.mktemp -analyzer-checker=security.insecureAPI.mkstemp -analyzer-checker=security.insecureAPI.vfork -analyzer-checker=nullability.NullPassedToNonnull -analyzer-checker=nullability.NullReturnedFromNonnull -analyzer-output plist -w -setup-static-analyzer -mrelocation-model pic -pic-level 2 -fhalf-no-semantic-interposition -fno-delete-null-pointer-checks -mframe-pointer=all -relaxed-aliasing -fmath-errno -ffp-contract=on -fno-rounding-math -ffloat16-excess-precision=fast -fbfloat16-excess-precision=fast -mconstructor-aliases -funwind-tables=2 -target-cpu x86-64 -tune-cpu generic -debugger-tuning=gdb -fdebug-compilation-dir=/builds/wireshark/wireshark/build -fcoverage-compilation-dir=/builds/wireshark/wireshark/build -resource-dir /usr/lib/llvm-21/lib/clang/21 -isystem /usr/include/glib-2.0 -isystem /usr/lib/x86_64-linux-gnu/glib-2.0/include -isystem /builds/wireshark/wireshark/build/ui/qt -isystem /builds/wireshark/wireshark/ui/qt -isystem /builds/wireshark/wireshark/ui/qt/lua_debugger -isystem /usr/include/x86_64-linux-gnu/qt6/QtWidgets -isystem /usr/include/x86_64-linux-gnu/qt6 -isystem /usr/include/x86_64-linux-gnu/qt6/QtCore -isystem /usr/lib/x86_64-linux-gnu/qt6/mkspecs/linux-g++ -isystem /usr/include/x86_64-linux-gnu/qt6/QtGui -isystem /usr/include/x86_64-linux-gnu/qt6/QtCore5Compat -isystem /usr/include/x86_64-linux-gnu/qt6/QtConcurrent -isystem /usr/include/x86_64-linux-gnu/qt6/QtPrintSupport -isystem /usr/include/x86_64-linux-gnu/qt6/QtNetwork -isystem /usr/include/x86_64-linux-gnu/qt6/QtMultimedia -isystem /usr/include/x86_64-linux-gnu/qt6/QtDBus -D G_DISABLE_DEPRECATED -D G_DISABLE_SINGLE_INCLUDES -D QT_CONCURRENT_LIB -D QT_CORE5COMPAT_LIB -D QT_CORE_LIB -D QT_DBUS_LIB -D QT_GUI_LIB -D QT_MULTIMEDIA_LIB -D QT_NETWORK_LIB -D QT_PRINTSUPPORT_LIB -D QT_WIDGETS_LIB -D WS_DEBUG -D WS_DEBUG_UTF_8 -I /builds/wireshark/wireshark/build/ui/qt/qtui_autogen/include -I /builds/wireshark/wireshark/build -I /builds/wireshark/wireshark -I /builds/wireshark/wireshark/include -D _GLIBCXX_ASSERTIONS -internal-isystem /usr/lib/gcc/x86_64-linux-gnu/14/../../../../include/c++/14 -internal-isystem /usr/lib/gcc/x86_64-linux-gnu/14/../../../../include/x86_64-linux-gnu/c++/14 -internal-isystem /usr/lib/gcc/x86_64-linux-gnu/14/../../../../include/c++/14/backward -internal-isystem /usr/lib/llvm-21/lib/clang/21/include -internal-isystem /usr/local/include -internal-isystem /usr/lib/gcc/x86_64-linux-gnu/14/../../../../x86_64-linux-gnu/include -internal-externc-isystem /usr/include/x86_64-linux-gnu -internal-externc-isystem /include -internal-externc-isystem /usr/include -fmacro-prefix-map=/builds/wireshark/wireshark/= -fmacro-prefix-map=/builds/wireshark/wireshark/build/= -fmacro-prefix-map=../= -Wno-format-nonliteral -std=c++17 -fdeprecated-macro -ferror-limit 19 -fwrapv -fwrapv-pointer -fstrict-flex-arrays=3 -stack-protector 2 -fstack-clash-protection -fcf-protection=full -fgnuc-version=4.2.1 -fskip-odr-check-in-gmf -fcxx-exceptions -fexceptions -fcolor-diagnostics -analyzer-output=html -faddrsig -D__GCC_HAVE_DWARF2_CFI_ASM=1 -o /builds/wireshark/wireshark/sbout/2026-05-04-100403-3642-1 -x c++ /builds/wireshark/wireshark/ui/qt/lua_debugger/lua_debugger_dialog.cpp

Keyboard shortcuts:

- Use 'j/k' keys for keyboard navigation
- Use 'Shift+S' to show/hide relevant lines
- Use '?' to toggle this window

Close
Show only relevant lines Show control flow arrows

1 | /* lua_debugger_dialog.cpp
2 | *
3 | * Wireshark - Network traffic analyzer
4 | * By Gerald Combs <gerald@wireshark.org>
5 | * Copyright 1998 Gerald Combs
6 | *
7 | * SPDX-License-Identifier: GPL-2.0-or-later
8 | */
9 |
10 | #include "lua_debugger_dialog.h"
11 | #include "accordion_frame.h"
12 | #include "lua_debugger_code_view.h"
13 | #include "lua_debugger_find_frame.h"
14 | #include "lua_debugger_goto_line_frame.h"
15 | #include "lua_debugger_pause_overlay.h"
16 | #include "lua_debugger_item_utils.h"
17 | #include "main_application.h"
18 | #include "main_window.h"
19 | #include "ui_lua_debugger_dialog.h"
20 | #include "utils/stock_icon.h"
21 | #include "widgets/collapsible_section.h"
22 |
23 | #ifdef HAVE_LIBPCAP1
24 | #include <ui/capture.h>
25 | #endif
26 |
27 | #include <QAction>
28 | #include <QApplication>
29 | #include <QCheckBox>
30 | #include <QChildEvent>
31 | #include <QClipboard>
32 | #include <QCloseEvent>
33 | #include <QDesktopServices>
34 | #include <QEvent>
35 | #if QT_VERSION((6<<16)|(4<<8)|(2)) >= QT_VERSION_CHECK(6, 0, 0)((6<<16)|(0<<8)|(0))
36 | #include <QKeyCombination>
37 | #endif
38 | #include <QKeyEvent>
39 | #include <QColor>
40 | #include <QComboBox>
41 | #include <QDir>
42 | #include <QAbstractItemView>
43 | #include <QDirIterator>
44 | #include <QDragMoveEvent>
45 | #include <QDropEvent>
46 | #include <QFile>
47 | #include <QFileInfo>
48 | #include <QFont>
49 | #include <QFontDatabase>
50 | #include <QFontMetricsF>
51 | #include <QFormLayout>
52 | #include <QGuiApplication>
53 | #include <QHeaderView>
54 | #include <QIcon>
55 | #include <QJsonArray>
56 | #include <QJsonParseError>
57 | #include <QIntValidator>
58 | #include <QLineEdit>
59 | #include <QListView>
60 | #include <QJsonDocument>
61 | #include <QJsonObject>
62 | #include <QKeySequence>
63 | #include <QList>
64 | #include <QMenu>
65 | #include <QMessageBox>
66 | #include <QMouseEvent>
67 | #include <QMetaObject>
68 | #include <QMutex>
69 | #include <QMutexLocker>
70 | #include <QPainter>
71 | #include <QPalette>
72 | #include <QPlainTextEdit>
73 | #include <QPointer>
74 | #include <QResizeEvent>
75 | #include <QShowEvent>
76 | #include <QSet>
77 | #include <QSizePolicy>
78 | #include <QStandardPaths>
79 | #include <QStringList>
80 | #include <climits>
81 |
82 | #include <QStyle>
83 | #include <QTextBlock>
84 | #include <QToolTip>
85 | #include <QTextDocument>
86 | #include <QSplitter>
87 | #include <QPersistentModelIndex>
88 | #include <QTimer>
89 | #include <QStyledItemDelegate>
90 | #include <QStyleOptionViewItem>
91 | #include <QAbstractItemModel>
92 | #include <QItemSelectionModel>
93 | #include <QModelIndex>
94 | #include <QStandardItem>
95 | #include <QStandardItemModel>
96 | #include <QTreeView>
97 | #include <QTextStream>
98 | #include <QUrl>
99 |
100 | #include <QVBoxLayout>
101 | #include <QToolButton>
102 | #include <QHBoxLayout>
103 | #include <algorithm>
104 |
105 | #include <glib.h>
106 |
107 | #include "app/application_flavor.h"
108 | #include "wsutil/filesystem.h"
109 | #include <epan/prefs.h>
110 | #include <ui/qt/utils/color_utils.h>
111 | #include <ui/qt/widgets/wireshark_file_dialog.h>
112 | #include <ui/qt/utils/qt_ui_utils.h>
113 |
114 | #define LUA_DEBUGGER_SETTINGS_FILE"lua_debugger.json" "lua_debugger.json"
115 |
116 | using namespace LuaDebuggerItems;
117 |
118 | namespace
119 | {
120 | /** Global personal config path — debugger settings are not profile-specific. */
121 | QString
122 | luaDebuggerSettingsFilePath()
123 | {
124 | char *p = get_persconffile_path(
125 | LUA_DEBUGGER_SETTINGS_FILE"lua_debugger.json", false,
126 | application_configuration_environment_prefix());
127 | return gchar_free_to_qstring(p);
128 | }
129 |
130 | /** Fullwidth ＋ (U+FF0B) and － (U+FF0D): same advance; reads wider than ASCII +/−. */
131 | static const QString kLuaDbgHeaderPlus{QStringLiteral("\uFF0B")(QString(QtPrivate::qMakeStringPrivate(u"" "\uFF0B")))};
132 | static const QString kLuaDbgHeaderMinus{QStringLiteral("\uFF0D")(QString(QtPrivate::qMakeStringPrivate(u"" "\uFF0D")))};
133 |
134 | /** Tight, flat so glyphs sit in the same vertical band as the HLine. */
135 | static const QString kLuaDbgHeaderToolButtonStyle{QStringLiteral((QString(QtPrivate::qMakeStringPrivate(u"" "QToolButton { border: none; padding: 0px; margin: 0px; }" )))
136 | "QToolButton { border: none; padding: 0px; margin: 0px; }")(QString(QtPrivate::qMakeStringPrivate(u"" "QToolButton { border: none; padding: 0px; margin: 0px; }" )))};
137 |
138 | /** Maximum number of lines the Evaluate / logpoint output retains. */
139 | static constexpr int kLuaDbgEvalOutputMaxLines = 5000;
140 |
141 | namespace {
142 |
143 | /**
144 | * Visual mode for the Breakpoints header “activate all / deactivate all”
145 | * control. The dot mirrors the gutter convention — red @c #DC3545 when any
146 | * breakpoint is active, gray @c #808080 when all are inactive — so the
147 | * header aggregates what the gutter shows. Click flips the aggregate state.
148 | */
149 | enum class LuaDbgBpHeaderIconMode
150 | {
151 | NoBreakpoints, /**< Gray, control disabled (Qt dims it automatically). */
152 | ActivateAll, /**< Gray — all BPs inactive, click activates all. */
153 | DeactivateAll, /**< Red — any BP active, click deactivates all. */
154 | };
155 |
156 | /**
157 | * Breakpoint header: same geometry and fill/rim as @c LineNumberArea
158 | * (diameter @c 2*(h/2-2) from the editor @c QFontMetrics), centered in
159 | * @a headerSide. Renders at @a dpr (device pixels) for crisp icons on HiDPI,
160 | * like @c updateEnabledCheckboxIcon(). @a editorFont nullptr uses
161 | * @c QGuiApplication::font.
162 | */
163 | static QIcon
164 | luaDbgBreakpointHeaderIconForMode(const QFont *editorFont,
165 | LuaDbgBpHeaderIconMode mode, int headerSide,
166 | qreal dpr)
167 | {
168 | if (headerSide < 4)
169 | {
170 | headerSide = 12;
171 | }
172 | if (dpr <= 0.0 || dpr > 8.0)
173 | {
174 | dpr = 1.0;
175 | }
176 | const QFont f =
177 | editorFont != nullptr ? *editorFont : QGuiApplication::font();
178 | const QFontMetrics fm(f);
179 | /* Match line_number_area: radius = h/2 - 2, diameter 2*radius. */
180 | const int r = fm.height() / 2 - 2;
181 | int diam = 2 * qMax(0, r);
182 | diam = qMax(6, qMin(diam, headerSide - 4));
183 | const qreal s = static_cast<qreal>(headerSide);
184 | const qreal d = static_cast<qreal>(diam);
185 | const QRectF circleRect((s - d) / 2.0, (s - d) / 2.0, d, d);
186 |
187 | QPixmap pm(QSize(headerSide, headerSide) * dpr);
188 | pm.setDevicePixelRatio(dpr);
189 | pm.fill(Qt::transparent);
190 | {
191 | QPainter p(&pm);
192 | p.setRenderHint(QPainter::Antialiasing, true);
193 | QColor fill;
194 | switch (mode)
195 | {
196 | case LuaDbgBpHeaderIconMode::NoBreakpoints:
197 | case LuaDbgBpHeaderIconMode::ActivateAll:
198 | /* Match LineNumberArea disabled-breakpoint @c #808080. */
199 | fill = QColor(QStringLiteral("#808080")(QString(QtPrivate::qMakeStringPrivate(u"" "#808080"))));
200 | break;
201 | case LuaDbgBpHeaderIconMode::DeactivateAll:
202 | fill = QColor(QStringLiteral("#DC3545")(QString(QtPrivate::qMakeStringPrivate(u"" "#DC3545"))));
203 | break;
204 | }
205 | p.setBrush(fill);
206 | p.setPen(QPen(fill.darker(140), 1.0));
207 | p.drawEllipse(circleRect);
208 | }
209 | /* Only register the Normal pixmap so Qt dims the disabled state itself,
210 | * giving the @c NoBreakpoints case a visibly different look. */
211 | return QIcon(pm);
212 | }
213 |
214 | } // namespace
215 |
216 | static void
217 | styleLuaDebuggerHeaderBreakpointToggleButton(QToolButton *btn, int side)
218 | {
219 | btn->setToolButtonStyle(Qt::ToolButtonIconOnly);
220 | btn->setIconSize(QSize(side, side));
221 | btn->setFixedSize(side, side);
222 | btn->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
223 | btn->setText(QString());
224 | }
225 |
226 | /**
227 | * @a glyphs: shrink key is @a glyphs.first() (＋/－: first only, matching legacy);
228 | * grow step requires all glyphs' bounding heights to fit.
229 | */
230 | static void
231 | styleLuaDebuggerHeaderFittedTextButton(QToolButton *btn, int side,
232 | const QFont &titleFont,
233 | const QStringList &glyphs)
234 | {
235 | if (glyphs.isEmpty()) {
236 | return;
237 | }
238 | const QString &shrinkKey = glyphs[0];
239 | btn->setToolButtonStyle(Qt::ToolButtonTextOnly);
240 | QFont f = titleFont;
241 | for (int k = 0; k < 45 && f.pointSizeF() > 3.0; ++k) {
242 | QFontMetricsF m(f);
243 | const QRectF r = m.boundingRect(shrinkKey);
244 | if (m.height() <= static_cast<qreal>(side) + 0.5
245 | && r.height() <= static_cast<qreal>(side) + 0.5) {
246 | break;
247 | }
248 | f.setPointSizeF(f.pointSizeF() - 0.5);
249 | }
250 | for (int k = 0; k < 3; ++k) {
251 | QFont tryF = f;
252 | tryF.setPointSizeF(f.pointSizeF() + 0.5);
253 | QFontMetricsF m(tryF);
254 | qreal rMax = 0.0;
255 | for (const QString &g : glyphs) {
256 | rMax = std::max(rMax, m.boundingRect(g).height());
257 | }
258 | if (m.height() <= static_cast<qreal>(side) + 0.5
259 | && rMax <= static_cast<qreal>(side) + 0.5) {
260 | f = tryF;
261 | } else {
262 | break;
263 | }
264 | }
265 | btn->setFont(f);
266 | btn->setFixedSize(side, side);
267 | btn->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
268 | btn->setIcon(QIcon());
269 | }
270 |
271 | /** Plain +/− labels: same @a side as @c CollapsibleSection::titleButtonHeight. */
272 | static void
273 | styleLuaDebuggerHeaderPlusMinusButton(QToolButton *btn, int side,
274 | const QFont &titleFont)
275 | {
276 | const QStringList pm{kLuaDbgHeaderPlus, kLuaDbgHeaderMinus};
277 | styleLuaDebuggerHeaderFittedTextButton(btn, side, titleFont, pm);
278 | }
279 |
280 | /**
281 | * Apply the standard "header icon-only" sizing to @a btn — the
282 | * pixel-perfect treatment the trash button gets on each platform
283 | * (Mac: full @a side; non-Mac: @a side &minus; 4 so the themed glyph
284 | * doesn't read taller than the @c +/&minus; / toggle buttons next to
285 | * it). The caller is responsible for installing the icon itself; this
286 | * helper exists so every header icon button (Edit, Remove All, …)
287 | * picks up the same Mac-vs-Linux-vs-Windows sizing without drifting.
288 | */
289 | static void
290 | styleLuaDebuggerHeaderIconOnlyButton(QToolButton *btn, int side)
291 | {
292 | btn->setToolButtonStyle(Qt::ToolButtonIconOnly);
293 | #ifdef Q_OS_MAC
294 | const int btnSide = side;
295 | #else
296 | const int btnSide = qMax(1, side - 4);
297 | #endif
298 | btn->setIconSize(QSize(btnSide, btnSide));
299 | btn->setFixedSize(btnSide, btnSide);
300 | btn->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
301 | btn->setText(QString());
302 | }
303 |
304 | /** Trash icon, sized like every other header icon-only button. */
305 | static void
306 | styleLuaDebuggerHeaderRemoveAllButton(QToolButton *btn, int side)
307 | {
308 | btn->setIcon(QIcon::fromTheme(QStringLiteral("edit-delete")(QString(QtPrivate::qMakeStringPrivate(u"" "edit-delete"))),
309 | StockIcon(QStringLiteral("edit-clear")(QString(QtPrivate::qMakeStringPrivate(u"" "edit-clear"))))));
310 | styleLuaDebuggerHeaderIconOnlyButton(btn, side);
311 | }
312 |
313 |
314 | /* Application-wide event filter installed while the debugger is paused.
315 | *
316 | * Two responsibilities:
317 | *
318 | * 1. Swallow user-input and WM-close events destined for any
319 | * top-level window other than the debugger dialog. This is
320 | * defense-in-depth on top of the setEnabled(false) cuts the
321 | * dialog applies to other top-level widgets, the main window's
322 | * centralWidget(), and every QAction outside the debugger — it
323 | * catches widgets that pop up DURING the pause (e.g. dialogs
324 | * spawned from queued signals or nested-loop timers) and events
325 | * that bypass the enabled check (notably WM-delivered Close).
326 | *
327 | * 2. Swallow QEvent::UpdateRequest and QEvent::LayoutRequest
328 | * destined for the main window. While Lua is suspended inside a
329 | * paint-triggered dissection (the very common case where the
330 | * user scrolled the packet list and the next visible row hits a
331 | * breakpoint), the outer paint cycle is still on the call stack
332 | * above us. Letting the nested event loop process more
333 | * UpdateRequests on the same window drives QWidgetRepaintManager
334 | * into a re-entrant paintAndFlush() against the same
335 | * QCALayerBackingStore, which on macOS faults inside
336 | * QCALayerBackingStore::blitBuffer() (buffer already in flight to
337 | * CoreAnimation). The wslua_debugger_is_paused() guard in
338 | * dissect_lua/heur_dissect_lua prevents the Lua-VM half of the
339 | * re-entrancy, but it cannot prevent the platform plugin from
340 | * touching the still-live backing store of the outer paint.
341 | * Filtering UpdateRequest/LayoutRequest at the top of the main
342 | * window's event delivery is the cleanest fence: no paint pass
343 | * starts on the main window for the duration of the pause.
344 | *
345 | * This is not a Q_OBJECT because it has no signals/slots/property use;
346 | * overriding eventFilter() only requires subclassing QObject. */
347 | class PauseInputFilter : public QObject
348 | {
349 | public:
350 | explicit PauseInputFilter(QWidget *debugger_dialog,
351 | QWidget *main_window,
352 | QObject *parent = nullptr)
353 | : QObject(parent), debugger_dialog_(debugger_dialog),
354 | main_window_(main_window)
355 | {
356 | }
357 |
358 | bool eventFilter(QObject *watched, QEvent *event) override
359 | {
360 | const QEvent::Type type = event->type();
361 |
362 | /* Paint/layout suppression for the main window only. */
363 | if (type == QEvent::UpdateRequest ||
364 | type == QEvent::LayoutRequest)
365 | {
366 | if (main_window_ && watched == main_window_) {
367 | event->accept();
368 | return true;
369 | }
370 | return QObject::eventFilter(watched, event);
371 | }
372 |
373 | /* Close events get policy-aware routing rather than being
374 | * uniformly forwarded or uniformly swallowed:
375 | *
376 | * - Main window: must reach MainWindow::closeEvent() so it
377 | * can ignore() and record a deferred close. Swallowing
378 | * here would hide the window without running closeEvent,
379 | * which ends with epan_cleanup() running while cf->epan
380 | * is still alive and file_scope is still entered.
381 | *
382 | * - Debugger dialog (and any window parented under it,
383 | * e.g. QMessageBox prompts): forward, so the user can
384 | * close the debugger normally and the dialog's own
385 | * closeEvent gets to do the synchronous unfreeze.
386 | *
387 | * - Any other top-level window (e.g. I/O Graph, About,
388 | * preferences, statistics dialogs): swallow. We are sitting
389 | * in handlePause()'s nested event loop with the rest of
390 | * the UI deliberately frozen; closing a stats dialog from
391 | * underneath the application -- typically because macOS
392 | * Dock-Quit fanned a single Close pulse out to every
393 | * top-level window -- destroys widgets whose models are
394 | * still referenced by suspended slots and queued events. */
395 | if (type == QEvent::Close) {
396 | QWidget *w = qobject_cast<QWidget *>(watched);
397 | if (!w) {
398 | return QObject::eventFilter(watched, event);
399 | }
400 | if (main_window_ && w == main_window_) {
401 | return QObject::eventFilter(watched, event);
402 | }
403 | if (isOwnedByDebugger(w)) {
404 | return QObject::eventFilter(watched, event);
405 | }
406 | if (w->isWindow()) {
407 | event->ignore();
408 | return true;
409 | }
410 | return QObject::eventFilter(watched, event);
411 | }
412 |
413 | switch (type)
414 | {
415 | case QEvent::MouseButtonPress:
416 | case QEvent::MouseButtonRelease:
417 | case QEvent::MouseButtonDblClick:
418 | case QEvent::KeyPress:
419 | case QEvent::KeyRelease:
420 | case QEvent::Wheel:
421 | case QEvent::Shortcut:
422 | case QEvent::ShortcutOverride:
423 | case QEvent::ContextMenu:
424 | break;
425 | default:
426 | return QObject::eventFilter(watched, event);
427 | }
428 |
429 | QWidget *w = qobject_cast<QWidget *>(watched);
430 | if (!w) {
431 | return QObject::eventFilter(watched, event);
432 | }
433 |
434 | /* Allow the debugger UI and any separate window that is a child
435 | * in the object tree of the debugger (QMessageBox, QDialog,
436 | * etc. parented with the debugger as QDialog::parent()).
437 | * Those popups are top-level windows themselves, so a plain
438 | * QWidget::isAncestorOf() check returns false (it short-circuits
439 | * at window boundaries) and a top == debugger_dialog_ check
440 | * would swallow the popups' button input. */
441 | if (isOwnedByDebugger(w))
442 | {
443 | return QObject::eventFilter(watched, event);
444 | }
445 |
446 | /* Swallow: prevent user input from reaching suspended Qt
447 | * widgets whose callbacks could reenter Lua or invalidate
448 | * dissection state. */
449 | event->accept();
450 | return true;
451 | }
452 |
453 | private:
454 | /* True when w is the debugger dialog, or any widget reachable
455 | * from the debugger via the QObject parent chain. Walks the
456 | * object tree (which crosses window boundaries via
457 | * QObject::setParent), unlike QWidget::isAncestorOf which is
458 | * scoped to a single window and so returns false for child
459 | * QMessageBoxes / QDialogs created with the debugger as their
460 | * parent. The walk also climbs out of the popup's children
461 | * (button -> layout widget -> ... -> messagebox -> debugger). */
462 | bool isOwnedByDebugger(const QWidget *w) const
463 | {
464 | if (!debugger_dialog_ || !w) {
465 | return false;
466 | }
467 | for (const QObject *o = w; o; o = o->parent()) {
468 | if (o == debugger_dialog_) {
469 | return true;
470 | }
471 | }
472 | return false;
473 | }
474 |
475 | QWidget *debugger_dialog_;
476 | QWidget *main_window_;
477 | };
478 | } // namespace
479 |
480 | extern "C" void wslua_debugger_ui_callback(const char *file_path, int64_t line)
481 | {
482 | LuaDebuggerDialog *dialog = LuaDebuggerDialog::instance();
483 | if (dialog)
484 | {
485 | dialog->handlePause(file_path, line);
486 | }
487 | }
488 |
489 | LuaDebuggerDialog *LuaDebuggerDialog::_instance = nullptr;
490 | int32_t LuaDebuggerDialog::currentTheme_ = WSLUA_DEBUGGER_THEME_AUTO;
491 | bool LuaDebuggerDialog::s_captureSuppressionActive_ = false;
492 | bool LuaDebuggerDialog::s_captureSuppressionPrevEnabled_ = false;
493 | bool LuaDebuggerDialog::s_mainCloseDeferredByPause_ = false;
494 |
495 | namespace
496 | {
497 | /* Cross-thread coalescing queue for logpoint emissions.
498 | *
499 | * The line hook fires on the Lua thread; the GUI mutation must run
500 | * on the GUI thread. Per-fire queued invocations through
501 | * QMetaObject::invokeMethod allocate a heap functor and post an
502 | * event each time, which saturates the main event queue when a
503 | * logpoint matches every packet. Funnelling fires through this
504 | * queue and posting a single drain task per non-empty transition
505 | * keeps the queue depth bounded regardless of the firing rate.
506 | *
507 | * The mutex is held only for trivial list operations; the actual
508 | * widget append happens on the GUI thread under no extra lock. */
509 | QMutex s_logEmitMutex;
510 | QStringList s_pendingLogMessages;
511 | bool s_logDrainScheduled = false;
512 | } // namespace
513 |
514 | void LuaDebuggerDialog::trampolineLogEmit(const char *file_path, int64_t line,
515 | const char *message)
516 | {
517 | Q_UNUSED(file_path)(void)file_path;;
518 | Q_UNUSED(line)(void)line;;
519 | LuaDebuggerDialog *dialog = _instance;
520 | if (!dialog)
521 | {
522 | return;
523 | }
524 | QString messageQ = message ? QString::fromUtf8(message) : QString();
525 |
526 | bool schedule = false;
527 | {
528 | QMutexLocker lock(&s_logEmitMutex);
529 | s_pendingLogMessages.append(messageQ);
530 | if (!s_logDrainScheduled)
531 | {
532 | s_logDrainScheduled = true;
533 | schedule = true;
534 | }
535 | }
536 | if (schedule)
537 | {
538 | QMetaObject::invokeMethod(dialog, "drainPendingLogs",
539 | Qt::QueuedConnection);
540 | }
541 | }
542 |
543 | bool LuaDebuggerDialog::handleMainCloseIfPaused(QCloseEvent *event)
544 | {
545 | LuaDebuggerDialog *dbg = _instance;
546 | if (!wslua_debugger_is_paused())
547 | {
548 | /* Keep main-window quit and debugger Ctrl+Q consistent: if the
549 | * debugger owns unsaved script edits, run the debugger close gate
550 | * first so Save/Discard/Cancel semantics stay identical. */
551 | if (!dbg || !dbg->isVisible() || !dbg->hasUnsavedChanges())
552 | {
553 | return false;
554 | }
555 | event->ignore();
556 | s_mainCloseDeferredByPause_ = true;
557 | QMetaObject::invokeMethod(dbg, "close", Qt::QueuedConnection);
558 | dbg->raise();
559 | dbg->activateWindow();
560 | return true;
561 | }
562 | event->ignore();
563 | s_mainCloseDeferredByPause_ = true;
564 | if (dbg)
565 | {
566 | dbg->raise();
567 | dbg->activateWindow();
568 | }
569 | return true;
570 | }
571 |
572 | void LuaDebuggerDialog::deliverDeferredMainCloseIfPending()
573 | {
574 | if (!s_mainCloseDeferredByPause_)
575 | {
576 | return;
577 | }
578 | s_mainCloseDeferredByPause_ = false;
579 |
580 | /* Queue the close on the next event loop tick rather than calling
581 | * close() inline. We are still inside handlePause()'s post-loop
582 | * cleanup; the Lua C stack above us has not unwound yet, and
583 | * MainWindow::closeEvent ultimately invokes mainApp->quit() which
584 | * tears down epan. Running that synchronously would re-introduce
585 | * the wmem_cleanup_scopes() abort the deferral exists to avoid. */
586 | if (mainApp)
587 | {
588 | QWidget *mw = mainApp->mainWindow();
589 | if (mw)
590 | {
591 | QMetaObject::invokeMethod(mw, "close", Qt::QueuedConnection);
592 | }
593 | }
594 | }
595 |
596 | bool LuaDebuggerDialog::isSuppressedByLiveCapture()
597 | {
598 | return s_captureSuppressionActive_;
599 | }
600 |
601 | bool LuaDebuggerDialog::enterLiveCaptureSuppression()
602 | {
603 | /* Suppress on the very first start-ish event of a session.
604 | * "prepared" already commits us to a live capture, and the
605 | * dumpcap child may begin writing packets before the
606 | * "update_started" / "fixed_started" event arrives. */
607 | if (s_captureSuppressionActive_)
608 | {
609 | return false;
610 | }
611 | s_captureSuppressionPrevEnabled_ = wslua_debugger_is_enabled();
612 | s_captureSuppressionActive_ = true;
613 | if (s_captureSuppressionPrevEnabled_)
614 | {
615 | wslua_debugger_set_enabled(false);
616 | }
617 | return true;
618 | }
619 |
620 | bool LuaDebuggerDialog::exitLiveCaptureSuppression()
621 | {
622 | if (!s_captureSuppressionActive_)
623 | {
624 | return false;
625 | }
626 | const bool restore_enabled = s_captureSuppressionPrevEnabled_;
627 | s_captureSuppressionActive_ = false;
628 | s_captureSuppressionPrevEnabled_ = false;
629 | if (restore_enabled)
630 | {
631 | wslua_debugger_set_enabled(true);
632 | }
633 | return true;
634 | }
635 |
636 | void LuaDebuggerDialog::reconcileWithLiveCaptureOnStartup()
637 | {
638 | /* The capture-session callback (onCaptureSessionEvent) is registered
639 | * at process start by LuaDebuggerUiCallbackRegistrar, so by the time
640 | * this dialog opens, s_captureSuppressionActive_ already reflects
641 | * whether a live capture is in progress. We use that as the source
642 | * of truth (no dependency on main-window internals).
643 | *
644 | * What this method exists to fix: ctor init paths can re-enable the
645 | * core debugger after the callback already established suppression.
646 | * Specifically, applyDialogSettings() → wslua_debugger_add_breakpoint,
647 | * then updateBreakpoints() → ensureDebuggerEnabledForActiveBreakpoints
648 | * can re-enable. Without this reconciliation step, opening the
649 | * dialog during a live capture would leave the core enabled despite
650 | * suppression being "active". */
651 | if (!s_captureSuppressionActive_)
652 | {
653 | return;
654 | }
655 | if (wslua_debugger_is_enabled())
656 | {
657 | /* Force the core back off without touching
658 | * s_captureSuppressionPrevEnabled_ — it was correctly snapshotted
659 | * to the user's pre-capture intent when the capture started, and
660 | * is what should be restored on capture stop. */
661 | wslua_debugger_set_enabled(false);
662 | }
663 | /* Always refresh state chrome: even if we didn't have to flip
664 | * the core, the early state sync above happened
665 | * before applyDialogSettings()/updateBreakpoints() ran, so the
666 | * widgets may still reflect a transient state. */
667 | refreshDebuggerStateUi();
668 | }
669 |
670 | void LuaDebuggerDialog::onCaptureSessionEvent(int event,
671 | struct _capture_session *cap_session,
672 | void *user_data)
673 | {
674 | Q_UNUSED(cap_session)(void)cap_session;;
675 | Q_UNUSED(user_data)(void)user_data;;
676 |
677 | #ifdef HAVE_LIBPCAP1
678 | bool state_changed = false;
679 |
680 | switch (event)
681 | {
682 | case capture_cb_capture_prepared:
683 | case capture_cb_capture_update_started:
684 | case capture_cb_capture_fixed_started:
685 | state_changed = enterLiveCaptureSuppression();
686 | break;
687 | case capture_cb_capture_update_finished:
688 | case capture_cb_capture_fixed_finished:
689 | case capture_cb_capture_failed:
690 | state_changed = exitLiveCaptureSuppression();
691 | break;
692 | default:
693 | break;
694 | }
695 |
696 | if (state_changed && _instance)
697 | {
698 | _instance->refreshDebuggerStateUi();
699 | }
700 | #else
701 | Q_UNUSED(event)(void)event;;
702 | #endif
703 | }
704 |
705 | int32_t LuaDebuggerDialog::currentTheme() {
706 | return currentTheme_;
707 | }
708 |
709 | namespace
710 | {
711 | // ============================================================================
712 | // Settings Keys (for JSON persistence)
713 | // ============================================================================
714 | namespace SettingsKeys
715 | {
716 | constexpr const char *Theme = "theme";
717 | constexpr const char *MainSplitter = "mainSplitterState";
718 | constexpr const char *LeftSplitter = "leftSplitterState";
719 | constexpr const char *EvalSplitter = "evalSplitterState";
720 | constexpr const char *SectionVariables = "sectionVariables";
721 | constexpr const char *SectionStack = "sectionStack";
722 | constexpr const char *SectionFiles = "sectionFiles";
723 | constexpr const char *SectionBreakpoints = "sectionBreakpoints";
724 | constexpr const char *SectionEval = "sectionEval";
725 | constexpr const char *SectionSettings = "sectionSettings";
726 | constexpr const char *SectionWatch = "sectionWatch";
727 | constexpr const char *Breakpoints = "breakpoints";
728 | constexpr const char *Watches = "watches";
729 | } // namespace SettingsKeys
730 |
731 | /** QVariantMap values for JSON arrays are typically QVariantList of QVariantMap. */
732 | static QJsonArray
733 | jsonArrayFromSettingsMap(const QVariantMap &map, const char *key)
734 | {
735 | const QVariant v = map.value(QString::fromUtf8(key));
736 | if (!v.isValid())
737 | {
738 | return QJsonArray();
739 | }
740 | return QJsonValue::fromVariant(v).toArray();
741 | }
742 |
743 | // ============================================================================
744 | // Tree Widget User Roles
745 | // ============================================================================
746 | constexpr qint32 FileTreePathRole = static_cast<qint32>(Qt::UserRole);
747 | constexpr qint32 FileTreeIsDirectoryRole = static_cast<qint32>(Qt::UserRole + 1);
748 | constexpr qint32 BreakpointFileRole = static_cast<qint32>(Qt::UserRole + 2);
749 | constexpr qint32 BreakpointLineRole = static_cast<qint32>(Qt::UserRole + 3);
750 | constexpr qint32 BreakpointConditionRole =
751 | static_cast<qint32>(Qt::UserRole + 30);
752 | constexpr qint32 BreakpointHitCountRole =
753 | static_cast<qint32>(Qt::UserRole + 31);
754 | constexpr qint32 BreakpointHitTargetRole =
755 | static_cast<qint32>(Qt::UserRole + 32);
756 | constexpr qint32 BreakpointConditionErrRole =
757 | static_cast<qint32>(Qt::UserRole + 33);
758 | constexpr qint32 BreakpointLogMessageRole =
759 | static_cast<qint32>(Qt::UserRole + 34);
760 | constexpr qint32 BreakpointHitModeRole =
761 | static_cast<qint32>(Qt::UserRole + 35);
762 | constexpr qint32 BreakpointLogAlsoPauseRole =
763 | static_cast<qint32>(Qt::UserRole + 36);
764 | constexpr qint32 StackItemFileRole = static_cast<qint32>(Qt::UserRole + 4);
765 | constexpr qint32 StackItemLineRole = static_cast<qint32>(Qt::UserRole + 5);
766 | constexpr qint32 StackItemNavigableRole = static_cast<qint32>(Qt::UserRole + 6);
767 | constexpr qint32 StackItemLevelRole = static_cast<qint32>(Qt::UserRole + 7);
768 | constexpr qint32 VariablePathRole = static_cast<qint32>(Qt::UserRole + 8);
769 | constexpr qint32 VariableTypeRole = static_cast<qint32>(Qt::UserRole + 9);
770 | constexpr qint32 VariableCanExpandRole = static_cast<qint32>(Qt::UserRole + 10);
771 | constexpr qint32 WatchSpecRole = static_cast<qint32>(Qt::UserRole + 11);
772 | constexpr qint32 WatchSubpathRole = static_cast<qint32>(Qt::UserRole + 13);
773 | constexpr qint32 WatchPendingNewRole = static_cast<qint32>(Qt::UserRole + 15);
774 | /*
775 | * Expansion state for watch roots and Variables sections is tracked in
776 | * LuaDebuggerDialog::watchExpansion_ and variablesExpansion_ (runtime-only
777 | * QHashes). The dialog members are the single source of truth and survive
778 | * child-item destruction during pause / resume / step.
779 | *
780 | * "Value changed since last pause" baselines live on the dialog too
781 | * (LuaDebuggerDialog::watchRootBaseline_ / watchChildBaseline_ /
782 | * variablesBaseline_ and their *Current_ mirrors); see the header.
783 | */
784 | /** Monotonic flash generation id stored on a value cell so a pending
785 | * clear-timer only clears its own flash, not a fresher one. */
786 | constexpr qint32 ChangedFlashSerialRole =
787 | static_cast<qint32>(Qt::UserRole + 20);
788 |
789 | constexpr qsizetype WATCH_TOOLTIP_MAX_CHARS = 4096;
790 | constexpr int WATCH_EXPR_MAX_CHARS = 65536;
791 | /** Transient background-flash duration for a value that just changed. */
792 | constexpr int CHANGED_FLASH_MS = 500;
793 | /**
794 | * Delay before applying the "Watch column shows —" placeholder after a step
795 | * resume. A typical Lua single-step re-pauses well within a few ms; running
796 | * the placeholder repaint immediately would visibly flicker every Watch row
797 | * value→—→value across the resume / re-pause boundary, even when the value
798 | * did not change. handlePause() bumps watchPlaceholderEpoch_, so any timer
799 | * scheduled with a stale epoch is dropped without touching the Watch tree.
800 | * Long-running steps (or scripts that simply terminate) still see the
801 | * placeholder appear after this delay.
802 | */
803 | constexpr int WATCH_PLACEHOLDER_DEFER_MS = 250;
804 | /** Separator used in composite (stackLevel, path) baseline-map keys. */
805 | constexpr QChar CHANGE_KEY_SEP = QChar(0x1F); // ASCII Unit Separator
806 |
807 | /** @brief Registers the UI callback with the Lua debugger core at load time.
808 | *
809 | * Also wires up a capture-session observer (when libpcap is available)
810 | * so the debugger is force-disabled for the duration of any live
811 | * capture; see LuaDebuggerDialog::onCaptureSessionEvent for rationale. */
812 | class LuaDebuggerUiCallbackRegistrar
813 | {
814 | public:
815 | LuaDebuggerUiCallbackRegistrar()
816 | {
817 | wslua_debugger_register_ui_callback(wslua_debugger_ui_callback);
818 | #ifdef HAVE_LIBPCAP1
819 | capture_callback_add(&LuaDebuggerDialog::onCaptureSessionEvent,
820 | nullptr);
821 | #endif
822 | }
823 |
824 | ~LuaDebuggerUiCallbackRegistrar()
825 | {
826 | wslua_debugger_register_ui_callback(NULL__null);
827 | #ifdef HAVE_LIBPCAP1
828 | capture_callback_remove(&LuaDebuggerDialog::onCaptureSessionEvent,
829 | nullptr);
830 | #endif
831 | }
832 | };
833 |
834 | static LuaDebuggerUiCallbackRegistrar g_luaDebuggerUiCallbackRegistrar;
835 |
836 | /** @brief Build a key sequence from a key event for matching QAction shortcuts. */
837 | static QKeySequence luaSeqFromKeyEvent(const QKeyEvent *ke)
838 | {
839 | #if QT_VERSION((6<<16)|(4<<8)|(2)) >= QT_VERSION_CHECK(6, 0, 0)((6<<16)|(0<<8)|(0))
840 | return QKeySequence(QKeyCombination(ke->modifiers(), static_cast<Qt::Key>(ke->key())));
841 | #else
842 | return QKeySequence(ke->key() | ke->modifiers());
843 | #endif
844 | }
845 |
846 | /** @brief Sequences for context-menu labels and eventFilter (must match). */
847 | const QKeySequence kCtxGoToLine(QKeySequence(Qt::CTRL | Qt::Key_G));
848 | const QKeySequence kCtxRunToLine(QKeySequence(Qt::CTRL | Qt::Key_F10));
849 | const QKeySequence kCtxWatchEdit(Qt::Key_F2);
850 | const QKeySequence kCtxWatchCopyValue(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_C));
851 | const QKeySequence kCtxWatchDuplicate(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_D));
852 | const QKeySequence kCtxWatchRemoveAll(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_K));
853 | const QKeySequence kCtxAddWatch(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_W));
854 | const QKeySequence kCtxToggleBreakpoint(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_B));
855 | const QKeySequence kCtxReloadLuaPlugins(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_L));
856 | const QKeySequence kCtxRemoveAllBreakpoints(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_F9));
857 |
858 | /**
859 | * @brief True if @a pressed is one of the debugger shortcuts that overlap the
860 | * main window and must be reserved in ShortcutOverride.
861 | */
862 | static bool matchesLuaDebuggerShortcutKeys(Ui::LuaDebuggerDialog *ui,
863 | const QKeySequence &pressed)
864 | {
865 | return (pressed.matches(ui->actionFind->shortcut()) == QKeySequence::ExactMatch) ||
866 | (pressed.matches(ui->actionSaveFile->shortcut()) == QKeySequence::ExactMatch) ||
867 | (pressed.matches(ui->actionGoToLine->shortcut()) == QKeySequence::ExactMatch) ||
868 | (pressed.matches(ui->actionReloadLuaPlugins->shortcut()) == QKeySequence::ExactMatch) ||
869 | (pressed.matches(ui->actionAddWatch->shortcut()) == QKeySequence::ExactMatch) ||
870 | (pressed.matches(ui->actionContinue->shortcut()) == QKeySequence::ExactMatch) ||
871 | (pressed.matches(ui->actionStepIn->shortcut()) == QKeySequence::ExactMatch) ||
872 | (pressed.matches(kCtxRunToLine) == QKeySequence::ExactMatch) ||
873 | (pressed.matches(kCtxToggleBreakpoint) == QKeySequence::ExactMatch) ||
874 | (pressed.matches(kCtxWatchCopyValue) == QKeySequence::ExactMatch) ||
875 | (pressed.matches(kCtxWatchDuplicate) == QKeySequence::ExactMatch);
876 | }
877 |
878 | /**
879 | * @brief Run debugger toolbar actions that share shortcuts with the main window.
880 | *
881 | * When a capture file is open, Wireshark enables Find Packet (Ctrl+F) and
882 | * Go to Packet (Ctrl+G). QEvent::ShortcutOverride is handled separately: we only
883 | * accept() there so Qt does not activate the main-window QAction; triggering
884 | * happens on KeyPress only. Doing both would call showAccordionFrame(..., true)
885 | * twice and toggle the bar closed immediately after opening.
886 | *
887 | * @return True if @a pressed matches one of these shortcuts (handled or not).
888 | */
889 | static bool triggerLuaDebuggerShortcuts(Ui::LuaDebuggerDialog *ui,
890 | const QKeySequence &pressed)
891 | {
892 | if (pressed.matches(ui->actionFind->shortcut()) == QKeySequence::ExactMatch)
893 | {
894 | if (ui->actionFind->isEnabled())
895 | {
896 | ui->actionFind->trigger();
897 | }
898 | return true;
899 | }
900 | if (pressed.matches(ui->actionSaveFile->shortcut()) == QKeySequence::ExactMatch)
901 | {
902 | if (ui->actionSaveFile->isEnabled())
903 | {
904 | ui->actionSaveFile->trigger();
905 | }
906 | return true;
907 | }
908 | if (pressed.matches(ui->actionGoToLine->shortcut()) == QKeySequence::ExactMatch)
909 | {
910 | if (ui->actionGoToLine->isEnabled())
911 | {
912 | ui->actionGoToLine->trigger();
913 | }
914 | return true;
915 | }
916 | if (pressed.matches(ui->actionReloadLuaPlugins->shortcut()) ==
917 | QKeySequence::ExactMatch)
918 | {
919 | if (ui->actionReloadLuaPlugins->isEnabled())
920 | {
921 | ui->actionReloadLuaPlugins->trigger();
922 | }
923 | return true;
924 | }
925 | if (pressed.matches(ui->actionAddWatch->shortcut()) ==
926 | QKeySequence::ExactMatch)
927 | {
928 | if (ui->actionAddWatch->isEnabled())
929 | {
930 | ui->actionAddWatch->trigger();
931 | }
932 | return true;
933 | }
934 | return false;
935 | }
936 |
937 | static LuaDebuggerCodeView *codeViewFromObject(QObject *obj)
938 | {
939 | for (QObject *o = obj; o; o = o->parent())
940 | {
941 | if (auto *cv = qobject_cast<LuaDebuggerCodeView *>(o))
942 | {
943 | return cv;
944 | }
945 | }
946 | return nullptr;
947 | }
948 |
949 | static QStandardItem *watchRootItem(QStandardItem *item)
950 | {
951 | while (item && item->parent())
952 | {
953 | item = item->parent();
954 | }
955 | return item;
956 | }
957 |
958 | /**
959 | * @brief Return the Lua identifier under the given cursor position, or
960 | * an empty string if the position is not on an identifier.
961 | *
962 | * Lua identifiers are `[A-Za-z_][A-Za-z0-9_]*`. The bracket grammar that
963 | * the Watch panel accepts (`a.b[1]`, `a.b["k"]`) is not synthesized here
964 | * — only the bare identifier under the caret is returned, mirroring the
965 | * "double-click to select word" affordance Qt's text editors offer.
966 | */
967 | static QString luaIdentifierUnderCursor(const QTextCursor &cursor)
968 | {
969 | const QString block = cursor.block().text();
970 | const int posInBlock = cursor.positionInBlock();
971 | if (block.isEmpty() || posInBlock < 0 || posInBlock > block.size())
972 | {
973 | return {};
974 | }
975 |
976 | auto isIdentChar = [](QChar c)
977 | {
978 | return c.isLetterOrNumber() || c == QLatin1Char('_');
979 | };
980 | auto isIdentStart = [](QChar c)
981 | {
982 | return c.isLetter() || c == QLatin1Char('_');
983 | };
984 |
985 | /* Caret may sit one past the end of the identifier; expand left
986 | * until we are inside, then sweep both directions. */
987 | int start = posInBlock;
988 | int end = posInBlock;
989 | if (start > 0 && !isIdentChar(block.at(start - 1)) &&
990 | (start >= block.size() || !isIdentChar(block.at(start))))
991 | {
992 | return {};
993 | }
994 | while (start > 0 && isIdentChar(block.at(start - 1)))
995 | {
996 | --start;
997 | }
998 | while (end < block.size() && isIdentChar(block.at(end)))
999 | {
1000 | ++end;
1001 | }
1002 | if (start >= end)
1003 | {
1004 | return {};
1005 | }
1006 | if (!isIdentStart(block.at(start)))
1007 | {
1008 | return {};
1009 | }
1010 | return block.mid(start, end - start);
1011 | }
1012 |
1013 | /**
1014 | * True when a watch spec resolves against `_G` (i.e. not frame-dependent).
1015 | * Used by changeKey() to avoid invalidating a Globals watch when the user
1016 | * switches the call-stack frame.
1017 | */
1018 | static bool watchSpecIsGlobalScoped(const QString &spec)
1019 | {
1020 | const QString t = spec.trimmed();
1021 | return t.startsWith(QLatin1String("Globals")) ||
1022 | t == QLatin1String("_G") ||
1023 | t.startsWith(QLatin1String("_G."));
1024 | }
1025 |
1026 | /**
1027 | * True when a Variables tree path is under `Globals` (frame-independent).
1028 | */
1029 | static bool variablesPathIsGlobalScoped(const QString &path)
1030 | {
1031 | return path == QLatin1String("Globals") ||
1032 | path.startsWith(QLatin1String("Globals."));
1033 | }
1034 |
1035 | /** Compose a baseline-map key from (stackLevel, path). */
1036 | static QString changeKey(int stackLevel, const QString &path)
1037 | {
1038 | return QString::number(stackLevel) + CHANGE_KEY_SEP + path;
1039 | }
1040 |
1041 | /** Parse the "spec" portion out of a composite key produced by changeKey(). */
1042 | static QString watchSpecFromChangeKey(const QString &key)
1043 | {
1044 | const qsizetype sep = key.indexOf(CHANGE_KEY_SEP);
1045 | return sep < 0 ? key : key.mid(sep + 1);
1046 | }
1047 |
1048 | /**
1049 | * Strip the synthetic @c "watch:N:" prefix Lua prepends to runtime errors
1050 | * raised inside an expression chunk. The C side loads the chunk with the
1051 | * name @c "=watch", so @c assert / @c error messages come back as
1052 | * @c "watch:1: <user message>". The chunk is always one line and the row's
1053 | * red error chrome already conveys "watch failed", so the prefix is pure
1054 | * noise in the *Value* cell — most painfully in predicate watches where
1055 | * the user-supplied message is the entire payload of the row. The full,
1056 | * unstripped string is preserved in the cell tooltip for anyone who wants
1057 | * the location.
1058 | *
1059 | * Path-watch errors ("Path not found: …") do not start with @c "watch:",
1060 | * so they are returned unchanged.
1061 | */
1062 | static QString stripWatchExpressionErrorPrefix(const QString &errStr)
1063 | {
1064 | static const QLatin1String kPrefix("watch:");
1065 | if (!errStr.startsWith(kPrefix))
1066 | {
1067 | return errStr;
1068 | }
1069 | qsizetype i = kPrefix.size();
1070 | const qsizetype digitStart = i;
1071 | while (i < errStr.size() && errStr.at(i).isDigit())
1072 | {
1073 | ++i;
1074 | }
1075 | if (i == digitStart || i >= errStr.size() ||
1076 | errStr.at(i) != QLatin1Char(':'))
1077 | {
1078 | return errStr;
1079 | }
1080 | ++i;
1081 | while (i < errStr.size() &&
1082 | (errStr.at(i) == QLatin1Char(' ') || errStr.at(i) == QLatin1Char('\t')))
1083 | {
1084 | ++i;
1085 | }
1086 | return errStr.mid(i);
1087 | }
1088 |
1089 | /**
1090 | * Lookup / compare in one place. Returns true when @a key was recorded in
1091 | * @a baseline with a different value, or (when @a flashNew is set) when @a
1092 | * key was absent from @a baseline but @a baseline itself is non-empty —
1093 | * i.e. we have *some* prior snapshot to compare against, so the absence of
1094 | * this key means the variable appeared since the last pause.
1095 | *
1096 | * An empty-string baseline is still a valid recorded value and can signal a
1097 | * change.
1098 | *
1099 | * The @a flashNew heuristic is used for runtime-discovered rows
1100 | * (Variables tree, Watch children inside an expanded table) but is
1101 | * deliberately false for Watch *roots* since a root with no baseline is
1102 | * almost always one the user just added and should not flash on its first
1103 | * evaluation.
1104 | *
1105 | * Factored out so the unit tests can exercise it without a live dialog.
1106 | */
1107 | template <class Key, class Map>
1108 | static bool shouldMarkChanged(const Map &baseline, const Key &key,
1109 | const QString &newVal, bool flashNew = false)
1110 | {
1111 | const auto it = baseline.constFind(key);
1112 | if (it != baseline.constEnd())
1113 | {
1114 | return *it != newVal;
1115 | }
1116 | return flashNew && !baseline.isEmpty();
1117 | }
1118 |
1119 |
1120 | /** Top-level Variables section key (`Locals` / `Globals` / `Upvalues`). */
1121 | static QString variableSectionRootKeyFromItem(const QStandardItem *item)
1122 | {
1123 | if (!item)
1124 | {
1125 | return QString();
1126 | }
1127 | const QStandardItem *walk = item;
1128 | while (walk->parent())
1129 | {
1130 | walk = walk->parent();
1131 | }
1132 | return walk->data(VariablePathRole).toString();
1133 | }
1134 |
1135 | static bool watchSpecUsesPathResolution(const QString &spec)
1136 | {
1137 | const QByteArray ba = spec.toUtf8();
1138 | return wslua_debugger_watch_spec_uses_path_resolution(ba.constData());
1139 | }
1140 |
1141 | /** Child variable path under @a parentPath (Variables tree and path-based Watch rows). */
1142 | static QString variableTreeChildPath(const QString &parentPath,
1143 | const QString &nameText)
1144 | {
1145 | if (parentPath.isEmpty())
1146 | {
1147 | return nameText;
1148 | }
1149 | if (nameText.startsWith(QLatin1Char('[')))
1150 | {
1151 | return parentPath + nameText;
1152 | }
1153 | return parentPath + QLatin1Char('.') + nameText;
1154 | }
1155 |
1156 | /**
1157 | * Subpath under @a parentSubpath addressed at @a nameText for an expression
1158 | * watch. Unlike the Variables-tree path (variableTreeChildPath), the
1159 | * top-level segment is anchored to "the expression result" (parentSubpath
1160 | * is empty) rather than a Locals/Upvalues/Globals section, so a string
1161 | * key always carries a leading @c '.' even at depth 1.
1162 | *
1163 | * The result is consumed by @c wslua_debugger_traverse_subpath_on_top via
1164 | * @c wslua_debugger_watch_expr_get_variables; that helper accepts subpaths
1165 | * starting with @c '.' or @c '['.
1166 | */
1167 | static QString expressionWatchChildSubpath(const QString &parentSubpath,
1168 | const QString &nameText)
1169 | {
1170 | if (nameText.startsWith(QLatin1Char('[')))
1171 | {
1172 | return parentSubpath + nameText;
1173 | }
1174 | return parentSubpath + QLatin1Char('.') + nameText;
1175 | }
1176 |
1177 | /** Globals subtree is sorted by name; Locals/Upvalues keep engine order. */
1178 | static bool variableChildrenShouldSortByName(const QString &parentPath)
1179 | {
1180 | return !parentPath.isEmpty() &&
1181 | parentPath.startsWith(QLatin1String("Globals"));
1182 | }
1183 |
1184 | /**
1185 | * Shared fields extracted from a `wslua_variable_t` for populating either the
1186 | * Variables tree or a Watch child row. Kept here so both call sites agree on
1187 | * how engine data maps to UI strings.
1188 | */
1189 | struct VariableRowFields
1190 | {
1191 | QString name;
1192 | QString value;
1193 | QString type;
1194 | bool canExpand = false;
1195 | QString childPath;
1196 | };
1197 |
1198 | static VariableRowFields readVariableRowFields(const wslua_variable_t &v,
1199 | const QString &parentPath)
1200 | {
1201 | VariableRowFields f;
1202 | f.name = QString::fromUtf8(v.name ? v.name : "");
1203 | f.value = QString::fromUtf8(v.value ? v.value : "");
1204 | f.type = QString::fromUtf8(v.type ? v.type : "");
1205 | f.canExpand = v.can_expand ? true : false;
1206 | f.childPath = variableTreeChildPath(parentPath, f.name);
1207 | return f;
1208 | }
1209 |
1210 | /**
1211 | * Install the expansion indicator on @a item and (optionally) a dummy child
1212 | * placeholder so that onItemExpanded can lazily populate children. Watch rows
1213 | * use `enabledOnlyPlaceholder=true` so the placeholder never becomes selectable.
1214 | */
1215 | static void applyVariableExpansionIndicator(QStandardItem *col0,
1216 | bool canExpand,
1217 | bool enabledOnlyPlaceholder,
1218 | int columnCount = 3)
1219 | {
1220 | if (!canExpand)
1221 | {
1222 | return;
1223 | }
1224 | if (columnCount == 2)
1225 | {
1226 | auto *p0 = new QStandardItem();
1227 | auto *p1 = new QStandardItem();
1228 | if (enabledOnlyPlaceholder)
1229 | {
1230 | p0->setFlags(Qt::ItemIsEnabled);
1231 | p1->setFlags(Qt::ItemIsEnabled);
1232 | }
1233 | col0->appendRow({p0, p1});
1234 | return;
1235 | }
1236 | auto *p0 = new QStandardItem();
1237 | auto *p1 = new QStandardItem();
1238 | auto *p2 = new QStandardItem();
1239 | if (enabledOnlyPlaceholder)
1240 | {
1241 | p0->setFlags(Qt::ItemIsEnabled);
1242 | p1->setFlags(Qt::ItemIsEnabled);
1243 | p2->setFlags(Qt::ItemIsEnabled);
1244 | }
1245 | else
1246 | {
1247 | for (QStandardItem *p : {p0, p1, p2})
1248 | {
1249 | p->setFlags((p->flags() | Qt::ItemIsSelectable | Qt::ItemIsEnabled) &
1250 | ~Qt::ItemIsEditable);
1251 | }
1252 | }
1253 | col0->appendRow({p0, p1, p2});
1254 | }
1255 |
1256 | /** Full Variables path for path-style watches (e.g. Locals.foo for "foo"). */
1257 | static QString watchVariablePathForSpec(const QString &spec)
1258 | {
1259 | char *p =
1260 | wslua_debugger_watch_variable_path_for_spec(spec.toUtf8().constData());
1261 | if (!p)
1262 | {
1263 | return QString();
1264 | }
1265 | QString s = QString::fromUtf8(p);
1266 | g_free(p);
1267 | return s;
1268 | }
1269 |
1270 | /**
1271 | * Variables-tree path for UI (matches locals / upvalues / globals resolution for
1272 | * the first path segment when paused; otherwise same as watchVariablePathForSpec).
1273 | */
1274 | static QString watchResolvedVariablePathForTooltip(const QString &spec)
1275 | {
1276 | if (spec.trimmed().isEmpty())
1277 | {
1278 | return QString();
1279 | }
1280 | char *p = wslua_debugger_watch_resolved_variable_path_for_spec(
1281 | spec.toUtf8().constData());
1282 | if (!p)
1283 | {
1284 | return QString();
1285 | }
1286 | QString s = QString::fromUtf8(p);
1287 | g_free(p);
1288 | return s;
1289 | }
1290 |
1291 | /** Sets VariablePathRole on a watch root from spec (resolved section when paused). */
1292 | static void watchRootSetVariablePathRoleFromSpec(QStandardItem *row,
1293 | const QString &spec)
1294 | {
1295 | if (!row)
1296 | {
1297 | return;
1298 | }
1299 | const QString t = spec.trimmed();
1300 | if (t.isEmpty())
1301 | {
1302 | row->setData(QVariant(), VariablePathRole);
1303 | return;
1304 | }
1305 | const QString vpRes = watchResolvedVariablePathForTooltip(t);
1306 | if (!vpRes.isEmpty())
1307 | {
1308 | row->setData(vpRes, VariablePathRole);
1309 | return;
1310 | }
1311 | const QString vp = watchVariablePathForSpec(t);
1312 | if (!vp.isEmpty())
1313 | {
1314 | row->setData(vp, VariablePathRole);
1315 | }
1316 | else
1317 | {
1318 | row->setData(QVariant(), VariablePathRole);
1319 | }
1320 | }
1321 |
1322 | /** Locals / Upvalues / Globals line for watch tooltips (full variable-tree path). */
1323 | static QString watchPathOriginSuffix(const QStandardItem *item,
1324 | const QString &spec)
1325 | {
1326 | /* Prefer resolver output (matches lookup order for unqualified names). */
1327 | QString vp;
1328 | if (!spec.trimmed().isEmpty())
1329 | {
1330 | vp = watchResolvedVariablePathForTooltip(spec);
1331 | }
1332 | if (vp.isEmpty() && item)
1333 | {
1334 | vp = item->data(VariablePathRole).toString();
1335 | }
1336 | if (vp.startsWith(QLatin1String("Locals.")) ||
1337 | vp == QLatin1String("Locals"))
1338 | {
1339 | return QStringLiteral("\n%1")(QString(QtPrivate::qMakeStringPrivate(u"" "\n%1"))).arg(
1340 | LuaDebuggerDialog::tr("From: Locals"));
1341 | }
1342 | if (vp.startsWith(QLatin1String("Upvalues.")) ||
1343 | vp == QLatin1String("Upvalues"))
1344 | {
1345 | return QStringLiteral("\n%1")(QString(QtPrivate::qMakeStringPrivate(u"" "\n%1"))).arg(
1346 | LuaDebuggerDialog::tr("From: Upvalues"));
1347 | }
1348 | if (vp.startsWith(QLatin1String("Globals.")) ||
1349 | vp == QLatin1String("Globals"))
1350 | {
1351 | return QStringLiteral("\n%1")(QString(QtPrivate::qMakeStringPrivate(u"" "\n%1"))).arg(
1352 | LuaDebuggerDialog::tr("From: Globals"));
1353 | }
1354 | return QString();
1355 | }
1356 |
1357 | static QString capWatchTooltipText(const QString &s)
1358 | {
1359 | if (s.size() <= WATCH_TOOLTIP_MAX_CHARS)
1360 | {
1361 | return s;
1362 | }
1363 | return s.left(WATCH_TOOLTIP_MAX_CHARS) +
1364 | LuaDebuggerDialog::tr("\n… (truncated)");
1365 | }
1366 |
1367 | /** Parent path key for Locals.a.b / a[1].x style watch paths (expression subpaths or variable paths). */
1368 | static QString watchPathParentKey(const QString &path)
1369 | {
1370 | if (path.isEmpty())
1371 | {
1372 | return QString();
1373 | }
1374 | if (path.endsWith(QLatin1Char(']')))
1375 | {
1376 | int depth = 0;
1377 | for (int i = static_cast<int>(path.size()) - 1; i >= 0; --i)
1378 | {
1379 | const QChar c = path.at(i);
1380 | if (c == QLatin1Char(']'))
1381 | {
1382 | depth++;
1383 | }
1384 | else if (c == QLatin1Char('['))
1385 | {
1386 | depth--;
1387 | if (depth == 0)
1388 | {
1389 | return path.left(i);
1390 | }
1391 | }
1392 | }
1393 | return QString();
1394 | }
1395 | const qsizetype dot = path.lastIndexOf(QLatin1Char('.'));
1396 | if (dot > 0)
1397 | {
1398 | return path.left(dot);
1399 | }
1400 | return QString();
1401 | }
1402 |
1403 | /**
1404 | * Populate the text and tooltip cells for one Watch-tree child row.
1405 | * "Value changed since last pause" visuals (accent + bold + optional flash)
1406 | * are applied by the dialog via `applyChangedVisuals` once this function
1407 | * has installed the display text; see `fillWatchPathChildren`.
1408 | */
1409 | static void applyWatchChildRowTextAndTooltip(QStandardItem *col0,
1410 | const QString &rawVal,
1411 | const QString &typeText)
1412 | {
1413 | auto *wm = qobject_cast<QStandardItemModel *>(col0->model());
1414 | if (!wm)
1415 | {
1416 | return;
1417 | }
1418 | setText(wm, col0, 1, rawVal);
1419 | const QString tooltipSuffix =
1420 | typeText.isEmpty()
1421 | ? QString()
1422 | : LuaDebuggerDialog::tr("Type: %1").arg(typeText);
1423 | setToolTip(
1424 | wm, col0, 0,
1425 | capWatchTooltipText(
1426 | tooltipSuffix.isEmpty()
1427 | ? col0->text()
1428 | : QStringLiteral("%1\n%2")(QString(QtPrivate::qMakeStringPrivate(u"" "%1\n%2"))).arg(col0->text(), tooltipSuffix)));
1429 | setToolTip(
1430 | wm, col0, 1,
1431 | capWatchTooltipText(
1432 | tooltipSuffix.isEmpty()
1433 | ? rawVal
1434 | : QStringLiteral("%1\n%2")(QString(QtPrivate::qMakeStringPrivate(u"" "%1\n%2"))).arg(rawVal, tooltipSuffix)));
1435 | }
1436 |
1437 | static int watchSubpathBoundaryCount(const QString &subpath)
1438 | {
1439 | QString p = subpath;
1440 | if (p.startsWith(QLatin1Char('.')))
1441 | {
1442 | p = p.mid(1);
1443 | }
1444 | int n = 0;
1445 | for (QChar ch : p)
1446 | {
1447 | if (ch == QLatin1Char('.') || ch == QLatin1Char('['))
1448 | {
1449 | n++;
1450 | }
1451 | }
1452 | return n;
1453 | }
1454 |
1455 | static QStandardItem *findWatchItemBySubpathOrPathKey(QStandardItem *subtree,
1456 | const QString &key)
1457 | {
1458 | if (!subtree || key.isEmpty())
1459 | {
1460 | return nullptr;
1461 | }
1462 | QList<QStandardItem *> queue;
1463 | queue.append(subtree);
1464 | while (!queue.isEmpty())
1465 | {
1466 | QStandardItem *it = queue.takeFirst();
1467 | const QString sp = it->data(WatchSubpathRole).toString();
1468 | const QString vp = it->data(VariablePathRole).toString();
1469 | if ((!sp.isEmpty() && sp == key) || (!vp.isEmpty() && vp == key))
1470 | {
1471 | return it;
1472 | }
1473 | for (int i = 0; i < it->rowCount(); ++i)
1474 | {
1475 | queue.append(it->child(i));
1476 | }
1477 | }
1478 | return nullptr;
1479 | }
1480 |
1481 | /** Variables tree: match @a key against VariablePathRole only. */
1482 | static QStandardItem *findVariableTreeItemByPathKey(QStandardItem *subtree,
1483 | const QString &key)
1484 | {
1485 | if (!subtree || key.isEmpty())
1486 | {
1487 | return nullptr;
1488 | }
1489 | QList<QStandardItem *> queue;
1490 | queue.append(subtree);
1491 | while (!queue.isEmpty())
1492 | {
1493 | QStandardItem *it = queue.takeFirst();
1494 | if (it->data(VariablePathRole).toString() == key)
1495 | {
1496 | return it;
1497 | }
1498 | for (int i = 0; i < it->rowCount(); ++i)
1499 | {
1500 | queue.append(it->child(i));
1501 | }
1502 | }
1503 | return nullptr;
1504 | }
1505 |
1506 | using TreePathKeyFinder = QStandardItem *(*)(QStandardItem *,
1507 | const QString &);
1508 |
1509 | /**
1510 | * Re-expand @a subtree's descendants whose path key matches one of @a pathKeys.
1511 | * Ancestors are expanded first so that Qt's lazy expand handlers populate each
1512 | * level before we descend.
1513 | *
1514 | * Shared by Watch (`findWatchItemBySubpathOrPathKey`, `onWatchItemExpanded`)
1515 | * and Variables (`findVariableTreeItemByPathKey`, `onVariableItemExpanded`).
1516 | *
1517 | * Keys are processed shallow-first (by path-boundary count). The per-key
1518 | * ancestor chain handles deep-only keys whose intermediate ancestors are not
1519 | * in @a pathKeys; missing items are skipped (structural gaps between pauses).
1520 | */
1521 | static void reexpandTreeDescendantsByPathKeys(QTreeView *tree,
1522 | QStandardItemModel *model,
1523 | QStandardItem *subtree,
1524 | QStringList pathKeys,
1525 | TreePathKeyFinder findByKey)
1526 | {
1527 | if (!tree || !model || !subtree || pathKeys.isEmpty() || !findByKey)
1528 | {
1529 | return;
1530 | }
1531 | std::sort(pathKeys.begin(), pathKeys.end(),
1532 | [](const QString &a, const QString &b)
1533 | {
1534 | const int ca = watchSubpathBoundaryCount(a);
1535 | const int cb = watchSubpathBoundaryCount(b);
1536 | if (ca != cb)
1537 | {
1538 | return ca < cb;
1539 | }
1540 | return a < b;
1541 | });
1542 | for (const QString &pathKey : pathKeys)
1543 | {
1544 | QStringList chain;
1545 | for (QString cur = pathKey; !cur.isEmpty();
1546 | cur = watchPathParentKey(cur))
1547 | {
1548 | chain.prepend(cur);
1549 | }
1550 | for (const QString &k : chain)
1551 | {
1552 | QStandardItem *n = findByKey(subtree, k);
1553 | if (!n)
1554 | {
1555 | continue;
1556 | }
1557 | const QModelIndex ix = model->indexFromItem(n);
1558 | if (ix.isValid() && !tree->isExpanded(ix))
1559 | {
1560 | tree->setExpanded(ix, true);
1561 | }
1562 | }
1563 | }
1564 | }
1565 |
1566 | static void reexpandWatchDescendantsByPathKeys(QTreeView *tree,
1567 | QStandardItemModel *model,
1568 | QStandardItem *subtree,
1569 | QStringList pathKeys)
1570 | {
1571 | reexpandTreeDescendantsByPathKeys(tree, model, subtree, std::move(pathKeys),
1572 | findWatchItemBySubpathOrPathKey);
1573 | }
1574 |
1575 | static void clearWatchFilterErrorChrome(QStandardItem *col0, QTreeView *tree)
1576 | {
1577 | auto *wm = qobject_cast<QStandardItemModel *>(col0 ? col0->model() : nullptr);
1578 | if (!wm || !tree)
1579 | {
1580 | return;
1581 | }
1582 | const QPalette &pal = tree->palette();
1583 | setForeground(wm, col0, 0, pal.brush(QPalette::Text));
1584 | setForeground(wm, col0, 1, pal.brush(QPalette::Text));
1585 | setBackground(wm, col0, 0, QBrush());
1586 | setBackground(wm, col0, 1, QBrush());
1587 | }
1588 |
1589 | static void applyWatchFilterErrorChrome(QStandardItem *col0, QTreeView *tree)
1590 | {
1591 | Q_UNUSED(tree)(void)tree;;
1592 | auto *wm = qobject_cast<QStandardItemModel *>(col0 ? col0->model() : nullptr);
1593 | if (!wm)
1594 | {
1595 | return;
1596 | }
1597 | QColor fg = ColorUtils::fromColorT(&prefs.gui_filter_invalid_fg);
1598 | QColor bg = ColorUtils::fromColorT(&prefs.gui_filter_invalid_bg);
1599 | setForeground(wm, col0, 0, fg);
1600 | setForeground(wm, col0, 1, fg);
1601 | setBackground(wm, col0, 0, bg);
1602 | setBackground(wm, col0, 1, bg);
1603 | }
1604 |
1605 | /* Initialize a freshly-created top-level watch row from a canonical spec.
1606 | * The on-disk "watches" array is a flat list of spec strings (see
1607 | * storeWatchList / rebuildWatchTreeFromSettings). */
1608 | static void setupWatchRootItemFromSpec(QStandardItem *col0, QStandardItem *col1,
1609 | const QString &spec)
1610 | {
1611 | col0->setFlags(col0->flags() | Qt::ItemIsEditable | Qt::ItemIsEnabled |
1612 | Qt::ItemIsSelectable | Qt::ItemIsDragEnabled);
1613 | /* Value column: drag the whole watch row, not a single cell, when reordering. */
1614 | col1->setFlags(Qt::ItemIsEnabled | Qt::ItemIsSelectable | Qt::ItemIsDragEnabled);
1615 | col0->setText(spec);
1616 | col1->setText(QString());
1617 | col0->setData(spec, WatchSpecRole);
1618 | col0->setData(QString(), WatchSubpathRole);
1619 | col0->setData(QVariant(false), WatchPendingNewRole);
1620 | watchRootSetVariablePathRoleFromSpec(col0, spec);
1621 | auto *const ph0 = new QStandardItem();
1622 | auto *const ph1 = new QStandardItem();
1623 | ph0->setFlags(Qt::ItemIsEnabled);
1624 | ph1->setFlags(Qt::ItemIsEnabled);
1625 | col0->appendRow({ph0, ph1});
1626 | }
1627 |
1628 | /**
1629 | * Watch list: @c QStandardItemModel::dropMimeData() uses the drop @a column when
1630 | * resolving the target index, so a drop on the Value column (column 1) can
1631 | * re-parent a row or move a single "cell" instead of reordering the
1632 | * two-column watch entry. For top-level drops, use column 0. For any index
1633 | * on a top-level watch row, use the Watch-spec column (0) as the parent/anchor.
1634 | */
1635 | class WatchItemModel : public QStandardItemModel
1636 | {
1637 | public:
1638 | using QStandardItemModel::QStandardItemModel;
1639 |
1640 | protected:
1641 | bool dropMimeData(const QMimeData *data, Qt::DropAction action, int row,
1642 | int column, const QModelIndex &parent) override
1643 | {
1644 | int c = column;
1645 | QModelIndex p = parent;
1646 | if (!p.isValid())
1647 | {
1648 | c = 0;
1649 | }
1650 | else if (!p.parent().isValid() && p.column() != 0)
1651 | {
1652 | p = p.sibling(p.row(), 0);
1653 | }
1654 | return QStandardItemModel::dropMimeData(data, action, row, c, p);
1655 | }
1656 | };
1657 |
1658 | /**
1659 | * @brief Watch tree that only allows top-level reordering via drag-and-drop.
1660 | *
1661 | * The view uses @c QAbstractItemView::SelectRows so the drop line spans both
1662 | * columns (Qt 6+). The Value column is drag-enabled for watch roots. Nested
1663 | * watch rows are not valid drop targets. A drop on a top-level row’s center
1664 | * (@c OnItem) is applied like @c AboveItem: insert that row at the same index
1665 | * the view would use for a drop just above, instead of re-parenting as a
1666 | * child of the target row.
1667 | *
1668 | * The dialog's settings map (`settings_`) is refreshed from the tree only at
1669 | * close time via `storeDialogSettings()` / `saveSettingsFile()`, so a drop
1670 | * event has no persistence work to do beyond the model’s internal move. After
1671 | * a successful move, panel monospace fonts are re-applied on the watch tree
1672 | * and related panels.
1673 | *
1674 | * Only top-level (watch spec) rows may be dragged: starting a drag is blocked
1675 | * when the selection includes any expanded variable (child) index.
1676 | */
1677 | class WatchTreeWidget : public QTreeView
1678 | {
1679 | public:
1680 | explicit WatchTreeWidget(LuaDebuggerDialog *dlg, QWidget *parent = nullptr)
1681 | : QTreeView(parent), dialog_(dlg)
1682 | {
1683 | }
1684 |
1685 | protected:
1686 | void startDrag(Qt::DropActions supportedActions) override
1687 | {
1688 | const QModelIndexList list = selectedIndexes();
1689 | for (const QModelIndex &ix : list)
1690 | {
1691 | if (ix.isValid() && ix.parent().isValid())
1692 | {
1693 | return;
1694 | }
1695 | }
1696 | QTreeView::startDrag(supportedActions);
1697 | }
1698 |
1699 | void dragMoveEvent(QDragMoveEvent *event) override
1700 | {
1701 | QTreeView::dragMoveEvent(event);
1702 | if (!event->isAccepted())
1703 | {
1704 | return;
1705 | }
1706 | #if QT_VERSION((6<<16)|(4<<8)|(2)) >= QT_VERSION_CHECK(6, 0, 0)((6<<16)|(0<<8)|(0))
1707 | const QPoint pos = event->position().toPoint();
1708 | #else
1709 | const QPoint pos = event->pos();
1710 | #endif
1711 | const QModelIndex idx = indexAt(pos);
1712 | if (idx.isValid() && idx.parent().isValid())
1713 | {
1714 | /* Nested (expanded variable) rows: do not make them drop targets. */
1715 | event->ignore();
1716 | }
1717 | }
1718 |
1719 | void dropEvent(QDropEvent *event) override
1720 | {
1721 | if (dragDropMode() == QAbstractItemView::InternalMove &&
1722 | (event->source() != this || !(event->possibleActions() & Qt::MoveAction)))
1723 | {
1724 | return;
1725 | }
1726 | #if QT_VERSION((6<<16)|(4<<8)|(2)) >= QT_VERSION_CHECK(6, 0, 0)((6<<16)|(0<<8)|(0))
1727 | const QPoint pos = event->position().toPoint();
1728 | #else
1729 | const QPoint pos = event->pos();
1730 | #endif
1731 | const QModelIndex raw = indexAt(pos);
1732 | if (raw.isValid() && raw.parent().isValid())
1733 | {
1734 | event->ignore();
1735 | return;
1736 | }
1737 | /* OnItem on a top-level index would insert as a child; treat like Above. */
1738 | if (raw.isValid() && !raw.parent().isValid() &&
1739 | dropIndicatorPosition() == QAbstractItemView::OnItem)
1740 | {
1741 | if (auto *m = qobject_cast<QStandardItemModel *>(model()))
1742 | {
1743 | const int destRow = raw.row();
1744 | if (m->dropMimeData(event->mimeData(), Qt::MoveAction, destRow,
1745 | 0, QModelIndex()))
1746 | {
1747 | event->setDropAction(Qt::MoveAction);
1748 | event->accept();
1749 | }
1750 | else
1751 | {
1752 | event->ignore();
1753 | }
1754 | }
1755 | else
1756 | {
1757 | event->ignore();
1758 | }
1759 | stopAutoScroll();
1760 | setState(QAbstractItemView::NoState);
1761 | if (viewport())
1762 | {
1763 | viewport()->update();
1764 | }
1765 | if (event->isAccepted() && dialog_)
1766 | {
1767 | LuaDebuggerDialog *const d = dialog_;
1768 | QTimer::singleShot(0, d, [d]()
1769 | { d->reapplyMonospacePanelFonts(); });
1770 | }
1771 | return;
1772 | }
1773 | QTreeView::dropEvent(event);
1774 | if (event->isAccepted() && dialog_)
1775 | {
1776 | LuaDebuggerDialog *const d = dialog_;
1777 | QTimer::singleShot(0, d, [d]() { d->reapplyMonospacePanelFonts(); });
1778 | }
1779 | }
1780 |
1781 | private:
1782 | LuaDebuggerDialog *dialog_ = nullptr;
1783 | };
1784 |
1785 | /** Variables tree: block inline editors on all columns (read-only display). */
1786 | class VariablesReadOnlyDelegate : public QStyledItemDelegate
1787 | {
1788 | public:
1789 | using QStyledItemDelegate::QStyledItemDelegate;
1790 |
1791 | QWidget *createEditor(QWidget *parent, const QStyleOptionViewItem &option,
1792 | const QModelIndex &index) const override
1793 | {
1794 | Q_UNUSED(parent)(void)parent;;
1795 | Q_UNUSED(option)(void)option;;
1796 | Q_UNUSED(index)(void)index;;
1797 | return nullptr;
1798 | }
1799 | };
1800 |
1801 | /** Elide long values in the Value column (plan: Qt::ElideMiddle). */
1802 | class WatchValueColumnDelegate : public QStyledItemDelegate
1803 | {
1804 | public:
1805 | using QStyledItemDelegate::QStyledItemDelegate;
1806 |
1807 | void paint(QPainter *painter, const QStyleOptionViewItem &option,
1808 | const QModelIndex &index) const override
1809 | {
1810 | QStyleOptionViewItem opt = option;
1811 | initStyleOption(&opt, index);
1812 | const QString full = index.data(Qt::DisplayRole).toString();
1813 | const int avail = qMax(opt.rect.width() - 8, 1);
1814 | opt.text = opt.fontMetrics.elidedText(full, Qt::ElideMiddle, avail);
1815 | const QWidget *w = opt.widget;
1816 | QStyle *style = w ? w->style() : QApplication::style();
1817 | style->drawControl(QStyle::CE_ItemViewItem, &opt, painter, w);
1818 | }
1819 |
1820 | /* The Value column is read-only: block the default item editor so
1821 | * double-click / F2 cannot open a line edit here. The item's
1822 | * Qt::ItemIsEditable flag is kept because column 0 (the Watch spec)
1823 | * remains editable through WatchRootDelegate. */
1824 | QWidget *createEditor(QWidget *parent, const QStyleOptionViewItem &option,
1825 | const QModelIndex &index) const override
1826 | {
1827 | Q_UNUSED(parent)(void)parent;;
1828 | Q_UNUSED(option)(void)option;;
1829 | Q_UNUSED(index)(void)index;;
1830 | return nullptr;
1831 | }
1832 | };
1833 |
1834 | static QStandardItem *
1835 | itemFromTreeIndex(const QTreeView *tree, const QModelIndex &index)
1836 | {
1837 | auto *m = qobject_cast<QStandardItemModel *>(tree ? tree->model() : nullptr);
1838 | return m ? m->itemFromIndex(index) : nullptr;
1839 | }
1840 |
1841 | class WatchRootDelegate : public QStyledItemDelegate
1842 | {
1843 | public:
1844 | WatchRootDelegate(QTreeView *tree, LuaDebuggerDialog *dialog,
1845 | QObject *parent = nullptr)
1846 | : QStyledItemDelegate(parent), tree_(tree), dialog_(dialog)
1847 | {
1848 | }
1849 |
1850 | QWidget *createEditor(QWidget *parent, const QStyleOptionViewItem &option,
1851 | const QModelIndex &index) const override;
1852 | void setEditorData(QWidget *editor, const QModelIndex &index) const override;
1853 | void setModelData(QWidget *editor, QAbstractItemModel *model,
1854 | const QModelIndex &index) const override;
1855 |
1856 | private:
1857 | QTreeView *tree_;
1858 | LuaDebuggerDialog *dialog_;
1859 | };
1860 |
1861 | QWidget *
1862 | WatchRootDelegate::createEditor(QWidget *parent,
1863 | const QStyleOptionViewItem &option,
1864 | const QModelIndex &index) const
1865 | {
1866 | Q_UNUSED(option)(void)option;;
1867 | if (!tree_ || !index.isValid() || index.column() != 0)
1868 | {
1869 | return nullptr;
1870 | }
1871 | QStandardItem *it = itemFromTreeIndex(tree_, index);
1872 | if (!it || it->parent() != nullptr)
1873 | {
1874 | return nullptr;
1875 | }
1876 | QLineEdit *editor = new QLineEdit(parent);
1877 | /* Suppress the blue rounded focus ring QMacStyle draws around an
1878 | * actively edited inline editor. The cell's own selection
1879 | * background plus the line edit's frame already make it obvious
1880 | * which row is being edited; the focus ring just adds visual
1881 | * noise on top. The attribute is a no-op on Linux / Windows. */
1882 | editor->setAttribute(Qt::WA_MacShowFocusRect, false);
1883 | return editor;
1884 | }
1885 |
1886 | void WatchRootDelegate::setEditorData(QWidget *editor,
1887 | const QModelIndex &index) const
1888 | {
1889 | auto *le = qobject_cast<QLineEdit *>(editor);
1890 | if (!le || !tree_)
1891 | {
1892 | return;
1893 | }
1894 | QStandardItem *it = itemFromTreeIndex(tree_, index);
1895 | if (!it)
1896 | {
1897 | return;
1898 | }
1899 | QString s = it->data(WatchSpecRole).toString();
1900 | if (s.isEmpty())
1901 | {
1902 | s = it->text();
1903 | }
1904 | le->setText(s);
1905 | }
1906 |
1907 | void WatchRootDelegate::setModelData(QWidget *editor, QAbstractItemModel *model,
1908 | const QModelIndex &index) const
1909 | {
1910 | Q_UNUSED(model)(void)model;;
1911 | auto *le = qobject_cast<QLineEdit *>(editor);
1912 | if (!le || !dialog_ || !tree_)
1913 | {
1914 | return;
1915 | }
1916 | QStandardItem *it = itemFromTreeIndex(tree_, index);
1917 | if (!it)
1918 | {
1919 | return;
1920 | }
1921 | dialog_->commitWatchRootSpec(it, le->text());
1922 | }
1923 |
1924 | // ============================================================================
1925 | // BreakpointConditionDelegate
1926 | // ============================================================================
1927 | //
1928 | // Inline editor for the Breakpoints list's Location column. Mirrors the
1929 | // VSCode breakpoint inline editor: a small mode picker on the left
1930 | // (Expression / Hit Count / Log Message) reconfigures the value line
1931 | // edit's validator / placeholder / tooltip to match the chosen mode and
1932 | // stashes the previously-typed text under a per-mode draft slot so
1933 | // switching back restores it. The Hit Count mode restricts input to
1934 | // non-negative integers via a QIntValidator. Each commit updates only
1935 | // the selected mode's field; the others are preserved unchanged on the
1936 | // model item.
1937 | //
1938 | // The editor IS the value @c QLineEdit (see @ref BreakpointInlineLineEdit
1939 | // above); the mode combo, hit-mode combo and pause checkbox are children
1940 | // of the line edit, positioned in the line edit's text margins. This
1941 | // keeps the inline editor's parent chain identical to the Watch tree's
1942 | // bare-@c QLineEdit editor, so the platform's native style draws both
1943 | // trees' edit fields at exactly the same height with exactly the same
1944 | // frame, focus ring and selection colours.
1945 | //
1946 | // Adding a fourth mode in the future is a one-row append to
1947 | // kBreakpointEditModes plus an extension of @c applyEditorMode and the
1948 | // commit/load logic in @c setEditorData / @c setModelData; no other
1949 | // site needs to change.
1950 |
1951 | enum class BreakpointEditMode : int
1952 | {
1953 | Expression = 0,
1954 | HitCount = 1,
1955 | LogMessage = 2,
1956 | };
1957 |
1958 | struct BreakpointEditModeSpec
1959 | {
1960 | BreakpointEditMode mode;
1961 | const char *label; /**< tr() applied at construction. */
1962 | const char *placeholder; /**< nullptr -> none. */
1963 | const char *valueTooltip; /**< Tooltip on the value-editor widget. */
1964 | };
1965 |
1966 | static const BreakpointEditModeSpec kBreakpointEditModes[] = {
1967 | {BreakpointEditMode::Expression, QT_TRANSLATE_NOOP("Expression"
1968 | "BreakpointConditionDelegate","Expression"
1969 | "Expression")"Expression",
1970 | QT_TRANSLATE_NOOP("BreakpointConditionDelegate","Lua expression — pause when truthy"
1971 | "Lua expression — pause when truthy")"Lua expression — pause when truthy",
1972 | QT_TRANSLATE_NOOP("Evaluated each time control reaches this line; locals, " "upvalues, and globals are visible like Watch / Evaluate.\n" "Runtime errors are treated as false (silent) and surface as " "a warning icon on the row."
1973 | "BreakpointConditionDelegate","Evaluated each time control reaches this line; locals, " "upvalues, and globals are visible like Watch / Evaluate.\n" "Runtime errors are treated as false (silent) and surface as " "a warning icon on the row."
1974 | "Evaluated each time control reaches this line; locals, ""Evaluated each time control reaches this line; locals, " "upvalues, and globals are visible like Watch / Evaluate.\n" "Runtime errors are treated as false (silent) and surface as " "a warning icon on the row."
1975 | "upvalues, and globals are visible like Watch / Evaluate.\n""Evaluated each time control reaches this line; locals, " "upvalues, and globals are visible like Watch / Evaluate.\n" "Runtime errors are treated as false (silent) and surface as " "a warning icon on the row."
1976 | "Runtime errors are treated as false (silent) and surface as ""Evaluated each time control reaches this line; locals, " "upvalues, and globals are visible like Watch / Evaluate.\n" "Runtime errors are treated as false (silent) and surface as " "a warning icon on the row."
1977 | "a warning icon on the row.")"Evaluated each time control reaches this line; locals, " "upvalues, and globals are visible like Watch / Evaluate.\n" "Runtime errors are treated as false (silent) and surface as " "a warning icon on the row."},
1978 | {BreakpointEditMode::HitCount,
1979 | QT_TRANSLATE_NOOP("BreakpointConditionDelegate", "Hit Count")"Hit Count",
1980 | QT_TRANSLATE_NOOP("Pause after N hits (0 disables)"
1981 | "BreakpointConditionDelegate","Pause after N hits (0 disables)"
1982 | "Pause after N hits (0 disables)")"Pause after N hits (0 disables)",
1983 | QT_TRANSLATE_NOOP("Gate the pause on a hit counter. The dropdown next to the " "integer picks the comparison mode: \xe2\x89\xa5 pauses " "every hit at or after N (default); = pauses once when the " "counter reaches N; every pauses on hits N, 2\xc3\x97N, " "3\xc3\x97N, \xe2\x80\xa6; once pauses on the Nth hit and " "deactivates the breakpoint. Use 0 to disable the gate. The " "counter is preserved across edits to Expression / Hit " "Count / Log Message; lowering the target below the current " "count rolls the counter back to 0 so the breakpoint can " "wait for the next N hits. Right-click the row to reset it " "explicitly. Combined with an Expression on the same row, " "the hit-count gate runs first."
1984 | "BreakpointConditionDelegate","Gate the pause on a hit counter. The dropdown next to the " "integer picks the comparison mode: \xe2\x89\xa5 pauses " "every hit at or after N (default); = pauses once when the " "counter reaches N; every pauses on hits N, 2\xc3\x97N, " "3\xc3\x97N, \xe2\x80\xa6; once pauses on the Nth hit and " "deactivates the breakpoint. Use 0 to disable the gate. The " "counter is preserved across edits to Expression / Hit " "Count / Log Message; lowering the target below the current " "count rolls the counter back to 0 so the breakpoint can " "wait for the next N hits. Right-click the row to reset it " "explicitly. Combined with an Expression on the same row, " "the hit-count gate runs first."
1985 | "Gate the pause on a hit counter. The dropdown next to the ""Gate the pause on a hit counter. The dropdown next to the " "integer picks the comparison mode: \xe2\x89\xa5 pauses " "every hit at or after N (default); = pauses once when the " "counter reaches N; every pauses on hits N, 2\xc3\x97N, " "3\xc3\x97N, \xe2\x80\xa6; once pauses on the Nth hit and " "deactivates the breakpoint. Use 0 to disable the gate. The " "counter is preserved across edits to Expression / Hit " "Count / Log Message; lowering the target below the current " "count rolls the counter back to 0 so the breakpoint can " "wait for the next N hits. Right-click the row to reset it " "explicitly. Combined with an Expression on the same row, " "the hit-count gate runs first."
1986 | "integer picks the comparison mode: \xe2\x89\xa5 pauses ""Gate the pause on a hit counter. The dropdown next to the " "integer picks the comparison mode: \xe2\x89\xa5 pauses " "every hit at or after N (default); = pauses once when the " "counter reaches N; every pauses on hits N, 2\xc3\x97N, " "3\xc3\x97N, \xe2\x80\xa6; once pauses on the Nth hit and " "deactivates the breakpoint. Use 0 to disable the gate. The " "counter is preserved across edits to Expression / Hit " "Count / Log Message; lowering the target below the current " "count rolls the counter back to 0 so the breakpoint can " "wait for the next N hits. Right-click the row to reset it " "explicitly. Combined with an Expression on the same row, " "the hit-count gate runs first."
1987 | "every hit at or after N (default); = pauses once when the ""Gate the pause on a hit counter. The dropdown next to the " "integer picks the comparison mode: \xe2\x89\xa5 pauses " "every hit at or after N (default); = pauses once when the " "counter reaches N; every pauses on hits N, 2\xc3\x97N, " "3\xc3\x97N, \xe2\x80\xa6; once pauses on the Nth hit and " "deactivates the breakpoint. Use 0 to disable the gate. The " "counter is preserved across edits to Expression / Hit " "Count / Log Message; lowering the target below the current " "count rolls the counter back to 0 so the breakpoint can " "wait for the next N hits. Right-click the row to reset it " "explicitly. Combined with an Expression on the same row, " "the hit-count gate runs first."
1988 | "counter reaches N; every pauses on hits N, 2\xc3\x97N, ""Gate the pause on a hit counter. The dropdown next to the " "integer picks the comparison mode: \xe2\x89\xa5 pauses " "every hit at or after N (default); = pauses once when the " "counter reaches N; every pauses on hits N, 2\xc3\x97N, " "3\xc3\x97N, \xe2\x80\xa6; once pauses on the Nth hit and " "deactivates the breakpoint. Use 0 to disable the gate. The " "counter is preserved across edits to Expression / Hit " "Count / Log Message; lowering the target below the current " "count rolls the counter back to 0 so the breakpoint can " "wait for the next N hits. Right-click the row to reset it " "explicitly. Combined with an Expression on the same row, " "the hit-count gate runs first."
1989 | "3\xc3\x97N, \xe2\x80\xa6; once pauses on the Nth hit and ""Gate the pause on a hit counter. The dropdown next to the " "integer picks the comparison mode: \xe2\x89\xa5 pauses " "every hit at or after N (default); = pauses once when the " "counter reaches N; every pauses on hits N, 2\xc3\x97N, " "3\xc3\x97N, \xe2\x80\xa6; once pauses on the Nth hit and " "deactivates the breakpoint. Use 0 to disable the gate. The " "counter is preserved across edits to Expression / Hit " "Count / Log Message; lowering the target below the current " "count rolls the counter back to 0 so the breakpoint can " "wait for the next N hits. Right-click the row to reset it " "explicitly. Combined with an Expression on the same row, " "the hit-count gate runs first."
1990 | "deactivates the breakpoint. Use 0 to disable the gate. The ""Gate the pause on a hit counter. The dropdown next to the " "integer picks the comparison mode: \xe2\x89\xa5 pauses " "every hit at or after N (default); = pauses once when the " "counter reaches N; every pauses on hits N, 2\xc3\x97N, " "3\xc3\x97N, \xe2\x80\xa6; once pauses on the Nth hit and " "deactivates the breakpoint. Use 0 to disable the gate. The " "counter is preserved across edits to Expression / Hit " "Count / Log Message; lowering the target below the current " "count rolls the counter back to 0 so the breakpoint can " "wait for the next N hits. Right-click the row to reset it " "explicitly. Combined with an Expression on the same row, " "the hit-count gate runs first."
1991 | "counter is preserved across edits to Expression / Hit ""Gate the pause on a hit counter. The dropdown next to the " "integer picks the comparison mode: \xe2\x89\xa5 pauses " "every hit at or after N (default); = pauses once when the " "counter reaches N; every pauses on hits N, 2\xc3\x97N, " "3\xc3\x97N, \xe2\x80\xa6; once pauses on the Nth hit and " "deactivates the breakpoint. Use 0 to disable the gate. The " "counter is preserved across edits to Expression / Hit " "Count / Log Message; lowering the target below the current " "count rolls the counter back to 0 so the breakpoint can " "wait for the next N hits. Right-click the row to reset it " "explicitly. Combined with an Expression on the same row, " "the hit-count gate runs first."
1992 | "Count / Log Message; lowering the target below the current ""Gate the pause on a hit counter. The dropdown next to the " "integer picks the comparison mode: \xe2\x89\xa5 pauses " "every hit at or after N (default); = pauses once when the " "counter reaches N; every pauses on hits N, 2\xc3\x97N, " "3\xc3\x97N, \xe2\x80\xa6; once pauses on the Nth hit and " "deactivates the breakpoint. Use 0 to disable the gate. The " "counter is preserved across edits to Expression / Hit " "Count / Log Message; lowering the target below the current " "count rolls the counter back to 0 so the breakpoint can " "wait for the next N hits. Right-click the row to reset it " "explicitly. Combined with an Expression on the same row, " "the hit-count gate runs first."
1993 | "count rolls the counter back to 0 so the breakpoint can ""Gate the pause on a hit counter. The dropdown next to the " "integer picks the comparison mode: \xe2\x89\xa5 pauses " "every hit at or after N (default); = pauses once when the " "counter reaches N; every pauses on hits N, 2\xc3\x97N, " "3\xc3\x97N, \xe2\x80\xa6; once pauses on the Nth hit and " "deactivates the breakpoint. Use 0 to disable the gate. The " "counter is preserved across edits to Expression / Hit " "Count / Log Message; lowering the target below the current " "count rolls the counter back to 0 so the breakpoint can " "wait for the next N hits. Right-click the row to reset it " "explicitly. Combined with an Expression on the same row, " "the hit-count gate runs first."
1994 | "wait for the next N hits. Right-click the row to reset it ""Gate the pause on a hit counter. The dropdown next to the " "integer picks the comparison mode: \xe2\x89\xa5 pauses " "every hit at or after N (default); = pauses once when the " "counter reaches N; every pauses on hits N, 2\xc3\x97N, " "3\xc3\x97N, \xe2\x80\xa6; once pauses on the Nth hit and " "deactivates the breakpoint. Use 0 to disable the gate. The " "counter is preserved across edits to Expression / Hit " "Count / Log Message; lowering the target below the current " "count rolls the counter back to 0 so the breakpoint can " "wait for the next N hits. Right-click the row to reset it " "explicitly. Combined with an Expression on the same row, " "the hit-count gate runs first."
1995 | "explicitly. Combined with an Expression on the same row, ""Gate the pause on a hit counter. The dropdown next to the " "integer picks the comparison mode: \xe2\x89\xa5 pauses " "every hit at or after N (default); = pauses once when the " "counter reaches N; every pauses on hits N, 2\xc3\x97N, " "3\xc3\x97N, \xe2\x80\xa6; once pauses on the Nth hit and " "deactivates the breakpoint. Use 0 to disable the gate. The " "counter is preserved across edits to Expression / Hit " "Count / Log Message; lowering the target below the current " "count rolls the counter back to 0 so the breakpoint can " "wait for the next N hits. Right-click the row to reset it " "explicitly. Combined with an Expression on the same row, " "the hit-count gate runs first."
1996 | "the hit-count gate runs first.")"Gate the pause on a hit counter. The dropdown next to the " "integer picks the comparison mode: \xe2\x89\xa5 pauses " "every hit at or after N (default); = pauses once when the " "counter reaches N; every pauses on hits N, 2\xc3\x97N, " "3\xc3\x97N, \xe2\x80\xa6; once pauses on the Nth hit and " "deactivates the breakpoint. Use 0 to disable the gate. The " "counter is preserved across edits to Expression / Hit " "Count / Log Message; lowering the target below the current " "count rolls the counter back to 0 so the breakpoint can " "wait for the next N hits. Right-click the row to reset it " "explicitly. Combined with an Expression on the same row, " "the hit-count gate runs first."},
1997 | {BreakpointEditMode::LogMessage,
1998 | QT_TRANSLATE_NOOP("BreakpointConditionDelegate", "Log Message")"Log Message",
1999 | QT_TRANSLATE_NOOP("Log message — supports {expr} and tags such as {filename}, " "{basename}, {line}, {function}, {hits}, {timestamp}, " "{delta}\xe2\x80\xa6"
2000 | "BreakpointConditionDelegate","Log message — supports {expr} and tags such as {filename}, " "{basename}, {line}, {function}, {hits}, {timestamp}, " "{delta}\xe2\x80\xa6"
2001 | "Log message — supports {expr} and tags such as {filename}, ""Log message — supports {expr} and tags such as {filename}, " "{basename}, {line}, {function}, {hits}, {timestamp}, " "{delta}\xe2\x80\xa6"
2002 | "{basename}, {line}, {function}, {hits}, {timestamp}, ""Log message — supports {expr} and tags such as {filename}, " "{basename}, {line}, {function}, {hits}, {timestamp}, " "{delta}\xe2\x80\xa6"
2003 | "{delta}\xe2\x80\xa6")"Log message — supports {expr} and tags such as {filename}, " "{basename}, {line}, {function}, {hits}, {timestamp}, " "{delta}\xe2\x80\xa6",
2004 | QT_TRANSLATE_NOOP("Logpoints write a message to the Evaluate output (and " "Wireshark's info log) each time the line is reached. By " "default execution continues without pausing; tick the " "Pause box on this editor to also pause after emitting " "(useful for log-then-inspect without duplicating the " "breakpoint). The line is emitted verbatim — there is no " "automatic file:line prefix. Inside {} the text is " "evaluated as a Lua expression in this frame and " "converted to text the same way tostring() does; " "reserved tags below shadow any same-named Lua local / " "upvalue / global. " "Origin: {filename}, {basename}, {line}, {function}, " "{what}. Counters and scope: {hits}, {depth}, {thread}. " "Time: {timestamp}, {datetime}, {epoch}, {epoch_ms}, " "{elapsed}, {delta}. Use {{ and }} for literal { and }. " "Per-placeholder errors substitute '<error: ...>' without " "aborting the line."
2005 | "BreakpointConditionDelegate","Logpoints write a message to the Evaluate output (and " "Wireshark's info log) each time the line is reached. By " "default execution continues without pausing; tick the " "Pause box on this editor to also pause after emitting " "(useful for log-then-inspect without duplicating the " "breakpoint). The line is emitted verbatim — there is no " "automatic file:line prefix. Inside {} the text is " "evaluated as a Lua expression in this frame and " "converted to text the same way tostring() does; " "reserved tags below shadow any same-named Lua local / " "upvalue / global. " "Origin: {filename}, {basename}, {line}, {function}, " "{what}. Counters and scope: {hits}, {depth}, {thread}. " "Time: {timestamp}, {datetime}, {epoch}, {epoch_ms}, " "{elapsed}, {delta}. Use {{ and }} for literal { and }. " "Per-placeholder errors substitute '<error: ...>' without " "aborting the line."
2006 | "Logpoints write a message to the Evaluate output (and ""Logpoints write a message to the Evaluate output (and " "Wireshark's info log) each time the line is reached. By " "default execution continues without pausing; tick the " "Pause box on this editor to also pause after emitting " "(useful for log-then-inspect without duplicating the " "breakpoint). The line is emitted verbatim — there is no " "automatic file:line prefix. Inside {} the text is " "evaluated as a Lua expression in this frame and " "converted to text the same way tostring() does; " "reserved tags below shadow any same-named Lua local / " "upvalue / global. " "Origin: {filename}, {basename}, {line}, {function}, " "{what}. Counters and scope: {hits}, {depth}, {thread}. " "Time: {timestamp}, {datetime}, {epoch}, {epoch_ms}, " "{elapsed}, {delta}. Use {{ and }} for literal { and }. " "Per-placeholder errors substitute '<error: ...>' without " "aborting the line."
2007 | "Wireshark's info log) each time the line is reached. By ""Logpoints write a message to the Evaluate output (and " "Wireshark's info log) each time the line is reached. By " "default execution continues without pausing; tick the " "Pause box on this editor to also pause after emitting " "(useful for log-then-inspect without duplicating the " "breakpoint). The line is emitted verbatim — there is no " "automatic file:line prefix. Inside {} the text is " "evaluated as a Lua expression in this frame and " "converted to text the same way tostring() does; " "reserved tags below shadow any same-named Lua local / " "upvalue / global. " "Origin: {filename}, {basename}, {line}, {function}, " "{what}. Counters and scope: {hits}, {depth}, {thread}. " "Time: {timestamp}, {datetime}, {epoch}, {epoch_ms}, " "{elapsed}, {delta}. Use {{ and }} for literal { and }. " "Per-placeholder errors substitute '<error: ...>' without " "aborting the line."
2008 | "default execution continues without pausing; tick the ""Logpoints write a message to the Evaluate output (and " "Wireshark's info log) each time the line is reached. By " "default execution continues without pausing; tick the " "Pause box on this editor to also pause after emitting " "(useful for log-then-inspect without duplicating the " "breakpoint). The line is emitted verbatim — there is no " "automatic file:line prefix. Inside {} the text is " "evaluated as a Lua expression in this frame and " "converted to text the same way tostring() does; " "reserved tags below shadow any same-named Lua local / " "upvalue / global. " "Origin: {filename}, {basename}, {line}, {function}, " "{what}. Counters and scope: {hits}, {depth}, {thread}. " "Time: {timestamp}, {datetime}, {epoch}, {epoch_ms}, " "{elapsed}, {delta}. Use {{ and }} for literal { and }. " "Per-placeholder errors substitute '<error: ...>' without " "aborting the line."
2009 | "Pause box on this editor to also pause after emitting ""Logpoints write a message to the Evaluate output (and " "Wireshark's info log) each time the line is reached. By " "default execution continues without pausing; tick the " "Pause box on this editor to also pause after emitting " "(useful for log-then-inspect without duplicating the " "breakpoint). The line is emitted verbatim — there is no " "automatic file:line prefix. Inside {} the text is " "evaluated as a Lua expression in this frame and " "converted to text the same way tostring() does; " "reserved tags below shadow any same-named Lua local / " "upvalue / global. " "Origin: {filename}, {basename}, {line}, {function}, " "{what}. Counters and scope: {hits}, {depth}, {thread}. " "Time: {timestamp}, {datetime}, {epoch}, {epoch_ms}, " "{elapsed}, {delta}. Use {{ and }} for literal { and }. " "Per-placeholder errors substitute '<error: ...>' without " "aborting the line."
2010 | "(useful for log-then-inspect without duplicating the ""Logpoints write a message to the Evaluate output (and " "Wireshark's info log) each time the line is reached. By " "default execution continues without pausing; tick the " "Pause box on this editor to also pause after emitting " "(useful for log-then-inspect without duplicating the " "breakpoint). The line is emitted verbatim — there is no " "automatic file:line prefix. Inside {} the text is " "evaluated as a Lua expression in this frame and " "converted to text the same way tostring() does; " "reserved tags below shadow any same-named Lua local / " "upvalue / global. " "Origin: {filename}, {basename}, {line}, {function}, " "{what}. Counters and scope: {hits}, {depth}, {thread}. " "Time: {timestamp}, {datetime}, {epoch}, {epoch_ms}, " "{elapsed}, {delta}. Use {{ and }} for literal { and }. " "Per-placeholder errors substitute '<error: ...>' without " "aborting the line."
2011 | "breakpoint). The line is emitted verbatim — there is no ""Logpoints write a message to the Evaluate output (and " "Wireshark's info log) each time the line is reached. By " "default execution continues without pausing; tick the " "Pause box on this editor to also pause after emitting " "(useful for log-then-inspect without duplicating the " "breakpoint). The line is emitted verbatim — there is no " "automatic file:line prefix. Inside {} the text is " "evaluated as a Lua expression in this frame and " "converted to text the same way tostring() does; " "reserved tags below shadow any same-named Lua local / " "upvalue / global. " "Origin: {filename}, {basename}, {line}, {function}, " "{what}. Counters and scope: {hits}, {depth}, {thread}. " "Time: {timestamp}, {datetime}, {epoch}, {epoch_ms}, " "{elapsed}, {delta}. Use {{ and }} for literal { and }. " "Per-placeholder errors substitute '<error: ...>' without " "aborting the line."
2012 | "automatic file:line prefix. Inside {} the text is ""Logpoints write a message to the Evaluate output (and " "Wireshark's info log) each time the line is reached. By " "default execution continues without pausing; tick the " "Pause box on this editor to also pause after emitting " "(useful for log-then-inspect without duplicating the " "breakpoint). The line is emitted verbatim — there is no " "automatic file:line prefix. Inside {} the text is " "evaluated as a Lua expression in this frame and " "converted to text the same way tostring() does; " "reserved tags below shadow any same-named Lua local / " "upvalue / global. " "Origin: {filename}, {basename}, {line}, {function}, " "{what}. Counters and scope: {hits}, {depth}, {thread}. " "Time: {timestamp}, {datetime}, {epoch}, {epoch_ms}, " "{elapsed}, {delta}. Use {{ and }} for literal { and }. " "Per-placeholder errors substitute '<error: ...>' without " "aborting the line."
2013 | "evaluated as a Lua expression in this frame and ""Logpoints write a message to the Evaluate output (and " "Wireshark's info log) each time the line is reached. By " "default execution continues without pausing; tick the " "Pause box on this editor to also pause after emitting " "(useful for log-then-inspect without duplicating the " "breakpoint). The line is emitted verbatim — there is no " "automatic file:line prefix. Inside {} the text is " "evaluated as a Lua expression in this frame and " "converted to text the same way tostring() does; " "reserved tags below shadow any same-named Lua local / " "upvalue / global. " "Origin: {filename}, {basename}, {line}, {function}, " "{what}. Counters and scope: {hits}, {depth}, {thread}. " "Time: {timestamp}, {datetime}, {epoch}, {epoch_ms}, " "{elapsed}, {delta}. Use {{ and }} for literal { and }. " "Per-placeholder errors substitute '<error: ...>' without " "aborting the line."
2014 | "converted to text the same way tostring() does; ""Logpoints write a message to the Evaluate output (and " "Wireshark's info log) each time the line is reached. By " "default execution continues without pausing; tick the " "Pause box on this editor to also pause after emitting " "(useful for log-then-inspect without duplicating the " "breakpoint). The line is emitted verbatim — there is no " "automatic file:line prefix. Inside {} the text is " "evaluated as a Lua expression in this frame and " "converted to text the same way tostring() does; " "reserved tags below shadow any same-named Lua local / " "upvalue / global. " "Origin: {filename}, {basename}, {line}, {function}, " "{what}. Counters and scope: {hits}, {depth}, {thread}. " "Time: {timestamp}, {datetime}, {epoch}, {epoch_ms}, " "{elapsed}, {delta}. Use {{ and }} for literal { and }. " "Per-placeholder errors substitute '<error: ...>' without " "aborting the line."
2015 | "reserved tags below shadow any same-named Lua local / ""Logpoints write a message to the Evaluate output (and " "Wireshark's info log) each time the line is reached. By " "default execution continues without pausing; tick the " "Pause box on this editor to also pause after emitting " "(useful for log-then-inspect without duplicating the " "breakpoint). The line is emitted verbatim — there is no " "automatic file:line prefix. Inside {} the text is " "evaluated as a Lua expression in this frame and " "converted to text the same way tostring() does; " "reserved tags below shadow any same-named Lua local / " "upvalue / global. " "Origin: {filename}, {basename}, {line}, {function}, " "{what}. Counters and scope: {hits}, {depth}, {thread}. " "Time: {timestamp}, {datetime}, {epoch}, {epoch_ms}, " "{elapsed}, {delta}. Use {{ and }} for literal { and }. " "Per-placeholder errors substitute '<error: ...>' without " "aborting the line."
2016 | "upvalue / global. ""Logpoints write a message to the Evaluate output (and " "Wireshark's info log) each time the line is reached. By " "default execution continues without pausing; tick the " "Pause box on this editor to also pause after emitting " "(useful for log-then-inspect without duplicating the " "breakpoint). The line is emitted verbatim — there is no " "automatic file:line prefix. Inside {} the text is " "evaluated as a Lua expression in this frame and " "converted to text the same way tostring() does; " "reserved tags below shadow any same-named Lua local / " "upvalue / global. " "Origin: {filename}, {basename}, {line}, {function}, " "{what}. Counters and scope: {hits}, {depth}, {thread}. " "Time: {timestamp}, {datetime}, {epoch}, {epoch_ms}, " "{elapsed}, {delta}. Use {{ and }} for literal { and }. " "Per-placeholder errors substitute '<error: ...>' without " "aborting the line."
2017 | "Origin: {filename}, {basename}, {line}, {function}, ""Logpoints write a message to the Evaluate output (and " "Wireshark's info log) each time the line is reached. By " "default execution continues without pausing; tick the " "Pause box on this editor to also pause after emitting " "(useful for log-then-inspect without duplicating the " "breakpoint). The line is emitted verbatim — there is no " "automatic file:line prefix. Inside {} the text is " "evaluated as a Lua expression in this frame and " "converted to text the same way tostring() does; " "reserved tags below shadow any same-named Lua local / " "upvalue / global. " "Origin: {filename}, {basename}, {line}, {function}, " "{what}. Counters and scope: {hits}, {depth}, {thread}. " "Time: {timestamp}, {datetime}, {epoch}, {epoch_ms}, " "{elapsed}, {delta}. Use {{ and }} for literal { and }. " "Per-placeholder errors substitute '<error: ...>' without " "aborting the line."
2018 | "{what}. Counters and scope: {hits}, {depth}, {thread}. ""Logpoints write a message to the Evaluate output (and " "Wireshark's info log) each time the line is reached. By " "default execution continues without pausing; tick the " "Pause box on this editor to also pause after emitting " "(useful for log-then-inspect without duplicating the " "breakpoint). The line is emitted verbatim — there is no " "automatic file:line prefix. Inside {} the text is " "evaluated as a Lua expression in this frame and " "converted to text the same way tostring() does; " "reserved tags below shadow any same-named Lua local / " "upvalue / global. " "Origin: {filename}, {basename}, {line}, {function}, " "{what}. Counters and scope: {hits}, {depth}, {thread}. " "Time: {timestamp}, {datetime}, {epoch}, {epoch_ms}, " "{elapsed}, {delta}. Use {{ and }} for literal { and }. " "Per-placeholder errors substitute '<error: ...>' without " "aborting the line."
2019 | "Time: {timestamp}, {datetime}, {epoch}, {epoch_ms}, ""Logpoints write a message to the Evaluate output (and " "Wireshark's info log) each time the line is reached. By " "default execution continues without pausing; tick the " "Pause box on this editor to also pause after emitting " "(useful for log-then-inspect without duplicating the " "breakpoint). The line is emitted verbatim — there is no " "automatic file:line prefix. Inside {} the text is " "evaluated as a Lua expression in this frame and " "converted to text the same way tostring() does; " "reserved tags below shadow any same-named Lua local / " "upvalue / global. " "Origin: {filename}, {basename}, {line}, {function}, " "{what}. Counters and scope: {hits}, {depth}, {thread}. " "Time: {timestamp}, {datetime}, {epoch}, {epoch_ms}, " "{elapsed}, {delta}. Use {{ and }} for literal { and }. " "Per-placeholder errors substitute '<error: ...>' without " "aborting the line."
2020 | "{elapsed}, {delta}. Use {{ and }} for literal { and }. ""Logpoints write a message to the Evaluate output (and " "Wireshark's info log) each time the line is reached. By " "default execution continues without pausing; tick the " "Pause box on this editor to also pause after emitting " "(useful for log-then-inspect without duplicating the " "breakpoint). The line is emitted verbatim — there is no " "automatic file:line prefix. Inside {} the text is " "evaluated as a Lua expression in this frame and " "converted to text the same way tostring() does; " "reserved tags below shadow any same-named Lua local / " "upvalue / global. " "Origin: {filename}, {basename}, {line}, {function}, " "{what}. Counters and scope: {hits}, {depth}, {thread}. " "Time: {timestamp}, {datetime}, {epoch}, {epoch_ms}, " "{elapsed}, {delta}. Use {{ and }} for literal { and }. " "Per-placeholder errors substitute '<error: ...>' without " "aborting the line."
2021 | "Per-placeholder errors substitute '<error: ...>' without ""Logpoints write a message to the Evaluate output (and " "Wireshark's info log) each time the line is reached. By " "default execution continues without pausing; tick the " "Pause box on this editor to also pause after emitting " "(useful for log-then-inspect without duplicating the " "breakpoint). The line is emitted verbatim — there is no " "automatic file:line prefix. Inside {} the text is " "evaluated as a Lua expression in this frame and " "converted to text the same way tostring() does; " "reserved tags below shadow any same-named Lua local / " "upvalue / global. " "Origin: {filename}, {basename}, {line}, {function}, " "{what}. Counters and scope: {hits}, {depth}, {thread}. " "Time: {timestamp}, {datetime}, {epoch}, {epoch_ms}, " "{elapsed}, {delta}. Use {{ and }} for literal { and }. " "Per-placeholder errors substitute '<error: ...>' without " "aborting the line."
2022 | "aborting the line.")"Logpoints write a message to the Evaluate output (and " "Wireshark's info log) each time the line is reached. By " "default execution continues without pausing; tick the " "Pause box on this editor to also pause after emitting " "(useful for log-then-inspect without duplicating the " "breakpoint). The line is emitted verbatim — there is no " "automatic file:line prefix. Inside {} the text is " "evaluated as a Lua expression in this frame and " "converted to text the same way tostring() does; " "reserved tags below shadow any same-named Lua local / " "upvalue / global. " "Origin: {filename}, {basename}, {line}, {function}, " "{what}. Counters and scope: {hits}, {depth}, {thread}. " "Time: {timestamp}, {datetime}, {epoch}, {epoch_ms}, " "{elapsed}, {delta}. Use {{ and }} for literal { and }. " "Per-placeholder errors substitute '<error: ...>' without " "aborting the line."},
2023 | };
2024 |
2025 | /**
2026 | * @brief Inline editor for the Breakpoints "Location" column.
2027 | *
2028 | * The editor IS a @c QLineEdit, exactly like the Watch tree's editor — same
2029 | * widget class, same parent chain (direct child of the view's viewport),
2030 | * same native rendering on every platform. This is what makes the
2031 | * Breakpoint edit field render at @c QLineEdit::sizeHint() height with the
2032 | * platform's native frame, focus ring, padding and selection colours,
2033 | * pixel-identical to the Watch edit field.
2034 | *
2035 | * The earlier implementation wrapped the line edit inside
2036 | * @c QStackedWidget inside @c QHBoxLayout inside a wrapper @c QWidget;
2037 | * with that nesting the layout sized the @c QLineEdit to whatever the
2038 | * row was (never to its own natural sizeHint), so on macOS the
2039 | * @c QMacStyle frame painter drew a much shorter line edit than the
2040 | * Watch tree's bare @c QLineEdit, even when the row itself was the same
2041 | * height. Embedding the auxiliary controls AS CHILDREN of the
2042 | * @c QLineEdit (the same idiom used by Qt Creator's
2043 | * @c Utils::FancyLineEdit and Chrome's omnibox) lets the line edit be
2044 | * the editor and reserve interior space for the embedded widgets via
2045 | * @c setTextMargins().
2046 | *
2047 | * The mode combo lives on the left edge; the hit-count comparison combo
2048 | * and the "also pause" toggle live on the right edge, hidden by
2049 | * default and shown only for the modes that own them. Caller
2050 | * (@ref BreakpointConditionDelegate::createEditor) wires up the
2051 | * mode-change behaviour, the focus / commit logic and the model
2052 | * read/write; this class is intentionally only responsible for the
2053 | * geometry of the embedded widgets and the corresponding text margins.
2054 | */
2055 | class BreakpointInlineLineEdit : public QLineEdit
2056 | {
2057 | public:
2058 | explicit BreakpointInlineLineEdit(QWidget *parent = nullptr)
2059 | : QLineEdit(parent)
2060 | {
2061 | }
2062 |
2063 | /** Hand the editor its three embedded widgets (already parented to
2064 | * @c this by the caller) so it can reserve text-margin space for
2065 | * them and reposition them on every resize. */
2066 | void setEmbeddedWidgets(QComboBox *modeCombo,
2067 | QComboBox *hitModeCombo,
2068 | QToolButton *pauseButton)
2069 | {
2070 | modeCombo_ = modeCombo;
2071 | hitModeCombo_ = hitModeCombo;
2072 | pauseButton_ = pauseButton;
2073 | relayout();
2074 | }
2075 |
2076 | /** Re-run the geometry pass — call after toggling the visibility of
2077 | * any embedded widget so the text margins (and therefore the
2078 | * caret-claim area) follow.
2079 | *
2080 | * Bails out when the editor has no real width yet (called e.g. from
2081 | * @c setEmbeddedWidgets / @c applyEditorMode before
2082 | * @c QAbstractItemView has placed us in the cell): with width()==0
2083 | * every right-anchored widget would land at a negative x. The first
2084 | * real layout pass happens via @c resizeEvent once the view sets
2085 | * our geometry, and a final pass via @c showEvent picks up any
2086 | * visibility changes that landed after that. */
2087 | void relayout()
2088 | {
2089 | if (!modeCombo_ || width() <= 0)
2090 | return;
2091 |
2092 | const int kInnerGap = 4;
2093 | /* The frame width Qt's style draws around the line edit's
2094 | * content rect. We push our embedded widgets just inside the
2095 | * frame so they don't overlap the native border. */
2096 | QStyleOptionFrame opt;
2097 | initStyleOption(&opt);
2098 | const int frameW = style()->pixelMetric(
2099 | QStyle::PM_DefaultFrameWidth, &opt, this);
2100 |
2101 | /* Vertically center every embedded widget on the line edit's
2102 | * own visual mid-line, using each widget's natural sizeHint
2103 | * height. This is the same alignment QLineEdit's built-in
2104 | * trailing/leading actions use, and it's what makes the row
2105 | * read as one coherent control on every platform — combos
2106 | * with a different intrinsic height than the line edit's text
2107 | * area sit pixel-aligned with the caret rather than stretched
2108 | * top-to-bottom. */
2109 | const auto centeredRect = [this](const QSize &hint, int x) {
2110 | int h = hint.height();
2111 | if (h > height())
2112 | h = height();
2113 | const int y = (height() - h) / 2;
2114 | return QRect(x, y, hint.width(), h);
2115 | };
2116 |
2117 | /* QMacStyle paints the @c QComboBox's native popup arrow with
2118 | * one pixel of optical padding above the label, which makes
2119 | * the combo's text baseline read 1 px higher than the
2120 | * @c QLineEdit's caret baseline when both are vertically
2121 | * centered in the same row. Other platforms render the combo
2122 | * flush with the line edit's text, so the nudge is macOS-only.
2123 | * Both combos (mode on the left, hit-count comparison on the
2124 | * right) need the same nudge so they land on a shared
2125 | * baseline. */
2126 | #ifdef Q_OS_MACOS
2127 | constexpr int comboBaselineNudge = 1;
2128 | #else
2129 | constexpr int comboBaselineNudge = 0;
2130 | #endif
2131 |
2132 | int leftEdge = frameW + kInnerGap;
2133 | int rightEdge = width() - frameW - kInnerGap;
2134 |
2135 | const QSize modeHint = modeCombo_->sizeHint();
2136 | QRect modeRect = centeredRect(modeHint, leftEdge);
2137 | modeRect.translate(0, comboBaselineNudge);
2138 | modeCombo_->setGeometry(modeRect);
2139 | leftEdge += modeHint.width() + kInnerGap;
2140 |
2141 | if (pauseButton_ && !pauseButton_->isHidden())
2142 | {
2143 | /* Force the toggle's height to @c editor.height() - 6 so
2144 | * its Highlight-color chip clears the line edit's frame
2145 | * by 3 px on top and 3 px on bottom regardless of the
2146 | * @c QToolButton's natural sizeHint.
2147 | *
2148 | * Two things conspire against a "shrink to a smaller
2149 | * height" attempt that goes through sizeHint or
2150 | * @c centeredRect:
2151 | * - @c centeredRect clamps @c h to @c editor.height()
2152 | * when sizeHint is taller, undoing any pre-shrink.
2153 | * - @c QToolButton's @c sizeHint() can be smaller than
2154 | * the editor on some platforms, so a @c qMin with
2155 | * sizeHint silently keeps the natural (larger
2156 | * relative to the chosen inset) height.
2157 | *
2158 | * @c setMaximumHeight is the belt-and-braces lock —
2159 | * @c setGeometry alone is enough today, but a future
2160 | * re-layout triggered by Qt's polish / size-policy
2161 | * machinery would otherwise bring back the natural
2162 | * height. The chip stylesheet renders at the button's
2163 | * geometry, so capping the geometry caps the chip. */
2164 | const QSize hint = pauseButton_->sizeHint();
2165 | const int h = qMax(0, height() - 6);
2166 | rightEdge -= hint.width();
2167 | pauseButton_->setMaximumHeight(h);
2168 | const int y = (height() - h) / 2;
2169 | pauseButton_->setGeometry(rightEdge, y, hint.width(), h);
2170 | rightEdge -= kInnerGap;
2171 | }
2172 | if (hitModeCombo_ && !hitModeCombo_->isHidden())
2173 | {
2174 | const QSize hint = hitModeCombo_->sizeHint();
2175 | rightEdge -= hint.width();
2176 | QRect hitRect = centeredRect(hint, rightEdge);
2177 | hitRect.translate(0, comboBaselineNudge);
2178 | hitModeCombo_->setGeometry(hitRect);
2179 | rightEdge -= kInnerGap;
2180 | }
2181 |
2182 | /* setTextMargins reserves space inside the line edit's content
2183 | * rect for our embedded widgets — the typing area and the
2184 | * placeholder text never collide with the combo / checkbox. */
2185 | const int leftMargin = leftEdge - frameW;
2186 | const int rightMargin = (width() - frameW) - rightEdge;
2187 | setTextMargins(leftMargin, 0, rightMargin, 0);
2188 | }
2189 |
2190 | protected:
2191 | void resizeEvent(QResizeEvent *e) override
2192 | {
2193 | QLineEdit::resizeEvent(e);
2194 | relayout();
2195 | }
2196 |
2197 | void showEvent(QShowEvent *e) override
2198 | {
2199 | QLineEdit::showEvent(e);
2200 | /* The editor was created and configured (mode, visibility of
2201 | * the auxiliary widgets) before the view called show() on us.
2202 | * Any earlier @c relayout() bailed out on width()==0; this
2203 | * is the first time we're guaranteed to have a real size and
2204 | * a settled visibility for every child. */
2205 | relayout();
2206 | }
2207 |
2208 | void paintEvent(QPaintEvent *e) override
2209 | {
2210 | QLineEdit::paintEvent(e);
2211 | /* Draw an explicit 1 px border on top of the native frame.
2212 | * QMacStyle's @c QLineEdit frame is intentionally faint
2213 | * (especially in dark mode) and disappears against the row's
2214 | * highlight; embedding mode / hit-mode combos and the pause
2215 | * toggle as children clutters the cell further, so without a
2216 | * visible border the user can no longer tell where the
2217 | * editable area begins and ends. We draw with @c QPalette::Mid
2218 | * so the stroke adapts to light and dark themes automatically.
2219 | *
2220 | * Antialiasing is left off so the 1 px stroke lands on integer
2221 | * pixel boundaries — a crisp line rather than a half-bright
2222 | * 2 px smear — and we inset by 1 pixel so the border lives
2223 | * inside the widget rect (which @c QLineEdit::paintEvent has
2224 | * just painted) instead of outside it where the native focus
2225 | * ring lives. */
2226 | QPainter p(this);
2227 | QPen pen(palette().color(QPalette::Active, QPalette::Mid));
2228 | pen.setWidth(1);
2229 | pen.setCosmetic(true);
2230 | p.setPen(pen);
2231 | p.setBrush(Qt::NoBrush);
2232 | p.drawRect(rect().adjusted(0, 0, -1, -1));
2233 | }
2234 |
2235 | private:
2236 | QComboBox *modeCombo_ = nullptr;
2237 | QComboBox *hitModeCombo_ = nullptr;
2238 | QToolButton *pauseButton_ = nullptr;
2239 | };
2240 |
2241 | /** Wrap @p base so it also publishes a @c QIcon::Selected variant
2242 | * tinted to the palette's @c HighlightedText color.
2243 | *
2244 | * Qt's default item delegate (and most widget styles) paint selected
2245 | * cells by requesting the icon with @c mode == @c QIcon::Selected.
2246 | * Fixed PNG / SVG theme icons usually don't ship a Selected pixmap,
2247 | * so the row's selection background ends up rendered with the
2248 | * original (often dark) glyph — which goes near-invisible on a dark
2249 | * blue selection in dark mode. We re-tint the alpha mask to
2250 | * @c HighlightedText so the glyph reads cleanly regardless of theme. */
2251 | static QIcon luaDbgMakeSelectionAwareIcon(const QIcon &base,
2252 | const QPalette &palette)
2253 | {
2254 | if (base.isNull())
2255 | return base;
2256 |
2257 | QIcon out;
2258 | QList<QSize> sizes = base.availableSizes();
2259 | if (sizes.isEmpty())
2260 | {
2261 | /* Theme icons (QIcon::fromTheme) sometimes report no available
2262 | * sizes when they're served from an SVG; fall back to the
2263 | * sizes the breakpoints / variables / watch trees actually
2264 | * request. The pixmap call below will rasterise on demand. */
2265 | sizes = {QSize(16, 16), QSize(22, 22), QSize(32, 32)};
2266 | }
2267 |
2268 | for (const QSize &sz : sizes)
2269 | {
2270 | const QPixmap normalPm = base.pixmap(sz);
2271 | if (normalPm.isNull())
2272 | continue;
2273 | out.addPixmap(normalPm, QIcon::Normal);
2274 |
2275 | QPixmap tintedPm(normalPm.size());
2276 | tintedPm.setDevicePixelRatio(normalPm.devicePixelRatio());
2277 | tintedPm.fill(Qt::transparent);
2278 | QPainter p(&tintedPm);
2279 | p.drawPixmap(0, 0, normalPm);
2280 | /* SourceIn keeps the original alpha mask and replaces the RGB
2281 | * channels with HighlightedText — works for monochrome line
2282 | * art (which is what every glyph we feed through here is). */
2283 | p.setCompositionMode(QPainter::CompositionMode_SourceIn);
2284 | p.fillRect(tintedPm.rect(),
2285 | palette.color(QPalette::Active, QPalette::HighlightedText));
2286 | p.end();
2287 | out.addPixmap(tintedPm, QIcon::Selected);
2288 | }
2289 | return out;
2290 | }
2291 |
2292 | /** Build a palette-aware "also pause" icon with separate Off / On
2293 | * variants. The toggled state is signalled by:
2294 | *
2295 | * - @c QIcon::Off : two solid pause bars in the palette's
2296 | * @c ButtonText color, on a transparent background. The
2297 | * @c QToolButton's background stylesheet is also transparent
2298 | * in this state, so the cell shows the bars on the cell's
2299 | * own background.
2300 | * - @c QIcon::On : two solid bars in @c HighlightedText (white).
2301 | * The bars sit on top of the @c QToolButton's checked-state
2302 | * stylesheet background — a rounded @c Highlight-color chip
2303 | * that fills the whole button, not just the 16×16 icon. The
2304 | * button-sized chip is the primary "armed" cue; doing it on
2305 | * the button (rather than baking it into the icon pixmap)
2306 | * covers the full clickable surface and reads as a real
2307 | * state-of-toggle indicator. */
2308 | static QIcon luaDbgMakePauseIcon(const QPalette &palette)
2309 | {
2310 | const int side = 16;
2311 | const qreal dpr = 2.0;
2312 |
2313 | /* Layout: two bars, 3 px wide, with a 2 px gap, occupying the
2314 | * central 8 px of a 16 px square. Rounded corners (1 px radius)
2315 | * match the visual weight of macOS / Windows 11 media glyphs. */
2316 | const qreal barW = 3.0;
2317 | const qreal gap = 2.0;
2318 | const qreal totalW = barW * 2 + gap;
2319 | const qreal x0 = (side - totalW) / 2.0;
2320 | const qreal y0 = 3.0;
2321 | const qreal h = side - 6.0;
2322 | const QRectF leftBar(x0, y0, barW, h);
2323 | const QRectF rightBar(x0 + barW + gap, y0, barW, h);
2324 |
2325 | const auto drawBars = [&](QPainter *p, const QColor &color)
2326 | {
2327 | p->setPen(Qt::NoPen);
2328 | p->setBrush(color);
2329 | p->drawRoundedRect(leftBar, 1.0, 1.0);
2330 | p->drawRoundedRect(rightBar, 1.0, 1.0);
2331 | };
2332 |
2333 | const auto makePixmap = [&]()
2334 | {
2335 | QPixmap pm(int(side * dpr), int(side * dpr));
2336 | pm.setDevicePixelRatio(dpr);
2337 | pm.fill(Qt::transparent);
2338 | return pm;
2339 | };
2340 |
2341 | QIcon out;
2342 |
2343 | /* Off: bars in regular text color on transparent background. */
2344 | {
2345 | QPixmap pm = makePixmap();
2346 | QPainter p(&pm);
2347 | p.setRenderHint(QPainter::Antialiasing, true);
2348 | drawBars(&p, palette.color(QPalette::Active, QPalette::ButtonText));
2349 | p.end();
2350 | out.addPixmap(pm, QIcon::Normal, QIcon::Off);
2351 | }
2352 |
2353 | /* On: white bars on transparent background. The stylesheet on
2354 | * the QToolButton supplies the colored rounded background that
2355 | * the bars sit on. */
2356 | {
2357 | QPixmap pm = makePixmap();
2358 | QPainter p(&pm);
2359 | p.setRenderHint(QPainter::Antialiasing, true);
2360 | drawBars(&p, palette.color(QPalette::Active, QPalette::HighlightedText));
2361 | p.end();
2362 | out.addPixmap(pm, QIcon::Normal, QIcon::On);
2363 | }
2364 |
2365 | return out;
2366 | }
2367 |
2368 | /** Stylesheet for the breakpoint-editor pause toggle: transparent
2369 | * when unchecked, filled rounded @c Highlight chip when checked.
2370 | *
2371 | * Going through @c setStyleSheet rather than overriding paintEvent
2372 | * keeps the toggle a vanilla @c QToolButton: native focus / hover
2373 | * feedback still works, and the chip covers the full clickable
2374 | * surface (not just the 16×16 icon). The colors are pulled from
2375 | * the live application palette via @c palette() so the chip tracks
2376 | * the user's accent color and adapts cleanly to dark mode without
2377 | * hard-coded hex values. */
2378 | static QString luaDbgPauseToggleStyleSheet()
2379 | {
2380 | return QStringLiteral((QString(QtPrivate::qMakeStringPrivate(u"" "QToolButton {" " border: none;" " background: transparent;" " padding: 2px;" "}" "QToolButton:checked {" " background-color: palette(highlight);" " border-radius: 4px;" "}" "QToolButton:!checked:hover {" " background-color: palette(midlight);" " border-radius: 4px;" "}")))
2381 | "QToolButton {"(QString(QtPrivate::qMakeStringPrivate(u"" "QToolButton {" " border: none;" " background: transparent;" " padding: 2px;" "}" "QToolButton:checked {" " background-color: palette(highlight);" " border-radius: 4px;" "}" "QToolButton:!checked:hover {" " background-color: palette(midlight);" " border-radius: 4px;" "}")))
2382 | " border: none;"(QString(QtPrivate::qMakeStringPrivate(u"" "QToolButton {" " border: none;" " background: transparent;" " padding: 2px;" "}" "QToolButton:checked {" " background-color: palette(highlight);" " border-radius: 4px;" "}" "QToolButton:!checked:hover {" " background-color: palette(midlight);" " border-radius: 4px;" "}")))
2383 | " background: transparent;"(QString(QtPrivate::qMakeStringPrivate(u"" "QToolButton {" " border: none;" " background: transparent;" " padding: 2px;" "}" "QToolButton:checked {" " background-color: palette(highlight);" " border-radius: 4px;" "}" "QToolButton:!checked:hover {" " background-color: palette(midlight);" " border-radius: 4px;" "}")))
2384 | " padding: 2px;"(QString(QtPrivate::qMakeStringPrivate(u"" "QToolButton {" " border: none;" " background: transparent;" " padding: 2px;" "}" "QToolButton:checked {" " background-color: palette(highlight);" " border-radius: 4px;" "}" "QToolButton:!checked:hover {" " background-color: palette(midlight);" " border-radius: 4px;" "}")))
2385 | "}"(QString(QtPrivate::qMakeStringPrivate(u"" "QToolButton {" " border: none;" " background: transparent;" " padding: 2px;" "}" "QToolButton:checked {" " background-color: palette(highlight);" " border-radius: 4px;" "}" "QToolButton:!checked:hover {" " background-color: palette(midlight);" " border-radius: 4px;" "}")))
2386 | "QToolButton:checked {"(QString(QtPrivate::qMakeStringPrivate(u"" "QToolButton {" " border: none;" " background: transparent;" " padding: 2px;" "}" "QToolButton:checked {" " background-color: palette(highlight);" " border-radius: 4px;" "}" "QToolButton:!checked:hover {" " background-color: palette(midlight);" " border-radius: 4px;" "}")))
2387 | " background-color: palette(highlight);"(QString(QtPrivate::qMakeStringPrivate(u"" "QToolButton {" " border: none;" " background: transparent;" " padding: 2px;" "}" "QToolButton:checked {" " background-color: palette(highlight);" " border-radius: 4px;" "}" "QToolButton:!checked:hover {" " background-color: palette(midlight);" " border-radius: 4px;" "}")))
2388 | " border-radius: 4px;"(QString(QtPrivate::qMakeStringPrivate(u"" "QToolButton {" " border: none;" " background: transparent;" " padding: 2px;" "}" "QToolButton:checked {" " background-color: palette(highlight);" " border-radius: 4px;" "}" "QToolButton:!checked:hover {" " background-color: palette(midlight);" " border-radius: 4px;" "}")))
2389 | "}"(QString(QtPrivate::qMakeStringPrivate(u"" "QToolButton {" " border: none;" " background: transparent;" " padding: 2px;" "}" "QToolButton:checked {" " background-color: palette(highlight);" " border-radius: 4px;" "}" "QToolButton:!checked:hover {" " background-color: palette(midlight);" " border-radius: 4px;" "}")))
2390 | "QToolButton:!checked:hover {"(QString(QtPrivate::qMakeStringPrivate(u"" "QToolButton {" " border: none;" " background: transparent;" " padding: 2px;" "}" "QToolButton:checked {" " background-color: palette(highlight);" " border-radius: 4px;" "}" "QToolButton:!checked:hover {" " background-color: palette(midlight);" " border-radius: 4px;" "}")))
2391 | " background-color: palette(midlight);"(QString(QtPrivate::qMakeStringPrivate(u"" "QToolButton {" " border: none;" " background: transparent;" " padding: 2px;" "}" "QToolButton:checked {" " background-color: palette(highlight);" " border-radius: 4px;" "}" "QToolButton:!checked:hover {" " background-color: palette(midlight);" " border-radius: 4px;" "}")))
2392 | " border-radius: 4px;"(QString(QtPrivate::qMakeStringPrivate(u"" "QToolButton {" " border: none;" " background: transparent;" " padding: 2px;" "}" "QToolButton:checked {" " background-color: palette(highlight);" " border-radius: 4px;" "}" "QToolButton:!checked:hover {" " background-color: palette(midlight);" " border-radius: 4px;" "}")))
2393 | "}")(QString(QtPrivate::qMakeStringPrivate(u"" "QToolButton {" " border: none;" " background: transparent;" " padding: 2px;" "}" "QToolButton:checked {" " background-color: palette(highlight);" " border-radius: 4px;" "}" "QToolButton:!checked:hover {" " background-color: palette(midlight);" " border-radius: 4px;" "}")));
2394 | }
2395 |
2396 | class BreakpointConditionDelegate : public QStyledItemDelegate
2397 | {
2398 | public:
2399 | explicit BreakpointConditionDelegate(LuaDebuggerDialog *dialog)
2400 | : QStyledItemDelegate(dialog)
2401 | {
2402 | }
2403 |
2404 | QWidget *createEditor(QWidget *parent,
2405 | const QStyleOptionViewItem & /*option*/,
2406 | const QModelIndex & /*index*/) const override
2407 | {
2408 | /* The editor IS a @c QLineEdit — same widget class as the Watch
2409 | * editor, so the platform style draws an identical inline
2410 | * edit. The mode combo, hit-count comparison combo and "also
2411 | * pause" checkbox are children of the line edit, positioned
2412 | * inside the line edit's text-margin area by
2413 | * @ref BreakpointInlineLineEdit::relayout. */
2414 | BreakpointInlineLineEdit *editor = new BreakpointInlineLineEdit(parent);
2415 | /* Suppress the macOS focus ring around the actively edited
2416 | * cell — same rationale as the Watch editor: the cell
2417 | * selection plus the explicit border drawn in
2418 | * BreakpointInlineLineEdit::paintEvent already make the
2419 | * edited row obvious. No-op on Linux / Windows. */
2420 | editor->setAttribute(Qt::WA_MacShowFocusRect, false);
2421 |
2422 | QComboBox *mode = new QComboBox(editor);
2423 | /* Force a Qt-managed popup view. macOS otherwise opens the
2424 | * combo as a native NSMenu, which is not a Qt widget and is
2425 | * outside the editor's parent chain; while that menu is
2426 | * active QApplication::focusWidget() returns @c nullptr, our
2427 | * focusChanged listener treats that as "click outside",
2428 | * commits the pending edit and tears the editor down before
2429 | * the user can pick a row from the dropdown. Setting an
2430 | * explicit QListView keeps the popup inside the editor's
2431 | * widget tree so isAncestorOf() recognises it as part of the
2432 | * edit session. */
2433 | mode->setView(new QListView(mode));
2434 |
2435 | for (const BreakpointEditModeSpec &spec : kBreakpointEditModes)
2436 | {
2437 | mode->addItem(QCoreApplication::translate(
2438 | "BreakpointConditionDelegate", spec.label),
2439 | static_cast<int>(spec.mode));
2440 | }
2441 |
2442 | /* The hit-count comparison-mode combo and the "also pause"
2443 | * checkbox are children of the @c BreakpointInlineLineEdit
2444 | * just like the mode combo. They are toggled visible by the
2445 | * mode-combo currentIndexChanged handler below; the line
2446 | * edit's @c relayout() pass reserves text-margin space for
2447 | * whichever ones are currently visible. */
2448 | QComboBox *hitModeCombo = new QComboBox(editor);
2449 | hitModeCombo->setView(new QListView(hitModeCombo));
2450 | /* Labels are deliberately short — the integer field next to
2451 | * the combo carries the value of N, and the tooltip below
2452 | * spells the modes out in full. The longest label drives the
2453 | * combo's sizeHint width inside the inline editor; keeping
2454 | * them at 1–5 visible characters lets the row stay narrow
2455 | * even on tight columns. */
2456 | hitModeCombo->addItem(
2457 | QCoreApplication::translate(
2458 | "BreakpointConditionDelegate", "from"),
2459 | static_cast<int>(WSLUA_HIT_COUNT_MODE_FROM));
2460 | hitModeCombo->addItem(
2461 | QCoreApplication::translate(
2462 | "BreakpointConditionDelegate", "every"),
2463 | static_cast<int>(WSLUA_HIT_COUNT_MODE_EVERY));
2464 | hitModeCombo->addItem(
2465 | QCoreApplication::translate(
2466 | "BreakpointConditionDelegate", "once"),
2467 | static_cast<int>(WSLUA_HIT_COUNT_MODE_ONCE));
2468 | hitModeCombo->setToolTip(QCoreApplication::translate(
2469 | "BreakpointConditionDelegate",
2470 | "Comparison mode for the hit count:\n"
2471 | "from — pause on every hit from N onwards.\n"
2472 | "every — pause on hits N, 2N, 3N…\n"
2473 | "once — pause once on the Nth hit and deactivate the "
2474 | "breakpoint."));
2475 | hitModeCombo->setVisible(false);
2476 |
2477 | /* Icon-only "also pause" toggle. The horizontal space inside
2478 | * the inline editor is tight (the QLineEdit must stay
2479 | * usable), so we drop the "Pause" word and rely on the
2480 | * platform pause glyph plus the tooltip. We use a checkable
2481 | * @c QToolButton (auto-raise, icon-only) rather than a
2482 | * @c QCheckBox so the cell shows just the pause glyph
2483 | * without an empty @c QCheckBox indicator next to it; the
2484 | * tool button's depressed-state visual already conveys the
2485 | * "checked" semantics. The accessibility name preserves the
2486 | * textual label for screen readers. */
2487 | QToolButton *pauseChk = new QToolButton(editor);
2488 | pauseChk->setCheckable(true);
2489 | pauseChk->setFocusPolicy(Qt::TabFocus);
2490 | pauseChk->setToolButtonStyle(Qt::ToolButtonIconOnly);
2491 | /* Icon is drawn from the editor's own palette so the bars
2492 | * automatically read white in dark mode and black in light
2493 | * mode — a fixed stock pixmap would be near-invisible in
2494 | * one of the two themes. */
2495 | pauseChk->setIcon(luaDbgMakePauseIcon(editor->palette()));
2496 | pauseChk->setIconSize(QSize(16, 16));
2497 | /* Stylesheet drives the on/off background: transparent when
2498 | * unchecked (just the bars on the cell background), full
2499 | * Highlight-color rounded chip filling the button when
2500 | * checked. The chip is the primary on/off signal; the icon
2501 | * colors (ButtonText vs HighlightedText) follow it.
2502 | *
2503 | * Using a stylesheet here also disables @c autoRaise (which
2504 | * is no longer needed since we paint our own hover / pressed
2505 | * feedback) — both controls would otherwise compete and
2506 | * leave the button looking ambiguous. */
2507 | pauseChk->setStyleSheet(luaDbgPauseToggleStyleSheet());
2508 | pauseChk->setAccessibleName(QCoreApplication::translate(
2509 | "BreakpointConditionDelegate", "Pause"));
2510 | pauseChk->setToolTip(QCoreApplication::translate(
2511 | "BreakpointConditionDelegate",
2512 | "Pause: format and emit the log message AND pause "
2513 | "execution.\n"
2514 | "Off = logpoint only (matches the historical "
2515 | "\"logpoints never pause\" convention)."));
2516 | pauseChk->setVisible(false);
2517 |
2518 | editor->setEmbeddedWidgets(mode, hitModeCombo, pauseChk);
2519 |
2520 | editor->setProperty("luaDbgModeCombo",
2521 | QVariant::fromValue<QObject *>(mode));
2522 | editor->setProperty("luaDbgHitModeCombo",
2523 | QVariant::fromValue<QObject *>(hitModeCombo));
2524 | editor->setProperty("luaDbgPauseCheckBox",
2525 | QVariant::fromValue<QObject *>(pauseChk));
2526 |
2527 | /* Per-mode draft text caches. The editor is a single line edit
2528 | * shared across all three modes, so when the user switches mode
2529 | * we have to remember what they typed under the previous mode
2530 | * and restore what they had typed (or the persisted value, see
2531 | * setEditorData) under the new mode. */
2532 | editor->setProperty(perModeDraftPropertyName(BreakpointEditMode::Expression),
2533 | QString());
2534 | editor->setProperty(perModeDraftPropertyName(BreakpointEditMode::HitCount),
2535 | QString());
2536 | editor->setProperty(perModeDraftPropertyName(BreakpointEditMode::LogMessage),
2537 | QString());
2538 | /* -1 means "not initialised yet" so the very first
2539 | * applyEditorMode does not write the empty current text into a
2540 | * draft slot before it has loaded the actual draft. */
2541 | editor->setProperty("luaDbgCurrentMode", -1);
2542 |
2543 | QObject::connect(
2544 | mode, QOverload<int>::of(&QComboBox::currentIndexChanged),
2545 | editor,
2546 | [editor](int idx) { applyEditorMode(editor, idx); });
2547 |
2548 | /* Install the event filter only on widgets whose lifetime
2549 | * we explicitly manage:
2550 | * - the editor itself, which IS the QLineEdit (focus /
2551 | * Escape / generic safety net),
2552 | * - the popup view of every QComboBox in the editor
2553 | * (Show/Hide tracking; lets the focus-out commit logic
2554 | * keep the editor alive while any combo dropdown is
2555 | * open, including the inner hit-count-mode combo).
2556 | *
2557 | * Restricting the filter to widgets we own keeps @c watched
2558 | * pointers stable: events emitted from partially-destroyed
2559 | * children during editor teardown (e.g. ~QComboBox calling
2560 | * close()/setVisible(false) and emitting Hide) never reach
2561 | * the filter, so qobject_cast on the watched pointer cannot
2562 | * dereference a freed vtable. */
2563 | BreakpointConditionDelegate *self =
2564 | const_cast<BreakpointConditionDelegate *>(this);
2565 | editor->installEventFilter(self);
2566 | const auto installPopupFilter = [self, editor](QComboBox *combo)
2567 | {
2568 | if (!combo || !combo->view())
2569 | return;
2570 | /* Tag the view with its owning editor so the eventFilter
2571 | * Show/Hide branch can update the popup-open counter
2572 | * without walking the parent chain (which during a
2573 | * shown-popup state goes through Qt's internal
2574 | * QComboBoxPrivateContainer top-level, not the editor). */
2575 | combo->view()->setProperty(
2576 | "luaDbgEditorOwner",
2577 | QVariant::fromValue<QObject *>(editor));
2578 | combo->view()->installEventFilter(self);
2579 | };
2580 | installPopupFilter(mode);
2581 | for (QComboBox *c : editor->findChildren<QComboBox *>())
2582 | {
2583 | if (c != mode)
2584 | installPopupFilter(c);
2585 | }
2586 |
2587 | /* Commit-on-Enter inside the value editors.
2588 | *
2589 | * Wired via @c QLineEdit::returnPressed on every QLineEdit
2590 | * inside the stack pages. We also walk page descendants so
2591 | * a future page that hosts multiple QLineEdit children is
2592 | * covered without changes here.
2593 | *
2594 | * The closeEditorOnAccept lambda is one-shot per editor —
2595 | * the @c luaDbgClosing guard ensures commitData/closeEditor
2596 | * are emitted at most once. Enter, focus loss and the
2597 | * delegate's own event filter can race to commit, and
2598 | * re-emitting on an already-tearing-down editor crashes the
2599 | * view. */
2600 | const auto closeEditorOnAccept =
2601 | [self](QWidget *editorWidget)
2602 | {
2603 | if (!editorWidget)
2604 | return;
2605 | if (editorWidget->property("luaDbgClosing").toBool())
2606 | return;
2607 | editorWidget->setProperty("luaDbgClosing", true);
2608 | emit self->commitData(editorWidget);
2609 | emit self->closeEditor(
2610 | editorWidget, QAbstractItemDelegate::SubmitModelCache);
2611 | };
2612 | QObject::connect(editor, &QLineEdit::returnPressed, editor,
2613 | [closeEditorOnAccept, editor]()
2614 | { closeEditorOnAccept(editor); });
2615 |
2616 | /* The editor IS the value line edit, so it receives keyboard
2617 | * focus by default when QAbstractItemView shows it. The mode
2618 | * combo, hit-mode combo and pause checkbox are reachable with
2619 | * Tab as ordinary children of the line edit. */
2620 |
2621 | /* Click-outside-to-commit. QStyledItemDelegate's built-in
2622 | * "FocusOut closes the editor" hook only watches the editor
2623 | * widget itself; if the user opens the mode combo's popup and
2624 | * then clicks somewhere outside the row, focus moves to a
2625 | * widget that is neither the editor nor a descendant, so the
2626 | * built-in handler doesn't fire — we have to do this in
2627 | * @c QApplication::focusChanged instead.
2628 | *
2629 | * Listen to QApplication::focusChanged instead, deferring the
2630 | * decision via a zero-delay timer so the new focus has settled
2631 | * (covers both ordinary clicks elsewhere and clicks that land
2632 | * on a widget with no focus policy, where focusWidget() ends
2633 | * up @c nullptr). The combo's popup and any tooltip we show
2634 | * all stay descendants of @a editor and leave the editor
2635 | * open. */
2636 | QPointer<QWidget> editorGuard(editor);
2637 | QPointer<QComboBox> modeGuard(mode);
2638 | QPointer<QAbstractItemView> popupGuard(mode->view());
2639 | /* Helper: is the user currently inside the mode combo's
2640 | * dropdown? Combines the explicit open/close flag we set from
2641 | * the eventFilter (most reliable) with `view->isVisible()`
2642 | * as a backup; in either case we treat "popup is open" as
2643 | * "still inside the editor" so the editor doesn't close
2644 | * while the user is picking a mode. */
2645 | auto popupOpen = [editorGuard, popupGuard]()
2646 | {
2647 | if (editorGuard &&
2648 | editorGuard->property("luaDbgPopupOpen").toBool())
2649 | {
2650 | return true;
2651 | }
2652 | return popupGuard && popupGuard->isVisible();
2653 | };
2654 | /* Helper: should the focus shift to @a w be treated as "still
2655 | * inside the editor"? True for the editor itself, any
2656 | * descendant, the mode combo or its descendants, and the
2657 | * combo popup view (which Qt may parent via a top-level
2658 | * Qt::Popup window — so isAncestorOf isn't reliable across
2659 | * platforms). */
2660 | auto stillInside = [editorGuard, modeGuard, popupGuard](QWidget *w)
2661 | {
2662 | if (!w)
2663 | return false;
2664 | if (editorGuard &&
2665 | (w == editorGuard.data() || editorGuard->isAncestorOf(w)))
2666 | {
2667 | return true;
2668 | }
2669 | if (modeGuard &&
2670 | (w == modeGuard.data() || modeGuard->isAncestorOf(w)))
2671 | {
2672 | return true;
2673 | }
2674 | if (popupGuard &&
2675 | (w == popupGuard.data() || popupGuard->isAncestorOf(w)))
2676 | {
2677 | return true;
2678 | }
2679 | return false;
2680 | };
2681 | QObject::connect(
2682 | qApp(static_cast<QApplication *>(QCoreApplication::instance ())), &QApplication::focusChanged, editor,
2683 | [self, editorGuard, popupOpen,
2684 | stillInside](QWidget *old, QWidget *now)
2685 | {
2686 | if (!editorGuard)
2687 | return;
2688 | /* Already torn down or in the process of being torn
2689 | * down by another commit path (Enter via
2690 | * returnPressed, or a previous focus-loss tick).
2691 | * Re-emitting commitData / closeEditor on a
2692 | * deleteLater'd editor crashes the view. */
2693 | if (editorGuard->property("luaDbgClosing").toBool())
2694 | return;
2695 | if (popupOpen())
2696 | return;
2697 | if (stillInside(now))
2698 | return;
2699 | /* Transient null-focus state (e.g. native menu/popup
2700 | * just took focus, app deactivation, or focus moving
2701 | * through a non-Qt widget): keep the editor open. The
2702 | * deferred timer below re-checks once focus settles. */
2703 | if (!now)
2704 | {
2705 | if (stillInside(old))
2706 | {
2707 | QTimer::singleShot(
2708 | 0, editorGuard.data(),
2709 | [editorGuard, popupOpen, stillInside, self]()
2710 | {
2711 | if (!editorGuard)
2712 | return;
2713 | if (editorGuard->property("luaDbgClosing")
2714 | .toBool())
2715 | return;
2716 | if (popupOpen())
2717 | return;
2718 | QWidget *fw = QApplication::focusWidget();
2719 | if (!fw || stillInside(fw))
2720 | return;
2721 | editorGuard->setProperty("luaDbgClosing",
2722 | true);
2723 | emit self->commitData(editorGuard.data());
2724 | emit self->closeEditor(
2725 | editorGuard.data(),
2726 | QAbstractItemDelegate::SubmitModelCache);
2727 | });
2728 | }
2729 | return;
2730 | }
2731 | editorGuard->setProperty("luaDbgClosing", true);
2732 | emit self->commitData(editorGuard.data());
2733 | emit self->closeEditor(
2734 | editorGuard.data(),
2735 | QAbstractItemDelegate::SubmitModelCache);
2736 | });
2737 |
2738 | return editor;
2739 | }
2740 |
2741 | void setEditorData(QWidget *editor,
2742 | const QModelIndex &index) const override
2743 | {
2744 | QLineEdit *valueEdit = qobject_cast<QLineEdit *>(editor);
2745 | QComboBox *mode =
2746 | qobject_cast<QComboBox *>(editor->property("luaDbgModeCombo")
2747 | .value<QObject *>());
2748 | if (!valueEdit || !mode)
2749 | {
2750 | return;
2751 | }
2752 |
2753 | const QAbstractItemModel *model = index.model();
2754 | const QModelIndex col0 = model->index(index.row(), 0, index.parent());
2755 |
2756 | const QString condition =
2757 | model->data(col0, BreakpointConditionRole).toString();
2758 | const qint64 target =
2759 | model->data(col0, BreakpointHitTargetRole).toLongLong();
2760 | const int hitMode =
2761 | model->data(col0, BreakpointHitModeRole)
2762 | .toInt();
2763 | const QString logMessage =
2764 | model->data(col0, BreakpointLogMessageRole).toString();
2765 |
2766 | /* Seed the per-mode draft caches with the persisted values
2767 | * before applyEditorMode() runs — applyEditorMode loads the
2768 | * draft for the active mode into the line edit. The Hit Count
2769 | * cache is the integer rendered as a string (empty for
2770 | * target == 0 so the field reads as unconfigured rather than
2771 | * literal "0"). */
2772 | editor->setProperty(perModeDraftPropertyName(BreakpointEditMode::Expression),
2773 | condition);
2774 | editor->setProperty(perModeDraftPropertyName(BreakpointEditMode::HitCount),
2775 | target > 0 ? QString::number(target) : QString());
2776 | editor->setProperty(perModeDraftPropertyName(BreakpointEditMode::LogMessage),
2777 | logMessage);
2778 |
2779 | if (QComboBox *hitModeCombo = editorHitModeCombo(editor))
2780 | {
2781 | const int comboIdx = hitModeCombo->findData(hitMode);
2782 | hitModeCombo->setCurrentIndex(comboIdx >= 0 ? comboIdx : 0);
2783 | }
2784 | if (QToolButton *logPauseChk = editorPauseToggle(editor))
2785 | {
2786 | logPauseChk->setChecked(
2787 | model->data(col0, BreakpointLogAlsoPauseRole).toBool());
2788 | }
2789 |
2790 | BreakpointEditMode initial = BreakpointEditMode::Expression;
2791 | if (!logMessage.isEmpty())
2792 | initial = BreakpointEditMode::LogMessage;
2793 | else if (!condition.isEmpty())
2794 | initial = BreakpointEditMode::Expression;
2795 | else if (target > 0)
2796 | initial = BreakpointEditMode::HitCount;
2797 |
2798 | const int idx = mode->findData(static_cast<int>(initial));
2799 | if (idx >= 0)
2800 | {
2801 | /* setCurrentIndex fires currentIndexChanged when the index
2802 | * actually changes, which the connected handler routes to
2803 | * applyEditorMode. The very first edit opens with the combo
2804 | * at its default index 0 (Expression); if @c initial is
2805 | * also Expression, no change → no signal → the line edit
2806 | * would never get seeded. Always invoke applyEditorMode
2807 | * explicitly here so the editor is fully configured
2808 | * regardless of whether the index changed. */
2809 | QSignalBlocker blocker(mode);
2810 | mode->setCurrentIndex(idx);
2811 | blocker.unblock();
2812 | applyEditorMode(editor, idx);
2813 | }
2814 | }
2815 |
2816 | void setModelData(QWidget *editor, QAbstractItemModel *model,
2817 | const QModelIndex &index) const override
2818 | {
2819 | QLineEdit *valueEdit = qobject_cast<QLineEdit *>(editor);
2820 | QComboBox *mode =
2821 | qobject_cast<QComboBox *>(editor->property("luaDbgModeCombo")
2822 | .value<QObject *>());
2823 | if (!valueEdit || !mode)
2824 | {
2825 | return;
2826 | }
2827 |
2828 | const BreakpointEditMode chosen = static_cast<BreakpointEditMode>(
2829 | mode->currentData().toInt());
2830 | const QModelIndex col0 = model->index(index.row(), 0, index.parent());
2831 | const QString currentText = valueEdit->text();
2832 |
2833 | switch (chosen)
2834 | {
2835 | case BreakpointEditMode::Expression:
2836 | {
2837 | /* Accept whatever the user typed unconditionally — empty
2838 | * (clears the condition) or syntactically invalid (the
2839 | * dispatch in onBreakpointModelDataChanged runs the parse
2840 | * checker after writing the condition and stamps the row
2841 | * with the @c condition_error warning icon + error string
2842 | * tooltip immediately, so a typo is visible at commit time
2843 | * rather than only after the line has been hit). */
2844 | model->setData(col0, currentText.trimmed(),
2845 | BreakpointConditionRole);
2846 | return;
2847 | }
2848 | case BreakpointEditMode::HitCount:
2849 | {
2850 | /* Empty / non-numeric / negative input maps to 0 ("no hit
2851 | * count"). The QIntValidator on the editor already rejects
2852 | * negatives and non-digits during typing, but we still
2853 | * tolerate empty text here so an explicit clear commits
2854 | * cleanly. */
2855 | const QString text = currentText.trimmed();
2856 | bool ok = false;
2857 | const qlonglong v = text.toLongLong(&ok);
2858 | const qlonglong target = (ok && v > 0) ? v : 0;
2859 | model->setData(col0, target, BreakpointHitTargetRole);
2860 | /* Persist the comparison-mode pick alongside the integer
2861 | * so the dispatch in onBreakpointModelDataChanged can
2862 | * forward both to the core in one tick. The mode is
2863 | * meaningful only when target > 0; we still write it for
2864 | * target == 0 so toggling the value back on later
2865 | * remembers the previous mode. */
2866 | if (QComboBox *hitModeCombo = editorHitModeCombo(editor))
2867 | {
2868 | model->setData(col0, hitModeCombo->currentData().toInt(),
2869 | BreakpointHitModeRole);
2870 | }
2871 | return;
2872 | }
2873 | case BreakpointEditMode::LogMessage:
2874 | {
2875 | /* Do NOT trim — leading / trailing whitespace can be
2876 | * intentional in a log line. */
2877 | model->setData(col0, currentText, BreakpointLogMessageRole);
2878 | if (QToolButton *logPauseChk = editorPauseToggle(editor))
2879 | {
2880 | model->setData(col0, logPauseChk->isChecked(),
2881 | BreakpointLogAlsoPauseRole);
2882 | }
2883 | return;
2884 | }
2885 | }
2886 | }
2887 |
2888 | void updateEditorGeometry(QWidget *editor,
2889 | const QStyleOptionViewItem &option,
2890 | const QModelIndex & /*index*/) const override
2891 | {
2892 | /* Use the row rect, but ensure the editor is at least as tall
2893 | * as a QLineEdit's natural sizeHint so the inline inputs read
2894 | * at the same comfortable height as the Watch inline editor.
2895 | * The accompanying @ref sizeHint override keeps the row itself
2896 | * tall enough to host this geometry without overlapping the
2897 | * row below. */
2898 | QRect rect = option.rect;
2899 | const int preferred = preferredEditorHeight();
2900 | if (rect.height() < preferred)
2901 | {
2902 | rect.setHeight(preferred);
2903 | }
2904 | editor->setGeometry(rect);
2905 | }
2906 |
2907 | QSize sizeHint(const QStyleOptionViewItem &option,
2908 | const QModelIndex &index) const override
2909 | {
2910 | /* The Watch tree's inline QLineEdit reads taller than the
2911 | * default text-only Breakpoints row because the row height
2912 | * matches QLineEdit::sizeHint(); mirror that on this column so
2913 | * the two inline editors visually agree. The row itself
2914 | * inherits this height through QTreeView's per-row sizing. */
2915 | QSize base = QStyledItemDelegate::sizeHint(option, index);
2916 | const int preferred = preferredEditorHeight();
2917 | if (base.height() < preferred)
2918 | {
2919 | base.setHeight(preferred);
2920 | }
2921 | return base;
2922 | }
2923 |
2924 | protected:
2925 | bool eventFilter(QObject *watched, QEvent *event) override
2926 | {
2927 | /* Track the open state of every QComboBox popup inside the
2928 | * editor via Show/Hide events on its view. We can't rely on
2929 | * `view->isVisible()` racing with focusChanged, and Qt has
2930 | * no aboutToShow/aboutToHide signal on QComboBox we can use
2931 | * here. We store a refcount on the editor (luaDbgPopupOpenCount)
2932 | * so that ANY open dropdown — outer mode selector or the
2933 | * inner hit-count-mode combo — keeps the editor alive
2934 | * during focus shifts to its popup. The boolean
2935 | * luaDbgPopupOpen is also kept in sync as a convenience for
2936 | * existing readers.
2937 | *
2938 | * @c watched is guaranteed to be a popup view we explicitly
2939 | * installed on in createEditor(), and its
2940 | * @c luaDbgEditorOwner property points to the owning editor
2941 | * that we set at install time. We avoid walking the runtime
2942 | * parent chain because Qt reparents popup views into a
2943 | * private top-level container while the popup is shown. */
2944 | if (event->type() == QEvent::Show || event->type() == QEvent::Hide)
2945 | {
2946 | QWidget *view = qobject_cast<QWidget *>(watched);
2947 | if (view)
2948 | {
2949 | QWidget *owner = qobject_cast<QWidget *>(
2950 | view->property("luaDbgEditorOwner")
2951 | .value<QObject *>());
2952 | if (owner)
2953 | {
2954 | int n = owner->property("luaDbgPopupOpenCount")
2955 | .toInt();
2956 | if (event->type() == QEvent::Show)
2957 | ++n;
2958 | else if (n > 0)
2959 | --n;
2960 | owner->setProperty("luaDbgPopupOpenCount", n);
2961 | owner->setProperty("luaDbgPopupOpen", n > 0);
2962 | }
2963 | }
2964 | }
2965 | /* Enter is intentionally NOT handled here. The dialog installs
2966 | * its own descendant-shortcut filter and the platform input
2967 | * method can both reorder/swallow key events before our
2968 | * delegate filter sees them, which made an event-filter-based
2969 | * Enter handler unreliable in practice. We instead wire the
2970 | * QLineEdit's canonical "user accepted the input" signal
2971 | * (returnPressed) in createEditor(); that is emitted by Qt
2972 | * only after the widget has actually processed the key, and
2973 | * it fires even when an outside filter swallowed the
2974 | * QKeyEvent.
2975 | *
2976 | * We still handle Escape here because there is no Qt signal
2977 | * for "user pressed Escape" on a QLineEdit. */
2978 | if (event->type() == QEvent::KeyPress)
2979 | {
2980 | QKeyEvent *ke = static_cast<QKeyEvent *>(event);
2981 | const int key = ke->key();
2982 | if (key != Qt::Key_Escape)
2983 | return QStyledItemDelegate::eventFilter(watched, event);
2984 |
2985 | QWidget *editor = qobject_cast<QWidget *>(watched);
2986 | if (!editor || !editor->isAncestorOf(QApplication::focusWidget()))
2987 | {
2988 | if (QWidget *w = qobject_cast<QWidget *>(watched))
2989 | {
2990 | editor = w;
2991 | while (editor->parentWidget())
2992 | {
2993 | if (editor->property("luaDbgModeCombo").isValid())
2994 | break;
2995 | editor = editor->parentWidget();
2996 | }
2997 | }
2998 | }
2999 | if (!editor)
3000 | return QStyledItemDelegate::eventFilter(watched, event);
3001 |
3002 | /* Don't hijack Escape inside the mode combo or its popup;
3003 | * the combo uses Escape to dismiss its dropdown, and we
3004 | * want that to keep the editor open. */
3005 | QComboBox *modeCombo = qobject_cast<QComboBox *>(
3006 | editor->property("luaDbgModeCombo").value<QObject *>());
3007 | QWidget *watchedWidget = qobject_cast<QWidget *>(watched);
3008 | const bool inModeCombo =
3009 | modeCombo && watchedWidget &&
3010 | (watchedWidget == modeCombo ||
3011 | modeCombo->isAncestorOf(watchedWidget) ||
3012 | (modeCombo->view() &&
3013 | (watchedWidget == modeCombo->view() ||
3014 | modeCombo->view()->isAncestorOf(watchedWidget))));
3015 | if (inModeCombo)
3016 | return QStyledItemDelegate::eventFilter(watched, event);
3017 |
3018 | editor->setProperty("luaDbgClosing", true);
3019 | emit const_cast<BreakpointConditionDelegate *>(this)
3020 | ->closeEditor(editor, RevertModelCache);
3021 | return true;
3022 | }
3023 | return QStyledItemDelegate::eventFilter(watched, event);
3024 | }
3025 |
3026 | private:
3027 | /**
3028 | * @brief Cached natural height for the inline inputs.
3029 | *
3030 | * Computed once per delegate (so a session-wide font-size change
3031 | * still applies on the next debugger open) from a freshly-created
3032 | * @c QLineEdit, the same control used by the Watch tree's inline
3033 | * editor — which is what the user is comparing against. The value
3034 | * is plumbed through @ref sizeHint and @ref updateEditorGeometry
3035 | * so both the row height and the editor geometry stay in sync.
3036 | */
3037 | int preferredEditorHeight() const
3038 | {
3039 | if (cachedPreferredHeight_ <= 0)
3040 | {
3041 | QLineEdit probe;
3042 | cachedPreferredHeight_ = probe.sizeHint().height();
3043 | }
3044 | return cachedPreferredHeight_;
3045 | }
3046 | mutable int cachedPreferredHeight_ = 0;
3047 |
3048 | /** Q_PROPERTY name of the per-mode draft text cache for @p m. The
3049 | * shared editor line edit is one widget across all three modes,
3050 | * so the user's typing under each mode is stashed on the editor
3051 | * via this property and restored when the mode is reactivated.
3052 | * @see applyEditorMode, setEditorData. */
3053 | static const char *perModeDraftPropertyName(BreakpointEditMode m)
3054 | {
3055 | switch (m)
3056 | {
3057 | case BreakpointEditMode::Expression:
3058 | return "luaDbgDraftExpression";
3059 | case BreakpointEditMode::HitCount:
3060 | return "luaDbgDraftHitCount";
3061 | case BreakpointEditMode::LogMessage:
3062 | return "luaDbgDraftLogMessage";
3063 | }
3064 | return "luaDbgDraftExpression";
3065 | }
3066 |
3067 | /** Switch the editor's line edit to display @p modeIndex's mode.
3068 | *
3069 | * Called by the mode-combo @c currentIndexChanged signal and
3070 | * directly from @c setEditorData on the initial open. Saves the
3071 | * current line-edit text into the previous mode's draft slot,
3072 | * loads the new mode's draft into the line edit, applies the
3073 | * mode-specific validator / placeholder / tooltip, and toggles
3074 | * the visibility of the auxiliary controls. The line edit then
3075 | * re-runs its layout pass so the new auxiliary visibility is
3076 | * reflected in the text margins. */
3077 | static void applyEditorMode(QWidget *editor, int modeIndex)
3078 | {
3079 | if (!editor || modeIndex < 0 ||
3080 | modeIndex >= static_cast<int>(
3081 | sizeof(kBreakpointEditModes) /
3082 | sizeof(kBreakpointEditModes[0])))
3083 | {
3084 | return;
3085 | }
3086 | QLineEdit *valueEdit = qobject_cast<QLineEdit *>(editor);
3087 | if (!valueEdit)
3088 | return;
3089 |
3090 | const BreakpointEditModeSpec &spec = kBreakpointEditModes[modeIndex];
3091 | const BreakpointEditMode newMode = spec.mode;
3092 | const int prevModeRaw = editor->property("luaDbgCurrentMode").toInt();
3093 |
3094 | /* Stash whatever was in the line edit under the OLD mode's
3095 | * draft slot before we overwrite it. -1 (the createEditor
3096 | * sentinel) means "first call, nothing to stash yet". */
3097 | if (prevModeRaw >= 0)
3098 | {
3099 | const auto prevMode = static_cast<BreakpointEditMode>(prevModeRaw);
3100 | editor->setProperty(perModeDraftPropertyName(prevMode),
3101 | valueEdit->text());
3102 | }
3103 |
3104 | /* Restore (or seed, on the very first call) the new mode's
3105 | * draft into the line edit. */
3106 | const QString draft =
3107 | editor->property(perModeDraftPropertyName(newMode)).toString();
3108 | valueEdit->setText(draft);
3109 |
3110 | /* Validator: only the Hit Count mode constrains input. The
3111 | * old validator (if any) is owned by the line edit, so
3112 | * setValidator(nullptr) lets Qt clean it up on next attach. */
3113 | if (newMode == BreakpointEditMode::HitCount)
3114 | {
3115 | valueEdit->setValidator(new QIntValidator(0, INT_MAX2147483647, valueEdit));
3116 | }
3117 | else
3118 | {
3119 | valueEdit->setValidator(nullptr);
3120 | }
3121 |
3122 | if (spec.placeholder)
3123 | {
3124 | valueEdit->setPlaceholderText(QCoreApplication::translate(
3125 | "BreakpointConditionDelegate", spec.placeholder));
3126 | }
3127 | else
3128 | {
3129 | valueEdit->setPlaceholderText(QString());
3130 | }
3131 | if (spec.valueTooltip)
3132 | {
3133 | valueEdit->setToolTip(QCoreApplication::translate(
3134 | "BreakpointConditionDelegate", spec.valueTooltip));
3135 | }
3136 | else
3137 | {
3138 | valueEdit->setToolTip(QString());
3139 | }
3140 |
3141 | if (QComboBox *hitModeCombo = editorHitModeCombo(editor))
3142 | {
3143 | hitModeCombo->setVisible(newMode == BreakpointEditMode::HitCount);
3144 | }
3145 | if (QToolButton *pauseChk = editorPauseToggle(editor))
3146 | {
3147 | pauseChk->setVisible(newMode == BreakpointEditMode::LogMessage);
3148 | }
3149 |
3150 | editor->setProperty("luaDbgCurrentMode",
3151 | static_cast<int>(newMode));
3152 |
3153 | /* The auxiliary visibility just changed; have the line edit
3154 | * re-run its embedded-widget layout so the right-side text
3155 | * margin matches what's currently shown. (BreakpointInlineLineEdit
3156 | * has no Q_OBJECT — it adds no signals/slots/Q_PROPERTYs over
3157 | * QLineEdit — so we use dynamic_cast rather than qobject_cast.) */
3158 | if (auto *bple =
3159 | dynamic_cast<BreakpointInlineLineEdit *>(editor))
3160 | {
3161 | bple->relayout();
3162 | }
3163 |
3164 | valueEdit->selectAll();
3165 | }
3166 |
3167 | static QComboBox *editorHitModeCombo(QWidget *editor)
3168 | {
3169 | if (!editor)
3170 | return nullptr;
3171 | return qobject_cast<QComboBox *>(
3172 | editor->property("luaDbgHitModeCombo").value<QObject *>());
3173 | }
3174 |
3175 | static QToolButton *editorPauseToggle(QWidget *editor)
3176 | {
3177 | if (!editor)
3178 | return nullptr;
3179 | return qobject_cast<QToolButton *>(
3180 | editor->property("luaDbgPauseCheckBox").value<QObject *>());
3181 | }
3182 | };
3183 |
3184 | } // namespace
3185 |
3186 | LuaDebuggerDialog::LuaDebuggerDialog(QWidget *parent)
3187 | : GeometryStateDialog(parent), ui(new Ui::LuaDebuggerDialog),
3188 | eventLoop(nullptr), enabledCheckBox(nullptr), breakpointTabsPrimed(false),
3189 | debuggerPaused(false), reloadDeferred(false), pauseInputFilter(nullptr),
3190 | stackSelectionLevel(0), variablesSection(nullptr),
3191 | watchSection(nullptr), stackSection(nullptr), breakpointsSection(nullptr),
3192 | filesSection(nullptr), evalSection(nullptr), settingsSection(nullptr),
3193 | variablesTree(nullptr), variablesModel(nullptr), watchTree(nullptr),
3194 | watchModel(nullptr), stackTree(nullptr), stackModel(nullptr),
3195 | fileTree(nullptr), fileModel(nullptr), breakpointsTree(nullptr),
3196 | breakpointsModel(nullptr),
3197 | evalInputEdit(nullptr), evalOutputEdit(nullptr), evalButton(nullptr),
3198 | evalClearButton(nullptr), themeComboBox(nullptr), watchRemoveButton_(nullptr),
3199 | watchRemoveAllButton_(nullptr), breakpointHeaderToggleButton_(nullptr),
3200 | breakpointHeaderRemoveButton_(nullptr),
3201 | breakpointHeaderRemoveAllButton_(nullptr),
3202 | breakpointHeaderEditButton_(nullptr)
3203 | {
3204 | _instance = this;
3205 | setAttribute(Qt::WA_DeleteOnClose);
3206 | ui->setupUi(this);
3207 | ui->actionAddWatch->setShortcut(kCtxAddWatch);
3208 | ui->actionAddWatch->setToolTip(
3209 | tr("Add Watch (%1)")
3210 | .arg(kCtxAddWatch.toString(QKeySequence::NativeText)));
3211 | loadGeometry();
3212 |
3213 | lastOpenDirectory =
3214 | QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation);
3215 | if (lastOpenDirectory.isEmpty())
3216 | {
3217 | lastOpenDirectory = QDir::homePath();
3218 | }
3219 |
3220 | // Create collapsible sections with their content widgets
3221 | createCollapsibleSections();
3222 |
3223 | fileTree->setRootIsDecorated(true);
3224 | fileTree->setHorizontalScrollBarPolicy(Qt::ScrollBarAsNeeded);
3225 | fileTree->header()->setStretchLastSection(true);
3226 | fileTree->header()->setSectionResizeMode(0, QHeaderView::ResizeToContents);
3227 |
3228 | // Compact toolbar styling with consistent icons
3229 | ui->toolBar->setIconSize(QSize(18, 18));
3230 | ui->toolBar->setToolButtonStyle(Qt::ToolButtonIconOnly);
3231 | ui->toolBar->setStyleSheet(QStringLiteral((QString(QtPrivate::qMakeStringPrivate(u"" "QToolBar {" " background-color: palette(window);" " border: none;" " spacing: 4px;" " padding: 2px 4px;" "}" )))
3232 | "QToolBar {"(QString(QtPrivate::qMakeStringPrivate(u"" "QToolBar {" " background-color: palette(window);" " border: none;" " spacing: 4px;" " padding: 2px 4px;" "}" )))
3233 | " background-color: palette(window);"(QString(QtPrivate::qMakeStringPrivate(u"" "QToolBar {" " background-color: palette(window);" " border: none;" " spacing: 4px;" " padding: 2px 4px;" "}" )))
3234 | " border: none;"(QString(QtPrivate::qMakeStringPrivate(u"" "QToolBar {" " background-color: palette(window);" " border: none;" " spacing: 4px;" " padding: 2px 4px;" "}" )))
3235 | " spacing: 4px;"(QString(QtPrivate::qMakeStringPrivate(u"" "QToolBar {" " background-color: palette(window);" " border: none;" " spacing: 4px;" " padding: 2px 4px;" "}" )))
3236 | " padding: 2px 4px;"(QString(QtPrivate::qMakeStringPrivate(u"" "QToolBar {" " background-color: palette(window);" " border: none;" " spacing: 4px;" " padding: 2px 4px;" "}" )))
3237 | "}")(QString(QtPrivate::qMakeStringPrivate(u"" "QToolBar {" " background-color: palette(window);" " border: none;" " spacing: 4px;" " padding: 2px 4px;" "}" ))));
3238 | ui->actionOpenFile->setIcon(StockIcon("document-open"));
3239 | ui->actionSaveFile->setIcon(
3240 | style()->standardIcon(QStyle::SP_DialogSaveButton));
3241 | ui->actionContinue->setIcon(StockIcon("x-lua-debug-continue"));
3242 | ui->actionStepOver->setIcon(StockIcon("x-lua-debug-step-over"));
3243 | ui->actionStepIn->setIcon(StockIcon("x-lua-debug-step-in"));
3244 | ui->actionStepOut->setIcon(StockIcon("x-lua-debug-step-out"));
3245 | ui->actionRunToLine->setIcon(StockIcon("x-lua-debug-run-to-line"));
3246 | ui->actionReloadLuaPlugins->setIcon(StockIcon("view-refresh"));
3247 | ui->actionAddWatch->setIcon(StockIcon("list-add"));
3248 | ui->actionFind->setIcon(StockIcon("edit-find"));
3249 | ui->actionOpenFile->setToolTip(tr("Open Lua Script"));
3250 | ui->actionSaveFile->setToolTip(tr("Save (%1)").arg(
3251 | QKeySequence(QKeySequence::Save)
3252 | .toString(QKeySequence::NativeText)));
3253 | ui->actionContinue->setToolTip(tr("Continue execution (F5)"));
3254 | ui->actionStepOver->setToolTip(tr("Step over (F10)"));
3255 | ui->actionStepIn->setToolTip(tr("Step into (F11)"));
3256 | ui->actionStepOut->setToolTip(tr("Step out (Shift+F11)"));
3257 | ui->actionRunToLine->setToolTip(
3258 | tr("Run to line (%1)")
3259 | .arg(kCtxRunToLine.toString(QKeySequence::NativeText)));
3260 | ui->actionReloadLuaPlugins->setToolTip(
3261 | tr("Reload Lua Plugins (Ctrl+Shift+L)"));
3262 | ui->actionAddWatch->setShortcutContext(Qt::WidgetWithChildrenShortcut);
3263 | ui->actionFind->setToolTip(tr("Find in script (%1)")
3264 | .arg(QKeySequence(QKeySequence::Find)
3265 | .toString(QKeySequence::NativeText)));
3266 | ui->actionGoToLine->setToolTip(tr("Go to line (%1)")
3267 | .arg(kCtxGoToLine
3268 | .toString(QKeySequence::NativeText)));
3269 | ui->actionContinue->setShortcutContext(Qt::WidgetWithChildrenShortcut);
3270 | ui->actionStepOver->setShortcutContext(Qt::WidgetWithChildrenShortcut);
3271 | ui->actionStepIn->setShortcutContext(Qt::WidgetWithChildrenShortcut);
3272 | ui->actionStepOut->setShortcutContext(Qt::WidgetWithChildrenShortcut);
3273 | ui->actionReloadLuaPlugins->setShortcut(kCtxReloadLuaPlugins);
3274 | ui->actionReloadLuaPlugins->setShortcutContext(
3275 | Qt::WidgetWithChildrenShortcut);
3276 | ui->actionSaveFile->setShortcut(QKeySequence::Save);
3277 | ui->actionSaveFile->setShortcutContext(Qt::WidgetWithChildrenShortcut);
3278 | ui->actionFind->setShortcut(QKeySequence::Find);
3279 | ui->actionFind->setShortcutContext(Qt::WidgetWithChildrenShortcut);
3280 | ui->actionGoToLine->setShortcut(kCtxGoToLine);
3281 | ui->actionGoToLine->setShortcutContext(Qt::WidgetWithChildrenShortcut);
3282 | folderIcon = StockIcon("folder");
3283 | fileIcon = StockIcon("text-x-generic");
3284 |
3285 | // Toolbar controls - Checkbox for enable/disable
3286 | // Order: Checkbox | Separator | Continue | Step Over/In/Out | Separator |
3287 | // Open | Reload
3288 | QAction *firstAction = ui->toolBar->actions().isEmpty()
3289 | ? nullptr
3290 | : ui->toolBar->actions().first();
3291 |
3292 | // Enable/Disable checkbox with colored icon
3293 | enabledCheckBox = new QCheckBox(ui->toolBar);
3294 | enabledCheckBox->setChecked(wslua_debugger_is_enabled());
3295 | ui->toolBar->insertWidget(firstAction, enabledCheckBox);
3296 |
3297 | connect(enabledCheckBox, &QCheckBox::toggled, this,
3298 | &LuaDebuggerDialog::onDebuggerToggled);
3299 | connect(ui->actionContinue, &QAction::triggered, this,
3300 | &LuaDebuggerDialog::onContinue);
3301 | connect(ui->actionStepOver, &QAction::triggered, this,
3302 | &LuaDebuggerDialog::onStepOver);
3303 | connect(ui->actionStepIn, &QAction::triggered, this,
3304 | &LuaDebuggerDialog::onStepIn);
3305 | connect(ui->actionStepOut, &QAction::triggered, this,
3306 | &LuaDebuggerDialog::onStepOut);
3307 | connect(ui->actionRunToLine, &QAction::triggered, this,
3308 | &LuaDebuggerDialog::onRunToLine);
3309 | connect(ui->actionAddWatch, &QAction::triggered, this, [this]() {
3310 | QString fromEditor;
3311 | if (LuaDebuggerCodeView *cv = currentCodeView())
3312 | {
3313 | if (cv->textCursor().hasSelection())
3314 | {
3315 | fromEditor = cv->textCursor().selectedText().trimmed();
3316 | }
3317 | }
3318 | if (fromEditor.isEmpty())
3319 | {
3320 | insertNewWatchRow(QString(), true);
3321 | }
3322 | else
3323 | {
3324 | insertNewWatchRow(fromEditor, false);
3325 | }
3326 | });
3327 | connect(ui->actionOpenFile, &QAction::triggered, this,
3328 | &LuaDebuggerDialog::onOpenFile);
3329 | connect(ui->actionSaveFile, &QAction::triggered, this,
3330 | &LuaDebuggerDialog::onSaveFile);
3331 | connect(ui->actionFind, &QAction::triggered, this,
3332 | &LuaDebuggerDialog::onEditorFind);
3333 | connect(ui->actionGoToLine, &QAction::triggered, this,
3334 | &LuaDebuggerDialog::onEditorGoToLine);
3335 | connect(ui->actionReloadLuaPlugins, &QAction::triggered, this,
3336 | &LuaDebuggerDialog::onReloadLuaPlugins);
3337 | addAction(ui->actionContinue);
3338 | addAction(ui->actionStepOver);
3339 | addAction(ui->actionStepIn);
3340 | addAction(ui->actionStepOut);
3341 | addAction(ui->actionReloadLuaPlugins);
3342 | addAction(ui->actionAddWatch);
3343 | addAction(ui->actionSaveFile);
3344 | addAction(ui->actionFind);
3345 | addAction(ui->actionGoToLine);
3346 |
3347 | /* "Remove All Breakpoints" needs a real, dialog-wide shortcut so
3348 | * Ctrl+Shift+F9 fires regardless of focus. Setting the keys only
3349 | * on the right-click menu action (built on demand) made the
3350 | * shortcut a label without a binding. */
3351 | actionRemoveAllBreakpoints_ = new QAction(tr("Remove All Breakpoints"), this);
3352 | actionRemoveAllBreakpoints_->setShortcut(kCtxRemoveAllBreakpoints);
3353 | actionRemoveAllBreakpoints_->setShortcutContext(
3354 | Qt::WidgetWithChildrenShortcut);
3355 | actionRemoveAllBreakpoints_->setEnabled(false);
3356 | connect(actionRemoveAllBreakpoints_, &QAction::triggered, this,
3357 | &LuaDebuggerDialog::onClearBreakpoints);
3358 | addAction(actionRemoveAllBreakpoints_);
3359 |
3360 | ui->luaDebuggerFindFrame->hide();
3361 | ui->luaDebuggerGoToLineFrame->hide();
3362 |
3363 | // Tab Widget
3364 | connect(ui->codeTabWidget, &QTabWidget::tabCloseRequested, this,
3365 | &LuaDebuggerDialog::onCodeTabCloseRequested);
3366 | connect(ui->codeTabWidget, &QTabWidget::currentChanged, this,
3367 | [this](int)
3368 | {
3369 | updateSaveActionState();
3370 | updateLuaEditorAuxFrames();
3371 | updateBreakpointHeaderButtonState();
3372 | updateContinueActionState();
3373 | });
3374 |
3375 | // Breakpoints
3376 | connect(breakpointsModel, &QStandardItemModel::itemChanged, this,
3377 | &LuaDebuggerDialog::onBreakpointItemChanged);
3378 | /* Role-based dispatch for the inline condition / hit-count / log-message
3379 | * editor. itemChanged covers checkbox toggles (CheckStateRole) but the
3380 | * delegate writes only data roles, which itemChanged still fires for
3381 | * but without role information; dataChanged exposes the role list and
3382 | * lets onBreakpointModelDataChanged route to the matching core APIs. */
3383 | connect(breakpointsModel, &QStandardItemModel::dataChanged, this,
3384 | &LuaDebuggerDialog::onBreakpointModelDataChanged);
3385 | connect(breakpointsTree, &QTreeView::doubleClicked, this,
3386 | &LuaDebuggerDialog::onBreakpointItemDoubleClicked);
3387 | connect(breakpointsTree, &QTreeView::customContextMenuRequested, this,
3388 | &LuaDebuggerDialog::onBreakpointContextMenuRequested);
3389 | connect(breakpointsModel, &QAbstractItemModel::rowsInserted, this, [this]() {
3390 | updateBreakpointHeaderButtonState();
3391 | });
3392 | connect(breakpointsModel, &QAbstractItemModel::rowsRemoved, this, [this]() {
3393 | updateBreakpointHeaderButtonState();
3394 | });
3395 | connect(breakpointsModel, &QAbstractItemModel::modelReset, this, [this]() {
3396 | updateBreakpointHeaderButtonState();
3397 | });
3398 | connect(breakpointsTree->selectionModel(),
3399 | &QItemSelectionModel::selectionChanged,
3400 | this, [this]() { updateBreakpointHeaderButtonState(); });
3401 | updateBreakpointHeaderButtonState();
3402 |
3403 | QHeaderView *breakpointHeader = breakpointsTree->header();
3404 | /* User-resizable columns: drag the divider between Active and
3405 | * Location to give the icon / location text whatever balance they
3406 | * want. The Location column still soaks up any leftover width via
3407 | * stretchLastSection, so by default the row looks the same as
3408 | * before and only the column boundary is now interactive. */
3409 | breakpointHeader->setStretchLastSection(true);
3410 | breakpointHeader->setSectionResizeMode(0, QHeaderView::Interactive);
3411 | breakpointHeader->setSectionResizeMode(1, QHeaderView::Interactive);
3412 | breakpointHeader->setSectionResizeMode(2, QHeaderView::Interactive);
3413 | breakpointsModel->setHeaderData(2, Qt::Horizontal, tr("Location"));
3414 | breakpointsTree->setColumnHidden(1, true);
3415 | /* Sensible default for the Active column: enough for the checkbox
3416 | * + indicator icon + a small inset. The user can drag from here. */
3417 | breakpointsTree->setColumnWidth(0, breakpointsTree->fontMetrics().height() * 4);
3418 |
3419 | /* Logpoint output sink. The core invokes the callback on the Lua
3420 | * thread that hit the line; we marshal to the GUI thread via a
3421 | * queued invokeMethod so the slot runs safely against
3422 | * evalOutputEdit. The lambda captures @c this; the destructor
3423 | * unregisters the callback before the dialog disappears. */
3424 | wslua_debugger_register_log_emit_callback(
3425 | &LuaDebuggerDialog::trampolineLogEmit);
3426 |
3427 | // Variables
3428 | connect(variablesTree, &QTreeView::expanded, this,
3429 | &LuaDebuggerDialog::onVariableItemExpanded);
3430 | connect(variablesTree, &QTreeView::collapsed, this,
3431 | &LuaDebuggerDialog::onVariableItemCollapsed);
3432 | variablesTree->setContextMenuPolicy(Qt::CustomContextMenu);
3433 | connect(variablesTree, &QTreeView::customContextMenuRequested, this,
3434 | &LuaDebuggerDialog::onVariablesContextMenuRequested);
3435 |
3436 | /* onWatchItemExpanded updates watchExpansion_ (the runtime expansion
3437 | * map) and performs lazy child fill; onWatchItemCollapsed mirrors the
3438 | * update on collapse. No storeWatchList() call is needed on expand /
3439 | * collapse because expansion state is intentionally not persisted to
3440 | * lua_debugger.json. */
3441 | connect(watchTree, &QTreeView::expanded, this,
3442 | &LuaDebuggerDialog::onWatchItemExpanded);
3443 | connect(watchTree, &QTreeView::collapsed, this,
3444 | &LuaDebuggerDialog::onWatchItemCollapsed);
3445 | watchTree->setContextMenuPolicy(Qt::CustomContextMenu);
3446 | connect(watchTree, &QTreeView::customContextMenuRequested, this,
3447 | &LuaDebuggerDialog::onWatchContextMenuRequested);
3448 | watchTree->setItemDelegateForColumn(
3449 | 0, new WatchRootDelegate(watchTree, this, watchTree));
3450 | watchTree->setItemDelegateForColumn(
3451 | 1, new WatchValueColumnDelegate(watchTree));
3452 | watchTree->viewport()->installEventFilter(this);
3453 |
3454 | connect(watchTree->selectionModel(), &QItemSelectionModel::currentChanged,
3455 | this, &LuaDebuggerDialog::onWatchCurrentItemChanged);
3456 | /* Header Remove button reflects the current selection; selectionChanged
3457 | * fires for every selection mutation (click, Shift/Ctrl+click, keyboard).
3458 | * No separate currentChanged hook is needed — the header buttons depend
3459 | * on selectedRows(), not on the current index. */
3460 | connect(watchTree->selectionModel(), &QItemSelectionModel::selectionChanged,
3461 | this, [this]() { updateWatchHeaderButtonState(); });
3462 | connect(watchModel, &QAbstractItemModel::rowsInserted, this, [this]() {
3463 | updateWatchHeaderButtonState();
3464 | });
3465 | connect(watchModel, &QAbstractItemModel::rowsRemoved, this, [this]() {
3466 | updateWatchHeaderButtonState();
3467 | });
3468 | connect(watchModel, &QAbstractItemModel::modelReset, this, [this]() {
3469 | updateWatchHeaderButtonState();
3470 | });
3471 | connect(variablesTree->selectionModel(),
3472 | &QItemSelectionModel::currentChanged, this,
3473 | &LuaDebuggerDialog::onVariablesCurrentItemChanged);
3474 | updateWatchHeaderButtonState();
3475 |
3476 | // Files
3477 | connect(fileTree, &QTreeView::doubleClicked, this,
3478 | [this](const QModelIndex &index)
3479 | {
3480 | if (!fileModel || !index.isValid())
3481 | {
3482 | return;
3483 | }
3484 | QStandardItem *item =
3485 | fileModel->itemFromIndex(index.sibling(index.row(), 0));
3486 | if (!item || item->data(FileTreeIsDirectoryRole).toBool())
3487 | {
3488 | return;
3489 | }
3490 | const QString path = item->data(FileTreePathRole).toString();
3491 | if (!path.isEmpty())
3492 | {
3493 | loadFile(path);
3494 | }
3495 | });
3496 | fileTree->setContextMenuPolicy(Qt::CustomContextMenu);
3497 | connect(fileTree, &QTreeView::customContextMenuRequested, this,
3498 | &LuaDebuggerDialog::onFileTreeContextMenuRequested);
3499 |
3500 | connect(stackTree, &QTreeView::doubleClicked, this,
3501 | &LuaDebuggerDialog::onStackItemDoubleClicked);
3502 | connect(stackTree->selectionModel(), &QItemSelectionModel::currentChanged,
3503 | this, &LuaDebuggerDialog::onStackCurrentItemChanged);
3504 | stackTree->setContextMenuPolicy(Qt::CustomContextMenu);
3505 | connect(stackTree, &QTreeView::customContextMenuRequested, this,
3506 | &LuaDebuggerDialog::onStackContextMenuRequested);
3507 |
3508 | // Evaluate panel
3509 | connect(evalButton, &QPushButton::clicked, this,
3510 | &LuaDebuggerDialog::onEvaluate);
3511 | connect(evalClearButton, &QPushButton::clicked, this,
3512 | &LuaDebuggerDialog::onEvalClear);
3513 |
3514 | configureVariablesTreeColumns();
3515 | configureWatchTreeColumns();
3516 | configureStackTreeColumns();
3517 | applyMonospaceFonts();
3518 | /* Seed the accent + flash brushes from the initial palette so the very
3519 | * first pause shows correctly themed cues without having to wait for a
3520 | * preference / color-scheme change. */
3521 | refreshChangedValueBrushes();
3522 |
3523 | /*
3524 | * Register our reload callback with the debugger core.
3525 | * This callback is invoked by wslua_reload_plugins() BEFORE
3526 | * Lua scripts are reloaded, allowing us to refresh our cached
3527 | * script content from disk.
3528 | */
3529 | wslua_debugger_register_reload_callback(
3530 | LuaDebuggerDialog::onLuaReloadCallback);
3531 |
3532 | /*
3533 | * Register a callback to be notified AFTER Lua plugins are reloaded.
3534 | * This allows us to refresh the file tree with newly loaded scripts.
3535 | */
3536 | wslua_debugger_register_post_reload_callback(
3537 | LuaDebuggerDialog::onLuaPostReloadCallback);
3538 |
3539 | /*
3540 | * Register a callback to be notified when a Lua script is loaded.
3541 | * This allows us to add the script to the file tree immediately.
3542 | */
3543 | wslua_debugger_register_script_loaded_callback(
3544 | LuaDebuggerDialog::onScriptLoadedCallback);
3545 |
3546 | if (mainApp)
3547 | {
3548 | connect(mainApp, &MainApplication::zoomMonospaceFont, this,
3549 | &LuaDebuggerDialog::onMonospaceFontUpdated, Qt::UniqueConnection);
3550 | connect(mainApp, &MainApplication::appInitialized, this,
3551 | &LuaDebuggerDialog::onMainAppInitialized, Qt::UniqueConnection);
3552 | connect(mainApp, &MainApplication::preferencesChanged, this,
3553 | &LuaDebuggerDialog::onPreferencesChanged, Qt::UniqueConnection);
3554 | /*
3555 | * Connect to colorsChanged signal to update code view themes when
3556 | * Wireshark's color scheme changes. This is important when the debugger
3557 | * theme preference is set to "Auto (follow color scheme)".
3558 | */
3559 | connect(mainApp, &MainApplication::colorsChanged, this,
3560 | &LuaDebuggerDialog::onColorsChanged, Qt::UniqueConnection);
3561 | if (mainApp->isInitialized())
3562 | {
3563 | onMainAppInitialized();
3564 | }
3565 | }
3566 |
3567 | refreshAvailableScripts();
3568 | refreshDebuggerStateUi();
3569 |
3570 | /*
3571 | * Apply all settings from JSON file (theme, font, sections, splitters,
3572 | * breakpoints). This is done after all widgets are created.
3573 | */
3574 | applyDialogSettings();
3575 | updateBreakpoints();
3576 | updateSaveActionState();
3577 | updateLuaEditorAuxFrames();
3578 |
3579 | installDescendantShortcutFilters();
3580 |
3581 | /* Reconcile with any live capture in progress AFTER all init paths
3582 | * that may have re-enabled the core debugger (applyDialogSettings
3583 | * / updateBreakpoints, including ensureDebuggerEnabledForActiveBreakpoints). */
3584 | reconcileWithLiveCaptureOnStartup();
3585 | }
3586 |
3587 | LuaDebuggerDialog::~LuaDebuggerDialog()
3588 | {
3589 | /*
3590 | * Persist JSON only from closeEvent(); if the dialog is destroyed without
3591 | * a normal close (rare), flush once here.
3592 | */
3593 | if (!luaDebuggerJsonSaved_)
3594 | {
3595 | storeDialogSettings();
3596 | saveSettingsFile();
3597 | }
3598 |
3599 | /*
3600 | * Unregister our reload callbacks when the dialog is destroyed.
3601 | */
3602 | wslua_debugger_register_reload_callback(NULL__null);
3603 | wslua_debugger_register_post_reload_callback(NULL__null);
3604 | wslua_debugger_register_script_loaded_callback(NULL__null);
3605 | wslua_debugger_register_log_emit_callback(NULL__null);
3606 |
3607 | /* Discard any logpoint messages that arrived after the
3608 | * unregister but before this destructor ran, and reset the
3609 | * drain-scheduled flag so the next debugger session starts
3610 | * fresh. Without this reset the next session's first fire
3611 | * would skip scheduling its drain (because the flag was left
3612 | * @c true), and messages would accumulate indefinitely. */
3613 | {
3614 | QMutexLocker lock(&s_logEmitMutex);
3615 | s_pendingLogMessages.clear();
3616 | s_logDrainScheduled = false;
3617 | }
3618 |
3619 | delete ui;
3620 | _instance = nullptr;
3621 | }
3622 |
3623 | void LuaDebuggerDialog::createCollapsibleSections()
3624 | {
3625 | QSplitter *splitter = ui->leftSplitter;
3626 |
3627 | // --- Variables Section ---
3628 | variablesSection = new CollapsibleSection(tr("Variables"), this);
3629 | variablesSection->setToolTip(
3630 | tr("<p><b>Locals</b><br/>"
3631 | "Parameters and local variables for the selected stack frame.</p>"
3632 | "<p><b>Upvalues</b><br/>"
3633 | "Outer variables that this function actually uses from surrounding code. "
3634 | "Anything the function does not reference does not appear here.</p>"
3635 | "<p><b>Globals</b><br/>"
3636 | "Names from the global environment table.</p>"
3637 | "<p>Values that differ from the previous pause are drawn in a "
3638 | "<b>bold accent color</b>, and briefly flash on the pause that "
3639 | "introduced the change.</p>"));
3640 | variablesModel = new QStandardItemModel(this);
3641 | variablesModel->setColumnCount(3);
3642 | variablesModel->setHorizontalHeaderLabels({tr("Name"), tr("Value"), tr("Type")});
3643 | variablesTree = new QTreeView();
3644 | variablesTree->setModel(variablesModel);
3645 | /* Type is folded into Name/Value tooltips; keep the column for model data. */
3646 | variablesTree->setColumnHidden(2, true);
3647 | variablesTree->setItemDelegate(
3648 | new VariablesReadOnlyDelegate(variablesTree));
3649 | variablesTree->setUniformRowHeights(true);
3650 | variablesTree->setWordWrap(false);
3651 | variablesSection->setContentWidget(variablesTree);
3652 | variablesSection->setExpanded(true);
3653 | splitter->addWidget(variablesSection);
3654 |
3655 | /*
3656 | * Watch panel: two columns; formats, expansion persistence, depth cap
3657 | * WSLUA_WATCH_MAX_PATH_SEGMENTS, drag reorder, error styling, muted em dash
3658 | * when no live value.
3659 | */
3660 | // --- Watch Section ---
3661 | watchSection = new CollapsibleSection(tr("Watch"), this);
3662 | watchSection->setToolTip(
3663 | tr("<p>Each row is either a <b>Variables-tree path</b> or a "
3664 | "<b>Lua expression</b>; the panel auto-detects which based on "
3665 | "the syntax you type.</p>"
3666 | "<p><b>Path watches</b> &mdash; resolved against the paused "
3667 | "frame's locals, upvalues, and globals:</p>"
3668 | "<ul>"
3669 | "<li>Section-qualified: <code>Locals.<i>name</i></code>, "
3670 | "<code>Upvalues.<i>name</i></code>, "
3671 | "<code>Globals.<i>name</i></code>.</li>"
3672 | "<li>Section root alone: <code>Locals</code>, "
3673 | "<code>Upvalues</code>, <code>Globals</code> "
3674 | "(<code>_G</code> is an alias for <code>Globals</code>).</li>"
3675 | "<li>Unqualified name: resolved in "
3676 | "<b>Locals &rarr; Upvalues &rarr; Globals</b> order; the row "
3677 | "tooltip shows which section matched.</li>"
3678 | "</ul>"
3679 | "<p>After the first segment, chain <code>.field</code> or "
3680 | "bracket keys &mdash; integer "
3681 | "(<code>[1]</code>, <code>[-1]</code>, <code>[0x1F]</code>), "
3682 | "boolean (<code>[true]</code>), or short-literal string "
3683 | "(<code>[\"key\"]</code>, <code>['k']</code>). Depth is capped "
3684 | "at 32 segments.</p>"
3685 | "<p><b>Expression watches</b> &mdash; anything that is not a "
3686 | "plain path (operators, function/method calls, table "
3687 | "constructors, length <code>#</code>, comparisons, &hellip;) is "
3688 | "evaluated as Lua against the same locals/upvalues/globals. "
3689 | "<b>You do not need a leading <code>=</code> or <code>return</code></b>; "
3690 | "value-returning expressions auto-return their value. "
3691 | "Examples: <code>#packets</code>, <code>tbl[i + 1]</code>, "
3692 | "<code>obj:method()</code>, <code>a == b</code>, "
3693 | "<code>{x, y}</code>. Tables produced by an expression are "
3694 | "expandable, and children re-resolve on every pause.</p>"
3695 | "<p>Values are only read while the debugger is "
3696 | "<b>paused</b>; otherwise the Value column shows a muted "
3697 | "em dash. Values that differ from the previous pause are "
3698 | "drawn in a <b>bold accent color</b>, and briefly flash on "
3699 | "the pause that introduced the change.</p>"
3700 | "<p>Double-click or press <b>F2</b> to edit a row; "
3701 | "<b>Delete</b> removes it; drag rows to reorder. Use the "
3702 | "<b>Evaluate</b> panel below to run statements with side "
3703 | "effects (assignments, blocks, loops).</p>"));
3704 | watchTree = new WatchTreeWidget(this);
3705 | watchModel = new WatchItemModel(this);
3706 | watchModel->setColumnCount(2);
3707 | watchModel->setHorizontalHeaderLabels({tr("Watch"), tr("Value")});
3708 | watchTree->setModel(watchModel);
3709 | watchTree->setRootIsDecorated(true);
3710 | watchTree->setDragDropMode(QAbstractItemView::InternalMove);
3711 | watchTree->setDefaultDropAction(Qt::MoveAction);
3712 | /* Row selection + full-row focus: horizontal drop line spans all columns. */
3713 | watchTree->setSelectionBehavior(QAbstractItemView::SelectRows);
3714 | watchTree->setAllColumnsShowFocus(true);
3715 | watchTree->setSelectionMode(QAbstractItemView::ExtendedSelection);
3716 | watchTree->setUniformRowHeights(true);
3717 | watchTree->setWordWrap(false);
3718 | {
3719 | auto *watchWrap = new QWidget();
3720 | auto *watchOuter = new QVBoxLayout(watchWrap);
3721 | watchOuter->setContentsMargins(0, 0, 0, 0);
3722 | watchOuter->setSpacing(4);
3723 | watchOuter->addWidget(watchTree, 1);
3724 | watchSection->setContentWidget(watchWrap);
3725 | }
3726 | {
3727 | const int hdrH = watchSection->titleButtonHeight();
3728 | const QFont hdrTitleFont = watchSection->titleButtonFont();
3729 | auto *const watchHeaderBtnRow = new QWidget(watchSection);
3730 | auto *const watchHeaderBtnLayout = new QHBoxLayout(watchHeaderBtnRow);
3731 | watchHeaderBtnLayout->setContentsMargins(0, 0, 0, 0);
3732 | watchHeaderBtnLayout->setSpacing(4);
3733 | watchHeaderBtnLayout->setAlignment(Qt::AlignVCenter);
3734 | QToolButton *const watchAddBtn = new QToolButton(watchHeaderBtnRow);
3735 | styleLuaDebuggerHeaderPlusMinusButton(watchAddBtn, hdrH, hdrTitleFont);
3736 | watchAddBtn->setText(kLuaDbgHeaderPlus);
3737 | watchAddBtn->setAutoRaise(true);
3738 | watchAddBtn->setStyleSheet(kLuaDbgHeaderToolButtonStyle);
3739 | /* Compute tooltip directly from the action's shortcut so this block
3740 | * does not depend on actionAddWatch's tooltip having already been set. */
3741 | watchAddBtn->setToolTip(
3742 | tr("Add Watch (%1)")
3743 | .arg(ui->actionAddWatch->shortcut()
3744 | .toString(QKeySequence::NativeText)));
3745 | connect(watchAddBtn, &QToolButton::clicked, ui->actionAddWatch,
3746 | &QAction::trigger);
3747 | QToolButton *const watchRemBtn = new QToolButton(watchHeaderBtnRow);
3748 | watchRemoveButton_ = watchRemBtn;
3749 | styleLuaDebuggerHeaderPlusMinusButton(watchRemBtn, hdrH, hdrTitleFont);
3750 | watchRemBtn->setText(kLuaDbgHeaderMinus);
3751 | watchRemBtn->setAutoRaise(true);
3752 | watchRemBtn->setStyleSheet(kLuaDbgHeaderToolButtonStyle);
3753 | watchRemBtn->setEnabled(false);
3754 | watchRemBtn->setToolTip(
3755 | tr("Remove Watch (%1)")
3756 | .arg(QKeySequence(QKeySequence::Delete)
3757 | .toString(QKeySequence::NativeText)));
3758 | QToolButton *const watchRemAllBtn = new QToolButton(watchHeaderBtnRow);
3759 | watchRemoveAllButton_ = watchRemAllBtn;
3760 | styleLuaDebuggerHeaderRemoveAllButton(watchRemAllBtn, hdrH);
3761 | watchRemAllBtn->setAutoRaise(true);
3762 | watchRemAllBtn->setStyleSheet(kLuaDbgHeaderToolButtonStyle);
3763 | watchRemAllBtn->setEnabled(false);
3764 | watchRemAllBtn->setToolTip(
3765 | tr("Remove All Watches (%1)")
3766 | .arg(
3767 | kCtxWatchRemoveAll.toString(QKeySequence::NativeText)));
3768 | watchHeaderBtnLayout->addWidget(watchAddBtn);
3769 | watchHeaderBtnLayout->addWidget(watchRemBtn);
3770 | watchHeaderBtnLayout->addWidget(watchRemAllBtn);
3771 | connect(watchRemBtn, &QToolButton::clicked, this, [this]() {
3772 | const QList<QStandardItem *> del = selectedWatchRootItemsForRemove();
3773 | if (!del.isEmpty())
3774 | {
3775 | deleteWatchRows(del);
3776 | }
3777 | });
3778 | connect(watchRemAllBtn, &QToolButton::clicked, this,
3779 | &LuaDebuggerDialog::removeAllWatchTopLevelItems);
3780 | watchSection->setHeaderTrailingWidget(watchHeaderBtnRow);
3781 | }
3782 | watchSection->setExpanded(true);
3783 | splitter->addWidget(watchSection);
3784 |
3785 | // --- Stack Trace Section ---
3786 | stackSection = new CollapsibleSection(tr("Stack Trace"), this);
3787 | stackModel = new QStandardItemModel(this);
3788 | stackModel->setColumnCount(2);
3789 | stackModel->setHorizontalHeaderLabels({tr("Function"), tr("Location")});
3790 | stackTree = new QTreeView();
3791 | stackTree->setModel(stackModel);
3792 | stackTree->setEditTriggers(QAbstractItemView::NoEditTriggers);
3793 | stackTree->setRootIsDecorated(true);
3794 | stackTree->setToolTip(
3795 | tr("Select a row to inspect locals and upvalues for that frame. "
3796 | "Double-click a Lua frame to open its source location."));
3797 | stackSection->setContentWidget(stackTree);
3798 | stackSection->setExpanded(true);
3799 | splitter->addWidget(stackSection);
3800 |
3801 | // --- Breakpoints Section ---
3802 | breakpointsSection = new CollapsibleSection(tr("Breakpoints"), this);
3803 | breakpointsSection->setToolTip(
3804 | tr("<p><b>Expression</b><br/>"
3805 | "Pause only when this Lua expression is truthy in the "
3806 | "current frame. Runtime errors count as false and surface a "
3807 | "warning icon on the row.</p>"
3808 | "<p><b>Hit Count</b><br/>"
3809 | "Gate the pause on a hit counter (<code>0</code> "
3810 | "disables). The dropdown next to the integer picks the "
3811 | "comparison mode: <code>&ge;</code> pauses every hit at or "
3812 | "after <i>N</i> (default); <code>=</code> pauses once when "
3813 | "the counter reaches <i>N</i>; <code>every</code> pauses on "
3814 | "hits <i>N</i>, 2&times;<i>N</i>, 3&times;<i>N</i>, "
3815 | "&hellip;; <code>once</code> pauses on the <i>N</i>th hit "
3816 | "and deactivates the breakpoint. The counter is preserved "
3817 | "across edits; right-click the row to reset it.</p>"
3818 | "<p><b>Log Message</b><br/>"
3819 | "Write a line to the <i>Evaluate</i> output (and "
3820 | "Wireshark's debug log) each time the breakpoint fires &mdash; "
3821 | "after the <i>Hit Count</i> gate and any <i>Expression</i> "
3822 | "allow it. By default execution continues; click the pause "
3823 | "toggle on the editor row to also pause after emitting. "
3824 | "Tags: <code>{expr}</code> (any Lua value); "
3825 | "<code>{filename}</code>, <code>{basename}</code>, "
3826 | "<code>{line}</code>, <code>{function}</code>, "
3827 | "<code>{what}</code>; <code>{hits}</code>, "
3828 | "<code>{depth}</code>, <code>{thread}</code>; "
3829 | "<code>{timestamp}</code>, <code>{datetime}</code>, "
3830 | "<code>{epoch}</code>, <code>{epoch_ms}</code>, "
3831 | "<code>{elapsed}</code>, <code>{delta}</code>; "
3832 | "<code>{{</code> / <code>}}</code> for literal braces.</p>"
3833 | "<p>Edit the <i>Location</i> cell (double-click, F2, or "
3834 | "right-click &rarr; Edit) to attach one of these. A white "
3835 | "core inside the breakpoint dot &mdash; in this list and in "
3836 | "the gutter &mdash; marks rows that carry extras. "
3837 | "Switching the editor's mode dropdown mid-edit discards "
3838 | "typed-but-uncommitted text on the other pages; press "
3839 | "Enter on a page before switching if you want to keep "
3840 | "what you typed.</p>"));
3841 | breakpointsModel = new QStandardItemModel(this);
3842 | breakpointsModel->setColumnCount(3);
3843 | breakpointsModel->setHorizontalHeaderLabels(
3844 | {tr("Active"), tr("Line"), tr("File")});
3845 | breakpointsTree = new QTreeView();
3846 | breakpointsTree->setModel(breakpointsModel);
3847 | /* Inline edit on the Location column (delegate-driven mode picker for
3848 | * Condition / Hit Count / Log Message). DoubleClicked is the default
3849 | * trigger; the slot in onBreakpointItemDoubleClicked redirects double-
3850 | * click on any row cell to the editable column so the editor opens
3851 | * even when the user clicked the Active checkbox or the hidden Line
3852 | * column. EditKeyPressed enables F2 to open the editor with keyboard. */
3853 | breakpointsTree->setEditTriggers(QAbstractItemView::DoubleClicked |
3854 | QAbstractItemView::EditKeyPressed |
3855 | QAbstractItemView::SelectedClicked);
3856 | breakpointsTree->setItemDelegateForColumn(
3857 | 2, new BreakpointConditionDelegate(this));
3858 | breakpointsTree->setRootIsDecorated(false);
3859 | breakpointsTree->setSelectionBehavior(QAbstractItemView::SelectRows);
3860 | breakpointsTree->setSelectionMode(QAbstractItemView::ExtendedSelection);
3861 | breakpointsTree->setAllColumnsShowFocus(true);
3862 | breakpointsTree->setContextMenuPolicy(Qt::CustomContextMenu);
3863 | breakpointsSection->setContentWidget(breakpointsTree);
3864 | {
3865 | const int hdrH = breakpointsSection->titleButtonHeight();
3866 | const QFont hdrTitleFont = breakpointsSection->titleButtonFont();
3867 | auto *const bpHeaderBtnRow = new QWidget(breakpointsSection);
3868 | auto *const bpHeaderBtnLayout = new QHBoxLayout(bpHeaderBtnRow);
3869 | bpHeaderBtnLayout->setContentsMargins(0, 0, 0, 0);
3870 | bpHeaderBtnLayout->setSpacing(4);
3871 | bpHeaderBtnLayout->setAlignment(Qt::AlignVCenter);
3872 | QToolButton *const bpTglBtn = new QToolButton(bpHeaderBtnRow);
3873 | breakpointHeaderToggleButton_ = bpTglBtn;
3874 | styleLuaDebuggerHeaderBreakpointToggleButton(bpTglBtn, hdrH);
3875 | bpTglBtn->setIcon(
3876 | luaDbgBreakpointHeaderIconForMode(
3877 | nullptr, LuaDbgBpHeaderIconMode::NoBreakpoints, hdrH,
3878 | bpTglBtn->devicePixelRatioF()));
3879 | bpTglBtn->setAutoRaise(true);
3880 | bpTglBtn->setStyleSheet(kLuaDbgHeaderToolButtonStyle);
3881 | bpTglBtn->setEnabled(false);
3882 | bpTglBtn->setToolTip(tr("No breakpoints"));
3883 | QToolButton *const bpEditBtn = new QToolButton(bpHeaderBtnRow);
3884 | breakpointHeaderEditButton_ = bpEditBtn;
3885 | /* Settings-gear glyph: same lookup chain (and fallback) used
3886 | * by the Active column's "this row has extras" indicator for
3887 | * conditional / hit-count breakpoints (see updateBreakpoints),
3888 | * so the header button and the row marker read as the same
3889 | * symbol. Sized through the shared header-icon helper so it
3890 | * matches the trash button on every platform. */
3891 | {
3892 | QIcon editIcon = QIcon::fromTheme(
3893 | QStringLiteral("emblem-system")(QString(QtPrivate::qMakeStringPrivate(u"" "emblem-system"))),
3894 | QIcon::fromTheme(QStringLiteral("preferences-other")(QString(QtPrivate::qMakeStringPrivate(u"" "preferences-other" )))));
3895 | if (editIcon.isNull())
3896 | {
3897 | editIcon =
3898 | style()->standardIcon(QStyle::SP_FileDialogDetailedView);
3899 | }
3900 | bpEditBtn->setIcon(editIcon);
3901 | }
3902 | styleLuaDebuggerHeaderIconOnlyButton(bpEditBtn, hdrH);
3903 | bpEditBtn->setAutoRaise(true);
3904 | bpEditBtn->setStyleSheet(kLuaDbgHeaderToolButtonStyle);
3905 | bpEditBtn->setEnabled(false);
3906 | bpEditBtn->setToolTip(tr("Edit Breakpoint"));
3907 | QToolButton *const bpRemBtn = new QToolButton(bpHeaderBtnRow);
3908 | breakpointHeaderRemoveButton_ = bpRemBtn;
3909 | styleLuaDebuggerHeaderPlusMinusButton(bpRemBtn, hdrH, hdrTitleFont);
3910 | bpRemBtn->setText(kLuaDbgHeaderMinus);
3911 | bpRemBtn->setAutoRaise(true);
3912 | bpRemBtn->setStyleSheet(kLuaDbgHeaderToolButtonStyle);
3913 | bpRemBtn->setEnabled(false);
3914 | bpRemBtn->setToolTip(
3915 | tr("Remove Breakpoint (%1)")
3916 | .arg(QKeySequence(QKeySequence::Delete)
3917 | .toString(QKeySequence::NativeText)));
3918 | QToolButton *const bpRemAllBtn = new QToolButton(bpHeaderBtnRow);
3919 | breakpointHeaderRemoveAllButton_ = bpRemAllBtn;
3920 | styleLuaDebuggerHeaderRemoveAllButton(bpRemAllBtn, hdrH);
3921 | bpRemAllBtn->setAutoRaise(true);
3922 | bpRemAllBtn->setStyleSheet(kLuaDbgHeaderToolButtonStyle);
3923 | bpRemAllBtn->setEnabled(false);
3924 | bpRemAllBtn->setToolTip(
3925 | tr("Remove All Breakpoints (%1)")
3926 | .arg(
3927 | kCtxRemoveAllBreakpoints.toString(
3928 | QKeySequence::NativeText)));
3929 | bpHeaderBtnLayout->addWidget(bpTglBtn);
3930 | bpHeaderBtnLayout->addWidget(bpRemBtn);
3931 | bpHeaderBtnLayout->addWidget(bpEditBtn);
3932 | bpHeaderBtnLayout->addWidget(bpRemAllBtn);
3933 | connect(bpTglBtn, &QToolButton::clicked, this,
3934 | &LuaDebuggerDialog::toggleAllBreakpointsActiveFromHeader);
3935 | connect(bpEditBtn, &QToolButton::clicked, this,
3936 | [this]()
3937 | {
3938 | if (!breakpointsTree)
3939 | return;
3940 | /* Resolve the edit target the same way the context
3941 | * menu does: prefer the focused / current row, fall
3942 | * back to the first selected row when nothing is
3943 | * focused. The button mirrors the Remove button's
3944 | * enable state (any selected row), and
3945 | * startInlineBreakpointEdit() silently skips stale
3946 | * (file-missing) rows, so an "always single row"
3947 | * launch is enough here. */
3948 | int row = -1;
3949 | const QModelIndex cur =
3950 | breakpointsTree->currentIndex();
3951 | if (cur.isValid())
3952 | {
3953 | row = cur.row();
3954 | }
3955 | else if (QItemSelectionModel *sel =
3956 | breakpointsTree->selectionModel())
3957 | {
3958 | for (const QModelIndex &si :
3959 | sel->selectedIndexes())
3960 | {
3961 | if (si.isValid())
3962 | {
3963 | row = si.row();
3964 | break;
3965 | }
3966 | }
3967 | }
3968 | startInlineBreakpointEdit(row);
3969 | });
3970 | connect(bpRemBtn, &QToolButton::clicked, this,
3971 | [this]() { removeSelectedBreakpoints(); });
3972 | connect(bpRemAllBtn, &QToolButton::clicked, this,
3973 | &LuaDebuggerDialog::onClearBreakpoints);
3974 | breakpointsSection->setHeaderTrailingWidget(bpHeaderBtnRow);
3975 | }
3976 | breakpointsSection->setExpanded(true);
3977 | splitter->addWidget(breakpointsSection);
3978 |
3979 | // --- Files Section ---
3980 | filesSection = new CollapsibleSection(tr("Files"), this);
3981 | fileModel = new QStandardItemModel(this);
3982 | fileModel->setColumnCount(1);
3983 | fileModel->setHorizontalHeaderLabels({tr("Files")});
3984 | fileTree = new QTreeView();
3985 | fileTree->setModel(fileModel);
3986 | fileTree->setEditTriggers(QAbstractItemView::NoEditTriggers);
3987 | fileTree->setRootIsDecorated(false);
3988 | filesSection->setContentWidget(fileTree);
3989 | filesSection->setExpanded(true);
3990 | splitter->addWidget(filesSection);
3991 |
3992 | // --- Evaluate Section ---
3993 | evalSection = new CollapsibleSection(tr("Evaluate"), this);
3994 | QWidget *evalWidget = new QWidget();
3995 | QVBoxLayout *evalMainLayout = new QVBoxLayout(evalWidget);
3996 | evalMainLayout->setContentsMargins(0, 0, 0, 0);
3997 | evalMainLayout->setSpacing(4);
3998 |
3999 | /* Held as a member so the input/output split (including either pane
4000 | * being collapsed to zero by the user) persists via
4001 | * storeDialogSettings()/applyDialogSettings(). */
4002 | evalSplitter_ = new QSplitter(Qt::Vertical);
4003 | evalInputEdit = new QPlainTextEdit();
4004 | /* Same cap as the output pane: a giant paste shouldn't grow
4005 | * the input buffer without bound either, and the document's
4006 | * per-line layout cost climbs linearly with size. */
4007 | evalInputEdit->setMaximumBlockCount(kLuaDbgEvalOutputMaxLines);
4008 | evalInputEdit->setPlaceholderText(
4009 | tr("Enter Lua expression (prefix with = to return value)"));
4010 | evalInputEdit->setToolTip(
4011 | tr("<b>Lua Expression Evaluation</b><br><br>"
4012 | "Code runs in a protected environment: runtime errors are "
4013 | "caught and shown in the output instead of propagating.<br><br>"
4014 | "<b>Prefix with <code>=</code></b> to return a value (e.g., "
4015 | "<code>=my_var</code>).<br><br>"
4016 | "<b>What works:</b><ul>"
4017 | "<li>Read/modify global variables (<code>_G.x = 42</code>)</li>"
4018 | "<li>Modify table contents (<code>my_table.field = 99</code>)</li>"
4019 | "<li>Call functions and inspect return values</li>"
4020 | "</ul>"
4021 | "<b>Limitations:</b><ul>"
4022 | "<li>Local variables cannot be modified directly (use "
4023 | "<code>debug.setlocal()</code>)</li>"
4024 | "<li>Long-running expressions are automatically aborted</li>"
4025 | "<li><b>Warning:</b> Changes to globals persist and can affect "
4026 | "ongoing dissection</li>"
4027 | "</ul>"));
4028 | evalOutputEdit = new QPlainTextEdit();
4029 | evalOutputEdit->setReadOnly(true);
4030 | evalOutputEdit->setPlaceholderText(tr("Output"));
4031 | /* Bound the output buffer. With per-packet logpoints the line
4032 | * count grows without limit otherwise, and QPlainTextEdit's
4033 | * per-line layout cost climbs linearly with the document size.
4034 | * QPlainTextEdit auto-evicts the oldest blocks once the cap is
4035 | * reached. */
4036 | evalOutputEdit->setMaximumBlockCount(kLuaDbgEvalOutputMaxLines);
4037 | evalSplitter_->addWidget(evalInputEdit);
4038 | evalSplitter_->addWidget(evalOutputEdit);
4039 | evalMainLayout->addWidget(evalSplitter_, 1);
4040 |
4041 | QHBoxLayout *evalButtonLayout = new QHBoxLayout();
4042 | evalButton = new QPushButton(tr("Evaluate"));
4043 | evalButton->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_Return));
4044 | evalButton->setToolTip(tr("Execute the Lua code (Ctrl+Return)"));
4045 | evalClearButton = new QPushButton(tr("Clear"));
4046 | evalClearButton->setToolTip(tr("Clear input and output"));
4047 | evalButtonLayout->addWidget(evalButton);
4048 | evalButtonLayout->addWidget(evalClearButton);
4049 | evalButtonLayout->addStretch();
4050 | evalMainLayout->addLayout(evalButtonLayout);
4051 |
4052 | evalSection->setContentWidget(evalWidget);
4053 | evalSection->setExpanded(false);
4054 | splitter->addWidget(evalSection);
4055 |
4056 | // --- Settings Section ---
4057 | settingsSection = new CollapsibleSection(tr("Settings"), this);
4058 | QWidget *settingsWidget = new QWidget();
4059 | QFormLayout *settingsLayout = new QFormLayout(settingsWidget);
4060 | settingsLayout->setContentsMargins(4, 4, 4, 4);
4061 | settingsLayout->setSpacing(6);
4062 |
4063 | themeComboBox = new QComboBox();
4064 | themeComboBox->addItem(tr("Auto (follow color scheme)"),
4065 | WSLUA_DEBUGGER_THEME_AUTO);
4066 | themeComboBox->addItem(tr("Dark"), WSLUA_DEBUGGER_THEME_DARK);
4067 | themeComboBox->addItem(tr("Light"), WSLUA_DEBUGGER_THEME_LIGHT);
4068 | themeComboBox->setToolTip(tr("Color theme for the code editor"));
4069 | // Theme will be set by applyDialogSettings() later
4070 | settingsLayout->addRow(tr("Code View Theme:"), themeComboBox);
4071 |
4072 | connect(themeComboBox, QOverload<int>::of(&QComboBox::currentIndexChanged),
4073 | this, &LuaDebuggerDialog::onThemeChanged);
4074 |
4075 | settingsSection->setContentWidget(settingsWidget);
4076 | settingsSection->setExpanded(false);
4077 | splitter->addWidget(settingsSection);
4078 |
4079 | QList<int> sizes;
4080 | int headerH = variablesSection->headerHeight();
4081 | sizes << 120 << 70 << 100 << headerH << 80 << headerH << headerH;
4082 | splitter->setSizes(sizes);
4083 |
4084 | /* Tell QSplitter that every section is allowed to absorb surplus
4085 | * vertical space. Collapsed sections cap themselves at headerHeight via
4086 | * setMaximumHeight, so this stretch only takes effect for sections that
4087 | * are actually expanded; without it, expanding one section while the
4088 | * others stay collapsed leaves the leftover height unallocated and the
4089 | * expanded section never grows past its savedHeight. */
4090 | for (int i = 0; i < splitter->count(); ++i)
4091 | splitter->setStretchFactor(i, 1);
4092 |
4093 | /* Trailing stretch in leftPanelLayout: absorbs leftover vertical space
4094 | * when every section is collapsed (in tandem with leftSplitter being
4095 | * clamped to its content height by updateLeftPanelStretch()), so the
4096 | * toolbar and section headers stay pinned to the top of the panel.
4097 | * When at least one section is expanded the stretch is set to 0 and
4098 | * the splitter takes all extra height. */
4099 | ui->leftPanelLayout->addStretch(0);
4100 |
4101 | const QList<CollapsibleSection *> allSections = {
4102 | variablesSection, watchSection, stackSection, breakpointsSection,
4103 | filesSection, evalSection, settingsSection};
4104 | for (CollapsibleSection *s : allSections)
4105 | connect(s, &CollapsibleSection::toggled,
4106 | this, &LuaDebuggerDialog::updateLeftPanelStretch);
4107 | updateLeftPanelStretch();
4108 | }
4109 |
4110 | void LuaDebuggerDialog::updateLeftPanelStretch()
4111 | {
4112 | if (!ui || !ui->leftSplitter || !ui->leftPanelLayout)
4113 | return;
4114 |
4115 | const QList<CollapsibleSection *> sections = {
4116 | variablesSection, watchSection, stackSection, breakpointsSection,
4117 | filesSection, evalSection, settingsSection};
4118 |
4119 | bool anyExpanded = false;
4120 | int contentH = 0;
4121 | int counted = 0;
4122 | for (CollapsibleSection *s : sections)
4123 | {
4124 | if (!s)
4125 | continue;
4126 | if (s->isExpanded())
4127 | anyExpanded = true;
4128 | contentH += s->headerHeight();
4129 | ++counted;
4130 | }
4131 | if (counted > 1)
4132 | contentH += (counted - 1) * ui->leftSplitter->handleWidth();
4133 |
4134 | const int splitterIdx = ui->leftPanelLayout->indexOf(ui->leftSplitter);
4135 | /* The trailing stretch is the last layout item appended in
4136 | * createCollapsibleSections(). */
4137 | const int stretchIdx = ui->leftPanelLayout->count() - 1;
4138 | if (splitterIdx < 0 || stretchIdx < 0 || splitterIdx == stretchIdx)
4139 | return;
4140 |
4141 | if (anyExpanded)
4142 | {
4143 | ui->leftSplitter->setMaximumHeight(QWIDGETSIZE_MAX((1<<24)-1));
4144 | ui->leftPanelLayout->setStretch(splitterIdx, 1);
4145 | ui->leftPanelLayout->setStretch(stretchIdx, 0);
4146 | }
4147 | else
4148 | {
4149 | ui->leftSplitter->setMaximumHeight(contentH);
4150 | ui->leftPanelLayout->setStretch(splitterIdx, 0);
4151 | ui->leftPanelLayout->setStretch(stretchIdx, 1);
4152 | }
4153 | }
4154 |
4155 | LuaDebuggerDialog *LuaDebuggerDialog::instance(QWidget *parent)
4156 | {
4157 | if (!_instance)
4158 | {
4159 | QWidget *resolved_parent = parent;
4160 | if (!resolved_parent && mainApp && mainApp->isInitialized())
4161 | {
4162 | resolved_parent = mainApp->mainWindow();
4163 | }
4164 | new LuaDebuggerDialog(resolved_parent);
4165 | }
4166 | return _instance;
4167 | }
4168 |
4169 | void LuaDebuggerDialog::handlePause(const char *file_path, int64_t line)
4170 | {
4171 | // Prevent deletion while in event loop
4172 | setAttribute(Qt::WA_DeleteOnClose, false);
4173 |
4174 | // Bring to front
4175 | show();
4176 | raise();
4177 | activateWindow();
4178 |
4179 | QString normalizedPath = normalizedFilePath(QString::fromUtf8(file_path));
4180 | ensureFileTreeEntry(normalizedPath);
4181 | LuaDebuggerCodeView *view = loadFile(normalizedPath);
4182 | if (view)
4183 | {
4184 | view->setCurrentLine(static_cast<qint32>(line));
4185 | }
4186 |
4187 | debuggerPaused = true;
4188 | /* Record the pause location so the breakpoints-tree refresh below can
4189 | * apply the same change-highlight visuals (bold + accent + one-shot
4190 | * background flash) the Watch / Variables trees use. The pair is
4191 | * cleared in clearPausedStateUi() on resume so a non-matching row
4192 | * never carries forward a stale cue across pauses. */
4193 | pausedFile_ = normalizedPath;
4194 | pausedLine_ = static_cast<qlonglong>(line);
4195 |
4196 | /* Cancel any deferred "Watch column shows —" placeholder still pending
4197 | * from the previous resume (typical for runDebuggerStep): we are
4198 | * about to repaint the Watch tree with real values, so the user must
4199 | * never see it briefly flip to "—" and back to the same value. */
4200 | ++watchPlaceholderEpoch_;
4201 |
4202 | /* One snapshot per pause entry: rotate last pause's "current" values
4203 | * into the baseline so every refresh below compares against them.
4204 | * This MUST happen before any refresh that walks the Watch / Variables
4205 | * trees, otherwise the very first refresh would overwrite
4206 | * *Current_ with this pause's values and the snapshot would then
4207 | * rotate those values into *Baseline_, losing the "changed since last
4208 | * pause" signal. updateWidgets() calls refreshWatchDisplay(), so it
4209 | * counts as such a refresh and must be preceded by the rotation.
4210 | *
4211 | * isPauseEntryRefresh_ is also set here so that every refresh inside
4212 | * the pause-entry sequence — including the one triggered by
4213 | * updateWidgets() — gets the transient row-flash in addition to the
4214 | * persistent bold accent. Subsequent intra-pause refreshes
4215 | * (stack-frame switch, theme change, watch edit, eval) read from the
4216 | * same baseline and stay stable. */
4217 | snapshotBaselinesOnPauseEntry();
4218 | /* Decide whether the just-rotated baseline still describes the same
4219 | * Lua function at frame 0. It does not after a call or return, and the
4220 | * cue must be suppressed for this one pause; see
4221 | * changeHighlightAllowed() and updatePauseEntryFrameIdentity(). Must
4222 | * run before the first refresh below so the gate is in effect for
4223 | * every paint in the pause-entry sequence. */
4224 | updatePauseEntryFrameIdentity();
4225 | isPauseEntryRefresh_ = true;
4226 | updateWidgets();
4227 |
4228 | stackSelectionLevel = 0;
4229 | /* Anchor the changed-value cue to the level we're about to paint at;
4230 | * intra-pause stack-frame switches will compare stackSelectionLevel
4231 | * against this and suppress the cue at every other level (see
4232 | * changeHighlightAllowed()). */
4233 | pauseEntryStackLevel_ = stackSelectionLevel;
4234 | updateStack();
4235 | if (variablesModel)
4236 | {
4237 | variablesModel->removeRows(0, variablesModel->rowCount());
4238 | }
4239 | updateVariables(nullptr, QString());
4240 | restoreVariablesExpansionState();
4241 | refreshWatchDisplay();
4242 | /* Pull in the latest hit_count / condition_error from core so the
4243 | * Breakpoints row tooltips reflect what just happened on the way in
4244 | * to this pause (logpoints and "below threshold" hits accumulate
4245 | * silently without taking us through the pause path). */
4246 | updateBreakpoints();
4247 | isPauseEntryRefresh_ = false;
4248 |
4249 | /*
4250 | * If an event loop is already running (e.g. we were called from a step
4251 | * action which triggered an immediate re-pause), reuse it instead of nesting.
4252 | * The outer loop.exec() is still on the stack and will return when we
4253 | * eventually quit it via Continue or close.
4254 | *
4255 | * The outer frame already set up UI freezing (disabled top-levels,
4256 | * overlay, application event filter) and suspended the live-capture
4257 | * pipe source; the re-entrant call leaves all that in place.
4258 | */
4259 | if (eventLoop)
4260 | {
4261 | return;
4262 | }
4263 |
4264 | /*
4265 | * Freeze the rest of the application while Lua is suspended.
4266 | *
4267 | * The main window's dissection/capture state and any Lua-owned
4268 | * objects that the paused dissector still references must not be
4269 | * mutated by the main application while we are on the Lua call
4270 | * stack. Lua tap/dissector callbacks hold pointers into packet
4271 | * scopes, tvbs, and the Lua state itself; letting the main
4272 | * application continue leads to use-after-free and VM reentrancy.
4273 | *
4274 | * Strategy:
4275 | * 1. setEnabled(false) every visible top-level except this dialog
4276 | * so existing dialogs (I/O Graph, Conversations, Follow Stream,
4277 | * Preferences, Lua-spawned TextWindows, …) visibly gray out
4278 | * and reject input.
4279 | * 2. Install an application-level event filter as defense in
4280 | * depth for widgets created DURING the pause and for events
4281 | * that bypass the enabled check (e.g. WM-delivered Close).
4282 | * 3. Show a translucent overlay on the main window with a
4283 | * pulsing pause glyph so the user can tell the frozen UI is
4284 | * intentional rather than hung.
4285 | * 4. Detach the live-capture pipe GSource from glib's main
4286 | * context so no new packets are delivered (and therefore no
4287 | * redissection) while Lua is paused. The GSource is kept
4288 | * alive via g_source_ref and reattached on resume, so no
4289 | * packets are lost.
4290 | *
4291 | * All four steps are guarded by the outermost-frame check above so
4292 | * a re-entrant pause does not double-disable or flicker the
4293 | * overlay.
4294 | */
4295 | /* Mark the freeze as active; endPauseFreeze() will flip this back
4296 | * on the first call (either from handlePause's post-loop or from
4297 | * closeEvent during a main-window close while paused). */
4298 | pauseUnfrozen_ = false;
4299 |
4300 | frozenTopLevels.clear();
4301 | /* Build the set of widgets we must NOT disable: ourselves, plus
4302 | * every parent up the chain. Qt's setEnabled(false) propagates
4303 | * through QObject::children() *across* window boundaries, so
4304 | * disabling the main window would also disable this dialog
4305 | * (toolbar, Continue/Step actions, watch tree, eval pane) and
4306 | * make stepping impossible. We walk parentWidget() manually
4307 | * because QWidget::isAncestorOf() stops at window boundaries —
4308 | * since this dialog is a Qt::Window, isAncestorOf() would
4309 | * incorrectly report that the main window is NOT an ancestor.
4310 | *
4311 | * The main window remains protected from user input by the
4312 | * PauseInputFilter installed below and is visually marked as
4313 | * paused by the LuaDebuggerPauseOverlay. */
4314 | QSet<QWidget *> ancestors;
4315 | ancestors.insert(this);
4316 | for (QWidget *p = parentWidget(); p; p = p->parentWidget()) {
4317 | ancestors.insert(p);
4318 | }
4319 |
4320 | const QList<QWidget *> top_level_widgets = QApplication::topLevelWidgets();
4321 | for (QWidget *w : top_level_widgets)
4322 | {
4323 | if (!w || ancestors.contains(w))
4324 | continue;
4325 | if (!w->isVisible() || !w->isEnabled())
4326 | continue;
4327 | w->setEnabled(false);
4328 | frozenTopLevels.append(QPointer<QWidget>(w));
4329 | }
4330 |
4331 | MainWindow *mw = mainApp ? mainApp->mainWindow() : nullptr;
4332 |
4333 | /* Disable every QAction outside the debugger dialog across the
4334 | * pause: menu items, toolbar buttons, and keyboard shortcuts.
4335 | *
4336 | * Why we cannot rely solely on the QApplication PauseInputFilter:
4337 | *
4338 | * - On macOS the menu bar is native (NSMenuBar/NSMenuItem).
4339 | * Native menu clicks fire the NSMenuItem's action selector
4340 | * and Qt translates that directly to QAction::triggered()
4341 | * WITHOUT generating QMouseEvents — so the event filter
4342 | * never sees them. The same path is used for menu keyboard
4343 | * equivalents (Cmd+I, Cmd+S, …).
4344 | * - Qt::ApplicationShortcut actions on background top-level
4345 | * dialogs can fire from any focused window, including the
4346 | * debugger dialog, even though those background dialogs are
4347 | * setEnabled(false).
4348 | *
4349 | * A disabled QAction grays out and inerts every UI representation
4350 | * of the action. Walking every top-level widget except the
4351 | * debugger dialog and disabling all QAction descendants gives a
4352 | * single, unambiguous "everything outside the debugger is inert"
4353 | * state — the user is not left guessing which menu item or
4354 | * shortcut is still live. */
4355 | frozenActions.clear();
4356 | /* Snapshot every QAction that lives inside the debugger dialog's
4357 | * QObject subtree so we never disable any of them, regardless of
4358 | * which top-level we walk below. The dialog is parented to the
4359 | * main window, so QObject::findChildren<QAction *>() on the main
4360 | * window recursively returns every debugger action (Continue,
4361 | * Step Over/In/Out, Reload, Add Watch, Open/Save File, Find, Go
4362 | * to Line, …) in addition to the main window's own. Without this
4363 | * exclusion list those would get setEnabled(false) along with
4364 | * everything else and the user could not control the debugger
4365 | * while paused. */
4366 | const QList<QAction *> debugger_actions = this->findChildren<QAction *>();
4367 | QSet<QAction *> debugger_action_set;
4368 | debugger_action_set.reserve(debugger_actions.size());
4369 | for (QAction *a : debugger_actions)
4370 | {
4371 | if (a)
4372 | debugger_action_set.insert(a);
4373 | }
4374 | for (QWidget *tlw : top_level_widgets)
4375 | {
4376 | if (!tlw || tlw == this)
4377 | continue;
4378 | const QList<QAction *> actions = tlw->findChildren<QAction *>();
4379 | for (QAction *a : actions)
4380 | {
4381 | if (a && a->isEnabled() && !debugger_action_set.contains(a))
4382 | {
4383 | a->setEnabled(false);
4384 | frozenActions.append(QPointer<QAction>(a));
4385 | }
4386 | }
4387 | }
4388 |
4389 | /* Disable the main window's central widget subtree — packet list,
4390 | * details tree, byte view, and whatever sits in the splitters
4391 | * around them. The pause overlay is a plain child widget with
4392 | * Qt::WA_TransparentForMouseEvents, so any click that reaches
4393 | * the widget under it is also handed straight through; the
4394 | * QApplication-level PauseInputFilter is supposed to swallow
4395 | * those but a disabled widget is the authoritative fence: Qt
4396 | * refuses to deliver user input to it or any descendant
4397 | * regardless of event source. A disabled subtree also re-enables
4398 | * paint via Qt's update() cascade on setEnabled(true) in the
4399 | * resume path, which is what gets the packet list out of its
4400 | * "stuck paused" state — the UpdateRequest filter swallowed every
4401 | * main-window paint during the pause, so without a forced
4402 | * repaint on resume the viewport backing store is left showing
4403 | * whatever it had when the filter went up. centralWidget() is
4404 | * NOT an ancestor of this dialog (the dialog is parented to the
4405 | * QMainWindow, not to its central widget) so disabling it does
4406 | * not inert the debugger. */
4407 | frozenCentralWidget.clear();
4408 | if (mw)
4409 | {
4410 | if (QWidget *cw = mw->centralWidget())
4411 | {
4412 | if (cw->isEnabled())
4413 | {
4414 | cw->setEnabled(false);
4415 | frozenCentralWidget = QPointer<QWidget>(cw);
4416 | }
4417 | }
4418 | }
4419 |
4420 | /* Create the pause overlay as a child of the main window and size
4421 | * it to cover the entire main-window client area (menu bar and
4422 | * toolbars included — the vignette reads more unified that way).
4423 | * The overlay is mouse-transparent and has no widget children of
4424 | * its own, so input remains governed by the setEnabled() fence
4425 | * and the PauseInputFilter installed below.
4426 | *
4427 | * Ordering matters — both are deliberate:
4428 | *
4429 | * 1. Create / show / repaint BEFORE PauseInputFilter is
4430 | * installed. show() only *schedules* a paint by posting a
4431 | * QEvent::UpdateRequest on the main window; the filter we
4432 | * install next swallows every main-window UpdateRequest for
4433 | * the rest of the pause, so a queued paint from show() alone
4434 | * would never actually run and the overlay would stay
4435 | * invisible. repaint() bypasses the event loop entirely —
4436 | * it paints synchronously onto the main window's backing
4437 | * store before the filter exists — so the overlay becomes
4438 | * visible on the very same stack frame as the pause setup.
4439 | *
4440 | * 2. Once painted, the overlay is otherwise static — no
4441 | * animation. The one thing that does still reach the main
4442 | * window while paused is the window manager's resize
4443 | * (QEvent::Resize is not in PauseInputFilter's filtered set),
4444 | * so the overlay installs its own event filter on the parent
4445 | * to track the new geometry and synchronously repaint. See
4446 | * LuaDebuggerPauseOverlay::eventFilter. */
4447 | if (mw && !pauseOverlay) {
4448 | pauseOverlay = new LuaDebuggerPauseOverlay(mw);
4449 | pauseOverlay->raise();
4450 | pauseOverlay->show();
4451 | pauseOverlay->repaint();
4452 | }
4453 |
4454 | /* Keep the debugger dialog in front — it is the only top-level
4455 | * the user is supposed to interact with while paused. */
4456 | this->raise();
4457 | this->activateWindow();
4458 |
4459 | pauseInputFilter = new PauseInputFilter(this, mw);
4460 | qApp(static_cast<QApplication *>(QCoreApplication::instance ()))->installEventFilter(pauseInputFilter);
4461 |
4462 | /* Note: live capture cannot be running here. The live-capture
4463 | * observer (onCaptureSessionEvent) force-disables the debugger
4464 | * for the duration of any capture, so wslua_debug_hook never
4465 | * dispatches into us while dumpcap is feeding the pipe. That is
4466 | * the only sane policy: suspending the pipe GSource for the
4467 | * duration of the pause is fragile (g_source_destroy frees the
4468 | * underlying GIOChannel, breaking any later resume) and racing
4469 | * the dumpcap child while a Lua dissector is on the C stack
4470 | * invites re-entrant dissection of partially-read packets. */
4471 |
4472 | QEventLoop loop;
4473 | eventLoop = &loop;
4474 |
4475 | /*
4476 | * If the parent window is destroyed while we're paused (e.g. the
4477 | * application is shutting down), quit the event loop so the Lua
4478 | * call stack can unwind cleanly.
4479 | */
4480 | QPointer<QWidget> parentGuard(parentWidget());
4481 | QMetaObject::Connection parentConn;
4482 | if (parentGuard) {
4483 | parentConn = connect(parentGuard, &QObject::destroyed, &loop,
4484 | &QEventLoop::quit);
4485 | }
4486 |
4487 | // Enter event loop - blocks until Continue or dialog close
4488 | loop.exec();
4489 |
4490 | if (parentConn) {
4491 | disconnect(parentConn);
4492 | }
4493 |
4494 | /* Undo the pause-entry UI freeze. Idempotent — may already have
4495 | * run from closeEvent() if the user closed the main window while
4496 | * we were paused (see endPauseFreeze() for details). */
4497 | endPauseFreeze();
4498 |
4499 | // Restore delete-on-close behavior and clear event loop pointer
4500 | eventLoop = nullptr;
4501 | setAttribute(Qt::WA_DeleteOnClose, true);
4502 |
4503 | /*
4504 | * If a Lua plugin reload was requested while we were paused,
4505 | * schedule it now that the Lua/C call stack has fully unwound.
4506 | * We must NOT schedule it from inside the event loop (via
4507 | * QTimer::singleShot) because the timer can fire before the
4508 | * loop exits, running cf_close/wslua_reload_plugins while
4509 | * cf_read is still on the C call stack.
4510 | */
4511 | if (reloadDeferred) {
4512 | reloadDeferred = false;
4513 | if (mainApp) {
4514 | mainApp->reloadLuaPluginsDelayed();
4515 | }
4516 | }
4517 |
4518 | /* If the user (or the OS, e.g. macOS Dock-Quit) tried to close
4519 | * the main window while we were paused, MainWindow::closeEvent
4520 | * recorded the request via handleMainCloseIfPaused() and
4521 | * ignored the QCloseEvent. The pause has now ended, so re-issue
4522 | * the close on the main window. Queued so it runs after the Lua
4523 | * C stack above us has unwound. */
4524 | deliverDeferredMainCloseIfPending();
4525 |
4526 | /* If the debugger window was closed while paused, closeEvent ran with
4527 | * WA_DeleteOnClose temporarily disabled, so Qt hid the dialog but kept
4528 | * this instance alive until the pause loop unwound. Tear that hidden
4529 | * instance down now so the next open always starts from a fresh, fully
4530 | * initialized dialog state instead of reusing a half-torn-down one. */
4531 | if (!isVisible())
4532 | {
4533 | deleteLater();
4534 | }
4535 | }
4536 |
4537 | void LuaDebuggerDialog::onContinue()
4538 | {
4539 | resumeDebuggerAndExitLoop();
4540 | updateWidgets();
4541 | }
4542 |
4543 | void LuaDebuggerDialog::runDebuggerStep(void (*step_fn)(void))
4544 | {
4545 | if (!debuggerPaused)
4546 | {
4547 | return;
4548 | }
4549 |
4550 | debuggerPaused = false;
4551 | clearPausedStateUi();
4552 |
4553 | /*
4554 | * The step function resumes the VM and may synchronously hit handlePause()
4555 | * again. handlePause() detects that eventLoop is already set and reuses
4556 | * it instead of nesting a new one — so the stack does NOT grow with each
4557 | * step.
4558 | */
4559 | step_fn();
4560 |
4561 | /* Synchronous re-pause: handlePause() already ran the full refresh
4562 | * (including the Watch tree) with debuggerPaused=true. Anything we do
4563 | * here would either be redundant or, worse, blank the freshly painted
4564 | * values back to the "—" placeholder. */
4565 | if (debuggerPaused)
4566 | {
4567 | return;
4568 | }
4569 |
4570 | /*
4571 | * If handlePause() was NOT called (e.g. step landed in C code
4572 | * and the hook didn't fire), we need to quit the event loop so
4573 | * the original handlePause() caller can return.
4574 | */
4575 | if (eventLoop)
4576 | {
4577 | eventLoop->quit();
4578 | }
4579 |
4580 | /* Update the non-Watch chrome (window title, action enabled-state, eval
4581 | * panel placeholder) immediately so the user sees the debugger is no
4582 | * longer paused. The Watch tree is a special case: a typical step
4583 | * re-pauses within a few ms and immediately blanking every Watch value
4584 | * to "—" only to repaint the same value right back looks like every
4585 | * row is "blinking". Defer the placeholder application; if handlePause()
4586 | * arrives before the timer it bumps watchPlaceholderEpoch_ and the
4587 | * deferred refresh becomes a no-op. If no pause arrives in the deferral
4588 | * window (long-running step, script ended), the placeholder is applied
4589 | * normally so stale values are not left displayed. */
4590 | updateEnabledCheckboxIcon();
4591 | updateStatusLabel();
4592 | updateContinueActionState();
4593 | updateEvalPanelState();
4594 |
4595 | const qint32 epoch = ++watchPlaceholderEpoch_;
4596 | QPointer<LuaDebuggerDialog> guard(this);
4597 | QTimer::singleShot(WATCH_PLACEHOLDER_DEFER_MS, this, [guard, epoch]() {
4598 | if (!guard || guard->debuggerPaused ||
4599 | guard->watchPlaceholderEpoch_ != epoch)
4600 | {
4601 | return;
4602 | }
4603 | guard->refreshWatchDisplay();
4604 | });
4605 | }
4606 |
4607 | void LuaDebuggerDialog::onStepOver()
4608 | {
4609 | runDebuggerStep(wslua_debugger_step_over);
4610 | }
4611 |
4612 | void LuaDebuggerDialog::onStepIn()
4613 | {
4614 | runDebuggerStep(wslua_debugger_step_in);
4615 | }
4616 |
4617 | void LuaDebuggerDialog::onStepOut()
4618 | {
4619 | runDebuggerStep(wslua_debugger_step_out);
4620 | }
4621 |
4622 | void LuaDebuggerDialog::onDebuggerToggled(bool checked)
4623 | {
4624 | if (isSuppressedByLiveCapture())
4625 | {
4626 | /* The checkbox is normally setEnabled(false) while a live
4627 | * capture is running, but a programmatic toggle (e.g. via
4628 | * QAbstractButton::click in tests, or any path that bypasses
4629 | * the disabled state) must not be allowed to flip the core
4630 | * enable on or off. Remember the user's intent so it is
4631 | * applied automatically when the capture stops, and re-sync
4632 | * the checkbox to the (still suppressed) core state. */
4633 | s_captureSuppressionPrevEnabled_ = checked;
4634 | refreshDebuggerStateUi();
4635 | return;
4636 | }
4637 | wslua_debugger_set_user_explicitly_disabled(!checked);
4638 | if (!checked && debuggerPaused)
4639 | {
4640 | onContinue();
4641 | }
4642 | wslua_debugger_set_enabled(checked);
4643 | if (!checked)
4644 | {
4645 | debuggerPaused = false;
4646 | clearPausedStateUi();
4647 | /* Disabling the debugger breaks the "changed since last pause"
4648 | * chain; drop every baseline so the next enable → pause cycle
4649 | * starts clean instead of comparing against a stale snapshot. */
4650 | clearAllChangeBaselines();
4651 | }
4652 | refreshDebuggerStateUi();
4653 | }
4654 |
4655 | void LuaDebuggerDialog::reject()
4656 | {
4657 | /* Base QDialog::reject() calls done(Rejected), which hides() without
4658 | * delivering QCloseEvent, so our closeEvent() unsaved-scripts check does
4659 | * not run (e.g. Esc). Synchronous close() from keyPressEvent → reject()
4660 | * can fail to finish closing; queue close() so closeEvent() runs on the
4661 | * next event-loop turn (same path as the window close control). */
4662 | QMetaObject::invokeMethod(this, "close", Qt::QueuedConnection);
4663 | }
4664 |
4665 | void LuaDebuggerDialog::closeEvent(QCloseEvent *event)
4666 | {
4667 | const bool pausedOnEntry = debuggerPaused || wslua_debugger_is_paused();
4668 | if (!ensureUnsavedChangesHandled(tr("Lua Debugger")))
4669 | {
4670 | /* User cancelled the debugger unsaved-file prompt; cancel any
4671 | * deferred app-quit request attached to this close attempt. */
4672 | s_mainCloseDeferredByPause_ = false;
4673 | event->ignore();
4674 | return;
4675 | }
4676 |
4677 | storeDialogSettings();
4678 | saveSettingsFile();
4679 | luaDebuggerJsonSaved_ = true;
4680 |
4681 | /* Disable the debugger so breakpoints won't fire and reopen the
4682 | * dialog after it has been closed. */
4683 | wslua_debugger_renounce_restore_after_reload();
4684 | /* "Stay off" is scoped to a visible dialog. Clear it so the next
4685 | * open can call ensureDebuggerEnabledForActiveBreakpoints() (same
4686 | * as pre–user-explicit–C–flag behavior: enable when BPs are active). */
4687 | wslua_debugger_set_user_explicitly_disabled(false);
4688 | wslua_debugger_set_enabled(false);
4689 | resumeDebuggerAndExitLoop();
4690 | debuggerPaused = false;
4691 | clearPausedStateUi();
4692 | refreshDebuggerStateUi();
4693 |
4694 | /* Tear the pause freeze down synchronously. If this closeEvent is
4695 | * running because WiresharkMainWindow::closeEvent called
4696 | * dbg->close() while the debugger was paused, control returns to
4697 | * main_window's closeEvent as soon as we return — and its
4698 | * tryClosingCaptureFile() may pop up a "Save unsaved capture?"
4699 | * modal that must be interactive. The nested QEventLoop inside
4700 | * handlePause has been asked to quit by resumeDebuggerAndExitLoop
4701 | * above but hasn't unwound yet; by the time it does,
4702 | * endPauseFreeze() there is a no-op thanks to pauseUnfrozen_. */
4703 | endPauseFreeze();
4704 |
4705 | /* For non-paused closes we can re-deliver a deferred main close now.
4706 | * Paused closes must wait for handlePause() post-loop cleanup so the
4707 | * Lua C stack is unwound first. */
4708 | if (!pausedOnEntry)
4709 | {
4710 | deliverDeferredMainCloseIfPending();
4711 | }
4712 |
4713 | /*
4714 | * Do not call QDialog::closeEvent (GeometryStateDialog inherits it):
4715 | * QDialog::closeEvent invokes reject(); our reject() queues close()
4716 | * asynchronously, so the dialog stays visible and Qt then ignores the
4717 | * close event (see qdialog.cpp: if (that && isVisible()) e->ignore()).
4718 | * QWidget::closeEvent only accepts the event so the window can close.
4719 | */
4720 | QWidget::closeEvent(event);
4721 | }
4722 |
4723 | void LuaDebuggerDialog::showEvent(QShowEvent *event)
4724 | {
4725 | GeometryStateDialog::showEvent(event);
4726 | /* Re-apply "enable if active breakpoints" on each show; closeEvent
4727 | * clears user-explicit-disable so this matches pre–C–flag behavior. */
4728 | ensureDebuggerEnabledForActiveBreakpoints();
4729 | updateWidgets();
4730 | }
4731 |
4732 | void LuaDebuggerDialog::handleEscapeKey()
4733 | {
4734 | QWidget *const modal = QApplication::activeModalWidget();
4735 | if (modal && modal != this)
4736 | {
4737 | return;
4738 | }
4739 | if (ui->luaDebuggerFindFrame->isVisible())
4740 | {
4741 | ui->luaDebuggerFindFrame->animatedHide();
4742 | return;
4743 | }
4744 | if (ui->luaDebuggerGoToLineFrame->isVisible())
4745 | {
4746 | ui->luaDebuggerGoToLineFrame->animatedHide();
4747 | return;
4748 | }
4749 | QMetaObject::invokeMethod(this, "close", Qt::QueuedConnection);
4750 | }
4751 |
4752 | void LuaDebuggerDialog::installDescendantShortcutFilters()
4753 | {
4754 | installEventFilter(this);
4755 | for (QWidget *w : findChildren<QWidget *>())
4756 | {
4757 | w->installEventFilter(this);
4758 | }
4759 | }
4760 |
4761 | void LuaDebuggerDialog::childEvent(QChildEvent *event)
4762 | {
4763 | if (event->added())
4764 | {
4765 | if (auto *w = qobject_cast<QWidget *>(event->child()))
4766 | {
4767 | w->installEventFilter(this);
4768 | for (QWidget *d : w->findChildren<QWidget *>())
4769 | {
4770 | d->installEventFilter(this);
4771 | }
4772 | }
4773 | }
4774 | QDialog::childEvent(event);
4775 | }
4776 |
4777 | bool LuaDebuggerDialog::eventFilter(QObject *obj, QEvent *event)
4778 | {
4779 | QWidget *const receiver = qobject_cast<QWidget *>(obj);
4780 | const bool inDebuggerUi =
4781 | receiver && isVisible() && isAncestorOf(receiver);
4782 |
4783 | if (watchTree && obj == watchTree->viewport() &&
4784 | event->type() == QEvent::MouseButtonDblClick)
4785 | {
4786 | auto *me = static_cast<QMouseEvent *>(event);
4787 | if (me->button() == Qt::LeftButton &&
4788 | !watchTree->indexAt(me->pos()).isValid())
4789 | {
4790 | insertNewWatchRow(QString(), true);
4791 | return true;
4792 | }
4793 | }
4794 |
4795 | if (inDebuggerUi && event->type() == QEvent::ShortcutOverride)
4796 | {
4797 | auto *ke = static_cast<QKeyEvent *>(event);
4798 | const QKeySequence pressed = luaSeqFromKeyEvent(ke);
4799 | /*
4800 | * Reserve debugger-owned overlaps before Qt can dispatch app-level
4801 | * shortcuts in the main window. Keep this matcher aligned with any
4802 | * debugger shortcut that can collide with global actions.
4803 | */
4804 | if (pressed.matches(QKeySequence::Quit) == QKeySequence::ExactMatch ||
4805 | matchesLuaDebuggerShortcutKeys(ui, pressed))
4806 | {
4807 | ke->accept();
4808 | return false;
4809 | }
4810 | }
4811 |
4812 | if (inDebuggerUi && event->type() == QEvent::KeyPress)
4813 | {
4814 | auto *ke = static_cast<QKeyEvent *>(event);
4815 | if (breakpointsTree &&
4816 | (obj == breakpointsTree || obj == breakpointsTree->viewport()))
4817 | {
4818 | if (breakpointsTree->hasFocus() ||
4819 | (breakpointsTree->viewport() &&
4820 | breakpointsTree->viewport()->hasFocus()))
4821 | {
4822 | const QKeySequence pressedB = luaSeqFromKeyEvent(ke);
4823 | if (pressedB.matches(QKeySequence::Delete) == QKeySequence::ExactMatch ||
4824 | pressedB.matches(Qt::Key_Backspace) == QKeySequence::ExactMatch)
4825 | {
4826 | if (removeSelectedBreakpoints())
4827 | {
4828 | return true;
4829 | }
4830 | }
4831 | }
4832 | }
4833 | if (watchTree && (obj == watchTree || obj == watchTree->viewport()))
4834 | {
4835 | if (watchTree->hasFocus() ||
4836 | (watchTree->viewport() && watchTree->viewport()->hasFocus()))
4837 | {
4838 | const QKeySequence pressedW = luaSeqFromKeyEvent(ke);
4839 | if (pressedW.matches(kCtxWatchRemoveAll) ==
4840 | QKeySequence::ExactMatch &&
4841 | watchModel && watchModel->rowCount() > 0)
4842 | {
4843 | removeAllWatchTopLevelItems();
4844 | return true;
4845 | }
4846 |
4847 | const QModelIndex curIx = watchTree->currentIndex();
4848 | QStandardItem *const cur =
4849 | watchModel
4850 | ? watchModel->itemFromIndex(
4851 | curIx.sibling(curIx.row(), 0))
4852 | : nullptr;
4853 | if (cur)
4854 | {
4855 | if (pressedW.matches(kCtxWatchCopyValue) ==
4856 | QKeySequence::ExactMatch)
4857 | {
4858 | copyWatchValueForItem(cur, curIx);
4859 | return true;
4860 | }
4861 | }
4862 | if (cur && cur->parent() == nullptr)
4863 | {
4864 | if (pressedW.matches(kCtxWatchDuplicate) ==
4865 | QKeySequence::ExactMatch)
4866 | {
4867 | duplicateWatchRootItem(cur);
4868 | return true;
4869 | }
4870 | }
4871 | if (cur && cur->parent() == nullptr)
4872 | {
4873 | if (pressedW.matches(QKeySequence::Delete) ==
4874 | QKeySequence::ExactMatch ||
4875 | pressedW.matches(Qt::Key_Backspace) ==
4876 | QKeySequence::ExactMatch)
4877 | {
4878 | QList<QStandardItem *> del;
4879 | if (watchTree->selectionModel())
4880 | {
4881 | for (const QModelIndex &six :
4882 | watchTree->selectionModel()
4883 | ->selectedIndexes())
4884 | {
4885 | if (six.column() != 0)
4886 | {
4887 | continue;
4888 | }
4889 | QStandardItem *it =
4890 | watchModel->itemFromIndex(six);
4891 | if (it && it->parent() == nullptr)
4892 | {
4893 | del.append(it);
4894 | }
4895 | }
4896 | }
4897 | if (del.isEmpty())
4898 | {
4899 | del.append(cur);
4900 | }
4901 | deleteWatchRows(del);
4902 | return true;
4903 | }
4904 | if (pressedW.matches(kCtxWatchEdit) ==
4905 | QKeySequence::ExactMatch)
4906 | {
4907 | const QModelIndex editIx =
4908 | watchModel->indexFromItem(cur);
4909 | if (editIx.isValid())
4910 | {
4911 | watchTree->edit(editIx);
4912 | }
4913 | return true;
4914 | }
4915 | }
4916 | }
4917 | }
4918 | {
4919 | LuaDebuggerCodeView *const focusCv = codeViewFromObject(obj);
4920 | if (focusCv)
4921 | {
4922 | if (focusCv->hasFocus() ||
4923 | (focusCv->viewport() &&
4924 | focusCv->viewport()->hasFocus()))
4925 | {
4926 | const QKeySequence pCv = luaSeqFromKeyEvent(ke);
4927 | const qint32 line = static_cast<qint32>(
4928 | focusCv->textCursor().blockNumber() + 1);
4929 | if (pCv.matches(kCtxToggleBreakpoint) ==
4930 | QKeySequence::ExactMatch)
4931 | {
4932 | toggleBreakpointOnCodeViewLine(focusCv, line);
4933 | return true;
4934 | }
4935 | if (eventLoop && pCv.matches(kCtxRunToLine) ==
4936 | QKeySequence::ExactMatch)
4937 | {
4938 | runToCurrentLineInPausedEditor(focusCv, line);
4939 | return true;
4940 | }
4941 | }
4942 | }
4943 | }
4944 | /*
4945 | * Esc must be handled here: QPlainTextEdit accepts Key_Escape without
4946 | * propagating to QDialog::keyPressEvent, so reject() never runs.
4947 | * Dismiss inline find/go bars first; then queue close() so closeEvent()
4948 | * runs (unsaved-scripts prompt). Skip if a different modal dialog owns
4949 | * the event (e.g. nested prompt).
4950 | */
4951 | const QKeySequence pressed = luaSeqFromKeyEvent(ke);
4952 | if (pressed.matches(Qt::Key_Escape) == QKeySequence::ExactMatch)
4953 | {
4954 | QWidget *const modal = QApplication::activeModalWidget();
4955 | if (modal && modal != this)
4956 | {
4957 | return QDialog::eventFilter(obj, event);
4958 | }
4959 | handleEscapeKey();
4960 | return true;
4961 | }
4962 | if (pressed.matches(QKeySequence::Quit) == QKeySequence::ExactMatch)
4963 | {
4964 | /*
4965 | * Keep Ctrl+Q semantics identical to main-window quit when the
4966 | * debugger has unsaved scripts: run the debugger close gate first
4967 | * (Save/Discard/Cancel), then re-deliver main close if accepted.
4968 | */
4969 | QWidget *const modal = QApplication::activeModalWidget();
4970 | if (modal && modal != this)
4971 | {
4972 | return QDialog::eventFilter(obj, event);
4973 | }
4974 | s_mainCloseDeferredByPause_ = true;
4975 | QMetaObject::invokeMethod(this, "close", Qt::QueuedConnection);
4976 | return true;
4977 | }
4978 | if (triggerLuaDebuggerShortcuts(ui, pressed))
4979 | {
4980 | return true;
4981 | }
4982 | }
4983 | return QDialog::eventFilter(obj, event);
4984 | }
4985 |
4986 | void LuaDebuggerDialog::onClearBreakpoints()
4987 | {
4988 | // Confirmation dialog
4989 | const unsigned count = wslua_debugger_get_breakpoint_count();
4990 | if (count == 0)
4991 | {
4992 | return;
4993 | }
4994 |
4995 | QMessageBox::StandardButton reply = QMessageBox::question(
4996 | this, tr("Clear All Breakpoints"),
4997 | tr("Are you sure you want to remove %Ln breakpoint(s)?", "", count),
4998 | QMessageBox::Yes | QMessageBox::No, QMessageBox::No);
4999 |
5000 | if (reply != QMessageBox::Yes)
5001 | {
5002 | return;
5003 | }
5004 |
5005 | wslua_debugger_clear_breakpoints();
5006 | updateBreakpoints();
5007 | const qint32 tabCount = static_cast<qint32>(ui->codeTabWidget->count());
5008 | for (qint32 tabIndex = 0; tabIndex < tabCount; ++tabIndex)
5009 | {
5010 | LuaDebuggerCodeView *view = qobject_cast<LuaDebuggerCodeView *>(
5011 | ui->codeTabWidget->widget(static_cast<int>(tabIndex)));
5012 | if (view)
5013 | view->updateBreakpointMarkers();
5014 | }
5015 | }
5016 |
5017 | void LuaDebuggerDialog::updateBreakpoints()
5018 | {
5019 | if (!breakpointsModel)
5020 | {
5021 | return;
5022 | }
5023 | /* Suppress dispatch through onBreakpointItemChanged while we rebuild the
5024 | * model; the inline-edit slot is fine for user-triggered checkbox /
5025 | * delegate-driven changes but a wholesale rebuild from core would
5026 | * otherwise loop. Restored unconditionally on the function tail so
5027 | * an early return does not leave the flag set. */
5028 | const bool prevSuppress = suppressBreakpointItemChanged_;
5029 | suppressBreakpointItemChanged_ = true;
5030 | breakpointsModel->removeRows(0, breakpointsModel->rowCount());
5031 | breakpointsModel->setHeaderData(2, Qt::Horizontal, tr("Location"));
5032 | unsigned count = wslua_debugger_get_breakpoint_count();
5033 | bool hasActiveBreakpoint = false;
5034 | const bool collectInitialFiles = !breakpointTabsPrimed;
5035 | QVector<QString> initialBreakpointFiles;
5036 | QSet<QString> seenInitialFiles;
5037 | for (unsigned i = 0; i < count; i++)
5038 | {
5039 | const char *file_path = nullptr;
5040 | int64_t line = 0;
5041 | bool active = false;
5042 | const char *condition_c = nullptr;
5043 | int64_t hit_count_target = 0;
5044 | int64_t hit_count = 0;
5045 | bool condition_error = false;
5046 | const char *log_message_c = nullptr;
5047 | wslua_hit_count_mode_t hit_count_mode =
5048 | WSLUA_HIT_COUNT_MODE_FROM;
5049 | bool log_also_pause = false;
5050 | if (!wslua_debugger_get_breakpoint_extended(
5051 | i, &file_path, &line, &active, &condition_c,
5052 | &hit_count_target, &hit_count, &condition_error,
5053 | &log_message_c, &hit_count_mode, &log_also_pause))
5054 | {
5055 | continue;
5056 | }
5057 |
5058 | QString normalizedPath =
5059 | normalizedFilePath(QString::fromUtf8(file_path));
5060 | const QString condition =
5061 | condition_c ? QString::fromUtf8(condition_c) : QString();
5062 | const QString logMessage =
5063 | log_message_c ? QString::fromUtf8(log_message_c) : QString();
5064 | const bool hasCondition = !condition.isEmpty();
5065 | const bool hasLog = !logMessage.isEmpty();
5066 | const bool hasHitTarget = hit_count_target > 0;
5067 |
5068 | /* Check if file exists */
5069 | QFileInfo fileInfo(normalizedPath);
5070 | bool fileExists = fileInfo.exists() && fileInfo.isFile();
5071 |
5072 | QStandardItem *const i0 = new QStandardItem();
5073 | QStandardItem *const i1 = new QStandardItem();
5074 | QStandardItem *const i2 = new QStandardItem();
5075 | /* QStandardItem ships with Qt::ItemIsEditable on by default; the
5076 | * Active checkbox cell and the (hidden) Line cell must not host
5077 | * an editor — the inline condition / hit-count / log-message
5078 | * editor lives on column 2 only. Without this, double-clicking
5079 | * the checkbox column opens a stray QLineEdit over the row. */
5080 | i0->setFlags(i0->flags() & ~Qt::ItemIsEditable);
5081 | i1->setFlags(i1->flags() & ~Qt::ItemIsEditable);
5082 | i0->setCheckable(true);
5083 | i0->setCheckState(active ? Qt::Checked : Qt::Unchecked);
5084 | i0->setData(normalizedPath, BreakpointFileRole);
5085 | i0->setData(static_cast<qlonglong>(line), BreakpointLineRole);
5086 | i0->setData(condition, BreakpointConditionRole);
5087 | i0->setData(static_cast<qlonglong>(hit_count_target),
5088 | BreakpointHitTargetRole);
5089 | i0->setData(static_cast<qlonglong>(hit_count),
5090 | BreakpointHitCountRole);
5091 | i0->setData(condition_error, BreakpointConditionErrRole);
5092 | i0->setData(logMessage, BreakpointLogMessageRole);
5093 | i0->setData(static_cast<int>(hit_count_mode),
5094 | BreakpointHitModeRole);
5095 | i0->setData(log_also_pause, BreakpointLogAlsoPauseRole);
5096 | i1->setText(QString::number(line));
5097 | const QString fileDisplayName = fileInfo.fileName();
5098 | QString locationText =
5099 | QStringLiteral("%1:%2")(QString(QtPrivate::qMakeStringPrivate(u"" "%1:%2")))
5100 | .arg(fileDisplayName.isEmpty() ? normalizedPath
5101 | : fileDisplayName)
5102 | .arg(line);
5103 | i2->setText(locationText);
5104 | i2->setTextAlignment(Qt::AlignLeft | Qt::AlignVCenter);
5105 | /* The location cell is the inline-edit target for condition /
5106 | * hit count / log message. Make it editable on existing files;
5107 | * stale rows below clear the flag. */
5108 | i2->setFlags((i2->flags() | Qt::ItemIsEditable)
5109 | & ~Qt::ItemIsUserCheckable);
5110 |
5111 | /* Compose a multi-line tooltip applied to all three cells, so
5112 | * hovering anywhere on the row reveals the full condition / hit
5113 | * count / log details that no longer have a dedicated column. */
5114 | QStringList tooltipLines;
5115 | tooltipLines.append(
5116 | tr("Location: %1:%2").arg(normalizedPath).arg(line));
5117 | if (hasCondition)
5118 | {
5119 | tooltipLines.append(tr("Condition: %1").arg(condition));
5120 | }
5121 | if (hasHitTarget)
5122 | {
5123 | QString modeDesc;
5124 | switch (hit_count_mode)
5125 | {
5126 | case WSLUA_HIT_COUNT_MODE_EVERY:
5127 | modeDesc = tr("pauses on hits %1, 2\xc3\x97%1, "
5128 | "3\xc3\x97%1, \xe2\x80\xa6")
5129 | .arg(hit_count_target);
5130 | break;
5131 | case WSLUA_HIT_COUNT_MODE_ONCE:
5132 | modeDesc =
5133 | tr("pauses once on hit %1, then deactivates the "
5134 | "breakpoint")
5135 | .arg(hit_count_target);
5136 | break;
5137 | case WSLUA_HIT_COUNT_MODE_FROM:
5138 | default:
5139 | modeDesc = tr("pauses on every hit from %1 onwards")
5140 | .arg(hit_count_target);
5141 | break;
5142 | }
5143 | tooltipLines.append(tr("Hit Count: %1 / %2 (%3)")
5144 | .arg(hit_count)
5145 | .arg(hit_count_target)
5146 | .arg(modeDesc));
5147 | }
5148 | else if (hit_count > 0)
5149 | {
5150 | tooltipLines.append(tr("Hits: %1").arg(hit_count));
5151 | }
5152 | if (hasLog)
5153 | {
5154 | tooltipLines.append(tr("Log: %1").arg(logMessage));
5155 | tooltipLines.append(log_also_pause
5156 | ? tr("(logpoint — also pauses)")
5157 | : tr("(logpoint — does not pause)"));
5158 | }
5159 | if (condition_error)
5160 | {
5161 | tooltipLines.append(
5162 | tr("Condition error on last evaluation — treated as "
5163 | "false (silent). Edit or reset the breakpoint to "
5164 | "clear."));
5165 | /* Surface the actual Lua error string so users don't have
5166 | * to guess which identifier was nil. The C-side getter
5167 | * returns a freshly allocated copy under the breakpoints
5168 | * mutex, so reading it here is safe even when the line
5169 | * hook is racing to overwrite the field. */
5170 | char *err_msg =
5171 | wslua_debugger_get_breakpoint_condition_error_message(i);
5172 | if (err_msg && err_msg[0])
5173 | {
5174 | tooltipLines.append(
5175 | tr("Condition error: %1")
5176 | .arg(QString::fromUtf8(err_msg)));
5177 | }
5178 | g_free(err_msg);
5179 | }
5180 |
5181 | /* Cell icons render with @c QIcon::Selected mode when the row
5182 | * is selected; theme icons (QIcon::fromTheme) usually don't
5183 | * ship that mode, so a dark glyph against the dark blue
5184 | * selection background reads as an invisible blob in dark
5185 | * mode. luaDbgMakeSelectionAwareIcon synthesises the Selected
5186 | * pixmap from the tree's palette (HighlightedText) so every
5187 | * row indicator stays legible while the row is highlighted. */
5188 | const QPalette bpPalette = breakpointsTree->palette();
5189 |
5190 | if (!fileExists)
5191 | {
5192 | /* Mark stale breakpoints with warning icon and gray text.
5193 | * The "file not found" indicator stays on the Location cell
5194 | * because it describes the *file*, not the breakpoint's
5195 | * extras (condition / hit count / log message). */
5196 | i2->setIcon(luaDbgMakeSelectionAwareIcon(
5197 | QIcon::fromTheme("dialog-warning"), bpPalette));
5198 | tooltipLines.prepend(tr("File not found: %1").arg(normalizedPath));
5199 | i0->setForeground(QBrush(Qt::gray));
5200 | i1->setForeground(QBrush(Qt::gray));
5201 | i2->setForeground(QBrush(Qt::gray));
5202 | /* Disable the checkbox + inline editor for stale breakpoints */
5203 | i0->setFlags(i0->flags() & ~Qt::ItemIsUserCheckable);
5204 | i0->setCheckState(Qt::Unchecked);
5205 | i2->setFlags(i2->flags() & ~Qt::ItemIsEditable);
5206 | }
5207 | else
5208 | {
5209 | /* Extras indicator on the Active column, drawn after the
5210 | * checkbox (Qt's standard cell layout: check indicator,
5211 | * decoration, then text). Mirrors the gutter dot's white
5212 | * core so users get a consistent at-a-glance cue both in
5213 | * the editor margin and in the Breakpoints list.
5214 | *
5215 | * Indicator priority: condition error > logpoint >
5216 | * conditional / hit count > plain. */
5217 | if (condition_error)
5218 | {
5219 | i0->setIcon(luaDbgMakeSelectionAwareIcon(
5220 | QIcon::fromTheme("dialog-warning"), bpPalette));
5221 | }
5222 | else if (hasLog)
5223 | {
5224 | QIcon icon = QIcon::fromTheme(
5225 | QStringLiteral("mail-send")(QString(QtPrivate::qMakeStringPrivate(u"" "mail-send"))),
5226 | QIcon::fromTheme(QStringLiteral("document-edit")(QString(QtPrivate::qMakeStringPrivate(u"" "document-edit")))));
5227 | if (icon.isNull())
5228 | {
5229 | icon = style()->standardIcon(
5230 | QStyle::SP_FileDialogContentsView);
5231 | }
5232 | i0->setIcon(luaDbgMakeSelectionAwareIcon(icon, bpPalette));
5233 | }
5234 | else if (hasCondition || hasHitTarget)
5235 | {
5236 | QIcon icon = QIcon::fromTheme(
5237 | QStringLiteral("emblem-system")(QString(QtPrivate::qMakeStringPrivate(u"" "emblem-system"))),
5238 | QIcon::fromTheme(QStringLiteral("preferences-other")(QString(QtPrivate::qMakeStringPrivate(u"" "preferences-other" )))));
5239 | if (icon.isNull())
5240 | {
5241 | icon = style()->standardIcon(
5242 | QStyle::SP_FileDialogDetailedView);
5243 | }
5244 | i0->setIcon(luaDbgMakeSelectionAwareIcon(icon, bpPalette));
5245 | }
5246 | }
5247 |
5248 | const QString tooltipText = tooltipLines.join(QChar('\n'));
5249 | i0->setToolTip(tooltipText);
5250 | i1->setToolTip(tooltipText);
5251 | i2->setToolTip(tooltipText);
5252 |
5253 | if (active && fileExists)
5254 | {
5255 | hasActiveBreakpoint = true;
5256 | }
5257 |
5258 | breakpointsModel->appendRow({i0, i1, i2});
5259 |
5260 | /* Highlight the breakpoint row that matches the current pause
5261 | * location with the same bold-accent (and one-shot background
5262 | * flash on pause entry) treatment the Watch / Variables trees
5263 | * use, so the row that "fired" stands out at a glance. The
5264 | * matching gate is the file + line pair captured in
5265 | * handlePause(); both are cleared in clearPausedStateUi(), so
5266 | * this branch is dormant whenever the debugger is not paused.
5267 | *
5268 | * applyChangedVisuals must run after appendRow so the cells
5269 | * have a concrete model index — scheduleFlashClear() captures
5270 | * a QPersistentModelIndex on each cell to drive its timed
5271 | * clear, and that index is only valid once the row is in the
5272 | * model. */
5273 | if (debuggerPaused && fileExists && !pausedFile_.isEmpty() &&
5274 | normalizedPath == pausedFile_ && line == pausedLine_)
5275 | {
5276 | applyChangedVisuals(i0, /*changed=*/true, isPauseEntryRefresh_);
5277 | }
5278 |
5279 | /* Only add to file tree if file exists */
5280 | if (fileExists)
5281 | {
5282 | ensureFileTreeEntry(normalizedPath);
5283 | }
5284 |
5285 | /* Only open existing files initially */
5286 | if (collectInitialFiles && fileExists &&
5287 | !seenInitialFiles.contains(normalizedPath))
5288 | {
5289 | initialBreakpointFiles.append(normalizedPath);
5290 | seenInitialFiles.insert(normalizedPath);
5291 | }
5292 | }
5293 |
5294 | if (hasActiveBreakpoint)
5295 | {
5296 | ensureDebuggerEnabledForActiveBreakpoints();
5297 | }
5298 | refreshDebuggerStateUi();
5299 |
5300 | if (collectInitialFiles)
5301 | {
5302 | breakpointTabsPrimed = true;
5303 | openInitialBreakpointFiles(initialBreakpointFiles);
5304 | }
5305 |
5306 | updateBreakpointHeaderButtonState();
5307 | suppressBreakpointItemChanged_ = prevSuppress;
5308 | }
5309 |
5310 | void LuaDebuggerDialog::updateStack()
5311 | {
5312 | if (!stackTree)
5313 | {
5314 | return;
5315 | }
5316 |
5317 | const bool signalsWereBlocked = stackTree->blockSignals(true);
5318 | if (stackModel)
5319 | {
5320 | stackModel->removeRows(0, stackModel->rowCount());
5321 | }
5322 |
5323 | int32_t frameCount = 0;
5324 | wslua_stack_frame_t *stack = wslua_debugger_get_stack(&frameCount);
5325 | QStandardItem *itemToSelect = nullptr;
5326 | if (stack && frameCount > 0)
5327 | {
5328 | const int maxLevel = static_cast<int>(frameCount) - 1;
5329 | stackSelectionLevel = qBound(0, stackSelectionLevel, maxLevel);
5330 | wslua_debugger_set_variable_stack_level(
5331 | static_cast<int32_t>(stackSelectionLevel));
5332 |
5333 | for (int32_t frameIndex = 0; frameIndex < frameCount; ++frameIndex)
5334 | {
5335 | QStandardItem *const nameItem = new QStandardItem();
5336 | QStandardItem *const locItem = new QStandardItem();
5337 | nameItem->setData(static_cast<qlonglong>(frameIndex),
5338 | StackItemLevelRole);
5339 | const char *rawSource = stack[frameIndex].source;
5340 | const bool isLuaFrame = rawSource && rawSource[0] == '@';
5341 | const QString functionName = QString::fromUtf8(
5342 | stack[frameIndex].name ? stack[frameIndex].name : "?");
5343 | QString locationText;
5344 | QString resolvedPath;
5345 | if (isLuaFrame)
5346 | {
5347 | const QString filePath = QString::fromUtf8(rawSource + 1);
5348 | resolvedPath = normalizedFilePath(filePath);
5349 | if (resolvedPath.isEmpty())
5350 | {
5351 | resolvedPath = filePath;
5352 | }
5353 | const QString fileDisplayName =
5354 | QFileInfo(resolvedPath).fileName();
5355 | locationText = QStringLiteral("%1:%2")(QString(QtPrivate::qMakeStringPrivate(u"" "%1:%2")))
5356 | .arg(fileDisplayName.isEmpty() ? resolvedPath
5357 | : fileDisplayName)
5358 | .arg(stack[frameIndex].line);
5359 | locItem->setToolTip(QStringLiteral("%1:%2")(QString(QtPrivate::qMakeStringPrivate(u"" "%1:%2")))
5360 | .arg(resolvedPath)
5361 | .arg(stack[frameIndex].line));
5362 | }
5363 | else
5364 | {
5365 | locationText = QString::fromUtf8(rawSource ? rawSource : "[C]");
5366 | }
5367 |
5368 | nameItem->setText(functionName);
5369 | locItem->setText(locationText);
5370 |
5371 | if (isLuaFrame)
5372 | {
5373 | nameItem->setData(true, StackItemNavigableRole);
5374 | nameItem->setData(resolvedPath, StackItemFileRole);
5375 | nameItem->setData(static_cast<qlonglong>(stack[frameIndex].line),
5376 | StackItemLineRole);
5377 | }
5378 | else
5379 | {
5380 | nameItem->setData(false, StackItemNavigableRole);
5381 | QColor disabledColor =
5382 | palette().color(QPalette::Disabled, QPalette::Text);
5383 | nameItem->setForeground(disabledColor);
5384 | locItem->setForeground(disabledColor);
5385 | }
5386 |
5387 | stackModel->appendRow({nameItem, locItem});
5388 |
5389 | if (frameIndex == stackSelectionLevel)
5390 | {
5391 | itemToSelect = nameItem;
5392 | }
5393 | }
5394 | wslua_debugger_free_stack(stack, frameCount);
5395 | }
5396 | else
5397 | {
5398 | stackSelectionLevel = 0;
5399 | wslua_debugger_set_variable_stack_level(0);
5400 | }
5401 |
5402 | if (itemToSelect && stackModel)
5403 | {
5404 | const QModelIndex ix = stackModel->indexFromItem(itemToSelect);
5405 | stackTree->setCurrentIndex(ix);
5406 | }
5407 | stackTree->blockSignals(signalsWereBlocked);
5408 | }
5409 |
5410 | void LuaDebuggerDialog::refreshVariablesForCurrentStackFrame()
5411 | {
5412 | if (!variablesTree || !debuggerPaused || !wslua_debugger_is_paused())
5413 | {
5414 | return;
5415 | }
5416 | if (variablesModel)
5417 | {
5418 | variablesModel->removeRows(0, variablesModel->rowCount());
5419 | }
5420 | updateVariables(nullptr, QString());
5421 | restoreVariablesExpansionState();
5422 | refreshWatchDisplay();
5423 | }
5424 |
5425 | void LuaDebuggerDialog::onStackCurrentItemChanged(const QModelIndex &current,
5426 | const QModelIndex &previous)
5427 | {
5428 | Q_UNUSED(previous)(void)previous;;
5429 | if (!stackTree || !stackModel || !current.isValid() || !debuggerPaused ||
5430 | !wslua_debugger_is_paused())
5431 | {
5432 | return;
5433 | }
5434 | QStandardItem *const rowItem =
5435 | stackModel->itemFromIndex(current.sibling(current.row(), 0));
5436 | if (!rowItem)
5437 | {
5438 | return;
5439 | }
5440 |
5441 | const int level =
5442 | static_cast<int>(rowItem->data(StackItemLevelRole).toLongLong());
5443 | if (level < 0 || level == stackSelectionLevel)
5444 | {
5445 | return;
5446 | }
5447 |
5448 | stackSelectionLevel = level;
5449 | wslua_debugger_set_variable_stack_level(static_cast<int32_t>(level));
5450 | refreshVariablesForCurrentStackFrame();
5451 | syncVariablesTreeToCurrentWatch();
5452 | }
5453 |
5454 | void LuaDebuggerDialog::updateVariables(QStandardItem *parent,
5455 | const QString &path)
5456 | {
5457 | if (!variablesModel)
5458 | {
5459 | return;
5460 | }
5461 | int32_t variableCount = 0;
5462 | wslua_variable_t *variables = wslua_debugger_get_variables(
5463 | path.isEmpty() ? NULL__null : path.toUtf8().constData(), &variableCount);
5464 |
5465 | /* "First-time expansion" guard for the new-child flash: the children
5466 | * about to be appended belong to @p path, and a child absent from
5467 | * baseline is only meaningfully "new" if @p path was *visited* (and
5468 | * therefore its then-children captured) at the previous pause. We
5469 | * record that directly — the companion set variablesCurrentParents_
5470 | * gets @p path's own change key on every paint and rotates into
5471 | * variablesBaselineParents_ at pause entry. Scanning baseline value
5472 | * keys by prefix cannot answer this question: a parent that was
5473 | * expanded last pause but had no children to show (e.g. a function
5474 | * with no locals yet, an empty table) would look identical to one
5475 | * that was collapsed, so the FIRST child appearing now could never
5476 | * flash. The level matches the one used for the child keys (Globals
5477 | * anchor to -1, everything else follows the current stack frame). */
5478 | const int parentChildLevel =
5479 | variablesPathIsGlobalScoped(path) ? -1 : stackSelectionLevel;
5480 | const QString parentVisitedKey = changeKey(parentChildLevel, path);
5481 | const bool parentVisitedInBaseline =
5482 | variablesBaselineParents_.contains(parentVisitedKey);
5483 | variablesCurrentParents_.insert(parentVisitedKey);
5484 |
5485 | if (variables)
5486 | {
5487 | for (int32_t variableIndex = 0; variableIndex < variableCount;
5488 | ++variableIndex)
5489 | {
5490 | auto *nameItem = new QStandardItem();
5491 | auto *valueItem = new QStandardItem();
5492 | auto *typeItem = new QStandardItem();
5493 |
5494 | const VariableRowFields f =
5495 | readVariableRowFields(variables[variableIndex], path);
5496 |
5497 | nameItem->setText(f.name);
5498 | valueItem->setText(f.value);
5499 | typeItem->setText(f.type);
5500 |
5501 | const QString tooltipSuffix =
5502 | f.type.isEmpty() ? QString() : tr("Type: %1").arg(f.type);
5503 | nameItem->setToolTip(tooltipSuffix.isEmpty()
5504 | ? f.name
5505 | : QStringLiteral("%1\n%2")(QString(QtPrivate::qMakeStringPrivate(u"" "%1\n%2")))
5506 | .arg(f.name, tooltipSuffix));
5507 | valueItem->setToolTip(tooltipSuffix.isEmpty()
5508 | ? f.value
5509 | : QStringLiteral("%1\n%2")(QString(QtPrivate::qMakeStringPrivate(u"" "%1\n%2")))
5510 | .arg(f.value, tooltipSuffix));
5511 | typeItem->setToolTip(tooltipSuffix.isEmpty()
5512 | ? f.type
5513 | : QStringLiteral("%1\n%2")(QString(QtPrivate::qMakeStringPrivate(u"" "%1\n%2")))
5514 | .arg(f.type, tooltipSuffix));
5515 | nameItem->setData(f.type, VariableTypeRole);
5516 | nameItem->setData(f.canExpand, VariableCanExpandRole);
5517 | nameItem->setData(f.childPath, VariablePathRole);
5518 |
5519 | for (QStandardItem *cell : {nameItem, valueItem, typeItem})
5520 | {
5521 | cell->setFlags(cell->flags() & ~Qt::ItemIsEditable);
5522 | }
5523 |
5524 | if (parent)
5525 | {
5526 | parent->appendRow({nameItem, valueItem, typeItem});
5527 | }
5528 | else
5529 | {
5530 | variablesModel->appendRow({nameItem, valueItem, typeItem});
5531 | }
5532 |
5533 | /* Scope Globals watchers by level=-1 so changing the selected
5534 | * stack frame does not invalidate a Globals baseline. All other
5535 | * paths are scoped by the current stack level. */
5536 | const bool isGlobal = variablesPathIsGlobalScoped(f.childPath);
5537 | const int level = isGlobal ? -1 : stackSelectionLevel;
5538 | const QString vk = changeKey(level, f.childPath);
5539 | /* flashNew=parentVisitedInBaseline: a variable absent from
5540 | * the previous pause's snapshot but present now is "new" (e.g.
5541 | * a fresh local binding, a key added to a table, a new upvalue)
5542 | * and gets the same visual cue as a value change — but ONLY
5543 | * when @p path itself was painted at the previous pause.
5544 | * Otherwise this is a first-time expansion and treating every
5545 | * child as "new" would be visual noise, not information.
5546 | *
5547 | * Non-Globals comparisons are also gated on
5548 | * changeHighlightAllowed(): walking to a different stack frame
5549 | * inside the same pause shows locals/upvalues from an unrelated
5550 | * scope where comparing against the pause-entry baseline at the
5551 | * same numeric level would either flag every variable as "new"
5552 | * or compare against an unrelated previous-pause snapshot. The
5553 | * cue resumes automatically when the user navigates back to the
5554 | * pause-entry frame. Globals are anchored to level=-1 and stay
5555 | * comparable across frames, so they keep their highlight. */
5556 | const bool changed =
5557 | (isGlobal || changeHighlightAllowed()) &&
5558 | shouldMarkChanged(variablesBaseline_, vk, f.value,
5559 | /*flashNew=*/parentVisitedInBaseline);
5560 | applyChangedVisuals(nameItem, changed, isPauseEntryRefresh_);
5561 | variablesCurrent_[vk] = f.value;
5562 |
5563 | applyVariableExpansionIndicator(nameItem, f.canExpand,
5564 | /*enabledOnlyPlaceholder=*/false);
5565 | }
5566 | // Sort Globals alphabetically; preserve declaration order for
5567 | // Locals and Upvalues since that is more natural for debugging.
5568 | if (variableChildrenShouldSortByName(path))
5569 | {
5570 | if (parent)
5571 | {
5572 | parent->sortChildren(0, Qt::AscendingOrder);
5573 | }
5574 | else
5575 | {
5576 | variablesModel->sort(0, Qt::AscendingOrder);
5577 | }
5578 | }
5579 |
5580 | wslua_debugger_free_variables(variables, variableCount);
5581 | }
5582 | }
5583 |
5584 | void LuaDebuggerDialog::onVariableItemExpanded(const QModelIndex &index)
5585 | {
5586 | if (!variablesModel || !index.isValid())
5587 | {
5588 | return;
5589 | }
5590 | QStandardItem *item =
5591 | variablesModel->itemFromIndex(index.sibling(index.row(), 0));
5592 | if (!item)
5593 | {
5594 | return;
5595 | }
5596 | const QString section = variableSectionRootKeyFromItem(item);
5597 | if (!item->parent())
5598 | {
5599 | recordTreeSectionRootExpansion(variablesExpansion_, section, true);
5600 | }
5601 | else
5602 | {
5603 | const QString key = item->data(VariablePathRole).toString();
5604 | recordTreeSectionSubpathExpansion(variablesExpansion_, section, key,
5605 | true);
5606 | }
5607 |
5608 | if (item->rowCount() == 1 && item->child(0) &&
5609 | item->child(0)->text().isEmpty())
5610 | {
5611 | item->removeRow(0);
5612 |
5613 | QString varPath = item->data(VariablePathRole).toString();
5614 | updateVariables(item, varPath);
5615 | }
5616 | }
5617 |
5618 | void LuaDebuggerDialog::onVariableItemCollapsed(const QModelIndex &index)
5619 | {
5620 | if (!variablesModel || !index.isValid())
5621 | {
5622 | return;
5623 | }
5624 | QStandardItem *item =
5625 | variablesModel->itemFromIndex(index.sibling(index.row(), 0));
5626 | if (!item)
5627 | {
5628 | return;
5629 | }
5630 | const QString section = variableSectionRootKeyFromItem(item);
5631 | if (!item->parent())
5632 | {
5633 | recordTreeSectionRootExpansion(variablesExpansion_, section, false);
5634 | }
5635 | else
5636 | {
5637 | const QString key = item->data(VariablePathRole).toString();
5638 | recordTreeSectionSubpathExpansion(variablesExpansion_, section, key,
5639 | false);
5640 | }
5641 | }
5642 |
5643 | LuaDebuggerCodeView *LuaDebuggerDialog::loadFile(const QString &file_path)
5644 | {
5645 | QString normalizedPath = normalizedFilePath(file_path);
5646 | if (normalizedPath.isEmpty())
5647 | {
5648 | normalizedPath = file_path;
5649 | }
5650 |
5651 | /* Check if file exists before creating a tab */
5652 | QFileInfo fileInfo(normalizedPath);
5653 | if (!fileInfo.exists() || !fileInfo.isFile())
5654 | {
5655 | /* File doesn't exist - don't create a tab */
5656 | return nullptr;
5657 | }
5658 |
5659 | // Check if already open
5660 | const qint32 existingTabCount =
5661 | static_cast<qint32>(ui->codeTabWidget->count());
5662 | for (qint32 tabIndex = 0; tabIndex < existingTabCount; ++tabIndex)
5663 | {
5664 | LuaDebuggerCodeView *view = qobject_cast<LuaDebuggerCodeView *>(
5665 | ui->codeTabWidget->widget(static_cast<int>(tabIndex)));
5666 | if (view && view->getFilename() == normalizedPath)
5667 | {
5668 | ui->codeTabWidget->setCurrentIndex(static_cast<int>(tabIndex));
5669 | return view;
5670 | }
5671 | }
5672 |
5673 | // Create new tab
5674 | LuaDebuggerCodeView *codeView = new LuaDebuggerCodeView(ui->codeTabWidget);
5675 | codeView->setEditorFont(effectiveMonospaceFont(true));
5676 | codeView->setFilename(normalizedPath);
5677 |
5678 | QFile file(normalizedPath);
5679 | if (file.open(QIODevice::ReadOnly | QIODevice::Text))
5680 | {
5681 | codeView->setPlainText(file.readAll());
5682 | }
5683 | else
5684 | {
5685 | /* This should not happen since we checked exists() above,
5686 | * but handle it gracefully just in case */
5687 | delete codeView;
5688 | return nullptr;
5689 | }
5690 |
5691 | ensureFileTreeEntry(normalizedPath);
5692 |
5693 | // Connect signals
5694 | codeView->setContextMenuPolicy(Qt::CustomContextMenu);
5695 | connect(codeView, &QWidget::customContextMenuRequested, this,
5696 | &LuaDebuggerDialog::onCodeViewContextMenu);
5697 |
5698 | /* Queued connection: the gutter emits this from inside its
5699 | * mousePressEvent, so dispatching the popup via a queued slot
5700 | * lets the press event fully unwind before QMenu::exec() spins
5701 | * its own nested loop. Avoids the "press grab still attached"
5702 | * artefacts that bite when a modal popup is opened directly out
5703 | * of a press handler. */
5704 | connect(codeView, &LuaDebuggerCodeView::breakpointGutterMenuRequested,
5705 | this, &LuaDebuggerDialog::onBreakpointGutterMenu,
5706 | Qt::QueuedConnection);
5707 |
5708 | connect(
5709 | codeView, &LuaDebuggerCodeView::breakpointToggled,
5710 | [this](const QString &file_path, qint32 line, bool toggleActive)
5711 | {
5712 | const int32_t state = wslua_debugger_get_breakpoint_state(
5713 | file_path.toUtf8().constData(), line);
5714 | if (state == -1)
5715 | {
5716 | wslua_debugger_add_breakpoint(file_path.toUtf8().constData(),
5717 | line);
5718 | if (toggleActive)
5719 | {
5720 | /* Shift+click on a bare gutter line: still create
5721 | * the breakpoint, but mark it inactive so the user
5722 | * can pre-arm a line without paying the line-hook
5723 | * cost until they activate it. Skip the
5724 | * ensure-enabled call because the new row carries
5725 | * no active flag yet. */
5726 | wslua_debugger_set_breakpoint_active(
5727 | file_path.toUtf8().constData(), line, false);
5728 | }
5729 | else
5730 | {
5731 | ensureDebuggerEnabledForActiveBreakpoints();
5732 | }
5733 | }
5734 | else if (toggleActive)
5735 | {
5736 | /* Shift+click on an existing breakpoint: enable/disable
5737 | * without removing. */
5738 | wslua_debugger_set_breakpoint_active(
5739 | file_path.toUtf8().constData(), line, state == 0);
5740 | }
5741 | else
5742 | {
5743 | wslua_debugger_remove_breakpoint(file_path.toUtf8().constData(),
5744 | line);
5745 | refreshDebuggerStateUi();
5746 | }
5747 | updateBreakpoints();
5748 | // Update all views as breakpoint might affect them (unlikely but
5749 | // safe)
5750 | const qint32 tabCount =
5751 | static_cast<qint32>(ui->codeTabWidget->count());
5752 | for (qint32 tabIndex = 0; tabIndex < tabCount; ++tabIndex)
5753 | {
5754 | LuaDebuggerCodeView *tabView =
5755 | qobject_cast<LuaDebuggerCodeView *>(
5756 | ui->codeTabWidget->widget(static_cast<int>(tabIndex)));
5757 | if (tabView)
5758 | tabView->updateBreakpointMarkers();
5759 | }
5760 | });
5761 |
5762 | connect(codeView->document(), &QTextDocument::modificationChanged, this,
5763 | [this, codeView]()
5764 | {
5765 | updateTabTextForCodeView(codeView);
5766 | updateWindowModifiedState();
5767 | if (ui->codeTabWidget->currentWidget() == codeView)
5768 | {
5769 | updateSaveActionState();
5770 | }
5771 | });
5772 | connect(codeView, &QPlainTextEdit::cursorPositionChanged, this,
5773 | &LuaDebuggerDialog::updateBreakpointHeaderButtonState);
5774 |
5775 | ui->codeTabWidget->addTab(codeView, QFileInfo(normalizedPath).fileName());
5776 | updateTabTextForCodeView(codeView);
5777 | ui->codeTabWidget->setCurrentWidget(codeView);
5778 | ui->codeTabWidget->show();
5779 | updateSaveActionState();
5780 | updateWindowModifiedState();
5781 | updateLuaEditorAuxFrames();
5782 | return codeView;
5783 | }
5784 |
5785 | LuaDebuggerCodeView *LuaDebuggerDialog::currentCodeView() const
5786 | {
5787 | return qobject_cast<LuaDebuggerCodeView *>(
5788 | ui->codeTabWidget->currentWidget());
5789 | }
5790 |
5791 | qint32 LuaDebuggerDialog::unsavedOpenScriptTabCount() const
5792 | {
5793 | qint32 count = 0;
5794 | const qint32 tabCount =
5795 | static_cast<qint32>(ui->codeTabWidget->count());
5796 | for (qint32 tabIndex = 0; tabIndex < tabCount; ++tabIndex)
5797 | {
5798 | LuaDebuggerCodeView *view = qobject_cast<LuaDebuggerCodeView *>(
5799 | ui->codeTabWidget->widget(static_cast<int>(tabIndex)));
5800 | if (view && view->document()->isModified())
5801 | {
5802 | ++count;
5803 | }
5804 | }
5805 | return count;
5806 | }
5807 |
5808 | bool LuaDebuggerDialog::hasUnsavedChanges() const
5809 | {
5810 | return unsavedOpenScriptTabCount() > 0;
5811 | }
5812 |
5813 | bool LuaDebuggerDialog::ensureUnsavedChangesHandled(const QString &title)
5814 | {
5815 | if (!hasUnsavedChanges())
5816 | {
5817 | return true;
5818 | }
5819 |
5820 | const qint32 unsavedCount = unsavedOpenScriptTabCount();
5821 | const QMessageBox::StandardButton reply = QMessageBox::question(
5822 | this, title,
5823 | tr("There are unsaved changes in %Ln open file(s).", "", unsavedCount),
5824 | QMessageBox::Save | QMessageBox::Discard | QMessageBox::Cancel,
5825 | QMessageBox::Save);
5826 |
5827 | if (reply == QMessageBox::Cancel)
5828 | {
5829 | return false;
5830 | }
5831 | if (reply == QMessageBox::Save)
5832 | {
5833 | return saveAllModified();
5834 | }
5835 | clearAllDocumentModified();
5836 | return true;
5837 | }
5838 |
5839 | void LuaDebuggerDialog::clearAllDocumentModified()
5840 | {
5841 | const qint32 tabCount =
5842 | static_cast<qint32>(ui->codeTabWidget->count());
5843 | for (qint32 tabIndex = 0; tabIndex < tabCount; ++tabIndex)
5844 | {
5845 | LuaDebuggerCodeView *view = qobject_cast<LuaDebuggerCodeView *>(
5846 | ui->codeTabWidget->widget(static_cast<int>(tabIndex)));
5847 | if (view)
5848 | {
5849 | view->document()->setModified(false);
5850 | }
5851 | }
5852 | }
5853 |
5854 | bool LuaDebuggerDialog::saveCodeView(LuaDebuggerCodeView *view)
5855 | {
5856 | if (!view)
5857 | {
5858 | return false;
5859 | }
5860 | const QString path = view->getFilename();
5861 | if (path.isEmpty())
5862 | {
5863 | return false;
5864 | }
5865 |
5866 | QFile file(path);
5867 | if (!file.open(QIODevice::WriteOnly | QIODevice::Text))
5868 | {
5869 | QMessageBox::warning(
5870 | this, tr("Save Lua Script"),
5871 | tr("Could not write to %1:\n%2").arg(path, file.errorString()));
5872 | return false;
5873 | }
5874 | QTextStream out(&file);
5875 | out << view->toPlainText();
5876 | file.close();
5877 | view->document()->setModified(false);
5878 | return true;
5879 | }
5880 |
5881 | bool LuaDebuggerDialog::saveAllModified()
5882 | {
5883 | const qint32 tabCount =
5884 | static_cast<qint32>(ui->codeTabWidget->count());
5885 | for (qint32 tabIndex = 0; tabIndex < tabCount; ++tabIndex)
5886 | {
5887 | LuaDebuggerCodeView *view = qobject_cast<LuaDebuggerCodeView *>(
5888 | ui->codeTabWidget->widget(static_cast<int>(tabIndex)));
5889 | if (view && view->document()->isModified())
5890 | {
5891 | if (!saveCodeView(view))
5892 | {
5893 | return false;
5894 | }
5895 | }
5896 | }
5897 | return true;
5898 | }
5899 |
5900 | void LuaDebuggerDialog::updateTabTextForCodeView(LuaDebuggerCodeView *view)
5901 | {
5902 | if (!view)
5903 | {
5904 | return;
5905 | }
5906 | const int tabIndex = ui->codeTabWidget->indexOf(view);
5907 | if (tabIndex < 0)
5908 | {
5909 | return;
5910 | }
5911 | QString label = QFileInfo(view->getFilename()).fileName();
5912 | if (view->document()->isModified())
5913 | {
5914 | label += QStringLiteral(" *")(QString(QtPrivate::qMakeStringPrivate(u"" " *")));
5915 | }
5916 | ui->codeTabWidget->setTabText(tabIndex, label);
5917 | }
5918 |
5919 | void LuaDebuggerDialog::updateSaveActionState()
5920 | {
5921 | LuaDebuggerCodeView *view = currentCodeView();
5922 | ui->actionSaveFile->setEnabled(view && view->document()->isModified());
5923 | }
5924 |
5925 | void LuaDebuggerDialog::updateWindowModifiedState()
5926 | {
5927 | setWindowModified(hasUnsavedChanges());
5928 | }
5929 |
5930 | void LuaDebuggerDialog::showAccordionFrame(AccordionFrame *show_frame,
5931 | bool toggle)
5932 | {
5933 | QList<AccordionFrame *> frame_list =
5934 | QList<AccordionFrame *>() << ui->luaDebuggerFindFrame
5935 | << ui->luaDebuggerGoToLineFrame;
5936 | frame_list.removeAll(show_frame);
5937 | for (AccordionFrame *af : frame_list)
5938 | {
5939 | if (af)
5940 | {
5941 | af->animatedHide();
5942 | }
5943 | }
5944 | if (!show_frame)
5945 | {
5946 | return;
5947 | }
5948 | if (toggle && show_frame->isVisible())
5949 | {
5950 | show_frame->animatedHide();
5951 | return;
5952 | }
5953 | LuaDebuggerGoToLineFrame *const goto_frame =
5954 | qobject_cast<LuaDebuggerGoToLineFrame *>(show_frame);
5955 | if (goto_frame)
5956 | {
5957 | goto_frame->syncLineFieldFromEditor();
5958 | }
5959 | show_frame->animatedShow();
5960 | if (LuaDebuggerFindFrame *const find_frame =
5961 | qobject_cast<LuaDebuggerFindFrame *>(show_frame))
5962 | {
5963 | find_frame->scheduleFindFieldFocus();
5964 | }
5965 | else if (goto_frame)
5966 | {
5967 | goto_frame->scheduleLineFieldFocus();
5968 | }
5969 | }
5970 |
5971 | void LuaDebuggerDialog::updateLuaEditorAuxFrames()
5972 | {
5973 | QPlainTextEdit *ed = currentCodeView();
5974 | ui->luaDebuggerFindFrame->setTargetEditor(ed);
5975 | ui->luaDebuggerGoToLineFrame->setTargetEditor(ed);
5976 | }
5977 |
5978 | void LuaDebuggerDialog::onEditorFind()
5979 | {
5980 | updateLuaEditorAuxFrames();
5981 | showAccordionFrame(ui->luaDebuggerFindFrame, true);
5982 | }
5983 |
5984 | void LuaDebuggerDialog::onEditorGoToLine()
5985 | {
5986 | updateLuaEditorAuxFrames();
5987 | showAccordionFrame(ui->luaDebuggerGoToLineFrame, true);
5988 | }
5989 |
5990 | void LuaDebuggerDialog::onSaveFile()
5991 | {
5992 | LuaDebuggerCodeView *view = currentCodeView();
5993 | if (!view || !view->document()->isModified())
5994 | {
5995 | return;
5996 | }
5997 | saveCodeView(view);
5998 | updateSaveActionState();
5999 | }
6000 |
6001 | void LuaDebuggerDialog::onCodeTabCloseRequested(int idx)
6002 | {
6003 | QWidget *widget = ui->codeTabWidget->widget(idx);
6004 | LuaDebuggerCodeView *view = qobject_cast<LuaDebuggerCodeView *>(widget);
6005 | if (view && view->document()->isModified())
6006 | {
6007 | const QMessageBox::StandardButton reply = QMessageBox::question(
6008 | this, tr("Lua Debugger"),
6009 | tr("Save changes to %1 before closing?")
6010 | .arg(QFileInfo(view->getFilename()).fileName()),
6011 | QMessageBox::Save | QMessageBox::Discard | QMessageBox::Cancel,
6012 | QMessageBox::Save);
6013 | if (reply == QMessageBox::Cancel)
6014 | {
6015 | return;
6016 | }
6017 | if (reply == QMessageBox::Save)
6018 | {
6019 | if (!saveCodeView(view))
6020 | {
6021 | return;
6022 | }
6023 | }
6024 | else
6025 | {
6026 | view->document()->setModified(false);
6027 | }
6028 | }
6029 |
6030 | ui->codeTabWidget->removeTab(idx);
6031 | delete widget;
6032 | updateSaveActionState();
6033 | updateWindowModifiedState();
6034 | }
6035 |
6036 | void LuaDebuggerDialog::onBreakpointItemChanged(QStandardItem *item)
6037 | {
6038 | if (!item)
6039 | {
6040 | return;
6041 | }
6042 | /* Re-entrancy guard: updateBreakpoints() rebuilds the model and writes
6043 | * many roles via setData; without this gate every set during the
6044 | * rebuild would loop back through wslua_debugger_set_breakpoint_*. */
6045 | if (suppressBreakpointItemChanged_)
6046 | {
6047 | return;
6048 | }
6049 | if (item->column() != 0)
6050 | {
6051 | return;
6052 | }
6053 | const QString file = item->data(BreakpointFileRole).toString();
6054 | const int64_t lineNumber = item->data(BreakpointLineRole).toLongLong();
6055 | const bool active = item->checkState() == Qt::Checked;
6056 | wslua_debugger_set_breakpoint_active(file.toUtf8().constData(), lineNumber,
6057 | active);
6058 | /* Activating or deactivating a breakpoint must never change the
6059 | * debugger's enabled state. This is especially important during a live
6060 | * capture, where debugging is suppressed and any flip (direct or
6061 | * deferred via s_captureSuppressionPrevEnabled_) would silently
6062 | * re-enable the debugger when the capture ends. Just refresh the UI to
6063 | * mirror the (unchanged) core state. */
6064 | refreshDebuggerStateUi();
6065 |
6066 | const qint32 tabCount = static_cast<qint32>(ui->codeTabWidget->count());
6067 | for (qint32 tabIndex = 0; tabIndex < tabCount; ++tabIndex)
6068 | {
6069 | LuaDebuggerCodeView *tabView = qobject_cast<LuaDebuggerCodeView *>(
6070 | ui->codeTabWidget->widget(static_cast<int>(tabIndex)));
6071 | if (tabView && tabView->getFilename() == file)
6072 | tabView->updateBreakpointMarkers();
6073 | }
6074 |
6075 | /* The Breakpoints table is the only mutation path that does not flow
6076 | * through updateBreakpoints(); refresh the section-header dot here so
6077 | * its color mirrors the new aggregate active state. */
6078 | updateBreakpointHeaderButtonState();
6079 | }
6080 |
6081 | void LuaDebuggerDialog::onBreakpointModelDataChanged(
6082 | const QModelIndex &topLeft, const QModelIndex &bottomRight,
6083 | const QVector<int> &roles)
6084 | {
6085 | if (suppressBreakpointItemChanged_ || !breakpointsModel)
6086 | {
6087 | return;
6088 | }
6089 | /* The delegate writes BreakpointConditionRole / BreakpointHitTargetRole
6090 | * / BreakpointLogMessageRole on column 0 of the touched row. Translate
6091 | * those changes into the matching wslua_debugger_set_breakpoint_*
6092 | * calls and refresh the row visuals. We dispatch on `roles` so this
6093 | * slot ignores the ordinary display / decoration churn that
6094 | * updateBreakpoints itself emits. */
6095 | const bool wantsCondition = roles.isEmpty() ||
6096 | roles.contains(BreakpointConditionRole);
6097 | const bool wantsTarget = roles.isEmpty() ||
6098 | roles.contains(BreakpointHitTargetRole);
6099 | const bool wantsLog = roles.isEmpty() ||
6100 | roles.contains(BreakpointLogMessageRole);
6101 | const bool wantsHitMode = roles.isEmpty() ||
6102 | roles.contains(BreakpointHitModeRole);
6103 | const bool wantsLogAlsoPause =
6104 | roles.isEmpty() || roles.contains(BreakpointLogAlsoPauseRole);
6105 | if (!wantsCondition && !wantsTarget && !wantsLog && !wantsHitMode &&
6106 | !wantsLogAlsoPause)
6107 | {
6108 | return;
6109 | }
6110 |
6111 | bool touched = false;
6112 | for (int row = topLeft.row(); row <= bottomRight.row(); ++row)
6113 | {
6114 | QStandardItem *col0 = breakpointsModel->item(row, 0);
6115 | if (!col0)
6116 | continue;
6117 | const QString file = col0->data(BreakpointFileRole).toString();
6118 | const int64_t line = col0->data(BreakpointLineRole).toLongLong();
6119 | if (file.isEmpty() || line <= 0)
6120 | continue;
6121 | const QByteArray fileUtf8 = file.toUtf8();
6122 |
6123 | if (wantsCondition)
6124 | {
6125 | const QString cond =
6126 | col0->data(BreakpointConditionRole).toString();
6127 | const QByteArray condUtf8 = cond.toUtf8();
6128 | wslua_debugger_set_breakpoint_condition(
6129 | fileUtf8.constData(), line,
6130 | cond.isEmpty() ? NULL__null : condUtf8.constData());
6131 | /* Parse-time validation. The runtime evaluator treats
6132 | * any error in the condition as silent-false, so without
6133 | * this check a typo (e.g. unbalanced parens, or a
6134 | * missing @c return inside a statement) would only
6135 | * surface as a row icon after the line is hit. Running
6136 | * the parse-only checker at commit time stamps the row
6137 | * with the condition_error flag/message immediately; on
6138 | * a successful parse the flag we just cleared via
6139 | * set_breakpoint_condition stays cleared. */
6140 | if (!cond.isEmpty())
6141 | {
6142 | char *parse_err = NULL__null;
6143 | const bool parses_ok =
6144 | wslua_debugger_check_condition_syntax(
6145 | condUtf8.constData(), &parse_err);
6146 | if (!parses_ok)
6147 | {
6148 | wslua_debugger_set_breakpoint_condition_error(
6149 | fileUtf8.constData(), line,
6150 | parse_err ? parse_err : "Parse error");
6151 | }
6152 | g_free(parse_err);
6153 | }
6154 | touched = true;
6155 | }
6156 | if (wantsTarget)
6157 | {
6158 | const qlonglong target =
6159 | col0->data(BreakpointHitTargetRole).toLongLong();
6160 | wslua_debugger_set_breakpoint_hit_count_target(
6161 | fileUtf8.constData(), line, static_cast<int64_t>(target));
6162 | touched = true;
6163 | }
6164 | if (wantsHitMode)
6165 | {
6166 | /* The mode role is meaningful only when target > 0, but
6167 | * we forward it regardless so toggling the integer back
6168 | * on later remembers the last mode the user picked. The
6169 | * core ignores the mode when target == 0. */
6170 | const int hitMode =
6171 | col0->data(BreakpointHitModeRole).toInt();
6172 | wslua_debugger_set_breakpoint_hit_count_mode(
6173 | fileUtf8.constData(), line,
6174 | static_cast<wslua_hit_count_mode_t>(hitMode));
6175 | touched = true;
6176 | }
6177 | if (wantsLog)
6178 | {
6179 | const QString msg =
6180 | col0->data(BreakpointLogMessageRole).toString();
6181 | wslua_debugger_set_breakpoint_log_message(
6182 | fileUtf8.constData(), line,
6183 | msg.isEmpty() ? NULL__null : msg.toUtf8().constData());
6184 | touched = true;
6185 | }
6186 | if (wantsLogAlsoPause)
6187 | {
6188 | const bool alsoPause =
6189 | col0->data(BreakpointLogAlsoPauseRole).toBool();
6190 | wslua_debugger_set_breakpoint_log_also_pause(
6191 | fileUtf8.constData(), line, alsoPause);
6192 | touched = true;
6193 | }
6194 | }
6195 |
6196 | if (touched)
6197 | {
6198 | /* Rebuild rows so the tooltip and Location-cell indicator reflect
6199 | * the updated condition / hit target / log message. Deferred to
6200 | * the next event-loop tick on purpose: we are still inside the
6201 | * model's dataChanged emit, immediately followed by an
6202 | * itemChanged emit on the same item; tearing down every row
6203 | * synchronously here would dangle the QStandardItem pointer
6204 | * delivered to onBreakpointItemChanged and would also leave the
6205 | * inline editor pointing at a destroyed model index, which can
6206 | * silently swallow the just-committed edit (the source of the
6207 | * "condition / hit count are sticky" symptom). The
6208 | * suppressBreakpointItemChanged_ guard inside updateBreakpoints
6209 | * still prevents this path from looping back into either slot. */
6210 | QPointer<LuaDebuggerDialog> self(this);
6211 | QTimer::singleShot(0, this, [self]()
6212 | {
6213 | if (self)
6214 | {
6215 | self->updateBreakpoints();
6216 | }
6217 | });
6218 | }
6219 | }
6220 |
6221 | void LuaDebuggerDialog::startInlineBreakpointEdit(int row)
6222 | {
6223 | /* Single entry point used by both the row context menu's "Edit..."
6224 | * action and the section-header edit button. Routes the edit to
6225 | * the Location cell, which is the column the
6226 | * BreakpointConditionDelegate is attached to. Stale (file-missing)
6227 | * rows have Qt::ItemIsEditable cleared in updateBreakpoints, so
6228 | * the early-out keeps us from briefly opening an empty editor on
6229 | * those rows. */
6230 | if (!breakpointsModel || !breakpointsTree)
6231 | {
6232 | return;
6233 | }
6234 | if (row < 0 || row >= breakpointsModel->rowCount())
6235 | {
6236 | return;
6237 | }
6238 | const QModelIndex editTarget = breakpointsModel->index(row, 2);
6239 | if (!editTarget.isValid() || !(editTarget.flags() & Qt::ItemIsEditable))
6240 | {
6241 | return;
6242 | }
6243 | breakpointsTree->setCurrentIndex(editTarget);
6244 | breakpointsTree->scrollTo(editTarget);
6245 | breakpointsTree->edit(editTarget);
6246 | }
6247 |
6248 | void LuaDebuggerDialog::onBreakpointGutterMenu(const QString &filename,
6249 | qint32 line,
6250 | const QPoint &globalPos)
6251 | {
6252 | /* Re-check the breakpoint state at popup time rather than trusting
6253 | * what the gutter saw on the click. The model is the source of
6254 | * truth and the C-side state may have changed between the click
6255 | * and the queued slot dispatch (e.g. a hit-count target just got
6256 | * met from another script line, or another reload-driven refresh
6257 | * landed in the queue first). If the breakpoint has gone away,
6258 | * silently skip — there's nothing meaningful to offer. */
6259 | const QByteArray filePathUtf8 = filename.toUtf8();
6260 | const int32_t state = wslua_debugger_get_breakpoint_state(
6261 | filePathUtf8.constData(), line);
6262 | if (state == -1)
6263 | {
6264 | return;
6265 | }
6266 | const bool currentlyActive = (state == 1);
6267 |
6268 | QMenu menu(this);
6269 | QAction *editAct = menu.addAction(tr("&Edit..."));
6270 | QAction *toggleAct =
6271 | menu.addAction(currentlyActive ? tr("&Disable") : tr("&Enable"));
6272 | menu.addSeparator();
6273 | QAction *removeAct = menu.addAction(tr("&Remove"));
6274 |
6275 | /* exec() returns the chosen action, or nullptr if the user
6276 | * dismissed the menu (Escape, click outside, focus loss). The
6277 | * dismiss path is a no-op by design — the user-typed condition /
6278 | * hit-count target / log message stays exactly as it was. */
6279 | QAction *chosen = menu.exec(globalPos);
6280 | if (!chosen)
6281 | {
6282 | return;
6283 | }
6284 |
6285 | if (chosen == editAct)
6286 | {
6287 | /* Find the row that matches this (file, line) pair so the
6288 | * Location-cell delegate can open in place. Compare against
6289 | * the *normalized* path stored under BreakpointFileRole — the
6290 | * gutter may have handed us a non-canonical filename. */
6291 | if (!breakpointsModel)
6292 | {
6293 | return;
6294 | }
6295 | const QString normalized = normalizedFilePath(filename);
6296 | int targetRow = -1;
6297 | for (int row = 0; row < breakpointsModel->rowCount(); ++row)
6298 | {
6299 | QStandardItem *col0 = breakpointsModel->item(row, 0);
6300 | if (!col0)
6301 | continue;
6302 | const int64_t rowLine =
6303 | col0->data(BreakpointLineRole).toLongLong();
6304 | if (rowLine != line)
6305 | continue;
6306 | const QString rowFile =
6307 | col0->data(BreakpointFileRole).toString();
6308 | if (rowFile == normalized)
6309 | {
6310 | targetRow = row;
6311 | break;
6312 | }
6313 | }
6314 | if (targetRow >= 0)
6315 | {
6316 | startInlineBreakpointEdit(targetRow);
6317 | }
6318 | return;
6319 | }
6320 |
6321 | if (chosen == toggleAct)
6322 | {
6323 | wslua_debugger_set_breakpoint_active(filePathUtf8.constData(), line,
6324 | !currentlyActive);
6325 | if (!currentlyActive)
6326 | {
6327 | ensureDebuggerEnabledForActiveBreakpoints();
6328 | }
6329 | }
6330 | else if (chosen == removeAct)
6331 | {
6332 | wslua_debugger_remove_breakpoint(filePathUtf8.constData(), line);
6333 | refreshDebuggerStateUi();
6334 | }
6335 |
6336 | updateBreakpoints();
6337 |
6338 | /* Refresh every open script tab's gutter so the dot updates
6339 | * immediately on the same tab the user clicked, plus any other
6340 | * tab that happens to show the same file. */
6341 | const qint32 tabCount = static_cast<qint32>(ui->codeTabWidget->count());
6342 | for (qint32 tabIndex = 0; tabIndex < tabCount; ++tabIndex)
6343 | {
6344 | LuaDebuggerCodeView *tabView = qobject_cast<LuaDebuggerCodeView *>(
6345 | ui->codeTabWidget->widget(static_cast<int>(tabIndex)));
6346 | if (tabView)
6347 | {
6348 | tabView->updateBreakpointMarkers();
6349 | }
6350 | }
6351 | }
6352 |
6353 | void LuaDebuggerDialog::onBreakpointItemDoubleClicked(const QModelIndex &index)
6354 | {
6355 | if (!index.isValid() || !breakpointsModel)
6356 | {
6357 | return;
6358 | }
6359 | /* Double-click on a Breakpoints row opens the source file at the
6360 | * matching line. Editing the condition / hit count / log message
6361 | * is triggered through the row context menu's "Edit..." action or
6362 | * the section-header edit button, which both call
6363 | * @ref startInlineBreakpointEdit. */
6364 | QStandardItem *col0 = breakpointsModel->item(index.row(), 0);
6365 | if (!col0)
6366 | {
6367 | return;
6368 | }
6369 | const QString file = col0->data(BreakpointFileRole).toString();
6370 | const int64_t lineNumber = col0->data(BreakpointLineRole).toLongLong();
6371 | if (file.isEmpty() || lineNumber <= 0)
6372 | {
6373 | return;
6374 | }
6375 | LuaDebuggerCodeView *view = loadFile(file);
6376 | if (view)
6377 | {
6378 | view->moveCaretToLineStart(static_cast<qint32>(lineNumber));
6379 | }
6380 | }
6381 |
6382 | bool LuaDebuggerDialog::removeBreakpointRows(const QList<int> &rows)
6383 | {
6384 | if (!breakpointsModel || rows.isEmpty())
6385 | {
6386 | return false;
6387 | }
6388 |
6389 | /* Collect (file, line) pairs for the requested rows before touching the
6390 | * model: rebuilding the model in updateBreakpoints() would invalidate
6391 | * any QStandardItem pointers we held. De-duplicate row indices so callers
6392 | * can pass selectionModel()->selectedIndexes() directly. */
6393 | QVector<QPair<QString, int64_t>> toRemove;
6394 | QSet<int> seenRows;
6395 | for (int row : rows)
6396 | {
6397 | if (row < 0 || seenRows.contains(row))
6398 | {
6399 | continue;
6400 | }
6401 | seenRows.insert(row);
6402 | QStandardItem *const row0 = breakpointsModel->item(row, 0);
6403 | if (!row0)
6404 | {
6405 | continue;
6406 | }
6407 | toRemove.append({row0->data(BreakpointFileRole).toString(),
6408 | row0->data(BreakpointLineRole).toLongLong()});
6409 | }
6410 | if (toRemove.isEmpty())
6411 | {
6412 | return false;
6413 | }
6414 |
6415 | QSet<QString> touchedFiles;
6416 | for (const auto &bp : toRemove)
6417 | {
6418 | wslua_debugger_remove_breakpoint(bp.first.toUtf8().constData(),
6419 | bp.second);
6420 | touchedFiles.insert(bp.first);
6421 | }
6422 | updateBreakpoints();
6423 |
6424 | const qint32 tabCount = static_cast<qint32>(ui->codeTabWidget->count());
6425 | for (qint32 tabIndex = 0; tabIndex < tabCount; ++tabIndex)
6426 | {
6427 | LuaDebuggerCodeView *tabView = qobject_cast<LuaDebuggerCodeView *>(
6428 | ui->codeTabWidget->widget(static_cast<int>(tabIndex)));
6429 | if (tabView && touchedFiles.contains(tabView->getFilename()))
6430 | {
6431 | tabView->updateBreakpointMarkers();
6432 | }
6433 | }
6434 | return true;
6435 | }
6436 |
6437 | bool LuaDebuggerDialog::removeSelectedBreakpoints()
6438 | {
6439 | if (!breakpointsTree)
6440 | {
6441 | return false;
6442 | }
6443 | QItemSelectionModel *const sm = breakpointsTree->selectionModel();
6444 | if (!sm)
6445 | {
6446 | return false;
6447 | }
6448 | QList<int> rows;
6449 | for (const QModelIndex &ix : sm->selectedIndexes())
6450 | {
6451 | if (ix.isValid())
6452 | {
6453 | rows.append(ix.row());
6454 | }
6455 | }
6456 | return removeBreakpointRows(rows);
6457 | }
6458 |
6459 | void LuaDebuggerDialog::onBreakpointContextMenuRequested(const QPoint &pos)
6460 | {
6461 | if (!breakpointsTree || !breakpointsModel)
6462 | {
6463 | return;
6464 | }
6465 |
6466 | const QModelIndex ix = breakpointsTree->indexAt(pos);
6467 | /* Ensure the row under the cursor is part of the selection so "Remove"
6468 | * operates on something sensible even when the user right-clicks a row
6469 | * that was not previously selected. */
6470 | if (ix.isValid() && breakpointsTree->selectionModel() &&
6471 | !breakpointsTree->selectionModel()->isRowSelected(
6472 | ix.row(), ix.parent()))
6473 | {
6474 | breakpointsTree->setCurrentIndex(ix);
6475 | }
6476 |
6477 | QMenu menu(this);
6478 | QAction *editAct = nullptr;
6479 | QAction *openAct = nullptr;
6480 | QAction *resetHitsAct = nullptr;
6481 | QAction *removeAct = nullptr;
6482 |
6483 | /* Decide whether "Reset Hit Count" should be enabled by scanning the
6484 | * current selection (or just the row under the cursor when nothing
6485 | * else is selected). The action shows up disabled when there is
6486 | * nothing to reset, so the user understands what it would do but
6487 | * can't accidentally fire it on a freshly-created breakpoint. */
6488 | auto rowHasResettableHits = [this](int row) -> bool
6489 | {
6490 | QStandardItem *col0 = breakpointsModel->item(row, 0);
6491 | if (!col0)
6492 | return false;
6493 | const qlonglong target =
6494 | col0->data(BreakpointHitTargetRole).toLongLong();
6495 | const qlonglong count =
6496 | col0->data(BreakpointHitCountRole).toLongLong();
6497 | return target > 0 || count > 0;
6498 | };
6499 |
6500 | bool anyResettable = false;
6501 | QSet<int> selRowsSet;
6502 | if (breakpointsTree->selectionModel())
6503 | {
6504 | for (const QModelIndex &si :
6505 | breakpointsTree->selectionModel()->selectedIndexes())
6506 | {
6507 | if (!si.isValid())
6508 | continue;
6509 | if (selRowsSet.contains(si.row()))
6510 | continue;
6511 | selRowsSet.insert(si.row());
6512 | if (rowHasResettableHits(si.row()))
6513 | {
6514 | anyResettable = true;
6515 | }
6516 | }
6517 | }
6518 | if (selRowsSet.isEmpty() && ix.isValid())
6519 | {
6520 | anyResettable = rowHasResettableHits(ix.row());
6521 | }
6522 |
6523 | /* "Reset All Hit Counts" is enabled when ANY breakpoint in the
6524 | * model has resettable hits, regardless of selection — it's the
6525 | * "wipe every counter" gesture, not "wipe the selection's
6526 | * counters". Compute it as a separate full scan so an unsele
6527 | * cted-but-resettable row keeps the menu item live. */
6528 | bool anyResettableInModel = false;
6529 | {
6530 | const int rc = breakpointsModel->rowCount();
6531 | for (int r = 0; r < rc; ++r)
6532 | {
6533 | if (rowHasResettableHits(r))
6534 | {
6535 | anyResettableInModel = true;
6536 | break;
6537 | }
6538 | }
6539 | }
6540 |
6541 | if (ix.isValid())
6542 | {
6543 | editAct = menu.addAction(tr("Edit..."));
6544 | editAct->setEnabled(ix.flags() & Qt::ItemIsEditable);
6545 | openAct = menu.addAction(tr("Open Source"));
6546 | menu.addSeparator();
6547 | resetHitsAct = menu.addAction(tr("Reset Hit Count"));
6548 | resetHitsAct->setEnabled(anyResettable);
6549 | menu.addSeparator();
6550 | removeAct = menu.addAction(tr("Remove"));
6551 | removeAct->setShortcut(QKeySequence::Delete);
6552 | }
6553 | QAction *resetAllHitsAct = nullptr;
6554 | QAction *removeAllAct = nullptr;
6555 | if (breakpointsModel->rowCount() > 0)
6556 | {
6557 | resetAllHitsAct = menu.addAction(tr("Reset All Hit Counts"));
6558 | resetAllHitsAct->setEnabled(anyResettableInModel);
6559 | removeAllAct = menu.addAction(tr("Remove All Breakpoints"));
6560 | removeAllAct->setShortcut(kCtxRemoveAllBreakpoints);
6561 | }
6562 | if (menu.isEmpty())
6563 | {
6564 | return;
6565 | }
6566 |
6567 | QAction *chosen = menu.exec(breakpointsTree->viewport()->mapToGlobal(pos));
6568 | if (!chosen)
6569 | {
6570 | return;
6571 | }
6572 | if (chosen == editAct)
6573 | {
6574 | startInlineBreakpointEdit(ix.row());
6575 | return;
6576 | }
6577 | if (chosen == openAct)
6578 | {
6579 | onBreakpointItemDoubleClicked(ix);
6580 | return;
6581 | }
6582 | if (chosen == resetHitsAct)
6583 | {
6584 | QSet<int> rows = selRowsSet;
6585 | if (rows.isEmpty() && ix.isValid())
6586 | {
6587 | rows.insert(ix.row());
6588 | }
6589 | for (int row : rows)
6590 | {
6591 | QStandardItem *col0 = breakpointsModel->item(row, 0);
6592 | if (!col0)
6593 | continue;
6594 | const QString file = col0->data(BreakpointFileRole).toString();
6595 | const int64_t line = col0->data(BreakpointLineRole).toLongLong();
6596 | if (file.isEmpty() || line <= 0)
6597 | continue;
6598 | wslua_debugger_reset_breakpoint_hit_count(
6599 | file.toUtf8().constData(), line);
6600 | }
6601 | updateBreakpoints();
6602 | return;
6603 | }
6604 | if (chosen == removeAct)
6605 | {
6606 | removeSelectedBreakpoints();
6607 | return;
6608 | }
6609 | if (chosen == resetAllHitsAct)
6610 | {
6611 | /* Wipes every counter under one mutex acquisition (cheaper
6612 | * than looping per-row from Qt). Also clears any sticky
6613 | * condition errors so a typo that's been "stuck red" since
6614 | * the last fire goes back to neutral until the line is hit
6615 | * again — matches the per-row Reset Hit Count semantics. */
6616 | wslua_debugger_reset_all_breakpoint_hit_counts();
6617 | updateBreakpoints();
6618 | return;
6619 | }
6620 | if (chosen == removeAllAct)
6621 | {
6622 | onClearBreakpoints();
6623 | return;
6624 | }
6625 | }
6626 |
6627 | void LuaDebuggerDialog::onCodeViewContextMenu(const QPoint &pos)
6628 | {
6629 | LuaDebuggerCodeView *codeView =
6630 | qobject_cast<LuaDebuggerCodeView *>(sender());
6631 | if (!codeView)
6632 | return;
6633 |
6634 | QMenu menu(this);
6635 |
6636 | QAction *undoAct = menu.addAction(tr("Undo"));
6637 | undoAct->setShortcut(QKeySequence::Undo);
6638 | undoAct->setEnabled(codeView->document()->isUndoAvailable());
6639 | connect(undoAct, &QAction::triggered, codeView, &QPlainTextEdit::undo);
6640 |
6641 | QAction *redoAct = menu.addAction(tr("Redo"));
6642 | redoAct->setShortcut(QKeySequence::Redo);
6643 | redoAct->setEnabled(codeView->document()->isRedoAvailable());
6644 | connect(redoAct, &QAction::triggered, codeView, &QPlainTextEdit::redo);
6645 |
6646 | menu.addSeparator();
6647 |
6648 | QAction *cutAct = menu.addAction(tr("Cut"));
6649 | cutAct->setShortcut(QKeySequence::Cut);
6650 | cutAct->setEnabled(codeView->textCursor().hasSelection());
6651 | connect(cutAct, &QAction::triggered, codeView, &QPlainTextEdit::cut);
6652 |
6653 | QAction *copyAct = menu.addAction(tr("Copy"));
6654 | copyAct->setShortcut(QKeySequence::Copy);
6655 | copyAct->setEnabled(codeView->textCursor().hasSelection());
6656 | connect(copyAct, &QAction::triggered, codeView, &QPlainTextEdit::copy);
6657 |
6658 | QAction *pasteAct = menu.addAction(tr("Paste"));
6659 | pasteAct->setShortcut(QKeySequence::Paste);
6660 | pasteAct->setEnabled(codeView->canPaste());
6661 | connect(pasteAct, &QAction::triggered, codeView, &QPlainTextEdit::paste);
6662 |
6663 | QAction *selAllAct = menu.addAction(tr("Select All"));
6664 | selAllAct->setShortcut(QKeySequence::SelectAll);
6665 | connect(selAllAct, &QAction::triggered, codeView, &QPlainTextEdit::selectAll);
6666 |
6667 | menu.addSeparator();
6668 | menu.addAction(ui->actionFind);
6669 | menu.addAction(ui->actionGoToLine);
6670 |
6671 | menu.addSeparator();
6672 |
6673 | QTextCursor cursor = codeView->cursorForPosition(pos);
6674 | const qint32 lineNumber = static_cast<qint32>(cursor.blockNumber() + 1);
6675 |
6676 | // Check if breakpoint exists
6677 | const int32_t state = wslua_debugger_get_breakpoint_state(
6678 | codeView->getFilename().toUtf8().constData(), lineNumber);
6679 |
6680 | if (state == -1)
6681 | {
6682 | QAction *addBp = menu.addAction(tr("Add Breakpoint"));
6683 | addBp->setShortcut(kCtxToggleBreakpoint);
6684 | connect(addBp, &QAction::triggered,
6685 | [this, codeView, lineNumber]()
6686 | { toggleBreakpointOnCodeViewLine(codeView, lineNumber); });
6687 | }
6688 | else
6689 | {
6690 | QAction *removeBp = menu.addAction(tr("Remove Breakpoint"));
6691 | removeBp->setShortcut(kCtxToggleBreakpoint);
6692 | connect(removeBp, &QAction::triggered,
6693 | [this, codeView, lineNumber]()
6694 | { toggleBreakpointOnCodeViewLine(codeView, lineNumber); });
6695 | }
6696 |
6697 | if (eventLoop)
6698 | { // Only if paused
6699 | QAction *runToLine = menu.addAction(tr("Run to this line"));
6700 | runToLine->setShortcut(kCtxRunToLine);
6701 | connect(runToLine, &QAction::triggered,
6702 | [this, codeView, lineNumber]()
6703 | { runToCurrentLineInPausedEditor(codeView, lineNumber); });
6704 | }
6705 |
6706 | /* Add Watch is available regardless of paused state, mirroring the
6707 | * toolbar action and the Watch-panel header `+` button. Prefer the
6708 | * current selection; otherwise fall back to the Lua identifier
6709 | * under the caret so a single right-click on a variable name is
6710 | * enough — no manual selection required. While the debugger is not
6711 | * paused the watch row simply renders a muted em dash for its
6712 | * value and resolves on the next pause. */
6713 | {
6714 | QString watchSpec = codeView->textCursor().selectedText().trimmed();
6715 | if (watchSpec.isEmpty())
6716 | {
6717 | const QTextCursor caretCursor =
6718 | codeView->cursorForPosition(pos);
6719 | watchSpec = luaIdentifierUnderCursor(caretCursor);
6720 | }
6721 | if (!watchSpec.isEmpty())
6722 | {
6723 | menu.addSeparator();
6724 | const QString shortLabel = watchSpec.length() > 48
6725 | ? watchSpec.left(48) +
6726 | QStringLiteral("…")(QString(QtPrivate::qMakeStringPrivate(u"" "…")))
6727 | : watchSpec;
6728 | QAction *addWatch = menu.addAction(
6729 | tr("Add Watch: \"%1\"").arg(shortLabel));
6730 | addWatch->setShortcut(ui->actionAddWatch->shortcut());
6731 | connect(addWatch, &QAction::triggered,
6732 | [this, watchSpec]()
6733 | {
6734 | /* Both path watches (Locals.x, Globals.t.k) and
6735 | * expression watches (e.g. pinfo.src:tostring(),
6736 | * #packets) are accepted; the watch panel decides
6737 | * how to evaluate based on whether the text
6738 | * validates as a Variables-tree path. */
6739 | addWatchFromSpec(watchSpec.trimmed());
6740 | });
6741 | }
6742 | }
6743 |
6744 | menu.exec(codeView->mapToGlobal(pos));
6745 | }
6746 |
6747 | void LuaDebuggerDialog::onStackItemDoubleClicked(const QModelIndex &index)
6748 | {
6749 | if (!stackModel || !index.isValid())
6750 | {
6751 | return;
6752 | }
6753 | QStandardItem *item =
6754 | stackModel->itemFromIndex(index.sibling(index.row(), 0));
6755 | if (!item)
6756 | {
6757 | return;
6758 | }
6759 | if (!item->data(StackItemNavigableRole).toBool())
6760 | {
6761 | return;
6762 | }
6763 | const QString file = item->data(StackItemFileRole).toString();
6764 | const qint64 line = item->data(StackItemLineRole).toLongLong();
6765 | if (file.isEmpty() || line <= 0)
6766 | {
6767 | return;
6768 | }
6769 | LuaDebuggerCodeView *view = loadFile(file);
6770 | if (view)
6771 | {
6772 | view->moveCaretToLineStart(static_cast<qint32>(line));
6773 | }
6774 | }
6775 |
6776 | void LuaDebuggerDialog::onMonospaceFontUpdated(const QFont &font)
6777 | {
6778 | applyCodeEditorFonts(font);
6779 | }
6780 |
6781 | void LuaDebuggerDialog::onMainAppInitialized()
6782 | {
6783 | applyMonospaceFonts();
6784 | }
6785 |
6786 | void LuaDebuggerDialog::onPreferencesChanged()
6787 | {
6788 | applyCodeViewThemes();
6789 | applyMonospaceFonts();
6790 | refreshWatchDisplay();
6791 | }
6792 |
6793 | void LuaDebuggerDialog::onThemeChanged(int idx)
6794 | {
6795 | if (themeComboBox)
6796 | {
6797 | int32_t theme = themeComboBox->itemData(idx).toInt();
6798 |
6799 | /* Update static theme for CodeView syntax highlighting */
6800 | currentTheme_ = theme;
6801 |
6802 | /* Store theme in our JSON settings */
6803 | if (theme == WSLUA_DEBUGGER_THEME_DARK)
6804 | settings_["theme"] = "dark";
6805 | else if (theme == WSLUA_DEBUGGER_THEME_LIGHT)
6806 | settings_["theme"] = "light";
6807 | else
6808 | settings_["theme"] = "auto";
6809 |
6810 | applyCodeViewThemes();
6811 | }
6812 | }
6813 |
6814 | void LuaDebuggerDialog::onColorsChanged()
6815 | {
6816 | /*
6817 | * When Wireshark's color scheme changes and the debugger theme is set to
6818 | * "Auto (follow color scheme)", we need to re-apply themes to all code
6819 | * views. The applyCodeViewThemes() function will query
6820 | * ColorUtils::themeIsDark() to determine the effective theme.
6821 | */
6822 | applyCodeViewThemes();
6823 | refreshWatchDisplay();
6824 | }
6825 |
6826 | /**
6827 | * @brief Static callback invoked before Lua plugins are reloaded.
6828 | *
6829 | * This callback is registered with wslua_debugger_register_reload_callback()
6830 | * and is called by wslua_reload_plugins() BEFORE any Lua scripts are
6831 | * unloaded or reloaded.
6832 | *
6833 | * The callback forwards to the dialog instance to reload all open
6834 | * script files from disk. This ensures that when a breakpoint is hit
6835 | * during the reload, the debugger displays the current version of
6836 | * the script (which the user may have edited externally).
6837 | */
6838 | void LuaDebuggerDialog::onLuaReloadCallback()
6839 | {
6840 | LuaDebuggerDialog *dialog = _instance;
6841 | if (dialog)
6842 | {
6843 | /*
6844 | * Plugin reload wipes the Lua state, so the set of variables /
6845 | * locals / globals the user is looking at may be meaningless after
6846 | * reload. Reset all baselines to avoid falsely flagging values as
6847 | * "changed" simply because the world was rebuilt.
6848 | */
6849 | dialog->clearAllChangeBaselines();
6850 | dialog->enterReloadUiStateIfEnabled();
6851 |
6852 | /*
6853 | * If the debugger was paused, the UI layer called
6854 | * wslua_debugger_notify_reload() which disabled the debugger
6855 | * (continuing execution) and invoked this callback.
6856 | * Exit the nested event loop so the Lua call stack can unwind.
6857 | * handlePause() will schedule a deferred reload afterwards.
6858 | */
6859 | if (dialog->debuggerPaused && dialog->eventLoop)
6860 | {
6861 | dialog->debuggerPaused = false;
6862 | dialog->clearPausedStateUi();
6863 | dialog->refreshDebuggerStateUi();
6864 | dialog->reloadDeferred = true;
6865 | dialog->eventLoop->quit();
6866 | return;
6867 | }
6868 |
6869 | /*
6870 | * Reload all script files from disk.
6871 | * This must happen BEFORE Lua executes any code.
6872 | */
6873 | dialog->reloadAllScriptFiles();
6874 |
6875 | /*
6876 | * Update breakpoint markers in all open code views.
6877 | * This ensures the gutter shows correct breakpoint indicators.
6878 | *
6879 | * Note: refreshAvailableScripts() and updateBreakpoints() are now
6880 | * called in onLuaPostReloadCallback() AFTER plugins are loaded,
6881 | * so new scripts appear in the file tree.
6882 | */
6883 | if (dialog->ui->codeTabWidget)
6884 | {
6885 | const qint32 tabCount =
6886 | static_cast<qint32>(dialog->ui->codeTabWidget->count());
6887 | for (qint32 tabIndex = 0; tabIndex < tabCount; ++tabIndex)
6888 | {
6889 | LuaDebuggerCodeView *view = qobject_cast<LuaDebuggerCodeView *>(
6890 | dialog->ui->codeTabWidget->widget(
6891 | static_cast<int>(tabIndex)));
6892 | if (view)
6893 | {
6894 | view->updateBreakpointMarkers();
6895 | }
6896 | }
6897 | }
6898 | dialog->refreshDebuggerStateUi();
6899 | }
6900 | }
6901 |
6902 | /**
6903 | * @brief Static callback invoked AFTER Lua plugins are reloaded.
6904 | *
6905 | * This callback refreshes the file tree with newly loaded scripts.
6906 | * It is called after wslua_init() completes, so the plugin list
6907 | * now contains all the new scripts.
6908 | */
6909 | void LuaDebuggerDialog::onLuaPostReloadCallback()
6910 | {
6911 | LuaDebuggerDialog *dialog = _instance;
6912 | if (dialog)
6913 | {
6914 | dialog->exitReloadUiState();
6915 | /*
6916 | * Refresh the file tree with newly loaded scripts.
6917 | * This is the correct place to do it because we're called
6918 | * AFTER wslua_init() has loaded all plugins.
6919 | */
6920 | dialog->refreshAvailableScripts();
6921 | dialog->updateBreakpoints();
6922 | }
6923 | }
6924 |
6925 | /**
6926 | * @brief Static callback invoked when a Lua script is loaded.
6927 | *
6928 | * This callback is called by the Lua loader for each script that is
6929 | * successfully loaded. We add the script to the file tree.
6930 | */
6931 | void LuaDebuggerDialog::onScriptLoadedCallback(const char *file_path)
6932 | {
6933 | LuaDebuggerDialog *dialog = _instance;
6934 | if (dialog && file_path)
6935 | {
6936 | dialog->ensureFileTreeEntry(QString::fromUtf8(file_path));
6937 | dialog->fileModel->sort(0, Qt::AscendingOrder);
6938 | }
6939 | }
6940 |
6941 | void LuaDebuggerDialog::reloadAllScriptFiles()
6942 | {
6943 | if (!ui->codeTabWidget)
6944 | {
6945 | return;
6946 | }
6947 |
6948 | const qint32 tabCount = static_cast<qint32>(ui->codeTabWidget->count());
6949 | for (qint32 tabIndex = 0; tabIndex < tabCount; ++tabIndex)
6950 | {
6951 | LuaDebuggerCodeView *view = qobject_cast<LuaDebuggerCodeView *>(
6952 | ui->codeTabWidget->widget(static_cast<int>(tabIndex)));
6953 | if (view)
6954 | {
6955 | if (view->document()->isModified())
6956 | {
6957 | /* Keep in-memory edits when this reload was not preceded by a
6958 | * save/discard prompt (e.g. Analyze → Reload Lua Plugins). */
6959 | continue;
6960 | }
6961 | QString filePath = view->getFilename();
6962 | if (!filePath.isEmpty())
6963 | {
6964 | QFile file(filePath);
6965 | if (file.open(QIODevice::ReadOnly | QIODevice::Text))
6966 | {
6967 | QTextStream in(&file);
6968 | QString content = in.readAll();
6969 | file.close();
6970 | view->setPlainText(content);
6971 | }
6972 | }
6973 | }
6974 | }
6975 | }
6976 |
6977 | void LuaDebuggerDialog::applyCodeViewThemes()
6978 | {
6979 | ui->luaDebuggerFindFrame->updateStyleSheet();
6980 | ui->luaDebuggerGoToLineFrame->updateStyleSheet();
6981 | /* Theme / palette changed — recompute the accent + flash brushes used
6982 | * by applyChangedVisuals so the Watch and Variables cues track the
6983 | * active light/dark theme. */
6984 | refreshChangedValueBrushes();
6985 | if (!ui->codeTabWidget)
6986 | {
6987 | return;
6988 | }
6989 | const qint32 tabCount = static_cast<qint32>(ui->codeTabWidget->count());
6990 | for (qint32 tabIndex = 0; tabIndex < tabCount; ++tabIndex)
6991 | {
6992 | LuaDebuggerCodeView *view = qobject_cast<LuaDebuggerCodeView *>(
6993 | ui->codeTabWidget->widget(static_cast<int>(tabIndex)));
6994 | if (view)
6995 | {
6996 | view->applyTheme();
6997 | }
6998 | }
6999 | }
7000 |
7001 | /**
7002 | * @brief Callback function for wslua_debugger_foreach_loaded_script.
7003 | *
7004 | * This C callback receives each loaded script path from the Lua subsystem
7005 | * and adds it to the file tree via the dialog instance.
7006 | */
7007 | static void loaded_script_callback(const char *file_path, void *user_data)
7008 | {
7009 | LuaDebuggerDialog *dialog = static_cast<LuaDebuggerDialog *>(user_data);
7010 | if (dialog && file_path)
7011 | {
7012 | dialog->ensureFileTreeEntry(QString::fromUtf8(file_path));
7013 | }
7014 | }
7015 |
7016 | void LuaDebuggerDialog::refreshAvailableScripts()
7017 | {
7018 | /* Clear existing file tree entries */
7019 | if (fileModel)
7020 | {
7021 | fileModel->removeRows(0, fileModel->rowCount());
7022 | }
7023 |
7024 | /*
7025 | * First, scan the plugin directories to show all available .lua files.
7026 | * This includes files that may not be loaded yet.
7027 | */
7028 | const char *envPrefix = application_configuration_environment_prefix();
7029 | if (envPrefix)
7030 | {
7031 | const char *personal = get_plugins_pers_dir(envPrefix);
7032 | const char *global = get_plugins_dir(envPrefix);
7033 | if (personal && personal[0])
7034 | {
7035 | scanScriptDirectory(QString::fromUtf8(personal));
7036 | }
7037 | if (global && global[0])
7038 | {
7039 | scanScriptDirectory(QString::fromUtf8(global));
7040 | }
7041 | }
7042 |
7043 | /*
7044 | * Then, add any loaded scripts that might be outside the plugin
7045 | * directories (e.g., command-line scripts).
7046 | */
7047 | wslua_debugger_foreach_loaded_script(loaded_script_callback, this);
7048 |
7049 | fileModel->sort(0, Qt::AscendingOrder);
7050 | fileTree->expandAll();
7051 | }
7052 |
7053 | void LuaDebuggerDialog::scanScriptDirectory(const QString &dir_path)
7054 | {
7055 | if (dir_path.isEmpty())
7056 | {
7057 | return;
7058 | }
7059 |
7060 | QDir scriptDirectory(dir_path);
7061 | if (!scriptDirectory.exists())
7062 | {
7063 | return;
7064 | }
7065 |
7066 | QDirIterator scriptIterator(dir_path, QStringList() << "*.lua", QDir::Files,
7067 | QDirIterator::Subdirectories);
7068 | while (scriptIterator.hasNext())
7069 | {
7070 | ensureFileTreeEntry(scriptIterator.next());
7071 | }
7072 | }
7073 |
7074 | bool LuaDebuggerDialog::ensureFileTreeEntry(const QString &file_path)
7075 | {
7076 | if (!fileModel)
7077 | {
7078 | return false;
7079 | }
7080 | QString normalized = normalizedFilePath(file_path);
7081 | if (normalized.isEmpty())
7082 | {
7083 | return false;
7084 | }
7085 |
7086 | QVector<QPair<QString, QString>> components;
7087 | if (!appendPathComponents(normalized, components))
7088 | {
7089 | return false;
7090 | }
7091 |
7092 | QStandardItem *parent = nullptr;
7093 | bool createdLeaf = false;
7094 | const qint32 componentCount = static_cast<qint32>(components.size());
7095 | for (qint32 componentIndex = 0; componentIndex < componentCount;
7096 | ++componentIndex)
7097 | {
7098 | const bool isLeaf = (componentIndex == componentCount - 1);
7099 | const QString displayName =
7100 | components.at(static_cast<int>(componentIndex)).first;
7101 | const QString absolutePath =
7102 | components.at(static_cast<int>(componentIndex)).second;
7103 | QStandardItem *item = findChildItemByPath(parent, absolutePath);
7104 | if (!item)
7105 | {
7106 | item = new QStandardItem();
7107 | item->setText(displayName);
7108 | item->setToolTip(absolutePath);
7109 | item->setData(absolutePath, FileTreePathRole);
7110 | item->setData(!isLeaf, FileTreeIsDirectoryRole);
7111 | item->setIcon(isLeaf ? fileIcon : folderIcon);
7112 | if (parent)
7113 | {
7114 | parent->appendRow(item);
7115 | parent->sortChildren(0, Qt::AscendingOrder);
7116 | }
7117 | else if (fileModel)
7118 | {
7119 | fileModel->appendRow(item);
7120 | fileModel->sort(0, Qt::AscendingOrder);
7121 | }
7122 | if (isLeaf)
7123 | {
7124 | createdLeaf = true;
7125 | }
7126 | }
7127 | parent = item;
7128 | }
7129 |
7130 | if (createdLeaf)
7131 | {
7132 | fileTree->expandAll();
7133 | }
7134 |
7135 | return createdLeaf;
7136 | }
7137 |
7138 | QString LuaDebuggerDialog::normalizedFilePath(const QString &file_path) const
7139 | {
7140 | QString trimmed = file_path.trimmed();
7141 | if (trimmed.startsWith("@"))
7142 | {
7143 | trimmed = trimmed.mid(1);
7144 | }
7145 |
7146 | QFileInfo info(trimmed);
7147 | QString absolutePath = info.absoluteFilePath();
7148 |
7149 | if (info.exists())
7150 | {
7151 | QString canonical = info.canonicalFilePath();
7152 | if (!canonical.isEmpty())
7153 | {
7154 | return canonical;
7155 | }
7156 | return QDir::cleanPath(absolutePath);
7157 | }
7158 |
7159 | if (!absolutePath.isEmpty())
7160 | {
7161 | return QDir::cleanPath(absolutePath);
7162 | }
7163 |
7164 | return trimmed;
7165 | }
7166 |
7167 | QStandardItem *
7168 | LuaDebuggerDialog::findChildItemByPath(QStandardItem *parent,
7169 | const QString &path) const
7170 | {
7171 | if (parent)
7172 | {
7173 | const qint32 childCount = static_cast<qint32>(parent->rowCount());
7174 | for (qint32 childIndex = 0; childIndex < childCount; ++childIndex)
7175 | {
7176 | QStandardItem *child =
7177 | parent->child(static_cast<int>(childIndex));
7178 | if (child->data(FileTreePathRole).toString() == path)
7179 | {
7180 | return child;
7181 | }
7182 | }
7183 | return nullptr;
7184 | }
7185 |
7186 | const qint32 topLevelCount =
7187 | static_cast<qint32>(fileModel->rowCount());
7188 | for (qint32 topLevelIndex = 0; topLevelIndex < topLevelCount;
7189 | ++topLevelIndex)
7190 | {
7191 | QStandardItem *item =
7192 | fileModel->item(static_cast<int>(topLevelIndex));
7193 | if (item->data(FileTreePathRole).toString() == path)
7194 | {
7195 | return item;
7196 | }
7197 | }
7198 | return nullptr;
7199 | }
7200 |
7201 | bool LuaDebuggerDialog::appendPathComponents(
7202 | const QString &absolute_path,
7203 | QVector<QPair<QString, QString>> &components) const
7204 | {
7205 | QString forwardPath = QDir::fromNativeSeparators(absolute_path);
7206 | QStringList segments = forwardPath.split('/', Qt::SkipEmptyParts);
7207 | const qint32 segmentCount = static_cast<qint32>(segments.size());
7208 | QString currentForward;
7209 | qint32 segmentStartIndex = 0;
7210 |
7211 | if (absolute_path.startsWith("\\\\") || absolute_path.startsWith("//"))
7212 | {
7213 | if (segmentCount < 2)
7214 | {
7215 | return false;
7216 | }
7217 | currentForward =
7218 | QStringLiteral("//%1/%2")(QString(QtPrivate::qMakeStringPrivate(u"" "//%1/%2"))).arg(segments.at(0), segments.at(1));
7219 | QString display =
7220 | QStringLiteral("\\\\%1\\%2")(QString(QtPrivate::qMakeStringPrivate(u"" "\\\\%1\\%2"))).arg(segments.at(0), segments.at(1));
7221 | components.append({display, QDir::toNativeSeparators(currentForward)});
7222 | segmentStartIndex = 2;
7223 | }
7224 | else if (segmentCount > 0 && segments.first().endsWith(QLatin1Char(':')))
7225 | {
7226 | currentForward = segments.first();
7227 | QString storedRoot = currentForward;
7228 | if (!storedRoot.endsWith(QLatin1Char('/')))
7229 | {
7230 | storedRoot += QLatin1Char('/');
7231 | }
7232 | components.append(
7233 | {currentForward, QDir::toNativeSeparators(storedRoot)});
7234 | segmentStartIndex = 1;
7235 | }
7236 | else if (absolute_path.startsWith('/'))
7237 | {
7238 | currentForward = QStringLiteral("/")(QString(QtPrivate::qMakeStringPrivate(u"" "/")));
7239 | components.append({currentForward, currentForward});
7240 | }
7241 | else if (segmentCount > 0)
7242 | {
7243 | currentForward = segments.first();
7244 | components.append(
7245 | {currentForward, QDir::toNativeSeparators(currentForward)});
7246 | segmentStartIndex = 1;
7247 | }
7248 |
7249 | if (currentForward.isEmpty() && segmentCount > 0)
7250 | {
7251 | currentForward = segments.first();
7252 | components.append(
7253 | {currentForward, QDir::toNativeSeparators(currentForward)});
7254 | segmentStartIndex = 1;
7255 | }
7256 |
7257 | for (qint32 segmentIndex = segmentStartIndex; segmentIndex < segmentCount;
7258 | ++segmentIndex)
7259 | {
7260 | const QString &segment = segments.at(static_cast<int>(segmentIndex));
7261 | if (currentForward.isEmpty() || currentForward == "/")
7262 | {
7263 | currentForward = currentForward == "/"
7264 | ? QStringLiteral("/%1")(QString(QtPrivate::qMakeStringPrivate(u"" "/%1"))).arg(segment)
7265 | : segment;
7266 | }
7267 | else
7268 | {
7269 | currentForward += "/" + segment;
7270 | }
7271 | components.append({segment, QDir::toNativeSeparators(currentForward)});
7272 | }
7273 |
7274 | return !components.isEmpty();
7275 | }
7276 |
7277 | void LuaDebuggerDialog::openInitialBreakpointFiles(
7278 | const QVector<QString> &files)
7279 | {
7280 | for (const QString &path : files)
7281 | {
7282 | loadFile(path);
7283 | }
7284 | }
7285 |
7286 | void LuaDebuggerDialog::configureVariablesTreeColumns()
7287 | {
7288 | if (!variablesTree || !variablesModel || !variablesTree->header())
7289 | {
7290 | return;
7291 | }
7292 | QHeaderView *header = variablesTree->header();
7293 | header->setStretchLastSection(true);
7294 | header->setSectionsMovable(false);
7295 | /* Visible columns: Name, Value (column 2 Type is hidden). */
7296 | header->setSectionResizeMode(0, QHeaderView::Interactive);
7297 | header->setSectionResizeMode(1, QHeaderView::Stretch);
7298 | // Initial width for Name column - Value column stretches to fill the rest
7299 | header->resizeSection(0, 150);
7300 | }
7301 |
7302 | void LuaDebuggerDialog::configureWatchTreeColumns()
7303 | {
7304 | if (!watchTree || !watchTree->header())
7305 | {
7306 | return;
7307 | }
7308 | QHeaderView *header = watchTree->header();
7309 | header->setStretchLastSection(true);
7310 | header->setSectionsMovable(false);
7311 | header->setSectionResizeMode(0, QHeaderView::Interactive);
7312 | header->setSectionResizeMode(1, QHeaderView::Stretch);
7313 | header->resizeSection(0, 200);
7314 | }
7315 |
7316 | void LuaDebuggerDialog::configureStackTreeColumns()
7317 | {
7318 | if (!stackTree || !stackTree->header())
7319 | {
7320 | return;
7321 | }
7322 | QHeaderView *header = stackTree->header();
7323 | header->setStretchLastSection(true);
7324 | header->setSectionsMovable(false);
7325 | header->setSectionResizeMode(0, QHeaderView::Interactive);
7326 | header->setSectionResizeMode(1, QHeaderView::Stretch);
7327 | // Initial width for Function column - Location column stretches to fill the
7328 | // rest
7329 | header->resizeSection(0, 150);
7330 | }
7331 |
7332 | void LuaDebuggerDialog::clearPausedStateUi()
7333 | {
7334 | if (variablesTree)
7335 | {
7336 | if (variablesModel)
7337 | {
7338 | variablesModel->removeRows(0, variablesModel->rowCount());
7339 | }
7340 | }
7341 | if (stackModel)
7342 | {
7343 | stackModel->removeRows(0, stackModel->rowCount());
7344 | }
7345 | clearAllCodeHighlights();
7346 | /* Drop the pause location and refresh the breakpoints tree so the
7347 | * row that was highlighted while paused returns to its normal
7348 | * appearance. Doing it here (rather than at every individual resume
7349 | * site) keeps the cue tied to the same teardown that already wipes
7350 | * the editor's pause-line stripe and the Variables / Stack trees. */
7351 | const bool hadPauseLocation = !pausedFile_.isEmpty();
7352 | pausedFile_.clear();
7353 | pausedLine_ = 0;
7354 | if (hadPauseLocation && breakpointsModel)
7355 | {
7356 | updateBreakpoints();
7357 | }
7358 | }
7359 |
7360 | void LuaDebuggerDialog::resumeDebuggerAndExitLoop()
7361 | {
7362 | if (debuggerPaused)
7363 | {
7364 | wslua_debugger_continue();
7365 | debuggerPaused = false;
7366 | clearPausedStateUi();
7367 | }
7368 |
7369 | if (eventLoop)
7370 | {
7371 | eventLoop->quit();
7372 | }
7373 | }
7374 |
7375 | void LuaDebuggerDialog::endPauseFreeze()
7376 | {
7377 | /* Idempotent: called both from handlePause()'s post-loop cleanup
7378 | * (normal Continue/Step exit) and from closeEvent() when the user
7379 | * closes the main window while we are paused. In the latter case
7380 | * the nested QEventLoop has been asked to quit via
7381 | * resumeDebuggerAndExitLoop() but has not yet unwound, so
7382 | * WiresharkMainWindow::closeEvent will continue running straight
7383 | * after dbg->close() returns — tryClosingCaptureFile() may then
7384 | * pop up a "Save unsaved capture?" modal. Tearing the pause
7385 | * freeze down here, synchronously, is what lets those prompts
7386 | * respond to input; by the time handlePause returns to this
7387 | * function the second call is a no-op. */
7388 | if (pauseUnfrozen_) {
7389 | return;
7390 | }
7391 | pauseUnfrozen_ = true;
7392 |
7393 | MainWindow *mw = mainApp ? mainApp->mainWindow() : nullptr;
7394 |
7395 | /* Remove the input/paint filter FIRST so the upcoming
7396 | * setEnabled(true) cascades can post normal QEvent::UpdateRequest
7397 | * events to the main window and actually repaint it. */
7398 | if (pauseInputFilter)
7399 | {
7400 | qApp(static_cast<QApplication *>(QCoreApplication::instance ()))->removeEventFilter(pauseInputFilter);
7401 | delete pauseInputFilter;
7402 | pauseInputFilter = nullptr;
7403 | }
7404 |
7405 | /* Tear the overlay down before re-enabling the main window so the
7406 | * setEnabled(true) cascade below repaints fresh viewport pixels
7407 | * with nothing on top. */
7408 | if (pauseOverlay) {
7409 | delete pauseOverlay;
7410 | pauseOverlay = nullptr;
7411 | }
7412 |
7413 | /* Re-enable the main window's central widget. setEnabled(true)
7414 | * triggers Qt's internal update() cascade over the widget and all
7415 | * its descendants (packet list, details tree, byte view, ...),
7416 | * which is what actually repaints the viewport backing store
7417 | * after the pause — without this pass the packet list would stay
7418 | * rendered as the frozen-at-pause-entry repaint() produced and
7419 | * appear "still paused" until an unrelated expose event (e.g.
7420 | * switching macOS spaces) forced a repaint. The PauseInputFilter
7421 | * has already been removed above, so the UpdateRequests posted by
7422 | * this cascade flow to the main window normally. Doing this
7423 | * BEFORE re-enabling the other top-levels lets the visually most
7424 | * prominent area of the app refresh first. */
7425 | if (frozenCentralWidget) {
7426 | frozenCentralWidget->setEnabled(true);
7427 | }
7428 | frozenCentralWidget.clear();
7429 |
7430 | /* Re-enable top-levels that were disabled at pause entry. QPointer
7431 | * guards against any that were destroyed during the pause (e.g.
7432 | * Qt reaped them when the main window closed). */
7433 | const QList<QPointer<QWidget>> frozen_snapshot = frozenTopLevels;
7434 | frozenTopLevels.clear();
7435 | for (const QPointer<QWidget> &w : frozen_snapshot)
7436 | {
7437 | if (w) {
7438 | w->setEnabled(true);
7439 | }
7440 | }
7441 |
7442 | /* Re-enable QActions disabled at pause entry. */
7443 | const QList<QPointer<QAction>> action_snapshot = frozenActions;
7444 | frozenActions.clear();
7445 | for (const QPointer<QAction> &a : action_snapshot)
7446 | {
7447 | if (a) {
7448 | a->setEnabled(true);
7449 | }
7450 | }
7451 |
7452 | /* Force a full repaint of the main window once the call stack has
7453 | * unwound. handlePause() is commonly entered from inside
7454 | * QWidgetRepaintManager::paintAndFlush() (scroll → packet list
7455 | * paintEvent → dissect_lua → Lua hook → handlePause), which means
7456 | * we are STILL inside the outer paint cycle right now. mw->update()
7457 | * here would post a QEvent::UpdateRequest, but Qt's repaint manager
7458 | * will mark the dirty regions of that update as "satisfied" by the
7459 | * outer paint that is finishing above us — the packet list ends up
7460 | * stuck rendered as it was at pause entry until the user does
7461 | * something that genuinely invalidates the viewport (resize,
7462 | * scroll, switch macOS Spaces, …). Queue an explicit repaint on
7463 | * the next event-loop iteration via QTimer::singleShot(0, …): by
7464 | * then the outer paint has fully completed, mw->repaint() runs
7465 | * synchronously on a clean stack, and every visible child widget
7466 | * (packet list, details tree, byte view) gets a fresh paintEvent.
7467 | * The QPointer guard handles the unlikely case that the main
7468 | * window is destroyed before the timer fires; QTimer::singleShot
7469 | * with mw as receiver also auto-cancels in that case. */
7470 | if (mw) {
7471 | QPointer<QWidget> mw_p(mw);
7472 | QTimer::singleShot(0, mw, [mw_p]() {
7473 | if (mw_p) {
7474 | mw_p->repaint();
7475 | }
7476 | });
7477 | }
7478 | }
7479 |
7480 | void LuaDebuggerDialog::onVariablesContextMenuRequested(const QPoint &pos)
7481 | {
7482 | if (!variablesTree || !variablesModel)
7483 | {
7484 | return;
7485 | }
7486 |
7487 | const QModelIndex ix = variablesTree->indexAt(pos);
7488 | if (!ix.isValid())
7489 | {
7490 | return;
7491 | }
7492 | QStandardItem *item =
7493 | variablesModel->itemFromIndex(ix.sibling(ix.row(), 0));
7494 | if (!item)
7495 | {
7496 | return;
7497 | }
7498 |
7499 | const QString nameText = item->text();
7500 | const QString valueText = text(variablesModel, item, 1);
7501 | const QString bothText =
7502 | valueText.isEmpty() ? nameText : tr("%1 = %2").arg(nameText, valueText);
7503 |
7504 | const QString varPath = item->data(VariablePathRole).toString();
7505 |
7506 | QMenu menu(this);
7507 | QAction *copyName = menu.addAction(tr("Copy Name"));
7508 | QAction *copyValue = menu.addAction(tr("Copy Value"));
7509 | QAction *copyPath = nullptr;
7510 | if (!varPath.isEmpty())
7511 | {
7512 | copyPath = menu.addAction(tr("Copy Path"));
7513 | }
7514 | QAction *copyNameValue = menu.addAction(tr("Copy Name && Value"));
7515 |
7516 | auto copyToClipboard = [](const QString &text)
7517 | {
7518 | if (QClipboard *clipboard = QGuiApplication::clipboard())
7519 | {
7520 | clipboard->setText(text);
7521 | }
7522 | };
7523 |
7524 | connect(copyName, &QAction::triggered, this,
7525 | [copyToClipboard, nameText]() { copyToClipboard(nameText); });
7526 | connect(copyValue, &QAction::triggered, this,
7527 | [copyToClipboard, valueText]() { copyToClipboard(valueText); });
7528 | if (copyPath)
7529 | {
7530 | connect(copyPath, &QAction::triggered, this,
7531 | [copyToClipboard, varPath]() { copyToClipboard(varPath); });
7532 | }
7533 | connect(copyNameValue, &QAction::triggered, this,
7534 | [copyToClipboard, bothText]() { copyToClipboard(bothText); });
7535 |
7536 | menu.addSeparator();
7537 | if (!varPath.isEmpty())
7538 | {
7539 | QAction *addWatch =
7540 | menu.addAction(tr("Add Watch: \"%1\"")
7541 | .arg(varPath.length() > 48
7542 | ? varPath.left(48) +
7543 | QStringLiteral("…")(QString(QtPrivate::qMakeStringPrivate(u"" "…")))
7544 | : varPath));
7545 | connect(addWatch, &QAction::triggered, this,
7546 | [this, varPath]() { addWatchFromSpec(varPath); });
7547 | }
7548 |
7549 | menu.exec(variablesTree->viewport()->mapToGlobal(pos));
7550 | }
7551 |
7552 | void LuaDebuggerDialog::onFileTreeContextMenuRequested(const QPoint &pos)
7553 | {
7554 | if (!fileTree || !fileModel)
7555 | {
7556 | return;
7557 | }
7558 | const QModelIndex ix = fileTree->indexAt(pos);
7559 | if (!ix.isValid())
7560 | {
7561 | return;
7562 | }
7563 | QStandardItem *item =
7564 | fileModel->itemFromIndex(ix.sibling(ix.row(), 0));
7565 | if (!item || item->data(FileTreeIsDirectoryRole).toBool())
7566 | {
7567 | /* Only file leaves get a menu — directory rows are
7568 | * decorative groupings. */
7569 | return;
7570 | }
7571 | const QString path = item->data(FileTreePathRole).toString();
7572 | if (path.isEmpty())
7573 | {
7574 | return;
7575 | }
7576 |
7577 | QMenu menu(this);
7578 | QAction *openAct = menu.addAction(tr("Open Source"));
7579 | QAction *revealAct = menu.addAction(tr("Reveal in File Manager"));
7580 | menu.addSeparator();
7581 | QAction *copyPathAct = menu.addAction(tr("Copy Path"));
7582 |
7583 | QAction *chosen = menu.exec(fileTree->viewport()->mapToGlobal(pos));
7584 | if (!chosen)
7585 | {
7586 | return;
7587 | }
7588 | if (chosen == openAct)
7589 | {
7590 | loadFile(path);
7591 | return;
7592 | }
7593 | if (chosen == revealAct)
7594 | {
7595 | /* Open the *parent* directory rather than the file itself: the
7596 | * file might be associated with an external editor that would
7597 | * launch on open, which is not what "Reveal" implies. */
7598 | const QString parentDir = QFileInfo(path).absolutePath();
7599 | if (!parentDir.isEmpty())
7600 | {
7601 | QDesktopServices::openUrl(QUrl::fromLocalFile(parentDir));
7602 | }
7603 | return;
7604 | }
7605 | if (chosen == copyPathAct)
7606 | {
7607 | if (QClipboard *clip = QGuiApplication::clipboard())
7608 | {
7609 | clip->setText(path);
7610 | }
7611 | return;
7612 | }
7613 | }
7614 |
7615 | void LuaDebuggerDialog::onStackContextMenuRequested(const QPoint &pos)
7616 | {
7617 | if (!stackTree || !stackModel)
7618 | {
7619 | return;
7620 | }
7621 | const QModelIndex ix = stackTree->indexAt(pos);
7622 | if (!ix.isValid())
7623 | {
7624 | return;
7625 | }
7626 | QStandardItem *item =
7627 | stackModel->itemFromIndex(ix.sibling(ix.row(), 0));
7628 | if (!item)
7629 | {
7630 | return;
7631 | }
7632 |
7633 | const bool navigable = item->data(StackItemNavigableRole).toBool();
7634 | const QString file = item->data(StackItemFileRole).toString();
7635 | const qint64 line = item->data(StackItemLineRole).toLongLong();
7636 |
7637 | QMenu menu(this);
7638 | QAction *openAct = menu.addAction(tr("Open Source"));
7639 | /* C frames cannot be opened — gray the entry instead of hiding it
7640 | * so the menu stays positionally consistent across rows. */
7641 | openAct->setEnabled(navigable && !file.isEmpty() && line > 0);
7642 | QAction *copyLocAct = menu.addAction(tr("Copy Location"));
7643 | copyLocAct->setEnabled(!file.isEmpty() && line > 0);
7644 |
7645 | QAction *chosen = menu.exec(stackTree->viewport()->mapToGlobal(pos));
7646 | if (!chosen)
7647 | {
7648 | return;
7649 | }
7650 | if (chosen == openAct && openAct->isEnabled())
7651 | {
7652 | LuaDebuggerCodeView *view = loadFile(file);
7653 | if (view)
7654 | {
7655 | view->moveCaretToLineStart(static_cast<qint32>(line));
7656 | }
7657 | return;
7658 | }
7659 | if (chosen == copyLocAct && copyLocAct->isEnabled())
7660 | {
7661 | if (QClipboard *clip = QGuiApplication::clipboard())
7662 | {
7663 | clip->setText(QStringLiteral("%1:%2")(QString(QtPrivate::qMakeStringPrivate(u"" "%1:%2"))).arg(file).arg(line));
7664 | }
7665 | return;
7666 | }
7667 | }
7668 |
7669 | void LuaDebuggerDialog::clearAllCodeHighlights()
7670 | {
7671 | if (!ui->codeTabWidget)
7672 | {
7673 | return;
7674 | }
7675 | const qint32 tabCount = static_cast<qint32>(ui->codeTabWidget->count());
7676 | for (qint32 tabIndex = 0; tabIndex < tabCount; ++tabIndex)
7677 | {
7678 | LuaDebuggerCodeView *view = qobject_cast<LuaDebuggerCodeView *>(
7679 | ui->codeTabWidget->widget(static_cast<int>(tabIndex)));
7680 | if (view)
7681 | {
7682 | view->clearCurrentLineHighlight();
7683 | }
7684 | }
7685 | }
7686 |
7687 | void LuaDebuggerDialog::applyMonospaceFonts()
7688 | {
7689 | applyCodeEditorFonts(effectiveMonospaceFont(true));
7690 | applyMonospacePanelFonts();
7691 | }
7692 |
7693 | void LuaDebuggerDialog::applyCodeEditorFonts(const QFont &monoFont)
7694 | {
7695 | QFont font = monoFont;
7696 | if (font.family().isEmpty())
7697 | {
7698 | font = effectiveMonospaceFont(true);
7699 | }
7700 |
7701 | if (!ui->codeTabWidget)
7702 | {
7703 | return;
7704 | }
7705 | const qint32 tabCount = static_cast<qint32>(ui->codeTabWidget->count());
7706 | for (qint32 tabIndex = 0; tabIndex < tabCount; ++tabIndex)
7707 | {
7708 | LuaDebuggerCodeView *view = qobject_cast<LuaDebuggerCodeView *>(
7709 | ui->codeTabWidget->widget(static_cast<int>(tabIndex)));
7710 | if (view)
7711 | {
7712 | view->setEditorFont(font);
7713 | }
7714 | }
7715 | }
7716 |
7717 | /**
7718 | * @brief Walk the watch @c QStandardItemModel tree: set each item’s
7719 | * @c QFont to the panel monospace while preserving the existing bold bit
7720 | * (change-highlight) so it wins over the tree widget font after row moves.
7721 | */
7722 | // NOLINTNEXTLINE(misc-no-recursion)
7723 | static void reapplyMonospaceToWatchItemModelRecursive(const QFont &base,
7724 | QStandardItemModel *m,
7725 | const QModelIndex &parent)
7726 | {
7727 | if (!m)
7728 | {
7729 | return;
7730 | }
7731 | const int rows = m->rowCount(parent);
7732 | const int cols = m->columnCount(parent);
7733 | for (int r = 0; r < rows; ++r)
7734 | {
7735 | for (int c = 0; c < cols; ++c)
7736 | {
7737 | const QModelIndex idx = m->index(r, c, parent);
7738 | if (QStandardItem *it = m->itemFromIndex(idx))
7739 | {
7740 | QFont f = base;
7741 | f.setBold(it->font().bold());
7742 | it->setFont(f);
7743 | }
7744 | }
7745 | const QModelIndex col0 = m->index(r, 0, parent);
7746 | if (col0.isValid() && m->rowCount(col0) > 0)
7747 | {
7748 | reapplyMonospaceToWatchItemModelRecursive(base, m, col0);
7749 | }
7750 | }
7751 | }
7752 |
7753 | void LuaDebuggerDialog::reapplyMonospaceToWatchItemFonts()
7754 | {
7755 | if (!watchModel)
7756 | {
7757 | return;
7758 | }
7759 | reapplyMonospaceToWatchItemModelRecursive(
7760 | effectiveMonospaceFont(false), watchModel, QModelIndex());
7761 | if (watchTree)
7762 | {
7763 | watchTree->update();
7764 | }
7765 | }
7766 |
7767 | void LuaDebuggerDialog::applyMonospacePanelFonts()
7768 | {
7769 | const QFont panelMono = effectiveMonospaceFont(false);
7770 | const QFont headerFont = effectiveRegularFont();
7771 |
7772 | const QList<QWidget *> widgets = {variablesTree, watchTree, stackTree,
7773 | breakpointsTree, evalInputEdit,
7774 | evalOutputEdit};
7775 | for (QWidget *widget : widgets)
7776 | {
7777 | if (widget)
7778 | {
7779 | widget->setFont(panelMono);
7780 | }
7781 | }
7782 |
7783 | const QList<QTreeView *> treesWithStandardHeaders = {
7784 | variablesTree, watchTree, stackTree, fileTree, breakpointsTree};
7785 | for (QTreeView *tree : treesWithStandardHeaders)
7786 | {
7787 | if (tree && tree->header())
7788 | {
7789 | tree->header()->setFont(headerFont);
7790 | }
7791 | }
7792 | reapplyMonospaceToWatchItemFonts();
7793 | }
7794 |
7795 | QFont LuaDebuggerDialog::effectiveMonospaceFont(bool zoomed) const
7796 | {
7797 | /* Monospace font for panels and the script editor. */
7798 | if (mainApp && mainApp->isInitialized())
7799 | {
7800 | return mainApp->monospaceFont(zoomed);
7801 | }
7802 |
7803 | /* Fall back to system fixed font */
7804 | return QFontDatabase::systemFont(QFontDatabase::FixedFont);
7805 | }
7806 |
7807 | QFont LuaDebuggerDialog::effectiveRegularFont() const
7808 | {
7809 | if (mainApp && mainApp->isInitialized())
7810 | {
7811 | return mainApp->font();
7812 | }
7813 | return QGuiApplication::font();
7814 | }
7815 |
7816 | void LuaDebuggerDialog::syncDebuggerToggleWithCore()
7817 | {
7818 | if (!enabledCheckBox)
7819 | {
7820 | return;
7821 | }
7822 | if (reloadUiActive_)
7823 | {
7824 | bool previousState = enabledCheckBox->blockSignals(true);
7825 | enabledCheckBox->setChecked(true);
7826 | enabledCheckBox->setEnabled(false);
7827 | enabledCheckBox->blockSignals(previousState);
7828 | return;
7829 | }
7830 | const bool debuggerEnabled = wslua_debugger_is_enabled();
7831 | bool previousState = enabledCheckBox->blockSignals(true);
7832 | enabledCheckBox->setChecked(debuggerEnabled);
7833 | enabledCheckBox->blockSignals(previousState);
7834 | /* Lock the toggle while a live capture is forcing the debugger
7835 | * off so the checkbox cannot drift out of sync with the core
7836 | * state, and the user gets an obvious "this is intentional, not
7837 | * me" affordance. The disabled icon's tooltip explains why. */
7838 | enabledCheckBox->setEnabled(!isSuppressedByLiveCapture());
7839 | }
7840 |
7841 | void LuaDebuggerDialog::refreshDebuggerStateUi()
7842 | {
7843 | /* Full reconciliation is centralized in updateWidgets() (which syncs
7844 | * the checkbox to the C core, then repaints status chrome). */
7845 | updateWidgets();
7846 | }
7847 |
7848 | void LuaDebuggerDialog::enterReloadUiStateIfEnabled()
7849 | {
7850 | if (!enabledCheckBox || reloadUiActive_)
7851 | {
7852 | return;
7853 | }
7854 |
7855 | bool shouldActivate = reloadUiRequestWasEnabled_;
7856 | if (!shouldActivate)
7857 | {
7858 | shouldActivate = enabledCheckBox->isChecked();
7859 | }
7860 | if (!shouldActivate)
7861 | {
7862 | return;
7863 | }
7864 |
7865 | reloadUiSavedCheckboxChecked_ = enabledCheckBox->isChecked();
7866 | reloadUiSavedCheckboxEnabled_ = enabledCheckBox->isEnabled();
7867 | reloadUiActive_ = true;
7868 |
7869 | bool previousState = enabledCheckBox->blockSignals(true);
7870 | enabledCheckBox->setChecked(true);
7871 | enabledCheckBox->setEnabled(false);
7872 | enabledCheckBox->blockSignals(previousState);
7873 |
7874 | updateWidgets();
7875 | }
7876 |
7877 | void LuaDebuggerDialog::exitReloadUiState()
7878 | {
7879 | reloadUiRequestWasEnabled_ = false;
7880 | if (!enabledCheckBox || !reloadUiActive_)
7881 | {
7882 | return;
7883 | }
7884 |
7885 | bool previousState = enabledCheckBox->blockSignals(true);
7886 | enabledCheckBox->setChecked(reloadUiSavedCheckboxChecked_);
7887 | enabledCheckBox->setEnabled(reloadUiSavedCheckboxEnabled_);
7888 | enabledCheckBox->blockSignals(previousState);
7889 |
7890 | reloadUiActive_ = false;
7891 | refreshDebuggerStateUi();
7892 | }
7893 |
7894 | LuaDebuggerDialog::DebuggerUiStatus
7895 | LuaDebuggerDialog::currentDebuggerUiStatus() const
7896 | {
7897 | if (reloadUiActive_)
7898 | {
7899 | return DebuggerUiStatus::Running;
7900 | }
7901 | const bool debuggerEnabled = wslua_debugger_is_enabled();
7902 | const bool showPausedChrome = wslua_debugger_is_paused() ||
7903 | (debuggerEnabled && debuggerPaused);
7904 | if (showPausedChrome)
7905 | {
7906 | return DebuggerUiStatus::Paused;
7907 | }
7908 | if (!debuggerEnabled)
7909 | {
7910 | if (isSuppressedByLiveCapture())
7911 | {
7912 | return DebuggerUiStatus::DisabledLiveCapture;
7913 | }
7914 | return DebuggerUiStatus::Disabled;
7915 | }
7916 | return DebuggerUiStatus::Running;
7917 | }
7918 |
7919 | void LuaDebuggerDialog::updateEnabledCheckboxIcon()
7920 | {
7921 | if (!enabledCheckBox)
7922 | {
7923 | return;
7924 | }
7925 |
7926 | // Create a colored circle icon to indicate enabled/disabled state.
7927 | // Render at the screen's native pixel density so the circle stays
7928 | // crisp on Retina / HiDPI displays instead of being upscaled from
7929 | // a 16x16 bitmap.
7930 | const qreal dpr = enabledCheckBox->devicePixelRatioF();
7931 | QPixmap pixmap(QSize(16, 16) * dpr);
7932 | pixmap.setDevicePixelRatio(dpr);
7933 | pixmap.fill(Qt::transparent);
7934 | QPainter painter(&pixmap);
7935 | painter.setRenderHint(QPainter::Antialiasing);
7936 |
7937 | const DebuggerUiStatus uiStatus = currentDebuggerUiStatus();
7938 | QColor fill;
7939 | switch (uiStatus)
7940 | {
7941 | case DebuggerUiStatus::Paused:
7942 | // Yellow circle for paused
7943 | fill = QColor("#FFC107");
7944 | enabledCheckBox->setToolTip(
7945 | tr("Debugger is paused. Uncheck to disable."));
7946 | break;
7947 | case DebuggerUiStatus::Running:
7948 | // Green circle for enabled
7949 | fill = QColor("#28A745");
7950 | enabledCheckBox->setToolTip(
7951 | tr("Debugger is enabled. Uncheck to disable."));
7952 | break;
7953 | case DebuggerUiStatus::DisabledLiveCapture:
7954 | // Red circle with a "locked by live capture" tooltip so
7955 | // the user understands the toggle is inert by design.
7956 | fill = QColor("#DC3545");
7957 | enabledCheckBox->setToolTip(
7958 | tr("Debugger is disabled while a live capture is running. "
7959 | "Stop the capture to re-enable."));
7960 | break;
7961 | case DebuggerUiStatus::Disabled:
7962 | // Gray circle for disabled
7963 | fill = QColor("#808080");
7964 | enabledCheckBox->setToolTip(
7965 | tr("Debugger is disabled. Check to enable."));
7966 | break;
7967 | }
7968 |
7969 | // Thin darker rim gives the circle definition on both light and dark backgrounds.
7970 | painter.setBrush(fill);
7971 | painter.setPen(QPen(fill.darker(140), 1));
7972 | painter.drawEllipse(QRectF(2.5, 2.5, 12.0, 12.0));
7973 | painter.end();
7974 |
7975 | /* Register the colored pixmap for BOTH QIcon::Normal and
7976 | * QIcon::Disabled. The checkbox widget is disabled in the
7977 | * "suppressed by live capture" state (see
7978 | * syncDebuggerToggleWithCore), and with only a Normal pixmap
7979 | * supplied, Qt synthesizes a Disabled pixmap by desaturating it.
7980 | * macOS's Cocoa style does this subtly enough that the red stays
7981 | * visible, but Linux styles (Fusion / Breeze / Adwaita / gtk3)
7982 | * desaturate aggressively, making the red circle look gray. */
7983 | QIcon icon;
7984 | icon.addPixmap(pixmap, QIcon::Normal);
7985 | icon.addPixmap(pixmap, QIcon::Disabled);
7986 | enabledCheckBox->setIcon(icon);
7987 | }
7988 |
7989 | void LuaDebuggerDialog::updateStatusLabel()
7990 | {
7991 | const DebuggerUiStatus uiStatus = currentDebuggerUiStatus();
7992 | /* [*] is required for setWindowModified() to show an unsaved
7993 | * indicator in the title. */
7994 | QString title = QStringLiteral("[*]%1")(QString(QtPrivate::qMakeStringPrivate(u"" "[*]%1"))).arg(tr("Lua Debugger"));
7995 |
7996 | #ifdef Q_OS_MAC
7997 | // On macOS we separate with a unicode em dash
7998 | title += QString(" " UTF8_EM_DASH"\u2014" " ");
7999 | #else
8000 | title += QString(" - ");
8001 | #endif
8002 |
8003 | switch (uiStatus)
8004 | {
8005 | case DebuggerUiStatus::Paused:
8006 | title += tr("Paused");
8007 | break;
8008 | case DebuggerUiStatus::DisabledLiveCapture:
8009 | title += tr("Disabled (live capture)");
8010 | break;
8011 | case DebuggerUiStatus::Disabled:
8012 | title += tr("Disabled");
8013 | break;
8014 | case DebuggerUiStatus::Running:
8015 | title += tr("Running");
8016 | break;
8017 | }
8018 |
8019 | setWindowTitle(title);
8020 | updateWindowModifiedState();
8021 | }
8022 |
8023 | void LuaDebuggerDialog::updateContinueActionState()
8024 | {
8025 | const bool allowContinue = wslua_debugger_is_enabled() && debuggerPaused;
8026 | ui->actionContinue->setEnabled(allowContinue);
8027 | ui->actionStepOver->setEnabled(allowContinue);
8028 | ui->actionStepIn->setEnabled(allowContinue);
8029 | ui->actionStepOut->setEnabled(allowContinue);
8030 | /* Run to this line additionally requires a focusable line in the editor,
8031 | * i.e. an active code view tab. */
8032 | ui->actionRunToLine->setEnabled(allowContinue && currentCodeView() != nullptr);
8033 | }
8034 |
8035 | void LuaDebuggerDialog::updateWidgets()
8036 | {
8037 | #ifndef QT_NO_DEBUG
8038 | if (wslua_debugger_is_paused())
8039 | {
8040 | Q_ASSERT(wslua_debugger_is_enabled())((wslua_debugger_is_enabled()) ? static_cast<void>(0) : qt_assert("wslua_debugger_is_enabled()", "ui/qt/lua_debugger/lua_debugger_dialog.cpp" , 8040));
8041 | }
8042 | #endif
8043 | syncDebuggerToggleWithCore();
8044 | updateEnabledCheckboxIcon();
8045 | updateStatusLabel();
8046 | updateContinueActionState();
8047 | updateEvalPanelState();
8048 | refreshWatchDisplay();
8049 | }
8050 |
8051 | void LuaDebuggerDialog::ensureDebuggerEnabledForActiveBreakpoints()
8052 | {
8053 | /* wslua_debugger owns enable *policy*; live capture gating is owned here
8054 | * (s_captureSuppression*): epan has no knowledge of the capture path. */
8055 | if (!wslua_debugger_may_auto_enable_for_breakpoints())
8056 | {
8057 | refreshDebuggerStateUi();
8058 | return;
8059 | }
8060 | if (isSuppressedByLiveCapture())
8061 | {
8062 | /* A breakpoint was just (re)armed during a live capture.
8063 | * Record the intent so the debugger comes back enabled when
8064 | * the capture stops, but do not flip the core flag now —
8065 | * pausing the dissector with the dumpcap pipe still feeding
8066 | * us packets is exactly what the suppression exists to
8067 | * prevent. */
8068 | s_captureSuppressionPrevEnabled_ = true;
8069 | refreshDebuggerStateUi();
8070 | return;
8071 | }
8072 | if (!wslua_debugger_is_enabled())
8073 | {
8074 | wslua_debugger_set_enabled(true);
8075 | refreshDebuggerStateUi();
8076 | }
8077 | }
8078 |
8079 | void LuaDebuggerDialog::onOpenFile()
8080 | {
8081 | QString startDir = lastOpenDirectory;
8082 | if (startDir.isEmpty())
8083 | {
8084 | startDir = QDir::homePath();
8085 | }
8086 |
8087 | const QString filePath = WiresharkFileDialog::getOpenFileName(
8088 | this, tr("Open Lua Script"), startDir,
8089 | tr("Lua Scripts (*.lua);;All Files (*)"));
8090 |
8091 | if (filePath.isEmpty())
8092 | {
8093 | return;
8094 | }
8095 |
8096 | lastOpenDirectory = QFileInfo(filePath).absolutePath();
8097 | loadFile(filePath);
8098 | }
8099 |
8100 | void LuaDebuggerDialog::onReloadLuaPlugins()
8101 | {
8102 | reloadUiRequestWasEnabled_ = false;
8103 | if (!ensureUnsavedChangesHandled(tr("Reload Lua Plugins")))
8104 | {
8105 | return;
8106 | }
8107 |
8108 | // Confirmation dialog
8109 | QMessageBox::StandardButton reply = QMessageBox::question(
8110 | this, tr("Reload Lua Plugins"),
8111 | tr("Are you sure you want to reload all Lua plugins?\n\nThis will "
8112 | "restart all Lua "
8113 | "scripts and may affect capture analysis."),
8114 | QMessageBox::Yes | QMessageBox::No, QMessageBox::No);
8115 |
8116 | if (reply != QMessageBox::Yes)
8117 | {
8118 | return;
8119 | }
8120 | reloadUiRequestWasEnabled_ = wslua_debugger_is_enabled();
8121 |
8122 | /*
8123 | * If the debugger is currently paused, disable it (which continues
8124 | * execution), signal the event loop to exit, and let handlePause()
8125 | * schedule a deferred reload after the Lua call stack unwinds.
8126 | */
8127 | if (debuggerPaused)
8128 | {
8129 | wslua_debugger_notify_reload();
8130 | /* onLuaReloadCallback() has already set reloadDeferred,
8131 | * cleared paused UI, and quit the event loop. */
8132 | updateWidgets();
8133 | return;
8134 | }
8135 |
8136 | /*
8137 | * Not paused — trigger the reload directly via the delayed
8138 | * path so it runs after this dialog method returns.
8139 | */
8140 | if (mainApp)
8141 | {
8142 | mainApp->reloadLuaPluginsDelayed();
8143 | }
8144 | }
8145 |
8146 | void LuaDebuggerDialog::updateEvalPanelState()
8147 | {
8148 | const bool canEvaluate = debuggerPaused && wslua_debugger_is_paused();
8149 | evalInputEdit->setEnabled(canEvaluate);
8150 | evalButton->setEnabled(canEvaluate);
8151 |
8152 | if (!canEvaluate)
8153 | {
8154 | evalInputEdit->setPlaceholderText(
8155 | tr("Evaluation available when debugger is paused"));
8156 | }
8157 | else
8158 | {
8159 | evalInputEdit->setPlaceholderText(
8160 | tr("Enter Lua expression (prefix with = to return value)"));
8161 | }
8162 | }
8163 |
8164 | void LuaDebuggerDialog::drainPendingLogs()
8165 | {
8166 | /* Swap-and-release the queue under the mutex so further
8167 | * trampoline calls reschedule us without contending with the
8168 | * GUI-side append loop below. */
8169 | QStringList batch;
8170 | {
8171 | QMutexLocker lock(&s_logEmitMutex);
8172 | batch.swap(s_pendingLogMessages);
8173 | s_logDrainScheduled = false;
8174 | }
8175 | if (!evalOutputEdit || batch.isEmpty())
8176 | {
8177 | return;
8178 | }
8179 | /* Logpoint lines are appended verbatim — no automatic location
8180 | * prefix. Users who want the originating file or breakpoint
8181 | * line can use the {filename} / {line} template tags. */
8182 | for (const QString &m : batch)
8183 | {
8184 | evalOutputEdit->appendPlainText(m);
8185 | }
8186 |
8187 | QTextCursor cursor = evalOutputEdit->textCursor();
8188 | cursor.movePosition(QTextCursor::End);
8189 | evalOutputEdit->setTextCursor(cursor);
8190 | }
8191 |
8192 | void LuaDebuggerDialog::onEvaluate()
8193 | {
8194 | if (!debuggerPaused || !wslua_debugger_is_paused())
8195 | {
8196 | return;
8197 | }
8198 |
8199 | QString expression = evalInputEdit->toPlainText().trimmed();
8200 | if (expression.isEmpty())
8201 | {
8202 | return;
8203 | }
8204 |
8205 | char *error_msg = nullptr;
8206 | char *result =
8207 | wslua_debugger_evaluate(expression.toUtf8().constData(), &error_msg);
8208 |
8209 | QString output;
8210 | if (result)
8211 | {
8212 | output = QString::fromUtf8(result);
8213 | g_free(result);
8214 | }
8215 | else if (error_msg)
8216 | {
8217 | output = tr("Error: %1").arg(QString::fromUtf8(error_msg));
8218 | g_free(error_msg);
8219 | }
8220 | else
8221 | {
8222 | output = tr("Error: Unknown error");
8223 | }
8224 |
8225 | /* Append the prompt-style "> expr" line and the result. Each
8226 | * appendPlainText call adds a paragraph break, so the result
8227 | * lands on its own line below the echoed expression. */
8228 | evalOutputEdit->appendPlainText(QStringLiteral("> %1")(QString(QtPrivate::qMakeStringPrivate(u"" "> %1"))).arg(expression));
8229 | evalOutputEdit->appendPlainText(output);
8230 |
8231 | QTextCursor cursor = evalOutputEdit->textCursor();
8232 | cursor.movePosition(QTextCursor::End);
8233 | evalOutputEdit->setTextCursor(cursor);
8234 |
8235 | // Update all views in case the expression modified state
8236 | updateStack();
8237 | if (variablesModel)
8238 | {
8239 | variablesModel->removeRows(0, variablesModel->rowCount());
8240 | }
8241 | updateVariables(nullptr, QString());
8242 | restoreVariablesExpansionState();
8243 | refreshAvailableScripts();
8244 | refreshWatchDisplay();
8245 | }
8246 |
8247 | void LuaDebuggerDialog::onEvalClear()
8248 | {
8249 | evalInputEdit->clear();
8250 | evalOutputEdit->clear();
8251 | }
8252 |
8253 | void LuaDebuggerDialog::storeWatchList()
8254 | {
8255 | if (!watchTree)
8256 | {
8257 | return;
8258 | }
8259 | /* On disk, "watches" is a flat array of canonical watch spec strings in
8260 | * visual order. Per-row expansion, editor origin, and other runtime state
8261 | * are tracked in QStandardItem data roles only and are not persisted. */
8262 | QStringList specs;
8263 | const int n = watchModel->rowCount();
8264 | for (int i = 0; i < n; ++i)
8265 | {
8266 | QStandardItem *it = watchModel->item(i);
8267 | if (!it)
8268 | {
8269 | continue;
8270 | }
8271 | const QString spec = it->data(WatchSpecRole).toString();
8272 | if (spec.isEmpty())
8273 | {
8274 | continue;
8275 | }
8276 | specs.append(spec);
8277 | }
8278 | settings_[SettingsKeys::Watches] = specs;
8279 | /* The runtime expansion map is keyed by root spec; drop entries for
8280 | * specs that no longer exist in the tree. `storeWatchList` only runs
8281 | * when the dialog is closing, which is also the last chance to avoid
8282 | * persisting stale expansion data for specs that have since been
8283 | * deleted or renamed. */
8284 | pruneWatchExpansionMap();
8285 | }
8286 |
8287 | void LuaDebuggerDialog::storeBreakpointsList()
8288 | {
8289 | QVariantList list;
8290 | const unsigned count = wslua_debugger_get_breakpoint_count();
8291 | for (unsigned i = 0; i < count; i++)
8292 | {
8293 | const char *file = nullptr;
8294 | int64_t line = 0;
8295 | bool active = false;
8296 | const char *condition = nullptr;
8297 | int64_t hit_target = 0;
8298 | int64_t hit_count = 0; /* runtime-only; not persisted */
8299 | bool cond_err = false; /* runtime-only; not persisted */
8300 | const char *log_message = nullptr;
8301 | wslua_hit_count_mode_t hit_mode = WSLUA_HIT_COUNT_MODE_FROM;
8302 | bool log_also_pause = false;
8303 | if (!wslua_debugger_get_breakpoint_extended(
8304 | i, &file, &line, &active, &condition, &hit_target,
8305 | &hit_count, &cond_err, &log_message, &hit_mode,
8306 | &log_also_pause))
8307 | {
8308 | continue;
8309 | }
8310 | QJsonObject bp;
8311 | bp[QStringLiteral("file")(QString(QtPrivate::qMakeStringPrivate(u"" "file")))] = QString::fromUtf8(file);
8312 | bp[QStringLiteral("line")(QString(QtPrivate::qMakeStringPrivate(u"" "line")))] = static_cast<qint64>(line);
8313 | bp[QStringLiteral("active")(QString(QtPrivate::qMakeStringPrivate(u"" "active")))] = active;
8314 | /* Omit defaults so older Wireshark builds without these keys
8315 | * still round-trip the file unchanged when nothing has been
8316 | * configured. */
8317 | if (condition && condition[0])
8318 | {
8319 | bp[QStringLiteral("condition")(QString(QtPrivate::qMakeStringPrivate(u"" "condition")))] = QString::fromUtf8(condition);
8320 | }
8321 | if (hit_target > 0)
8322 | {
8323 | bp[QStringLiteral("hitCountTarget")(QString(QtPrivate::qMakeStringPrivate(u"" "hitCountTarget")) )] =
8324 | static_cast<qint64>(hit_target);
8325 | }
8326 | /* @c hitCountMode is persisted as a string ("from" / "every" /
8327 | * "once") so the JSON file is self-describing and matches the
8328 | * UI dropdown verbatim. Omit the key when the mode is the
8329 | * default @c FROM so the file stays minimal for users who
8330 | * never touch the dropdown. */
8331 | if (hit_target > 0 && hit_mode != WSLUA_HIT_COUNT_MODE_FROM)
8332 | {
8333 | const char *modeStr = "from";
| Value stored to 'modeStr' during its initialization is never read
8334 | switch (hit_mode)
8335 | {
8336 | case WSLUA_HIT_COUNT_MODE_EVERY:
8337 | modeStr = "every";
8338 | break;
8339 | case WSLUA_HIT_COUNT_MODE_ONCE:
8340 | modeStr = "once";
8341 | break;
8342 | case WSLUA_HIT_COUNT_MODE_FROM:
8343 | default:
8344 | modeStr = "from";
8345 | break;
8346 | }
8347 | bp[QStringLiteral("hitCountMode")(QString(QtPrivate::qMakeStringPrivate(u"" "hitCountMode")))] =
8348 | QString::fromLatin1(modeStr);
8349 | }
8350 | if (log_message && log_message[0])
8351 | {
8352 | bp[QStringLiteral("logMessage")(QString(QtPrivate::qMakeStringPrivate(u"" "logMessage")))] = QString::fromUtf8(log_message);
8353 | }
8354 | if (log_message && log_message[0] && log_also_pause)
8355 | {
8356 | /* Persist only when meaningful (non-empty log message AND
8357 | * non-default true) so older Wireshark builds keep
8358 | * round-tripping the file unchanged for the common case. */
8359 | bp[QStringLiteral("logAlsoPause")(QString(QtPrivate::qMakeStringPrivate(u"" "logAlsoPause")))] = true;
8360 | }
8361 | list.append(bp.toVariantMap());
8362 | }
8363 | settings_[SettingsKeys::Breakpoints] = list;
8364 | }
8365 |
8366 | void LuaDebuggerDialog::rebuildWatchTreeFromSettings()
8367 | {
8368 | if (!watchTree || !watchModel)
8369 | {
8370 | return;
8371 | }
8372 | watchModel->removeRows(0, watchModel->rowCount());
8373 | /* The tree is being repopulated from settings; any stale baselines for
8374 | * specs that end up in the tree will be rebuilt naturally on the next
8375 | * refresh. Wipe everything so a fresh session starts with no "changed"
8376 | * flags. Variables baselines are kept because they are not tied to
8377 | * watch specs. */
8378 | watchRootBaseline_.clear();
8379 | watchRootCurrent_.clear();
8380 | watchChildBaseline_.clear();
8381 | watchChildCurrent_.clear();
8382 | /* The watch list on disk is a flat array of canonical spec strings.
8383 | * Both path watches (resolved against the Variables tree) and
8384 | * expression watches (re-evaluated as Lua on every pause) round-trip
8385 | * through this list; only empty / container entries are dropped. */
8386 | const QVariantList rawList =
8387 | settings_.value(QString::fromUtf8(SettingsKeys::Watches)).toList();
8388 | for (const QVariant &entry : rawList)
8389 | {
8390 | /* Container QVariants (QVariantMap / QVariantList) toString() to an
8391 | * empty string and are dropped here. Scalar-like values (numbers,
8392 | * booleans) convert to a non-empty string and are kept as
8393 | * expression watches; they will simply produce a Lua error on
8394 | * evaluation if they are not valid expressions. */
8395 | const QString spec = entry.toString();
8396 | if (spec.isEmpty())
8397 | {
8398 | continue;
8399 | }
8400 | auto *col0 = new QStandardItem();
8401 | auto *col1 = new QStandardItem();
8402 | setupWatchRootItemFromSpec(col0, col1, spec);
8403 | watchModel->appendRow({col0, col1});
8404 | }
8405 | refreshWatchDisplay();
8406 | restoreWatchExpansionState();
8407 | }
8408 |
8409 | namespace
8410 | {
8411 | // NOLINTNEXTLINE(misc-no-recursion)
8412 | static QStandardItem *findVariableItemByPathRecursive(QStandardItem *node,
8413 | const QString &path)
8414 | {
8415 | if (!node)
8416 | {
8417 | return nullptr;
8418 | }
8419 | if (node->data(VariablePathRole).toString() == path)
8420 | {
8421 | return node;
8422 | }
8423 | const int n = node->rowCount();
8424 | for (int i = 0; i < n; ++i)
8425 | {
8426 | QStandardItem *r =
8427 | findVariableItemByPathRecursive(node->child(i), path);
8428 | if (r)
8429 | {
8430 | return r;
8431 | }
8432 | }
8433 | return nullptr;
8434 | }
8435 | } // namespace
8436 |
8437 | void LuaDebuggerDialog::deleteWatchRows(const QList<QStandardItem *> &items)
8438 | {
8439 | if (!watchModel || items.isEmpty())
8440 | {
8441 | return;
8442 | }
8443 | QVector<int> indices;
8444 | indices.reserve(items.size());
8445 | for (QStandardItem *it : items)
8446 | {
8447 | if (!it || it->parent() != nullptr)
8448 | {
8449 | continue;
8450 | }
8451 | indices.append(it->row());
8452 | }
8453 | if (indices.isEmpty())
8454 | {
8455 | return;
8456 | }
8457 | /* Delete highest-index first so earlier indices remain valid. */
8458 | std::sort(indices.begin(), indices.end(), std::greater<int>());
8459 | for (int idx : indices)
8460 | {
8461 | watchModel->removeRow(idx);
8462 | }
8463 | /* After deletion, drop baselines for specs that are no longer present
8464 | * in the tree so a later "Add Watch" of the same spec starts clean. */
8465 | pruneChangeBaselinesToLiveWatchSpecs();
8466 | refreshWatchDisplay();
8467 | }
8468 |
8469 | QList<QStandardItem *>
8470 | LuaDebuggerDialog::selectedWatchRootItemsForRemove() const
8471 | {
8472 | QList<QStandardItem *> del;
8473 | if (!watchModel || !watchTree || !watchTree->selectionModel())
8474 | {
8475 | return del;
8476 | }
8477 | for (const QModelIndex &six :
8478 | watchTree->selectionModel()->selectedRows(0))
8479 | {
8480 | QStandardItem *it = watchModel->itemFromIndex(six);
8481 | if (it && it->parent() == nullptr)
8482 | {
8483 | del.append(it);
8484 | }
8485 | }
8486 | /* Intentionally no QTreeView::currentIndex fallback: after a remove the
8487 | * selection can be empty while current still points at a row, which would
8488 | * leave the header button enabled and the next click would remove the
8489 | * wrong (non-selected) entry. The context menu and Del key have their
8490 | * own item/current handling. */
8491 | return del;
8492 | }
8493 |
8494 | void LuaDebuggerDialog::updateWatchHeaderButtonState()
8495 | {
8496 | if (watchRemoveButton_)
8497 | {
8498 | watchRemoveButton_->setEnabled(
8499 | !selectedWatchRootItemsForRemove().isEmpty());
8500 | }
8501 | if (watchRemoveAllButton_)
8502 | {
8503 | watchRemoveAllButton_->setEnabled(
8504 | watchModel && watchModel->rowCount() > 0);
8505 | }
8506 | }
8507 |
8508 | void LuaDebuggerDialog::toggleAllBreakpointsActiveFromHeader()
8509 | {
8510 | const unsigned n = wslua_debugger_get_breakpoint_count();
8511 | if (n == 0U)
8512 | {
8513 | return;
8514 | }
8515 | /* Activate all only when every BP is off; if any is on (all on or mix),
8516 | * this control shows “deactivate” and turns all off. */
8517 | bool allInactive = true;
8518 | for (unsigned i = 0; i < n; ++i)
8519 | {
8520 | const char *file_path;
8521 | int64_t line;
8522 | bool active;
8523 | if (wslua_debugger_get_breakpoint(i, &file_path, &line, &active) &&
8524 | active)
8525 | {
8526 | allInactive = false;
8527 | break;
8528 | }
8529 | }
8530 | const bool makeActive = allInactive;
8531 | for (unsigned i = 0; i < n; ++i)
8532 | {
8533 | const char *file_path;
8534 | int64_t line;
8535 | bool active;
8536 | if (wslua_debugger_get_breakpoint(i, &file_path, &line, &active))
8537 | {
8538 | wslua_debugger_set_breakpoint_active(file_path, line, makeActive);
8539 | }
8540 | }
8541 | updateBreakpoints();
8542 | const qint32 tabCount = static_cast<qint32>(ui->codeTabWidget->count());
8543 | for (qint32 tabIndex = 0; tabIndex < tabCount; ++tabIndex)
8544 | {
8545 | LuaDebuggerCodeView *const tabView = qobject_cast<LuaDebuggerCodeView *>(
8546 | ui->codeTabWidget->widget(static_cast<int>(tabIndex)));
8547 | if (tabView)
8548 | {
8549 | tabView->updateBreakpointMarkers();
8550 | }
8551 | }
8552 | }
8553 |
8554 | void LuaDebuggerDialog::updateBreakpointHeaderButtonState()
8555 | {
8556 | if (breakpointHeaderToggleButton_)
8557 | {
8558 | const int side = std::max(breakpointHeaderToggleButton_->height(),
8559 | breakpointHeaderToggleButton_->width());
8560 | const qreal dpr = breakpointHeaderToggleButton_->devicePixelRatioF();
8561 | LuaDebuggerCodeView *const cv = currentCodeView();
8562 | const QFont *const editorFont =
8563 | (cv && !cv->getFilename().isEmpty()) ? &cv->font() : nullptr;
8564 | const unsigned n = wslua_debugger_get_breakpoint_count();
8565 | bool allInactive = n > 0U;
8566 | for (unsigned i = 0; allInactive && i < n; ++i)
8567 | {
8568 | const char *file_path;
8569 | int64_t line;
8570 | bool active;
8571 | if (wslua_debugger_get_breakpoint(i, &file_path, &line, &active))
8572 | {
8573 | if (active)
8574 | {
8575 | allInactive = false;
8576 | }
8577 | }
8578 | }
8579 | LuaDbgBpHeaderIconMode mode;
8580 | const QString tglLineKeys =
8581 | kCtxToggleBreakpoint.toString(QKeySequence::NativeText);
8582 | if (n == 0U)
8583 | {
8584 | mode = LuaDbgBpHeaderIconMode::NoBreakpoints;
8585 | breakpointHeaderToggleButton_->setEnabled(false);
8586 | breakpointHeaderToggleButton_->setToolTip(
8587 | tr("No breakpoints\n%1: add or remove breakpoint on the current "
8588 | "line in the editor")
8589 | .arg(tglLineKeys));
8590 | }
8591 | else if (allInactive)
8592 | {
8593 | /* All BPs off: dot is gray (mirrors gutter); click activates all. */
8594 | mode = LuaDbgBpHeaderIconMode::ActivateAll;
8595 | breakpointHeaderToggleButton_->setEnabled(true);
8596 | breakpointHeaderToggleButton_->setToolTip(
8597 | tr("All breakpoints are inactive — click to activate all\n"
8598 | "%1: add or remove on the current line in the editor")
8599 | .arg(tglLineKeys));
8600 | }
8601 | else
8602 | {
8603 | /* Any BP on (all-on or mix): dot is red (mirrors gutter); click
8604 | * deactivates all. */
8605 | mode = LuaDbgBpHeaderIconMode::DeactivateAll;
8606 | breakpointHeaderToggleButton_->setEnabled(true);
8607 | breakpointHeaderToggleButton_->setToolTip(
8608 | tr("Click to deactivate all breakpoints\n"
8609 | "%1: add or remove on the current line in the editor")
8610 | .arg(tglLineKeys));
8611 | }
8612 | /* Cache the three icons keyed by (font, side, dpr); cursor moves
8613 | * fire updateBreakpointHeaderButtonState() frequently and only the
8614 | * mode actually varies on hot paths. */
8615 | const QString cacheKey =
8616 | QStringLiteral("%1/%2/%3")(QString(QtPrivate::qMakeStringPrivate(u"" "%1/%2/%3")))
8617 | .arg(editorFont != nullptr ? editorFont->key()
8618 | : QGuiApplication::font().key())
8619 | .arg(side)
8620 | .arg(dpr);
8621 | if (cacheKey != bpHeaderIconCacheKey_)
8622 | {
8623 | bpHeaderIconCacheKey_ = cacheKey;
8624 | for (QIcon &cached : bpHeaderIconCache_)
8625 | {
8626 | cached = QIcon();
8627 | }
8628 | }
8629 | const int modeIdx = static_cast<int>(mode);
8630 | if (bpHeaderIconCache_[modeIdx].isNull())
8631 | {
8632 | bpHeaderIconCache_[modeIdx] =
8633 | luaDbgBreakpointHeaderIconForMode(editorFont, mode, side, dpr);
8634 | }
8635 | breakpointHeaderToggleButton_->setIcon(bpHeaderIconCache_[modeIdx]);
8636 | }
8637 | /* The Edit and Remove header buttons share enable state: both act on
8638 | * the breakpoint row(s) the user has selected, so a selection-only
8639 | * gate keeps them visually and behaviourally in lockstep. Edit only
8640 | * ever opens one editor (the current/first-selected row); the click
8641 | * handler resolves a single row internally, and
8642 | * startInlineBreakpointEdit() is a no-op on stale (file-missing)
8643 | * rows, so we don't need to inspect editability here. */
8644 | QItemSelectionModel *const bpSelectionModel =
8645 | breakpointsTree ? breakpointsTree->selectionModel() : nullptr;
8646 | const bool hasBreakpointSelection =
8647 | bpSelectionModel && !bpSelectionModel->selectedRows().isEmpty();
8648 | if (breakpointHeaderRemoveButton_)
8649 | {
8650 | breakpointHeaderRemoveButton_->setEnabled(hasBreakpointSelection);
8651 | }
8652 | if (breakpointHeaderEditButton_)
8653 | {
8654 | breakpointHeaderEditButton_->setEnabled(hasBreakpointSelection);
8655 | }
8656 | if (breakpointHeaderRemoveAllButton_)
8657 | {
8658 | const bool hasBreakpoints =
8659 | breakpointsModel && breakpointsModel->rowCount() > 0;
8660 | breakpointHeaderRemoveAllButton_->setEnabled(hasBreakpoints);
8661 | if (actionRemoveAllBreakpoints_)
8662 | {
8663 | actionRemoveAllBreakpoints_->setEnabled(hasBreakpoints);
8664 | }
8665 | }
8666 | }
8667 |
8668 | QStandardItem *
8669 | LuaDebuggerDialog::findVariablesItemByPath(const QString &path) const
8670 | {
8671 | if (!variablesTree || path.isEmpty())
8672 | {
8673 | return nullptr;
8674 | }
8675 | const int top = variablesModel->rowCount();
8676 | for (int i = 0; i < top; ++i)
8677 | {
8678 | QStandardItem *r =
8679 | findVariableItemByPathRecursive(variablesModel->item(i, 0),
8680 | path);
8681 | if (r)
8682 | {
8683 | return r;
8684 | }
8685 | }
8686 | return nullptr;
8687 | }
8688 |
8689 | QStandardItem *
8690 | LuaDebuggerDialog::findWatchRootForVariablePath(const QString &path) const
8691 | {
8692 | if (!watchTree || path.isEmpty())
8693 | {
8694 | return nullptr;
8695 | }
8696 | const int n = watchModel->rowCount();
8697 | for (int i = 0; i < n; ++i)
8698 | {
8699 | QStandardItem *w = watchModel->item(i, 0);
8700 | const QString spec = w->data(WatchSpecRole).toString();
8701 | QString vp = watchResolvedVariablePathForTooltip(spec);
8702 | if (vp.isEmpty())
8703 | {
8704 | vp = watchVariablePathForSpec(spec);
8705 | }
8706 | if (!vp.isEmpty() && vp == path)
8707 | {
8708 | return w;
8709 | }
8710 | if (w->data(VariablePathRole).toString() == path)
8711 | {
8712 | return w;
8713 | }
8714 | }
8715 | return nullptr;
8716 | }
8717 |
8718 | void LuaDebuggerDialog::expandAncestorsOf(QTreeView *tree,
8719 | QStandardItemModel *model,
8720 | QStandardItem *item)
8721 | {
8722 | if (!tree || !model || !item)
8723 | {
8724 | return;
8725 | }
8726 | QList<QStandardItem *> chain;
8727 | for (QStandardItem *p = item->parent(); p; p = p->parent())
8728 | {
8729 | chain.prepend(p);
8730 | }
8731 | for (QStandardItem *a : chain)
8732 | {
8733 | const QModelIndex ix = model->indexFromItem(a);
8734 | if (ix.isValid())
8735 | {
8736 | tree->setExpanded(ix, true);
8737 | }
8738 | }
8739 | }
8740 |
8741 | void LuaDebuggerDialog::onVariablesCurrentItemChanged(
8742 | const QModelIndex &current, const QModelIndex &previous)
8743 | {
8744 | Q_UNUSED(previous)(void)previous;;
8745 | if (syncWatchVariablesSelection_ || !watchTree || !watchModel ||
8746 | !variablesTree || !variablesModel || !current.isValid())
8747 | {
8748 | return;
8749 | }
8750 | QStandardItem *curItem =
8751 | variablesModel->itemFromIndex(current.sibling(current.row(), 0));
8752 | QStandardItem *watch = nullptr;
8753 | if (curItem)
8754 | {
8755 | const QString path = curItem->data(VariablePathRole).toString();
8756 | if (!path.isEmpty())
8757 | {
8758 | watch = findWatchRootForVariablePath(path);
8759 | }
8760 | }
8761 | syncWatchVariablesSelection_ = true;
8762 | if (watch)
8763 | {
8764 | const QModelIndex wix = watchModel->indexFromItem(watch);
8765 | watchTree->setCurrentIndex(wix);
8766 | watchTree->scrollTo(wix);
8767 | }
8768 | else if (QItemSelectionModel *sm = watchTree->selectionModel())
8769 | {
8770 | /* No matching watch for this Variables row — clear the stale
8771 | * Watch selection so the two trees stay in sync. */
8772 | sm->clearSelection();
8773 | sm->setCurrentIndex(QModelIndex(), QItemSelectionModel::Clear);
8774 | }
8775 | syncWatchVariablesSelection_ = false;
8776 | }
8777 |
8778 | void LuaDebuggerDialog::syncVariablesTreeToCurrentWatch()
8779 | {
8780 | if (syncWatchVariablesSelection_ || !watchTree || !variablesTree)
8781 | {
8782 | return;
8783 | }
8784 | const QModelIndex curIx = watchTree->currentIndex();
8785 | QStandardItem *const cur =
8786 | watchModel
8787 | ? watchModel->itemFromIndex(curIx.sibling(curIx.row(), 0))
8788 | : nullptr;
8789 | QStandardItem *v = nullptr;
8790 | if (cur && cur->parent() == nullptr)
8791 | {
8792 | const QString spec = cur->data(WatchSpecRole).toString();
8793 | if (!spec.isEmpty())
8794 | {
8795 | QString path = cur->data(VariablePathRole).toString();
8796 | if (path.isEmpty())
8797 | {
8798 | path = watchResolvedVariablePathForTooltip(spec);
8799 | if (path.isEmpty())
8800 | {
8801 | path = watchVariablePathForSpec(spec);
8802 | }
8803 | }
8804 | if (!path.isEmpty())
8805 | {
8806 | v = findVariablesItemByPath(path);
8807 | }
8808 | }
8809 | }
8810 | syncWatchVariablesSelection_ = true;
8811 | if (v)
8812 | {
8813 | expandAncestorsOf(variablesTree, variablesModel, v);
8814 | const QModelIndex vix = variablesModel->indexFromItem(v);
8815 | variablesTree->setCurrentIndex(vix);
8816 | variablesTree->scrollTo(vix);
8817 | }
8818 | else if (QItemSelectionModel *sm = variablesTree->selectionModel())
8819 | {
8820 | /* No matching Variables row for the current watch — clear the
8821 | * stale Variables selection so the two trees stay in sync. */
8822 | sm->clearSelection();
8823 | sm->setCurrentIndex(QModelIndex(), QItemSelectionModel::Clear);
8824 | }
8825 | syncWatchVariablesSelection_ = false;
8826 | }
8827 |
8828 | void LuaDebuggerDialog::onWatchCurrentItemChanged(const QModelIndex &current,
8829 | const QModelIndex &previous)
8830 | {
8831 | Q_UNUSED(previous)(void)previous;;
8832 | if (syncWatchVariablesSelection_ || !watchTree || !watchModel ||
8833 | !variablesTree || !current.isValid())
8834 | {
8835 | return;
8836 | }
8837 | QStandardItem *rowItem =
8838 | watchModel->itemFromIndex(current.sibling(current.row(), 0));
8839 | const QString spec =
8840 | (rowItem && rowItem->parent() == nullptr)
8841 | ? rowItem->data(WatchSpecRole).toString()
8842 | : QString();
8843 |
8844 | if (!spec.isEmpty())
8845 | {
8846 | const bool live = wslua_debugger_is_enabled() && debuggerPaused &&
8847 | wslua_debugger_is_paused();
8848 | if (live)
8849 | {
8850 | const int32_t desired =
8851 | wslua_debugger_find_stack_level_for_watch_spec(
8852 | spec.toUtf8().constData());
8853 | if (desired >= 0 && desired != stackSelectionLevel)
8854 | {
8855 | stackSelectionLevel = static_cast<int>(desired);
8856 | wslua_debugger_set_variable_stack_level(desired);
8857 | refreshVariablesForCurrentStackFrame();
8858 | updateStack();
8859 | }
8860 | }
8861 | }
8862 |
8863 | /* Always sync: when the current watch has no resolvable path, the
8864 | * helper clears the stale Variables selection. */
8865 | syncVariablesTreeToCurrentWatch();
8866 | }
8867 |
8868 | namespace
8869 | {
8870 | /**
8871 | * Blend two RGB colors by @a alpha (0 = all @a base, 255 = all @a accent).
8872 | * Used to build a theme-aware transient flash background that sits at a
8873 | * low opacity over the view's base color so it does not overpower the
8874 | * text or the selection highlight.
8875 | */
8876 | static QColor blendRgb(const QColor &base, const QColor &accent, int alpha)
8877 | {
8878 | const int a = std::max(0, std::min(255, alpha));
8879 | const int inv = 255 - a;
8880 | return QColor::fromRgb(
8881 | (base.red() * inv + accent.red() * a) / 255,
8882 | (base.green() * inv + accent.green() * a) / 255,
8883 | (base.blue() * inv + accent.blue() * a) / 255);
8884 | }
8885 | } // namespace
8886 |
8887 | void LuaDebuggerDialog::refreshChangedValueBrushes()
8888 | {
8889 | /* Bold accent matches application link color (see ColorUtils::themeLinkBrush).
8890 | * Flash still blends the watch tree Base + Highlight for row-local context. */
8891 | QPalette pal = palette();
8892 | if (watchTree)
8893 | {
8894 | pal = watchTree->palette();
8895 | }
8896 |
8897 | QColor accent = ColorUtils::themeLinkBrush().color();
8898 | if (!accent.isValid())
8899 | {
8900 | accent = QApplication::palette().color(QPalette::Highlight);
8901 | }
8902 | if (!accent.isValid())
8903 | {
8904 | accent = QColor(0x1F, 0x6F, 0xEB); // reasonable fallback
8905 | }
8906 | changedValueBrush_ = QBrush(accent);
8907 |
8908 | const QColor base = pal.color(QPalette::Base);
8909 | const QColor hi = pal.color(QPalette::Highlight);
8910 | /* ~20% opacity mix feels visible on light themes and doesn't wash out
8911 | * text on dark themes. Pre-blend with Base so the resulting solid color
8912 | * renders the same regardless of whether the style composites alpha. */
8913 | changedFlashBrush_ = QBrush(blendRgb(base, hi, 50));
8914 | }
8915 |
8916 | void LuaDebuggerDialog::snapshotBaselinesOnPauseEntry()
8917 | {
8918 | /* Rotate Current → Baseline for every changed-tracking map, then clear
8919 | * Current. The paint helpers will repopulate Current with this pause's
8920 | * displayed values as they run. Missing keys mean "no baseline yet"
8921 | * and deliberately do not light up as "changed" on the first sighting. */
8922 | watchRootBaseline_ = std::move(watchRootCurrent_);
8923 | watchRootCurrent_.clear();
8924 | watchChildBaseline_ = std::move(watchChildCurrent_);
8925 | watchChildCurrent_.clear();
8926 | variablesBaseline_ = std::move(variablesCurrent_);
8927 | variablesCurrent_.clear();
8928 | /* Rotate the parent-visited sets in lockstep with the value maps;
8929 | * the gate they feed (parent visited last pause?) is meaningful only
8930 | * relative to the same rotation boundary. */
8931 | variablesBaselineParents_ = std::move(variablesCurrentParents_);
8932 | variablesCurrentParents_.clear();
8933 | watchChildBaselineParents_ = std::move(watchChildCurrentParents_);
8934 | watchChildCurrentParents_.clear();
8935 | }
8936 |
8937 | void LuaDebuggerDialog::updatePauseEntryFrameIdentity()
8938 | {
8939 | /* Compute "<source>:<linedefined>" for the function at frame 0 and
8940 | * compare against the identity captured at the previous pause. The
8941 | * baseline rotation just done in snapshotBaselinesOnPauseEntry() keys
8942 | * Locals/Upvalues at numeric stack level 0 — that is meaningful only
8943 | * if frame 0 is still the same Lua function. After a call or return
8944 | * it is a different function whose locals never lived under those
8945 | * keys, so changeHighlightAllowed() must report false for one pause
8946 | * (Globals are unaffected; they are anchored to level=-1).
8947 | *
8948 | * Self-correcting: painting still runs, so the next pause's rotate
8949 | * will leave Baseline holding values that match the new function. */
8950 | int32_t frameCount = 0;
8951 | wslua_stack_frame_t *stack = wslua_debugger_get_stack(&frameCount);
8952 |
8953 | QString newIdentity;
8954 | if (stack && frameCount > 0)
8955 | {
8956 | const char *src = stack[0].source ? stack[0].source : "";
8957 | newIdentity =
8958 | QStringLiteral("%1:%2")(QString(QtPrivate::qMakeStringPrivate(u"" "%1:%2"))).arg(QString::fromUtf8(src)).arg(
8959 | static_cast<qlonglong>(stack[0].linedefined));
8960 | }
8961 | if (stack)
8962 | {
8963 | wslua_debugger_free_stack(stack, frameCount);
8964 | }
8965 |
8966 | /* Empty newIdentity (no frames at all — should not happen at a real
8967 | * pause, but be defensive) is treated as "different from anything",
8968 | * so the cue is suppressed. The match flag is also false on the very
8969 | * first pause because pauseEntryFrame0Identity_ starts empty; that is
8970 | * harmless because the baselines are empty too. */
8971 | pauseEntryFrame0MatchesPrev_ =
8972 | !newIdentity.isEmpty() && newIdentity == pauseEntryFrame0Identity_;
8973 | pauseEntryFrame0Identity_ = newIdentity;
8974 | }
8975 |
8976 | namespace
8977 | {
8978 | /**
8979 | * Collect every column-cell in the same row as @p anchor (inclusive).
8980 | * Works for both top-level rows (anchor->parent() == nullptr) and child
8981 | * rows. Cells with different models, or missing columns, are skipped.
8982 | */
8983 | static QVector<QStandardItem *> rowCellsFor(QStandardItem *anchor)
8984 | {
8985 | QVector<QStandardItem *> out;
8986 | if (!anchor)
8987 | {
8988 | return out;
8989 | }
8990 | auto *model = qobject_cast<QStandardItemModel *>(anchor->model());
8991 | if (!model)
8992 | {
8993 | return out;
8994 | }
8995 | const int cols = model->columnCount();
8996 | QStandardItem *parent = anchor->parent();
8997 | const int row = anchor->row();
8998 | for (int c = 0; c < cols; ++c)
8999 | {
9000 | QStandardItem *cell = parent ? parent->child(row, c)
9001 | : model->item(row, c);
9002 | if (cell)
9003 | {
9004 | out.append(cell);
9005 | }
9006 | }
9007 | return out;
9008 | }
9009 |
9010 | /**
9011 | * Schedule a one-shot clear for @p cell tagged with @p serial. The clear
9012 | * only runs if the cell's current serial still matches, so a newer flash
9013 | * installed on the same cell is not wiped by a stale timer.
9014 | */
9015 | static void scheduleFlashClear(QObject *owner, QStandardItem *cell,
9016 | qint32 serial, int delayMs)
9017 | {
9018 | if (!cell || !cell->model())
9019 | {
9020 | return;
9021 | }
9022 | QPointer<QAbstractItemModel> modelGuard(cell->model());
9023 | const QPersistentModelIndex pix(cell->index());
9024 | QTimer::singleShot(delayMs, owner, [modelGuard, pix, serial]() {
9025 | if (!modelGuard || !pix.isValid())
9026 | {
9027 | return;
9028 | }
9029 | auto *sim =
9030 | qobject_cast<QStandardItemModel *>(modelGuard.data());
9031 | if (!sim)
9032 | {
9033 | return;
9034 | }
9035 | QStandardItem *c = sim->itemFromIndex(pix);
9036 | if (!c)
9037 | {
9038 | return;
9039 | }
9040 | if (c->data(ChangedFlashSerialRole).toInt() != serial)
9041 | {
9042 | return;
9043 | }
9044 | c->setBackground(QBrush());
9045 | c->setData(QVariant(), ChangedFlashSerialRole);
9046 | });
9047 | }
9048 | } // namespace
9049 |
9050 | void LuaDebuggerDialog::applyChangedVisuals(QStandardItem *anchor,
9051 | bool changed,
9052 | bool isPauseEntryRefresh)
9053 | {
9054 | if (!anchor)
9055 | {
9056 | return;
9057 | }
9058 |
9059 | const QVector<QStandardItem *> cells = rowCellsFor(anchor);
9060 | if (cells.isEmpty())
9061 | {
9062 | return;
9063 | }
9064 |
9065 | if (changed)
9066 | {
9067 | /* One serial per row-flash; every cell in the row tags itself with
9068 | * the same serial so a re-flash on this row cleanly supersedes the
9069 | * previous row-flash's pending timers. */
9070 | const qint32 serial =
9071 | isPauseEntryRefresh ? ++flashSerial_ : 0;
9072 | for (QStandardItem *cell : cells)
9073 | {
9074 | QFont f = cell->font();
9075 | f.setBold(true);
9076 | cell->setFont(f);
9077 | cell->setForeground(changedValueBrush_);
9078 | if (isPauseEntryRefresh)
9079 | {
9080 | cell->setData(serial, ChangedFlashSerialRole);
9081 | cell->setBackground(changedFlashBrush_);
9082 | scheduleFlashClear(this, cell, serial, CHANGED_FLASH_MS);
9083 | }
9084 | }
9085 | }
9086 | else
9087 | {
9088 | /* Clear ONLY the change-specific visuals (bold, and any flash this
9089 | * helper installed). Leave the caller-managed foreground /
9090 | * background untouched so error chrome (red) and the no-live-
9091 | * context placeholder coloring survive. A pending flash timer is
9092 | * cancelled by invalidating the serial. */
9093 | for (QStandardItem *cell : cells)
9094 | {
9095 | QFont f = cell->font();
9096 | f.setBold(false);
9097 | cell->setFont(f);
9098 | if (cell->data(ChangedFlashSerialRole).isValid())
9099 | {
9100 | cell->setData(QVariant(), ChangedFlashSerialRole);
9101 | cell->setBackground(QBrush());
9102 | }
9103 | }
9104 | }
9105 | }
9106 |
9107 | void LuaDebuggerDialog::clearAllChangeBaselines()
9108 | {
9109 | watchRootBaseline_.clear();
9110 | watchRootCurrent_.clear();
9111 | watchChildBaseline_.clear();
9112 | watchChildCurrent_.clear();
9113 | variablesBaseline_.clear();
9114 | variablesCurrent_.clear();
9115 | variablesBaselineParents_.clear();
9116 | variablesCurrentParents_.clear();
9117 | watchChildBaselineParents_.clear();
9118 | watchChildCurrentParents_.clear();
9119 | /* The frame-0 identity gate is part of the same "comparable across
9120 | * pauses" contract: a debugger toggle or Lua reload also breaks that
9121 | * contract, and starting the next session by comparing against a
9122 | * stale identity would suppress the cue on the very first pause for
9123 | * no reason. */
9124 | pauseEntryFrame0Identity_.clear();
9125 | pauseEntryFrame0MatchesPrev_ = false;
9126 | }
9127 |
9128 | void LuaDebuggerDialog::clearChangeBaselinesForWatchSpec(const QString &spec)
9129 | {
9130 | if (spec.isEmpty())
9131 | {
9132 | return;
9133 | }
9134 | const auto matches = [&spec](const QString &key)
9135 | {
9136 | return watchSpecFromChangeKey(key) == spec;
9137 | };
9138 | for (auto *m : {&watchRootBaseline_, &watchRootCurrent_})
9139 | {
9140 | for (auto it = m->begin(); it != m->end();)
9141 | {
9142 | if (matches(it.key()))
9143 | {
9144 | it = m->erase(it);
9145 | }
9146 | else
9147 | {
9148 | ++it;
9149 | }
9150 | }
9151 | }
9152 | for (auto *m : {&watchChildBaseline_, &watchChildCurrent_})
9153 | {
9154 | for (auto it = m->begin(); it != m->end();)
9155 | {
9156 | if (matches(it.key()))
9157 | {
9158 | it = m->erase(it);
9159 | }
9160 | else
9161 | {
9162 | ++it;
9163 | }
9164 | }
9165 | }
9166 | for (auto *m : {&watchChildBaselineParents_, &watchChildCurrentParents_})
9167 | {
9168 | for (auto it = m->begin(); it != m->end();)
9169 | {
9170 | if (matches(it.key()))
9171 | {
9172 | it = m->erase(it);
9173 | }
9174 | else
9175 | {
9176 | ++it;
9177 | }
9178 | }
9179 | }
9180 | }
9181 |
9182 | void LuaDebuggerDialog::pruneChangeBaselinesToLiveWatchSpecs()
9183 | {
9184 | if (!watchModel)
9185 | {
9186 | return;
9187 | }
9188 | QSet<QString> liveSpecs;
9189 | const int n = watchModel->rowCount();
9190 | for (int i = 0; i < n; ++i)
9191 | {
9192 | const QStandardItem *it = watchModel->item(i);
9193 | if (!it)
9194 | {
9195 | continue;
9196 | }
9197 | const QString spec = it->data(WatchSpecRole).toString();
9198 | if (!spec.isEmpty())
9199 | {
9200 | liveSpecs.insert(spec);
9201 | }
9202 | }
9203 | const auto pruneMap = [&](auto &m)
9204 | {
9205 | for (auto it = m.begin(); it != m.end();)
9206 | {
9207 | if (!liveSpecs.contains(watchSpecFromChangeKey(it.key())))
9208 | {
9209 | it = m.erase(it);
9210 | }
9211 | else
9212 | {
9213 | ++it;
9214 | }
9215 | }
9216 | };
9217 | pruneMap(watchRootBaseline_);
9218 | pruneMap(watchRootCurrent_);
9219 | pruneMap(watchChildBaseline_);
9220 | pruneMap(watchChildCurrent_);
9221 | pruneMap(watchChildBaselineParents_);
9222 | pruneMap(watchChildCurrentParents_);
9223 | }
9224 |
9225 | void LuaDebuggerDialog::refreshWatchDisplay()
9226 | {
9227 | if (!watchTree)
9228 | {
9229 | return;
9230 | }
9231 | const bool liveContext = wslua_debugger_is_enabled() && debuggerPaused &&
9232 | wslua_debugger_is_paused();
9233 | const QString muted = QStringLiteral("\u2014")(QString(QtPrivate::qMakeStringPrivate(u"" "\u2014")));
9234 | const int n = watchModel->rowCount();
9235 | for (int i = 0; i < n; ++i)
9236 | {
9237 | QStandardItem *root = watchModel->item(i);
9238 | applyWatchItemState(root, liveContext, muted);
9239 | if (liveContext && root &&
9240 | LuaDebuggerItems::isExpanded(watchTree, watchModel, root))
9241 | {
9242 | refreshWatchBranch(root);
9243 | }
9244 | }
9245 | }
9246 |
9247 | void LuaDebuggerDialog::applyWatchItemEmpty(QStandardItem *item,
9248 | const QString &muted,
9249 | const QString &watchTipExtra)
9250 | {
9251 | if (!watchModel)
9252 | {
9253 | return;
9254 | }
9255 | clearWatchFilterErrorChrome(item, watchTree);
9256 | setText(watchModel, item, 1, muted);
9257 | item->setToolTip(watchTipExtra);
9258 | /* Explain the muted em dash instead of leaving an empty tooltip: a blank
9259 | * row has no variable path to evaluate, so there is nothing to show in
9260 | * the Value column. */
9261 | LuaDebuggerItems::setToolTip(
9262 | watchModel, item, 1,
9263 | capWatchTooltipText(
9264 | tr("Enter a variable path (e.g. Locals.x, Globals.t.k) or a "
9265 | "Lua expression in the Watch column to see a value here.")));
9266 | applyChangedVisuals(item,
9267 | /*changed=*/false, /*isPauseEntryRefresh=*/false);
9268 | while (item->rowCount() > 0)
9269 | {
9270 | item->removeRow(0);
9271 | }
9272 | }
9273 |
9274 | void LuaDebuggerDialog::applyWatchItemNoLiveContext(QStandardItem *item,
9275 | const QString &muted,
9276 | const QString &watchTipExtra)
9277 | {
9278 | if (!watchModel || !watchTree)
9279 | {
9280 | return;
9281 | }
9282 | setText(watchModel, item, 1, muted);
9283 | LuaDebuggerItems::setForeground(watchModel, item, 1,
9284 | watchTree->palette().brush(
9285 | QPalette::PlaceholderText));
9286 | /* Replace the previous `muted \n Type: muted` tooltip (which just
9287 | * repeated the em dash) with a short explanation so the user knows
9288 | * *why* there is no value: watches are only evaluated while the
9289 | * debugger is paused. */
9290 | const QString mutedReason =
9291 | wslua_debugger_is_enabled()
9292 | ? tr("Value shown only while the debugger is paused.")
9293 | : tr("Value shown only while the debugger is paused. "
9294 | "The debugger is currently disabled.");
9295 | const QString ttSuf = tr("Type: %1").arg(muted);
9296 | item->setToolTip(
9297 | capWatchTooltipText(
9298 | QStringLiteral("%1\n%2\n%3")(QString(QtPrivate::qMakeStringPrivate(u"" "%1\n%2\n%3")))
9299 | .arg(item->text(), mutedReason, ttSuf) +
9300 | watchTipExtra));
9301 | LuaDebuggerItems::setToolTip(watchModel, item, 1,
9302 | capWatchTooltipText(mutedReason));
9303 | /* Clear the accent/bold/flash but do NOT touch the baseline maps:
9304 | * bold-on-change must survive resume → pause cycles so the next pause
9305 | * can compare against the value displayed at the end of this pause.
9306 | * applyChangedVisuals(false) only unbolds; it leaves the caller's
9307 | * foreground / background intact, so the placeholder brush set above
9308 | * and the normal column-0 text stay as the caller wants. */
9309 | applyChangedVisuals(item,
9310 | /*changed=*/false, /*isPauseEntryRefresh=*/false);
9311 | /* A previous pause may have left the Watch-column (col 0) foreground
9312 | * set to the accent. Reset it to the default text color so the spec
9313 | * looks normal while unpaused. */
9314 | LuaDebuggerItems::setForeground(
9315 | watchModel, item, 0,
9316 | watchTree->palette().brush(QPalette::Text));
9317 | if (item->parent() == nullptr)
9318 | {
9319 | while (item->rowCount() > 0)
9320 | {
9321 | item->removeRow(0);
9322 | }
9323 | }
9324 | }
9325 |
9326 | void LuaDebuggerDialog::applyWatchItemError(QStandardItem *item,
9327 | const QString &errStr,
9328 | const QString &watchTipExtra)
9329 | {
9330 | if (!watchModel)
9331 | {
9332 | return;
9333 | }
9334 | applyWatchFilterErrorChrome(item, watchTree);
9335 | /* The cell text shows a tidied error: for expression watches the
9336 | * synthetic "watch:N: " prefix is stripped (the row's red chrome
9337 | * already says "watch failed", and the chunk is always one line);
9338 | * path-watch errors pass through unchanged. The tooltip below keeps
9339 | * the full untouched string for diagnostics. */
9340 | const QString cellErrStr = stripWatchExpressionErrorPrefix(errStr);
9341 | setText(watchModel, item, 1, cellErrStr);
9342 | const QString ttSuf = tr("Type: %1").arg(tr("error"));
9343 | item->setToolTip(
9344 | capWatchTooltipText(
9345 | QStringLiteral("%1\n%2")(QString(QtPrivate::qMakeStringPrivate(u"" "%1\n%2"))).arg(item->text(), ttSuf) + watchTipExtra));
9346 | /* "Could not evaluate watch" works for both flavors: a path watch
9347 | * fails to resolve or an expression watch fails to compile / runs
9348 | * into a Lua error. The detailed @a errStr below carries the
9349 | * specific reason (e.g. "Path not found", "watch:1: ...") so the
9350 | * generic header just acknowledges that a value could not be read. */
9351 | LuaDebuggerItems::setToolTip(
9352 | watchModel, item, 1,
9353 | capWatchTooltipText(
9354 | QStringLiteral("%1\n%2\n%3")(QString(QtPrivate::qMakeStringPrivate(u"" "%1\n%2\n%3")))
9355 | .arg(tr("Could not evaluate watch."), errStr, ttSuf)));
9356 | applyChangedVisuals(item,
9357 | /*changed=*/false, /*isPauseEntryRefresh=*/false);
9358 | /* An error invalidates the comparison: drop baselines for this root so
9359 | * the next successful evaluation does not flag a change vs the pre-error
9360 | * value. */
9361 | if (item->parent() == nullptr)
9362 | {
9363 | const QString spec = item->data(WatchSpecRole).toString();
9364 | if (!spec.isEmpty())
9365 | {
9366 | clearChangeBaselinesForWatchSpec(spec);
9367 | }
9368 | }
9369 | while (item->rowCount() > 0)
9370 | {
9371 | item->removeRow(0);
9372 | }
9373 | }
9374 |
9375 | void LuaDebuggerDialog::applyWatchItemSuccess(QStandardItem *item,
9376 | const QString &spec,
9377 | const char *val, const char *typ,
9378 | bool can_expand,
9379 | const QString &watchTipExtra)
9380 | {
9381 | if (item->parent() == nullptr)
9382 | {
9383 | watchRootSetVariablePathRoleFromSpec(item, spec);
9384 | }
9385 | if (!watchModel)
9386 | {
9387 | return;
9388 | }
9389 | const QString v = val ? QString::fromUtf8(val) : QString();
9390 | const QString typStr = typ ? QString::fromUtf8(typ) : QString();
9391 | setText(watchModel, item, 1, v);
9392 | const QString ttSuf =
9393 | typStr.isEmpty() ? QString() : tr("Type: %1").arg(typStr);
9394 | item->setToolTip(
9395 | capWatchTooltipText(
9396 | (ttSuf.isEmpty()
9397 | ? item->text()
9398 | : QStringLiteral("%1\n%2")(QString(QtPrivate::qMakeStringPrivate(u"" "%1\n%2"))).arg(item->text(), ttSuf)) +
9399 | watchTipExtra));
9400 | LuaDebuggerItems::setToolTip(
9401 | watchModel, item, 1,
9402 | capWatchTooltipText(
9403 | ttSuf.isEmpty() ? v : QStringLiteral("%1\n%2")(QString(QtPrivate::qMakeStringPrivate(u"" "%1\n%2"))).arg(v, ttSuf)));
9404 | /* Only watch roots are routed through applyWatchItemSuccess; children go
9405 | * through applyWatchChildRowTextAndTooltip + applyChangedVisuals inside
9406 | * fillWatchPathChildren. The Globals branch is excluded from
9407 | * changeHighlightAllowed() because it is anchored to level=-1 and
9408 | * therefore stays comparable across stack-frame switches. */
9409 | const bool isGlobal = watchSpecIsGlobalScoped(spec);
9410 | const int level = isGlobal ? -1 : stackSelectionLevel;
9411 | const QString rk = changeKey(level, spec);
9412 | const bool changed = (isGlobal || changeHighlightAllowed()) &&
9413 | shouldMarkChanged(watchRootBaseline_, rk, v);
9414 | applyChangedVisuals(item, changed, isPauseEntryRefresh_);
9415 | watchRootCurrent_[rk] = v;
9416 |
9417 | if (can_expand)
9418 | {
9419 | if (item->rowCount() == 0)
9420 | {
9421 | QStandardItem *const ph0 = new QStandardItem();
9422 | QStandardItem *const ph1 = new QStandardItem();
9423 | ph0->setFlags(Qt::ItemIsEnabled);
9424 | ph1->setFlags(Qt::ItemIsEnabled);
9425 | item->appendRow({ph0, ph1});
9426 | }
9427 | }
9428 | else
9429 | {
9430 | while (item->rowCount() > 0)
9431 | {
9432 | item->removeRow(0);
9433 | }
9434 | }
9435 | }
9436 |
9437 | void LuaDebuggerDialog::applyWatchItemExpression(
9438 | QStandardItem *item, const QString &spec, const char *val,
9439 | const char *typ, bool can_expand, const QString &watchTipExtra)
9440 | {
9441 | if (item->parent() == nullptr)
9442 | {
9443 | /* Expression watches have no Variables-tree counterpart; clear
9444 | * the role so leftover state from a prior path-style spec on the
9445 | * same row does not leak into Variables-tree selection sync. */
9446 | item->setData(QVariant(), VariablePathRole);
9447 | }
9448 | if (!watchModel)
9449 | {
9450 | return;
9451 | }
9452 | const QString v = val ? QString::fromUtf8(val) : QString();
9453 | const QString typStr = typ ? QString::fromUtf8(typ) : QString();
9454 | setText(watchModel, item, 1, v);
9455 |
9456 | const QString exprNote =
9457 | tr("Expression — re-evaluated on every pause.");
9458 | const QString ttSuf =
9459 | typStr.isEmpty() ? QString() : tr("Type: %1").arg(typStr);
9460 |
9461 | QString col0Tooltip = item->text();
9462 | if (!ttSuf.isEmpty())
9463 | {
9464 | col0Tooltip = QStringLiteral("%1\n%2")(QString(QtPrivate::qMakeStringPrivate(u"" "%1\n%2"))).arg(col0Tooltip, ttSuf);
9465 | }
9466 | col0Tooltip = QStringLiteral("%1\n%2")(QString(QtPrivate::qMakeStringPrivate(u"" "%1\n%2"))).arg(col0Tooltip, exprNote);
9467 | item->setToolTip(capWatchTooltipText(col0Tooltip + watchTipExtra));
9468 |
9469 | QString col1Tooltip = v;
9470 | if (!ttSuf.isEmpty())
9471 | {
9472 | col1Tooltip = QStringLiteral("%1\n%2")(QString(QtPrivate::qMakeStringPrivate(u"" "%1\n%2"))).arg(col1Tooltip, ttSuf);
9473 | }
9474 | col1Tooltip = QStringLiteral("%1\n%2")(QString(QtPrivate::qMakeStringPrivate(u"" "%1\n%2"))).arg(col1Tooltip, exprNote);
9475 | LuaDebuggerItems::setToolTip(watchModel, item, 1,
9476 | capWatchTooltipText(col1Tooltip));
9477 |
9478 | /* Change tracking — same scheme as path roots, but expression specs
9479 | * are not Globals-anchored (watchSpecIsGlobalScoped returns false on
9480 | * non-path specs), so the cue is fully gated on changeHighlightAllowed. */
9481 | const QString rk = changeKey(stackSelectionLevel, spec);
9482 | const bool changed = changeHighlightAllowed() &&
9483 | shouldMarkChanged(watchRootBaseline_, rk, v);
9484 | applyChangedVisuals(item, changed, isPauseEntryRefresh_);
9485 | watchRootCurrent_[rk] = v;
9486 |
9487 | if (can_expand)
9488 | {
9489 | if (item->rowCount() == 0)
9490 | {
9491 | QStandardItem *const ph0 = new QStandardItem();
9492 | QStandardItem *const ph1 = new QStandardItem();
9493 | ph0->setFlags(Qt::ItemIsEnabled);
9494 | ph1->setFlags(Qt::ItemIsEnabled);
9495 | item->appendRow({ph0, ph1});
9496 | }
9497 | }
9498 | else
9499 | {
9500 | while (item->rowCount() > 0)
9501 | {
9502 | item->removeRow(0);
9503 | }
9504 | }
9505 | }
9506 |
9507 | void LuaDebuggerDialog::applyWatchItemState(QStandardItem *item,
9508 | bool liveContext,
9509 | const QString &muted)
9510 | {
9511 | if (!item || !watchModel || !watchTree)
9512 | {
9513 | return;
9514 | }
9515 |
9516 | const QString spec = item->data(WatchSpecRole).toString();
9517 | const QString watchTipExtra = watchPathOriginSuffix(item, spec);
9518 |
9519 | if (item->parent() == nullptr && spec.isEmpty())
9520 | {
9521 | applyWatchItemEmpty(item, muted, watchTipExtra);
9522 | return;
9523 | }
9524 |
9525 | clearWatchFilterErrorChrome(item, watchTree);
9526 | LuaDebuggerItems::setForeground(watchModel, item, 1,
9527 | watchTree->palette().brush(QPalette::Text));
9528 |
9529 | if (!liveContext)
9530 | {
9531 | applyWatchItemNoLiveContext(item, muted, watchTipExtra);
9532 | return;
9533 | }
9534 |
9535 | const bool isPathSpec = watchSpecUsesPathResolution(spec);
9536 |
9537 | char *val = nullptr;
9538 | char *typ = nullptr;
9539 | bool can_expand = false;
9540 | char *err = nullptr;
9541 |
9542 | const bool ok =
9543 | isPathSpec
9544 | ? wslua_debugger_watch_read_root(spec.toUtf8().constData(), &val,
9545 | &typ, &can_expand, &err)
9546 | : wslua_debugger_watch_expr_read_root(
9547 | spec.toUtf8().constData(), &val, &typ, &can_expand, &err);
9548 | if (!ok)
9549 | {
9550 | const QString errStr = err ? QString::fromUtf8(err) : muted;
9551 | applyWatchItemError(item, errStr, watchTipExtra);
9552 | g_free(err);
9553 | return;
9554 | }
9555 |
9556 | if (isPathSpec)
9557 | {
9558 | applyWatchItemSuccess(item, spec, val, typ, can_expand, watchTipExtra);
9559 | }
9560 | else
9561 | {
9562 | applyWatchItemExpression(item, spec, val, typ, can_expand,
9563 | watchTipExtra);
9564 | }
9565 | g_free(val);
9566 | g_free(typ);
9567 | }
9568 |
9569 | void LuaDebuggerDialog::fillWatchPathChildren(QStandardItem *parent,
9570 | const QString &path)
9571 | {
9572 | if (!watchModel || !watchTree)
9573 | {
9574 | return;
9575 | }
9576 | /* Path watches drill down with wslua_debugger_get_variables (same tree as
9577 | * Variables); expression watches use wslua_debugger_watch_* elsewhere. */
9578 | if (watchSubpathBoundaryCount(path) >= WSLUA_WATCH_MAX_PATH_SEGMENTS32)
9579 | {
9580 | auto *sent0 = new QStandardItem(QStringLiteral("\u2026")(QString(QtPrivate::qMakeStringPrivate(u"" "\u2026"))));
9581 | auto *sent1 = new QStandardItem(tr("Maximum watch depth reached"));
9582 | sent0->setFlags(Qt::ItemIsEnabled);
9583 | sent1->setFlags(Qt::ItemIsEnabled);
9584 | LuaDebuggerItems::setForeground(
9585 | watchModel, sent0, 1,
9586 | watchTree->palette().brush(QPalette::PlaceholderText));
9587 | LuaDebuggerItems::setToolTip(
9588 | watchModel, sent0, 1,
9589 | capWatchTooltipText(tr("Maximum watch depth reached.")));
9590 | parent->appendRow({sent0, sent1});
9591 | return;
9592 | }
9593 |
9594 | int32_t variableCount = 0;
9595 | wslua_variable_t *variables = wslua_debugger_get_variables(
9596 | path.isEmpty() ? NULL__null : path.toUtf8().constData(), &variableCount);
9597 |
9598 | if (!variables)
9599 | {
9600 | return;
9601 | }
9602 |
9603 | const QStandardItem *const rootWatch = watchRootItem(parent);
9604 | const QString rootSpec =
9605 | rootWatch ? rootWatch->data(WatchSpecRole).toString() : QString();
9606 | const bool rootIsGlobal = watchSpecIsGlobalScoped(rootSpec);
9607 | const int rootLevel = rootIsGlobal ? -1 : stackSelectionLevel;
9608 | const QString rootKey = changeKey(rootLevel, rootSpec);
9609 | auto &baseline = watchChildBaseline_[rootKey];
9610 | auto &current = watchChildCurrent_[rootKey];
9611 | /* Globals-scoped roots are anchored to level=-1 and stay comparable
9612 | * across stack-frame switches; everything else is suppressed when the
9613 | * user has navigated away from the pause-entry frame (see
9614 | * changeHighlightAllowed()). */
9615 | const bool highlightAllowed = rootIsGlobal || changeHighlightAllowed();
9616 | /* "First-time expansion" guard, mirror of the one in updateVariables():
9617 | * a child absent from baseline is only meaningfully "new" if the
9618 | * parent @p path was painted at the previous pause. We record that
9619 | * fact directly via the visited-parents companion set; scanning the
9620 | * value baseline by prefix cannot tell "collapsed last pause" apart
9621 | * from "expanded last pause with no children yet", so the FIRST
9622 | * child to appear under a parent that has always been empty (an
9623 | * empty table just got its first key) would otherwise never flash. */
9624 | auto &baselineParents = watchChildBaselineParents_[rootKey];
9625 | auto &currentParents = watchChildCurrentParents_[rootKey];
9626 | const bool parentVisitedInBaseline = baselineParents.contains(path);
9627 | currentParents.insert(path);
9628 |
9629 | for (int32_t variableIndex = 0; variableIndex < variableCount;
9630 | ++variableIndex)
9631 | {
9632 | auto *nameItem = new QStandardItem();
9633 | auto *valueItem = new QStandardItem();
9634 |
9635 | const VariableRowFields f =
9636 | readVariableRowFields(variables[variableIndex], path);
9637 |
9638 | nameItem->setText(f.name);
9639 | nameItem->setFlags(Qt::ItemIsEnabled | Qt::ItemIsSelectable);
9640 | nameItem->setData(f.type, VariableTypeRole);
9641 | nameItem->setData(f.canExpand, VariableCanExpandRole);
9642 | nameItem->setData(f.childPath, VariablePathRole);
9643 |
9644 | parent->appendRow({nameItem, valueItem});
9645 |
9646 | applyWatchChildRowTextAndTooltip(nameItem, f.value, f.type);
9647 |
9648 | /* flashNew=parentVisitedInBaseline: a child key that appeared
9649 | * since the previous pause inside an already-visited watch path
9650 | * is a legitimate change (e.g. a new key inserted into a table)
9651 | * and gets the cue. But when the user expands a parent for the
9652 | * first time, the parent itself was never painted at the previous
9653 | * pause, so lighting up the whole subtree as "new" is misleading. The whole
9654 | * comparison is also gated on highlightAllowed (see above) to
9655 | * suppress the cue when the user is browsing a different stack
9656 | * frame than the pause was entered at — Globals-scoped roots are
9657 | * exempt and stay comparable. */
9658 | const bool changed =
9659 | highlightAllowed &&
9660 | shouldMarkChanged(baseline, f.childPath, f.value,
9661 | /*flashNew=*/parentVisitedInBaseline);
9662 | applyChangedVisuals(nameItem, changed, isPauseEntryRefresh_);
9663 | current[f.childPath] = f.value;
9664 |
9665 | applyVariableExpansionIndicator(nameItem, f.canExpand,
9666 | /*enabledOnlyPlaceholder=*/true,
9667 | /*columnCount=*/2);
9668 | }
9669 |
9670 | if (variableChildrenShouldSortByName(path))
9671 | {
9672 | parent->sortChildren(0, Qt::AscendingOrder);
9673 | }
9674 |
9675 | wslua_debugger_free_variables(variables, variableCount);
9676 | }
9677 |
9678 | void LuaDebuggerDialog::fillWatchExprChildren(QStandardItem *parent,
9679 | const QString &rootSpec,
9680 | const QString &subpath)
9681 | {
9682 | if (!watchModel || !watchTree)
9683 | {
9684 | return;
9685 | }
9686 | /* The depth cap mirrors the path-based variant: deeply nested
9687 | * expression children would otherwise grow without bound and we want
9688 | * a recognizable sentinel rather than a runaway tree. */
9689 | if (watchSubpathBoundaryCount(subpath) >= WSLUA_WATCH_MAX_PATH_SEGMENTS32)
9690 | {
9691 | auto *sent0 = new QStandardItem(QStringLiteral("\u2026")(QString(QtPrivate::qMakeStringPrivate(u"" "\u2026"))));
9692 | auto *sent1 = new QStandardItem(tr("Maximum watch depth reached"));
9693 | sent0->setFlags(Qt::ItemIsEnabled);
9694 | sent1->setFlags(Qt::ItemIsEnabled);
9695 | LuaDebuggerItems::setForeground(
9696 | watchModel, sent0, 1,
9697 | watchTree->palette().brush(QPalette::PlaceholderText));
9698 | LuaDebuggerItems::setToolTip(
9699 | watchModel, sent0, 1,
9700 | capWatchTooltipText(tr("Maximum watch depth reached.")));
9701 | parent->appendRow({sent0, sent1});
9702 | return;
9703 | }
9704 |
9705 | if (rootSpec.trimmed().isEmpty())
9706 | {
9707 | return;
9708 | }
9709 |
9710 | char *err = nullptr;
9711 | wslua_variable_t *variables = nullptr;
9712 | int32_t variableCount = 0;
9713 | const QByteArray rootSpecUtf8 = rootSpec.toUtf8();
9714 | const QByteArray subpathUtf8 = subpath.toUtf8();
9715 | const bool ok = wslua_debugger_watch_expr_get_variables(
9716 | rootSpecUtf8.constData(),
9717 | subpath.isEmpty() ? nullptr : subpathUtf8.constData(),
9718 | &variables, &variableCount, &err);
9719 | g_free(err);
9720 | if (!ok || !variables)
9721 | {
9722 | return;
9723 | }
9724 |
9725 | /* Expression watches have no Globals anchor, so changes are only
9726 | * highlighted under changeHighlightAllowed() (i.e. on the same stack
9727 | * frame as the pause entered at). */
9728 | const QString rootKey = changeKey(stackSelectionLevel, rootSpec);
9729 | auto &baseline = watchChildBaseline_[rootKey];
9730 | auto &current = watchChildCurrent_[rootKey];
9731 | auto &baselineParents = watchChildBaselineParents_[rootKey];
9732 | auto &currentParents = watchChildCurrentParents_[rootKey];
9733 | /* The subpath is the parent key for change tracking; it doubles as the
9734 | * "visited parent" identity. Empty subpath = the expression result
9735 | * itself, which is the same identity as the root row. */
9736 | const bool parentVisitedInBaseline = baselineParents.contains(subpath);
9737 | currentParents.insert(subpath);
9738 | const bool highlightAllowed = changeHighlightAllowed();
9739 |
9740 | for (int32_t i = 0; i < variableCount; ++i)
9741 | {
9742 | auto *nameItem = new QStandardItem();
9743 | auto *valueItem = new QStandardItem();
9744 |
9745 | const QString name =
9746 | QString::fromUtf8(variables[i].name ? variables[i].name : "");
9747 | const QString value =
9748 | QString::fromUtf8(variables[i].value ? variables[i].value : "");
9749 | const QString type =
9750 | QString::fromUtf8(variables[i].type ? variables[i].type : "");
9751 | const bool canExpand = variables[i].can_expand ? true : false;
9752 | const QString childSub = expressionWatchChildSubpath(subpath, name);
9753 |
9754 | nameItem->setText(name);
9755 | nameItem->setFlags(Qt::ItemIsEnabled | Qt::ItemIsSelectable);
9756 | nameItem->setData(type, VariableTypeRole);
9757 | nameItem->setData(canExpand, VariableCanExpandRole);
9758 | nameItem->setData(childSub, WatchSubpathRole);
9759 | /* VariablePathRole is intentionally left empty: expression
9760 | * children have no Variables-tree counterpart, so any sync against
9761 | * it would mismatch a real path watch with the same composed name. */
9762 |
9763 | parent->appendRow({nameItem, valueItem});
9764 |
9765 | applyWatchChildRowTextAndTooltip(nameItem, value, type);
9766 |
9767 | const bool changed =
9768 | highlightAllowed &&
9769 | shouldMarkChanged(baseline, childSub, value,
9770 | /*flashNew=*/parentVisitedInBaseline);
9771 | applyChangedVisuals(nameItem, changed, isPauseEntryRefresh_);
9772 | current[childSub] = value;
9773 |
9774 | applyVariableExpansionIndicator(nameItem, canExpand,
9775 | /*enabledOnlyPlaceholder=*/true,
9776 | /*columnCount=*/2);
9777 | }
9778 |
9779 | wslua_debugger_free_variables(variables, variableCount);
9780 | }
9781 |
9782 | namespace
9783 | {
9784 | /** Subpath / variable-path key used to address @p item inside a watch root. */
9785 | static QString watchItemExpansionKey(const QStandardItem *item)
9786 | {
9787 | if (!item || !item->parent())
9788 | {
9789 | return QString();
9790 | }
9791 | const QString sp = item->data(WatchSubpathRole).toString();
9792 | if (!sp.isEmpty())
9793 | {
9794 | return sp;
9795 | }
9796 | return item->data(VariablePathRole).toString();
9797 | }
9798 | } // namespace
9799 |
9800 | void LuaDebuggerDialog::recordTreeSectionRootExpansion(
9801 | QHash<QString, TreeSectionExpansionState> &map, const QString &rootKey,
9802 | bool expanded)
9803 | {
9804 | if (rootKey.isEmpty())
9805 | {
9806 | return;
9807 | }
9808 | if (!expanded && !map.contains(rootKey))
9809 | {
9810 | return;
9811 | }
9812 | TreeSectionExpansionState &e = map[rootKey];
9813 | e.rootExpanded = expanded;
9814 | if (!expanded && e.subpaths.isEmpty())
9815 | {
9816 | map.remove(rootKey);
9817 | }
9818 | }
9819 |
9820 | void LuaDebuggerDialog::recordTreeSectionSubpathExpansion(
9821 | QHash<QString, TreeSectionExpansionState> &map, const QString &rootKey,
9822 | const QString &key, bool expanded)
9823 | {
9824 | if (rootKey.isEmpty() || key.isEmpty())
9825 | {
9826 | return;
9827 | }
9828 | if (expanded)
9829 | {
9830 | TreeSectionExpansionState &e = map[rootKey];
9831 | if (!e.subpaths.contains(key))
9832 | {
9833 | e.subpaths.append(key);
9834 | }
9835 | }
9836 | else
9837 | {
9838 | auto it = map.find(rootKey);
9839 | if (it == map.end())
9840 | {
9841 | return;
9842 | }
9843 | it->subpaths.removeAll(key);
9844 | if (!it->rootExpanded && it->subpaths.isEmpty())
9845 | {
9846 | map.erase(it);
9847 | }
9848 | }
9849 | }
9850 |
9851 | QStringList LuaDebuggerDialog::treeSectionExpandedSubpaths(
9852 | const QHash<QString, TreeSectionExpansionState> &map,
9853 | const QString &rootKey) const
9854 | {
9855 | if (rootKey.isEmpty())
9856 | {
9857 | return QStringList();
9858 | }
9859 | const auto it = map.constFind(rootKey);
9860 | if (it == map.constEnd())
9861 | {
9862 | return QStringList();
9863 | }
9864 | return it->subpaths;
9865 | }
9866 |
9867 | void LuaDebuggerDialog::recordWatchRootExpansion(const QString &rootSpec,
9868 | bool expanded)
9869 | {
9870 | recordTreeSectionRootExpansion(watchExpansion_, rootSpec, expanded);
9871 | }
9872 |
9873 | void LuaDebuggerDialog::recordWatchSubpathExpansion(const QString &rootSpec,
9874 | const QString &key,
9875 | bool expanded)
9876 | {
9877 | recordTreeSectionSubpathExpansion(watchExpansion_, rootSpec, key, expanded);
9878 | }
9879 |
9880 | QStringList
9881 | LuaDebuggerDialog::watchExpandedSubpathsForSpec(const QString &rootSpec) const
9882 | {
9883 | return treeSectionExpandedSubpaths(watchExpansion_, rootSpec);
9884 | }
9885 |
9886 | void LuaDebuggerDialog::pruneWatchExpansionMap()
9887 | {
9888 | if (!watchTree || watchExpansion_.isEmpty())
9889 | {
9890 | return;
9891 | }
9892 | QSet<QString> liveSpecs;
9893 | const int n = watchModel->rowCount();
9894 | for (int i = 0; i < n; ++i)
9895 | {
9896 | const QStandardItem *it = watchModel->item(i);
9897 | if (!it)
9898 | {
9899 | continue;
9900 | }
9901 | const QString spec = it->data(WatchSpecRole).toString();
9902 | if (!spec.isEmpty())
9903 | {
9904 | liveSpecs.insert(spec);
9905 | }
9906 | }
9907 | for (auto it = watchExpansion_.begin(); it != watchExpansion_.end();)
9908 | {
9909 | if (!liveSpecs.contains(it.key()))
9910 | {
9911 | it = watchExpansion_.erase(it);
9912 | }
9913 | else
9914 | {
9915 | ++it;
9916 | }
9917 | }
9918 | }
9919 |
9920 | void LuaDebuggerDialog::onWatchItemExpanded(const QModelIndex &index)
9921 | {
9922 | if (!watchModel || !index.isValid())
9923 | {
9924 | return;
9925 | }
9926 | QStandardItem *item =
9927 | watchModel->itemFromIndex(index.sibling(index.row(), 0));
9928 | if (!item)
9929 | {
9930 | return;
9931 | }
9932 | /* Track expansion in the runtime map. This fires for both user-driven
9933 | * expansion and the programmatic setExpanded(true) calls made by
9934 | * reexpandWatchDescendantsByPathKeys; re-recording an already-tracked
9935 | * key is idempotent. */
9936 | const QStandardItem *const rootWatch = watchRootItem(item);
9937 | const QString rootSpec =
9938 | rootWatch ? rootWatch->data(WatchSpecRole).toString() : QString();
9939 | if (!item->parent())
9940 | {
9941 | recordWatchRootExpansion(rootSpec, true);
9942 | }
9943 | else
9944 | {
9945 | recordWatchSubpathExpansion(rootSpec, watchItemExpansionKey(item),
9946 | true);
9947 | }
9948 |
9949 | if (item->rowCount() == 1)
9950 | {
9951 | const QModelIndex parentIx = watchModel->indexFromItem(item);
9952 | const QModelIndex firstChildIx =
9953 | watchModel->index(0, 0, parentIx);
9954 | const QString t0 =
9955 | LuaDebuggerItems::rowColumnDisplayText(firstChildIx, 0);
9956 | const QString t1 =
9957 | LuaDebuggerItems::rowColumnDisplayText(firstChildIx, 1);
9958 | if (t0.isEmpty() && t1.isEmpty())
9959 | {
9960 | item->removeRow(0);
9961 | }
9962 | else
9963 | {
9964 | return;
9965 | }
9966 | }
9967 | else if (item->rowCount() > 0)
9968 | {
9969 | return;
9970 | }
9971 |
9972 | refillWatchChildren(item);
9973 | }
9974 |
9975 | void LuaDebuggerDialog::onWatchItemCollapsed(const QModelIndex &index)
9976 | {
9977 | if (!watchModel || !index.isValid())
9978 | {
9979 | return;
9980 | }
9981 | QStandardItem *item =
9982 | watchModel->itemFromIndex(index.sibling(index.row(), 0));
9983 | if (!item)
9984 | {
9985 | return;
9986 | }
9987 | const QStandardItem *const rootWatch = watchRootItem(item);
9988 | const QString rootSpec =
9989 | rootWatch ? rootWatch->data(WatchSpecRole).toString() : QString();
9990 | if (!item->parent())
9991 | {
9992 | recordWatchRootExpansion(rootSpec, false);
9993 | }
9994 | else
9995 | {
9996 | recordWatchSubpathExpansion(rootSpec, watchItemExpansionKey(item),
9997 | false);
9998 | }
9999 | }
10000 |
10001 | void LuaDebuggerDialog::refillWatchChildren(QStandardItem *item)
10002 | {
10003 | if (!item)
10004 | {
10005 | return;
10006 | }
10007 | while (item->rowCount() > 0)
10008 | {
10009 | item->removeRow(0);
10010 | }
10011 |
10012 | const QStandardItem *const rootWatch = watchRootItem(item);
10013 | if (!rootWatch)
10014 | {
10015 | return;
10016 | }
10017 | const QString rootSpec = rootWatch->data(WatchSpecRole).toString();
10018 |
10019 | if (watchSpecUsesPathResolution(rootSpec))
10020 | {
10021 | QString path = item->data(VariablePathRole).toString();
10022 | if (path.isEmpty())
10023 | {
10024 | path = watchResolvedVariablePathForTooltip(rootSpec);
10025 | if (path.isEmpty())
10026 | {
10027 | path = watchVariablePathForSpec(rootSpec);
10028 | }
10029 | }
10030 | fillWatchPathChildren(item, path);
10031 | return;
10032 | }
10033 |
10034 | /* Expression watch: descendants are addressed by a Lua-style subpath
10035 | * relative to the expression's root value, stored in WatchSubpathRole.
10036 | * The subpath of the root itself is empty — children of the root then
10037 | * fan out through expressionWatchChildSubpath() in
10038 | * fillWatchExprChildren(). */
10039 | const QString subpath =
10040 | item->parent() == nullptr
10041 | ? QString()
10042 | : item->data(WatchSubpathRole).toString();
10043 | fillWatchExprChildren(item, rootSpec, subpath);
10044 | }
10045 |
10046 | void LuaDebuggerDialog::refreshWatchBranch(QStandardItem *item)
10047 | {
10048 | if (!item || !watchTree || !watchModel ||
10049 | !LuaDebuggerItems::isExpanded(watchTree, watchModel, item))
10050 | {
10051 | return;
10052 | }
10053 | /* refillWatchChildren deletes and re-creates every descendant, so the
10054 | * tree alone cannot remember which sub-elements were expanded. Instead,
10055 | * consult the dialog-level runtime expansion map (watchExpansion_),
10056 | * which is kept up to date by onWatchItemExpanded / onWatchItemCollapsed
10057 | * and survives both refills and the children-clearing that happens while
10058 | * the debugger is not paused. This lets deep subtrees survive stepping,
10059 | * pause / resume, and Variables tree refreshes without being tied to
10060 | * transient QStandardItem lifetimes. */
10061 | const QStandardItem *const rootWatch = watchRootItem(item);
10062 | const QString rootSpec =
10063 | rootWatch ? rootWatch->data(WatchSpecRole).toString() : QString();
10064 | refillWatchChildren(item);
10065 | reexpandWatchDescendantsByPathKeys(
10066 | watchTree, watchModel, item, watchExpandedSubpathsForSpec(rootSpec));
10067 | }
10068 |
10069 | namespace
10070 | {
10071 | /** Pointers into the context menu built by buildWatchContextMenu(). */
10072 | struct WatchContextMenuActions
10073 | {
10074 | QAction *addWatch = nullptr;
10075 | QAction *copyValue = nullptr;
10076 | QAction *duplicate = nullptr;
10077 | QAction *editWatch = nullptr;
10078 | QAction *remove = nullptr;
10079 | QAction *removeAllWatches = nullptr;
10080 | };
10081 | } /* namespace */
10082 |
10083 | /**
10084 | * Populate @a menu with the watch context-menu actions appropriate for
10085 | * @a item (may be null / a child row), returning pointers to each action
10086 | * so the caller can dispatch on the chosen QAction.
10087 | *
10088 | * Sub-element rows (descendants of a watch root) only expose `Add Watch`
10089 | * and `Copy Value`. Watch roots also get duplicate, edit, copy value, remove
10090 | * one, and remove all.
10091 | */
10092 | static void buildWatchContextMenu(
10093 | QMenu &menu, QStandardItem *item, WatchContextMenuActions *acts,
10094 | const QStandardItemModel *watchModel, const QKeySequence &addWatchShortcut)
10095 | {
10096 | acts->addWatch = menu.addAction(QObject::tr("Add Watch"));
10097 | if (!addWatchShortcut.isEmpty())
10098 | {
10099 | acts->addWatch->setShortcut(addWatchShortcut);
10100 | }
10101 | if (!item)
10102 | {
10103 | if (watchModel && watchModel->rowCount() > 0)
10104 | {
10105 | menu.addSeparator();
10106 | acts->removeAllWatches = menu.addAction(
10107 | QObject::tr("Remove All Watches"));
10108 | acts->removeAllWatches->setShortcut(kCtxWatchRemoveAll);
10109 | }
10110 | return;
10111 | }
10112 |
10113 | if (item->parent() == nullptr)
10114 | {
10115 | /* Watch root: Add Watch, then duplicate / edit, then the rest. */
10116 | acts->duplicate = menu.addAction(QObject::tr("Duplicate Watch"));
10117 | acts->duplicate->setShortcut(kCtxWatchDuplicate);
10118 | acts->editWatch = menu.addAction(QObject::tr("Edit Watch"));
10119 | acts->editWatch->setShortcut(kCtxWatchEdit);
10120 | menu.addSeparator();
10121 | }
10122 |
10123 | acts->copyValue = menu.addAction(QObject::tr("Copy Value"));
10124 | acts->copyValue->setShortcut(kCtxWatchCopyValue);
10125 |
10126 | if (item->parent() != nullptr)
10127 | {
10128 | return;
10129 | }
10130 |
10131 | menu.addSeparator();
10132 | acts->remove = menu.addAction(QObject::tr("Remove"));
10133 | acts->remove->setShortcut(QKeySequence::Delete);
10134 | if (watchModel->rowCount() > 0)
10135 | {
10136 | acts->removeAllWatches = menu.addAction(
10137 | QObject::tr("Remove All Watches"));
10138 | acts->removeAllWatches->setShortcut(kCtxWatchRemoveAll);
10139 | }
10140 | }
10141 |
10142 | void LuaDebuggerDialog::onWatchContextMenuRequested(const QPoint &pos)
10143 | {
10144 | if (!watchTree || !watchModel)
10145 | {
10146 | return;
10147 | }
10148 |
10149 | const QModelIndex ix = watchTree->indexAt(pos);
10150 | QStandardItem *item = nullptr;
10151 | if (ix.isValid())
10152 | {
10153 | item = watchModel->itemFromIndex(ix.sibling(ix.row(), 0));
10154 | }
10155 |
10156 | QMenu menu(this);
10157 | WatchContextMenuActions acts;
10158 | buildWatchContextMenu(menu, item, &acts, watchModel,
10159 | ui->actionAddWatch->shortcut());
10160 |
10161 | QAction *chosen = menu.exec(watchTree->viewport()->mapToGlobal(pos));
10162 | if (!chosen)
10163 | {
10164 | return;
10165 | }
10166 |
10167 | if (chosen == acts.addWatch)
10168 | {
10169 | insertNewWatchRow(QString(), true);
10170 | return;
10171 | }
10172 | if (chosen == acts.removeAllWatches)
10173 | {
10174 | removeAllWatchTopLevelItems();
10175 | return;
10176 | }
10177 | if (!item)
10178 | {
10179 | return;
10180 | }
10181 |
10182 | if (chosen == acts.copyValue)
10183 | {
10184 | copyWatchValueForItem(item, ix);
10185 | return;
10186 | }
10187 |
10188 | if (item->parent() != nullptr)
10189 | {
10190 | return;
10191 | }
10192 |
10193 | if (chosen == acts.editWatch)
10194 | {
10195 | QTimer::singleShot(0, this, [this, item]()
10196 | {
10197 | if (!watchModel || !watchTree)
10198 | {
10199 | return;
10200 | }
10201 | const QModelIndex editIx =
10202 | watchModel->indexFromItem(item);
10203 | if (!editIx.isValid())
10204 | {
10205 | return;
10206 | }
10207 | watchTree->scrollTo(editIx);
10208 | watchTree->setCurrentIndex(editIx);
10209 | watchTree->edit(editIx);
10210 | });
10211 | return;
10212 | }
10213 |
10214 | if (chosen == acts.remove)
10215 | {
10216 | QList<QStandardItem *> del;
10217 | for (const QModelIndex &six :
10218 | watchTree->selectionModel()->selectedRows(0))
10219 | {
10220 | QStandardItem *it = watchModel->itemFromIndex(six);
10221 | if (it && it->parent() == nullptr)
10222 | {
10223 | del.append(it);
10224 | }
10225 | }
10226 | if (del.isEmpty())
10227 | {
10228 | del.append(item);
10229 | }
10230 | deleteWatchRows(del);
10231 | return;
10232 | }
10233 |
10234 | if (chosen == acts.duplicate)
10235 | {
10236 | duplicateWatchRootItem(item);
10237 | return;
10238 | }
10239 | }
10240 |
10241 | void LuaDebuggerDialog::copyWatchValueForItem(QStandardItem *item,
10242 | const QModelIndex &ix)
10243 | {
10244 | auto copyToClipboard = [](const QString &s)
10245 | {
10246 | if (QClipboard *c = QGuiApplication::clipboard())
10247 | {
10248 | c->setText(s);
10249 | }
10250 | };
10251 | QString value;
10252 | if (item && debuggerPaused && wslua_debugger_is_enabled() &&
10253 | wslua_debugger_is_paused())
10254 | {
10255 | const QStandardItem *const rootWatch = watchRootItem(item);
10256 | const QString rootSpec =
10257 | rootWatch ? rootWatch->data(WatchSpecRole).toString() : QString();
10258 |
10259 | char *val = nullptr;
10260 | char *err = nullptr;
10261 | bool ok = false;
10262 |
10263 | if (watchSpecUsesPathResolution(rootSpec))
10264 | {
10265 | /* Path-style: prefer the row's resolved Variables-tree path so
10266 | * children copy the correct nested value, not the root. */
10267 | const QString varPath = item->data(VariablePathRole).toString();
10268 | if (!varPath.isEmpty())
10269 | {
10270 | ok = wslua_debugger_read_variable_value_full(
10271 | varPath.toUtf8().constData(), &val, &err);
10272 | }
10273 | }
10274 | else if (!rootSpec.isEmpty())
10275 | {
10276 | /* Expression-style: re-evaluate against the root spec, then
10277 | * walk the row's stored subpath (empty for the root itself). */
10278 | const QString subpath =
10279 | item->parent() == nullptr
10280 | ? QString()
10281 | : item->data(WatchSubpathRole).toString();
10282 | const QByteArray rootSpecUtf8 = rootSpec.toUtf8();
10283 | const QByteArray subpathUtf8 = subpath.toUtf8();
10284 | ok = wslua_debugger_watch_expr_read_full(
10285 | rootSpecUtf8.constData(),
10286 | subpath.isEmpty() ? nullptr : subpathUtf8.constData(), &val,
10287 | &err);
10288 | }
10289 |
10290 | if (ok)
10291 | {
10292 | value = QString::fromUtf8(val ? val : "");
10293 | }
10294 | g_free(val);
10295 | g_free(err);
10296 | }
10297 | if (value.isNull())
10298 | {
10299 | value = LuaDebuggerItems::rowColumnDisplayText(ix, 1);
10300 | }
10301 | copyToClipboard(value);
10302 | }
10303 |
10304 | void LuaDebuggerDialog::duplicateWatchRootItem(QStandardItem *item)
10305 | {
10306 | if (!watchModel || !item || item->parent() != nullptr)
10307 | {
10308 | return;
10309 | }
10310 | auto *copy0 = new QStandardItem();
10311 | auto *copy1 = new QStandardItem();
10312 | copy0->setFlags(copy0->flags() | Qt::ItemIsEditable | Qt::ItemIsEnabled |
10313 | Qt::ItemIsSelectable | Qt::ItemIsDragEnabled);
10314 | copy1->setFlags(Qt::ItemIsEnabled | Qt::ItemIsSelectable | Qt::ItemIsDragEnabled);
10315 | copy0->setText(item->text());
10316 | {
10317 | const QModelIndex srcRow0 = watchModel->indexFromItem(item);
10318 | LuaDebuggerItems::setText(
10319 | watchModel, copy0, 1,
10320 | LuaDebuggerItems::rowColumnDisplayText(srcRow0, 1));
10321 | }
10322 | for (int r = WatchSpecRole; r <= WatchPendingNewRole; ++r)
10323 | {
10324 | copy0->setData(item->data(r), r);
10325 | }
10326 | copy0->setData(false, WatchPendingNewRole);
10327 | copy0->setData(item->data(VariablePathRole), VariablePathRole);
10328 | copy0->setData(item->data(VariableTypeRole), VariableTypeRole);
10329 | copy0->setData(item->data(VariableCanExpandRole), VariableCanExpandRole);
10330 | /* The duplicate is a brand-new row: it has no baseline yet, so the
10331 | * first refresh will not show it as "changed". No per-item role data
10332 | * to clear — baselines live on the dialog, keyed by spec+level, and
10333 | * the copy shares the spec of its source. */
10334 | {
10335 | auto *ph0 = new QStandardItem();
10336 | auto *ph1 = new QStandardItem();
10337 | ph0->setFlags(Qt::ItemIsEnabled);
10338 | ph1->setFlags(Qt::ItemIsEnabled);
10339 | copy0->appendRow({ph0, ph1});
10340 | }
10341 | watchModel->insertRow(item->row() + 1, {copy0, copy1});
10342 | refreshWatchDisplay();
10343 | }
10344 |
10345 | void LuaDebuggerDialog::removeAllWatchTopLevelItems()
10346 | {
10347 | if (!watchModel)
10348 | {
10349 | return;
10350 | }
10351 | QList<QStandardItem *> all;
10352 | for (int i = 0; i < watchModel->rowCount(); ++i)
10353 | {
10354 | if (QStandardItem *r = watchModel->item(i, 0))
10355 | {
10356 | all.append(r);
10357 | }
10358 | }
10359 | if (all.isEmpty())
10360 | {
10361 | return;
10362 | }
10363 |
10364 | /* Confirmation dialog. Mirrors onClearBreakpoints(): the destructive
10365 | * "wipe everything" gesture is reachable from the header button, the
10366 | * Ctrl+Shift+W keyboard shortcut and the watch context menu, so the
10367 | * prompt lives here (instead of at each call site) to guarantee the
10368 | * user always gets one chance to back out. Default is No so a stray
10369 | * Enter on a focused dialog does not silently delete the user's
10370 | * watch list. */
10371 | const int count = static_cast<int>(all.size());
10372 | QMessageBox::StandardButton reply = QMessageBox::question(
10373 | this, tr("Clear All Watches"),
10374 | tr("Are you sure you want to remove %Ln watch(es)?", "", count),
10375 | QMessageBox::Yes | QMessageBox::No, QMessageBox::No);
10376 | if (reply != QMessageBox::Yes)
10377 | {
10378 | return;
10379 | }
10380 |
10381 | deleteWatchRows(all);
10382 | }
10383 |
10384 | void LuaDebuggerDialog::toggleBreakpointOnCodeViewLine(
10385 | LuaDebuggerCodeView *codeView, qint32 line)
10386 | {
10387 | if (!codeView || line < 1)
10388 | {
10389 | return;
10390 | }
10391 | const QString file_path = codeView->getFilename();
10392 | const int32_t state = wslua_debugger_get_breakpoint_state(
10393 | file_path.toUtf8().constData(), line);
10394 | if (state == -1)
10395 | {
10396 | wslua_debugger_add_breakpoint(file_path.toUtf8().constData(), line);
10397 | ensureDebuggerEnabledForActiveBreakpoints();
10398 | }
10399 | else
10400 | {
10401 | wslua_debugger_remove_breakpoint(file_path.toUtf8().constData(), line);
10402 | refreshDebuggerStateUi();
10403 | }
10404 | updateBreakpoints();
10405 | const qint32 tabCount =
10406 | static_cast<qint32>(ui->codeTabWidget->count());
10407 | for (qint32 tabIndex = 0; tabIndex < tabCount; ++tabIndex)
10408 | {
10409 | LuaDebuggerCodeView *tabView = qobject_cast<LuaDebuggerCodeView *>(
10410 | ui->codeTabWidget->widget(static_cast<int>(tabIndex)));
10411 | if (tabView)
10412 | {
10413 | tabView->updateBreakpointMarkers();
10414 | }
10415 | }
10416 | }
10417 |
10418 | void LuaDebuggerDialog::onRunToLine()
10419 | {
10420 | LuaDebuggerCodeView *codeView = currentCodeView();
10421 | if (!codeView || !eventLoop)
10422 | {
10423 | return;
10424 | }
10425 | const qint32 line =
10426 | static_cast<qint32>(codeView->textCursor().blockNumber() + 1);
10427 | runToCurrentLineInPausedEditor(codeView, line);
10428 | }
10429 |
10430 | void LuaDebuggerDialog::runToCurrentLineInPausedEditor(
10431 | LuaDebuggerCodeView *codeView, qint32 line)
10432 | {
10433 | if (!codeView || !eventLoop || line < 1)
10434 | {
10435 | return;
10436 | }
10437 | ensureDebuggerEnabledForActiveBreakpoints();
10438 | wslua_debugger_run_to_line(codeView->getFilename().toUtf8().constData(),
10439 | line);
10440 | if (eventLoop)
10441 | {
10442 | eventLoop->quit();
10443 | }
10444 | debuggerPaused = false;
10445 | updateWidgets();
10446 | clearPausedStateUi();
10447 | }
10448 |
10449 | void LuaDebuggerDialog::addWatchFromSpec(const QString &watchSpec)
10450 | {
10451 | insertNewWatchRow(watchSpec, false);
10452 | }
10453 |
10454 | void LuaDebuggerDialog::commitWatchRootSpec(QStandardItem *item,
10455 | const QString &text)
10456 | {
10457 | if (!watchTree || !watchModel || !item || item->parent() != nullptr)
10458 | {
10459 | return;
10460 | }
10461 |
10462 | const QString t = text.trimmed();
10463 | if (t.isEmpty())
10464 | {
10465 | /* Clearing the text of a brand-new row discards it (no persisted
10466 | * entry ever existed); clearing an existing row removes it. */
10467 | if (item->data(WatchPendingNewRole).toBool())
10468 | {
10469 | watchModel->removeRow(item->row());
10470 | refreshWatchDisplay();
10471 | }
10472 | else
10473 | {
10474 | deleteWatchRows({item});
10475 | }
10476 | return;
10477 | }
10478 |
10479 | if (t.size() > WATCH_EXPR_MAX_CHARS)
10480 | {
10481 | QMessageBox::warning(
10482 | this, tr("Lua Debugger"),
10483 | tr("Watch expression is too long (maximum %Ln characters).", "",
10484 | static_cast<qlonglong>(WATCH_EXPR_MAX_CHARS)));
10485 | return;
10486 | }
10487 |
10488 | /* Both path watches (Locals.x, Globals.t.k) and expression watches
10489 | * (any Lua expression — pinfo.src:tostring(), #packets, t[i] + 1)
10490 | * are accepted; the watch panel decides how to evaluate based on
10491 | * whether @c t validates as a Variables-tree path. */
10492 |
10493 | /* Editing a spec invalidates baselines for both old and new specs:
10494 | * the old spec no longer applies to this row, and the new spec has
10495 | * never been evaluated on this row before (so the first refresh must
10496 | * not flag it as "changed" against an unrelated old value). */
10497 | const QString oldSpec = item->data(WatchSpecRole).toString();
10498 | if (!oldSpec.isEmpty() && oldSpec != t)
10499 | {
10500 | clearChangeBaselinesForWatchSpec(oldSpec);
10501 | }
10502 | clearChangeBaselinesForWatchSpec(t);
10503 |
10504 | item->setData(t, WatchSpecRole);
10505 | item->setText(t);
10506 | item->setData(false, WatchPendingNewRole);
10507 | watchRootSetVariablePathRoleFromSpec(item, t);
10508 | if (item->rowCount() == 0)
10509 | {
10510 | auto *ph0 = new QStandardItem();
10511 | auto *ph1 = new QStandardItem();
10512 | ph0->setFlags(Qt::ItemIsEnabled);
10513 | ph1->setFlags(Qt::ItemIsEnabled);
10514 | item->appendRow({ph0, ph1});
10515 | }
10516 | refreshWatchDisplay();
10517 | }
10518 |
10519 | void LuaDebuggerDialog::insertNewWatchRow(const QString &initialSpec,
10520 | bool openEditor)
10521 | {
10522 | if (!watchTree || !watchModel)
10523 | {
10524 | return;
10525 | }
10526 |
10527 | const QString init = initialSpec.trimmed();
10528 | for (int i = 0; i < watchModel->rowCount(); ++i)
10529 | {
10530 | if (QStandardItem *r = watchModel->item(i, 0))
10531 | {
10532 | if (r->data(WatchSpecRole).toString() == init)
10533 | {
10534 | const QModelIndex wix = watchModel->indexFromItem(r);
10535 | watchTree->scrollTo(wix);
10536 | watchTree->setCurrentIndex(wix);
10537 | return;
10538 | }
10539 | }
10540 | }
10541 | /* Both path watches and expression watches are accepted; the watch
10542 | * panel decides how to evaluate. */
10543 |
10544 | auto *row0 = new QStandardItem();
10545 | auto *row1 = new QStandardItem();
10546 | row0->setFlags(row0->flags() | Qt::ItemIsEditable | Qt::ItemIsEnabled |
10547 | Qt::ItemIsSelectable | Qt::ItemIsDragEnabled);
10548 | row1->setFlags(Qt::ItemIsEnabled | Qt::ItemIsSelectable | Qt::ItemIsDragEnabled);
10549 | row0->setData(init, WatchSpecRole);
10550 | row0->setText(init);
10551 | row0->setData(QString(), WatchSubpathRole);
10552 | row0->setData(QVariant(init.isEmpty()), WatchPendingNewRole);
10553 | if (!init.isEmpty())
10554 | {
10555 | watchRootSetVariablePathRoleFromSpec(row0, init);
10556 | }
10557 | {
10558 | auto *ph0 = new QStandardItem();
10559 | auto *ph1 = new QStandardItem();
10560 | ph0->setFlags(Qt::ItemIsEnabled);
10561 | ph1->setFlags(Qt::ItemIsEnabled);
10562 | row0->appendRow({ph0, ph1});
10563 | }
10564 | watchModel->appendRow({row0, row1});
10565 | refreshWatchDisplay();
10566 |
10567 | if (openEditor)
10568 | {
10569 | QTimer::singleShot(0, this, [this, row0]()
10570 | {
10571 | const QModelIndex editIx =
10572 | watchModel->indexFromItem(row0);
10573 | watchTree->scrollTo(editIx);
10574 | watchTree->setCurrentIndex(editIx);
10575 | watchTree->edit(editIx);
10576 | });
10577 | }
10578 | }
10579 |
10580 | void LuaDebuggerDialog::restoreWatchExpansionState()
10581 | {
10582 | if (!watchTree)
10583 | {
10584 | return;
10585 | }
10586 | /* Re-apply each root's expansion from the runtime map. After a fresh load
10587 | * from lua_debugger.json the map is empty (rows open collapsed). */
10588 | for (int i = 0; i < watchModel->rowCount(); ++i)
10589 | {
10590 | QStandardItem *root = watchModel->item(i);
10591 | const QString spec = root->data(WatchSpecRole).toString();
10592 | bool rootExpanded = false;
10593 | QStringList subpaths;
10594 | const auto it = watchExpansion_.constFind(spec);
10595 | if (it != watchExpansion_.cend())
10596 | {
10597 | rootExpanded = it->rootExpanded;
10598 | subpaths = it->subpaths;
10599 | }
10600 | if (rootExpanded !=
10601 | LuaDebuggerItems::isExpanded(watchTree, watchModel, root))
10602 | {
10603 | LuaDebuggerItems::setExpanded(watchTree, watchModel, root,
10604 | rootExpanded);
10605 | }
10606 | if (rootExpanded)
10607 | {
10608 | reexpandTreeDescendantsByPathKeys(
10609 | watchTree, watchModel, root, subpaths,
10610 | findWatchItemBySubpathOrPathKey);
10611 | }
10612 | }
10613 | }
10614 |
10615 | void LuaDebuggerDialog::restoreVariablesExpansionState()
10616 | {
10617 | if (!variablesTree)
10618 | {
10619 | return;
10620 | }
10621 | for (int i = 0; i < variablesModel->rowCount(); ++i)
10622 | {
10623 | QStandardItem *root = variablesModel->item(i);
10624 | const QString section = root->data(VariablePathRole).toString();
10625 | if (section.isEmpty())
10626 | {
10627 | continue;
10628 | }
10629 | bool rootExpanded = false;
10630 | QStringList subpaths;
10631 | const auto it = variablesExpansion_.constFind(section);
10632 | if (it == variablesExpansion_.cend())
10633 | {
10634 | if (section == QLatin1String("Locals"))
10635 | {
10636 | rootExpanded = true;
10637 | }
10638 | }
10639 | else
10640 | {
10641 | rootExpanded = it->rootExpanded;
10642 | subpaths = it->subpaths;
10643 | }
10644 | if (rootExpanded !=
10645 | LuaDebuggerItems::isExpanded(variablesTree, variablesModel, root))
10646 | {
10647 | LuaDebuggerItems::setExpanded(variablesTree, variablesModel, root,
10648 | rootExpanded);
10649 | }
10650 | if (rootExpanded)
10651 | {
10652 | reexpandTreeDescendantsByPathKeys(
10653 | variablesTree, variablesModel, root, subpaths,
10654 | findVariableTreeItemByPathKey);
10655 | }
10656 | }
10657 | }
10658 |
10659 | // Qt-based JSON Settings Persistence
10660 | void LuaDebuggerDialog::loadSettingsFile()
10661 | {
10662 | const QString path = luaDebuggerSettingsFilePath();
10663 | QFileInfo fi(path);
10664 | if (!fi.exists() || !fi.isFile())
10665 | {
10666 | return;
10667 | }
10668 |
10669 | QFile loadFile(path);
10670 | if (!loadFile.open(QIODevice::ReadOnly))
10671 | {
10672 | return;
10673 | }
10674 |
10675 | QByteArray loadData = loadFile.readAll();
10676 | if (loadData.startsWith("\xef\xbb\xbf"))
10677 | {
10678 | loadData = loadData.mid(3);
10679 | }
10680 | loadData = loadData.trimmed();
10681 |
10682 | QJsonParseError parseError;
10683 | const QJsonDocument document =
10684 | QJsonDocument::fromJson(loadData, &parseError);
10685 | if (parseError.error != QJsonParseError::NoError || !document.isObject())
10686 | {
10687 | return;
10688 | }
10689 | settings_ = document.object().toVariantMap();
10690 | }
10691 |
10692 | void LuaDebuggerDialog::saveSettingsFile()
10693 | {
10694 | /*
10695 | * Always merge live watch rows and engine breakpoints before writing so
10696 | * callers that only touch theme/splitters (or watches alone) do not persist
10697 | * stale or empty breakpoint/watch entries.
10698 | */
10699 | if (watchTree)
10700 | {
10701 | storeWatchList();
10702 | }
10703 | storeBreakpointsList();
10704 |
10705 | const QString savePath = luaDebuggerSettingsFilePath();
10706 | QFileInfo fileInfo(savePath);
10707 |
10708 | QFile saveFile(savePath);
10709 | if (fileInfo.exists() && !fileInfo.isFile())
10710 | {
10711 | return;
10712 | }
10713 |
10714 | if (saveFile.open(QIODevice::WriteOnly))
10715 | {
10716 | QJsonDocument document(QJsonObject::fromVariantMap(settings_));
10717 | QByteArray saveData = document.toJson(QJsonDocument::Indented);
10718 | saveFile.write(saveData);
10719 | }
10720 | }
10721 |
10722 | void LuaDebuggerDialog::applyDialogSettings()
10723 | {
10724 | loadSettingsFile();
10725 |
10726 | /*
10727 | * Load JSON into the engine and watch tree. JSON is read only here (dialog
10728 | * construction); it is written only from closeEvent() (see saveSettingsFile).
10729 | * Apply breakpoints first so that list is never empty before rebuild.
10730 | */
10731 | QJsonArray breakpointsArray =
10732 | jsonArrayFromSettingsMap(settings_, SettingsKeys::Breakpoints);
10733 | for (const QJsonValue &val : breakpointsArray)
10734 | {
10735 | QJsonObject bp = val.toObject();
10736 | QString file = bp.value("file").toString();
10737 | int64_t line = bp.value("line").toVariant().toLongLong();
10738 | bool active = bp.value("active").toBool(true);
10739 | /* Forward-compat: missing keys mean "feature not configured" so we
10740 | * skip the corresponding setter call rather than clearing it. The
10741 | * core defaults to "no condition / no target / no log message" on
10742 | * add_breakpoint, so this matches a pristine entry. */
10743 | const bool hasCondition = bp.contains("condition");
10744 | const QString condition = hasCondition
10745 | ? bp.value("condition").toString()
10746 | : QString();
10747 | const bool hasTarget = bp.contains("hitCountTarget");
10748 | const int64_t hitCountTarget =
10749 | hasTarget ? bp.value("hitCountTarget").toVariant().toLongLong()
10750 | : int64_t{0};
10751 | const bool hasMode = bp.contains("hitCountMode");
10752 | wslua_hit_count_mode_t hitCountMode = WSLUA_HIT_COUNT_MODE_FROM;
10753 | if (hasMode)
10754 | {
10755 | const QString modeStr =
10756 | bp.value("hitCountMode").toString().toLower();
10757 | if (modeStr == QStringLiteral("every")(QString(QtPrivate::qMakeStringPrivate(u"" "every"))))
10758 | {
10759 | hitCountMode = WSLUA_HIT_COUNT_MODE_EVERY;
10760 | }
10761 | else if (modeStr == QStringLiteral("once")(QString(QtPrivate::qMakeStringPrivate(u"" "once"))))
10762 | {
10763 | hitCountMode = WSLUA_HIT_COUNT_MODE_ONCE;
10764 | }
10765 | else
10766 | {
10767 | /* Unknown / empty / "from" all collapse to the default
10768 | * so a JSON file from a future Wireshark with extra
10769 | * modes degrades gracefully instead of corrupting the
10770 | * gate. */
10771 | hitCountMode = WSLUA_HIT_COUNT_MODE_FROM;
10772 | }
10773 | }
10774 | const bool hasLog = bp.contains("logMessage");
10775 | const QString logMessage =
10776 | hasLog ? bp.value("logMessage").toString() : QString();
10777 | const bool hasLogAlsoPause = bp.contains("logAlsoPause");
10778 | const bool logAlsoPause =
10779 | hasLogAlsoPause ? bp.value("logAlsoPause").toBool() : false;
10780 |
10781 | if (!file.isEmpty() && line > 0)
10782 | {
10783 | int32_t state = wslua_debugger_get_breakpoint_state(
10784 | file.toUtf8().constData(), line);
10785 | if (state < 0)
10786 | {
10787 | wslua_debugger_add_breakpoint(file.toUtf8().constData(), line);
10788 | }
10789 | wslua_debugger_set_breakpoint_active(file.toUtf8().constData(),
10790 | line, active);
10791 | if (hasCondition)
10792 | {
10793 | const QByteArray fb = file.toUtf8();
10794 | const QByteArray cb = condition.toUtf8();
10795 | wslua_debugger_set_breakpoint_condition(
10796 | fb.constData(), line,
10797 | condition.isEmpty() ? NULL__null : cb.constData());
10798 | }
10799 | if (hasTarget)
10800 | {
10801 | wslua_debugger_set_breakpoint_hit_count_target(
10802 | file.toUtf8().constData(), line, hitCountTarget);
10803 | }
10804 | if (hasMode)
10805 | {
10806 | wslua_debugger_set_breakpoint_hit_count_mode(
10807 | file.toUtf8().constData(), line, hitCountMode);
10808 | }
10809 | if (hasLog)
10810 | {
10811 | const QByteArray fb = file.toUtf8();
10812 | const QByteArray mb = logMessage.toUtf8();
10813 | wslua_debugger_set_breakpoint_log_message(
10814 | fb.constData(), line,
10815 | logMessage.isEmpty() ? NULL__null : mb.constData());
10816 | }
10817 | if (hasLogAlsoPause)
10818 | {
10819 | wslua_debugger_set_breakpoint_log_also_pause(
10820 | file.toUtf8().constData(), line, logAlsoPause);
10821 | }
10822 | }
10823 | }
10824 |
10825 | rebuildWatchTreeFromSettings();
10826 |
10827 | // Apply theme setting
10828 | QString themeStr = settings_.value(SettingsKeys::Theme, "auto").toString();
10829 | int32_t theme = WSLUA_DEBUGGER_THEME_AUTO;
10830 | if (themeStr == "dark")
10831 | theme = WSLUA_DEBUGGER_THEME_DARK;
10832 | else if (themeStr == "light")
10833 | theme = WSLUA_DEBUGGER_THEME_LIGHT;
10834 | currentTheme_ = theme;
10835 |
10836 | if (themeComboBox)
10837 | {
10838 | int idx = themeComboBox->findData(theme);
10839 | if (idx >= 0)
10840 | themeComboBox->setCurrentIndex(idx);
10841 | }
10842 |
10843 | QString mainSplitterHex =
10844 | settings_.value(SettingsKeys::MainSplitter).toString();
10845 | QString leftSplitterHex =
10846 | settings_.value(SettingsKeys::LeftSplitter).toString();
10847 | QString evalSplitterHex =
10848 | settings_.value(SettingsKeys::EvalSplitter).toString();
10849 |
10850 | bool splittersRestored = false;
10851 | if (!mainSplitterHex.isEmpty() && ui->mainSplitter)
10852 | {
10853 | ui->mainSplitter->restoreState(
10854 | QByteArray::fromHex(mainSplitterHex.toLatin1()));
10855 | splittersRestored = true;
10856 | }
10857 | if (!leftSplitterHex.isEmpty() && ui->leftSplitter)
10858 | {
10859 | ui->leftSplitter->restoreState(
10860 | QByteArray::fromHex(leftSplitterHex.toLatin1()));
10861 | splittersRestored = true;
10862 | }
10863 | /* The Evaluate input/output splitter is independent of the outer panel
10864 | * splitters; restore even if the others are missing so a user who has
10865 | * only ever collapsed an Evaluate pane keeps that preference. */
10866 | if (!evalSplitterHex.isEmpty() && evalSplitter_)
10867 | {
10868 | evalSplitter_->restoreState(
10869 | QByteArray::fromHex(evalSplitterHex.toLatin1()));
10870 | }
10871 |
10872 | if (!splittersRestored && ui->mainSplitter)
10873 | {
10874 | ui->mainSplitter->setStretchFactor(0, 1);
10875 | ui->mainSplitter->setStretchFactor(1, 2);
10876 | QList<int> sizes;
10877 | sizes << 300 << 600;
10878 | ui->mainSplitter->setSizes(sizes);
10879 | }
10880 |
10881 | if (variablesSection)
10882 | variablesSection->setExpanded(
10883 | settings_.value(SettingsKeys::SectionVariables, true).toBool());
10884 | if (stackSection)
10885 | stackSection->setExpanded(
10886 | settings_.value(SettingsKeys::SectionStack, true).toBool());
10887 | if (breakpointsSection)
10888 | breakpointsSection->setExpanded(
10889 | settings_.value(SettingsKeys::SectionBreakpoints, true).toBool());
10890 | if (filesSection)
10891 | filesSection->setExpanded(
10892 | settings_.value(SettingsKeys::SectionFiles, false).toBool());
10893 | if (evalSection)
10894 | evalSection->setExpanded(
10895 | settings_.value(SettingsKeys::SectionEval, false).toBool());
10896 | if (settingsSection)
10897 | settingsSection->setExpanded(
10898 | settings_.value(SettingsKeys::SectionSettings, false).toBool());
10899 | if (watchSection)
10900 | watchSection->setExpanded(
10901 | settings_.value(SettingsKeys::SectionWatch, true).toBool());
10902 | /* The setExpanded() calls above each fire the section's toggled signal
10903 | * which triggers updateLeftPanelStretch(). Call once more explicitly to
10904 | * guarantee the splitter max-height and layout stretch factors reflect
10905 | * the final restored expansion state regardless of signal-ordering. */
10906 | updateLeftPanelStretch();
10907 | /* Match Qt enable intent to C: persist active breakpoints, then
10908 | * enable only if the user is not in "disabled" mode. */
10909 | ensureDebuggerEnabledForActiveBreakpoints();
10910 | }
10911 |
10912 | void LuaDebuggerDialog::storeDialogSettings()
10913 | {
10914 | /*
10915 | * Refresh settings_ from UI only (no disk I/O). JSON is written from
10916 | * closeEvent() via saveSettingsFile().
10917 | */
10918 | // Store theme from combo box (or current C-side value)
10919 | int32_t theme = WSLUA_DEBUGGER_THEME_AUTO;
10920 | if (themeComboBox)
10921 | {
10922 | theme = themeComboBox->itemData(themeComboBox->currentIndex()).toInt();
10923 | }
10924 | if (theme == WSLUA_DEBUGGER_THEME_DARK)
10925 | settings_[SettingsKeys::Theme] = "dark";
10926 | else if (theme == WSLUA_DEBUGGER_THEME_LIGHT)
10927 | settings_[SettingsKeys::Theme] = "light";
10928 | else
10929 | settings_[SettingsKeys::Theme] = "auto";
10930 |
10931 | // Store splitter states as hex strings
10932 | if (ui->mainSplitter)
10933 | {
10934 | settings_[SettingsKeys::MainSplitter] =
10935 | QString::fromLatin1(ui->mainSplitter->saveState().toHex());
10936 | }
10937 | if (ui->leftSplitter)
10938 | {
10939 | settings_[SettingsKeys::LeftSplitter] =
10940 | QString::fromLatin1(ui->leftSplitter->saveState().toHex());
10941 | }
10942 | /* Evaluate input/output splitter: preserves whether either pane is
10943 | * collapsed (size 0) so the user's chosen layout survives close/reopen. */
10944 | if (evalSplitter_)
10945 | {
10946 | settings_[SettingsKeys::EvalSplitter] =
10947 | QString::fromLatin1(evalSplitter_->saveState().toHex());
10948 | }
10949 |
10950 | // Store section expanded states
10951 | settings_[SettingsKeys::SectionVariables] =
10952 | variablesSection ? variablesSection->isExpanded() : true;
10953 | settings_[SettingsKeys::SectionStack] =
10954 | stackSection ? stackSection->isExpanded() : true;
10955 | settings_[SettingsKeys::SectionBreakpoints] =
10956 | breakpointsSection ? breakpointsSection->isExpanded() : true;
10957 | settings_[SettingsKeys::SectionFiles] =
10958 | filesSection ? filesSection->isExpanded() : false;
10959 | settings_[SettingsKeys::SectionEval] =
10960 | evalSection ? evalSection->isExpanded() : false;
10961 | settings_[SettingsKeys::SectionSettings] =
10962 | settingsSection ? settingsSection->isExpanded() : false;
10963 | settings_[SettingsKeys::SectionWatch] =
10964 | watchSection ? watchSection->isExpanded() : true;
10965 |
10966 | if (watchTree)
10967 | {
10968 | storeWatchList();
10969 | }
10970 | }