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

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-03-100323-3642-1 -x c++ /builds/wireshark/wireshark/ui/qt/lua_debugger/lua_debugger_dialog.cpp
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
116using namespace LuaDebuggerItems;
117
118namespace
119{
120/** Global personal config path — debugger settings are not profile-specific. */
121QString
122luaDebuggerSettingsFilePath()
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 +/−. */
131static const QString kLuaDbgHeaderPlus{QStringLiteral("\uFF0B")(QString(QtPrivate::qMakeStringPrivate(u"" "\uFF0B")))};
132static 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. */
135static 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. */
139static constexpr int kLuaDbgEvalOutputMaxLines = 5000;
140
141namespace {
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 */
149enum 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 */
163static QIcon
164luaDbgBreakpointHeaderIconForMode(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
216static void
217styleLuaDebuggerHeaderBreakpointToggleButton(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 */
230static void
231styleLuaDebuggerHeaderFittedTextButton(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. */
272static void
273styleLuaDebuggerHeaderPlusMinusButton(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 */
289static void
290styleLuaDebuggerHeaderIconOnlyButton(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. */
305static void
306styleLuaDebuggerHeaderRemoveAllButton(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. */
347class 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
480extern "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
489LuaDebuggerDialog *LuaDebuggerDialog::_instance = nullptr;
490int32_t LuaDebuggerDialog::currentTheme_ = WSLUA_DEBUGGER_THEME_AUTO;
491bool LuaDebuggerDialog::s_captureSuppressionActive_ = false;
492bool LuaDebuggerDialog::s_captureSuppressionPrevEnabled_ = false;
493bool LuaDebuggerDialog::s_mainCloseDeferredByPause_ = false;
494
495namespace
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. */
509QMutex s_logEmitMutex;
510QStringList s_pendingLogMessages;
511bool s_logDrainScheduled = false;
512} // namespace
513
514void 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
543bool 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
572void 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
596bool LuaDebuggerDialog::isSuppressedByLiveCapture()
597{
598 return s_captureSuppressionActive_;
599}
600
601bool 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
620bool 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
636void 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
670void 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
705int32_t LuaDebuggerDialog::currentTheme() {
706 return currentTheme_;
707}
708
709namespace
710{
711// ============================================================================
712// Settings Keys (for JSON persistence)
713// ============================================================================
714namespace SettingsKeys
715{
716constexpr const char *Theme = "theme";
717constexpr const char *MainSplitter = "mainSplitterState";
718constexpr const char *LeftSplitter = "leftSplitterState";
719constexpr const char *EvalSplitter = "evalSplitterState";
720constexpr const char *SectionVariables = "sectionVariables";
721constexpr const char *SectionStack = "sectionStack";
722constexpr const char *SectionFiles = "sectionFiles";
723constexpr const char *SectionBreakpoints = "sectionBreakpoints";
724constexpr const char *SectionEval = "sectionEval";
725constexpr const char *SectionSettings = "sectionSettings";
726constexpr const char *SectionWatch = "sectionWatch";
727constexpr const char *Breakpoints = "breakpoints";
728constexpr const char *Watches = "watches";
729} // namespace SettingsKeys
730
731/** QVariantMap values for JSON arrays are typically QVariantList of QVariantMap. */
732static QJsonArray
733jsonArrayFromSettingsMap(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// ============================================================================
746constexpr qint32 FileTreePathRole = static_cast<qint32>(Qt::UserRole);
747constexpr qint32 FileTreeIsDirectoryRole = static_cast<qint32>(Qt::UserRole + 1);
748constexpr qint32 BreakpointFileRole = static_cast<qint32>(Qt::UserRole + 2);
749constexpr qint32 BreakpointLineRole = static_cast<qint32>(Qt::UserRole + 3);
750constexpr qint32 BreakpointConditionRole =
751 static_cast<qint32>(Qt::UserRole + 30);
752constexpr qint32 BreakpointHitCountRole =
753 static_cast<qint32>(Qt::UserRole + 31);
754constexpr qint32 BreakpointHitTargetRole =
755 static_cast<qint32>(Qt::UserRole + 32);
756constexpr qint32 BreakpointConditionErrRole =
757 static_cast<qint32>(Qt::UserRole + 33);
758constexpr qint32 BreakpointLogMessageRole =
759 static_cast<qint32>(Qt::UserRole + 34);
760constexpr qint32 BreakpointHitModeRole =
761 static_cast<qint32>(Qt::UserRole + 35);
762constexpr qint32 BreakpointLogAlsoPauseRole =
763 static_cast<qint32>(Qt::UserRole + 36);
764constexpr qint32 StackItemFileRole = static_cast<qint32>(Qt::UserRole + 4);
765constexpr qint32 StackItemLineRole = static_cast<qint32>(Qt::UserRole + 5);
766constexpr qint32 StackItemNavigableRole = static_cast<qint32>(Qt::UserRole + 6);
767constexpr qint32 StackItemLevelRole = static_cast<qint32>(Qt::UserRole + 7);
768constexpr qint32 VariablePathRole = static_cast<qint32>(Qt::UserRole + 8);
769constexpr qint32 VariableTypeRole = static_cast<qint32>(Qt::UserRole + 9);
770constexpr qint32 VariableCanExpandRole = static_cast<qint32>(Qt::UserRole + 10);
771constexpr qint32 WatchSpecRole = static_cast<qint32>(Qt::UserRole + 11);
772constexpr qint32 WatchSubpathRole = static_cast<qint32>(Qt::UserRole + 13);
773constexpr 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. */
786constexpr qint32 ChangedFlashSerialRole =
787 static_cast<qint32>(Qt::UserRole + 20);
788
789constexpr qsizetype WATCH_TOOLTIP_MAX_CHARS = 4096;
790constexpr int WATCH_EXPR_MAX_CHARS = 65536;
791/** Transient background-flash duration for a value that just changed. */
792constexpr 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 */
803constexpr int WATCH_PLACEHOLDER_DEFER_MS = 250;
804/** Separator used in composite (stackLevel, path) baseline-map keys. */
805constexpr 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. */
812class 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
834static LuaDebuggerUiCallbackRegistrar g_luaDebuggerUiCallbackRegistrar;
835
836/** @brief Build a key sequence from a key event for matching QAction shortcuts. */
837static 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). */
847const QKeySequence kCtxGoToLine(QKeySequence(Qt::CTRL | Qt::Key_G));
848const QKeySequence kCtxRunToLine(QKeySequence(Qt::CTRL | Qt::Key_F10));
849const QKeySequence kCtxWatchEdit(Qt::Key_F2);
850const QKeySequence kCtxWatchCopyValue(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_C));
851const QKeySequence kCtxWatchDuplicate(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_D));
852const QKeySequence kCtxWatchRemoveAll(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_K));
853const QKeySequence kCtxAddWatch(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_W));
854const QKeySequence kCtxToggleBreakpoint(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_B));
855const QKeySequence kCtxReloadLuaPlugins(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_L));
856const 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 */
862static 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 */
889static 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
937static 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
949static 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 */
967static 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 */
1018static 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 */
1029static 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). */
1036static 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(). */
1042static 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 */
1062static 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 */
1107template <class Key, class Map>
1108static 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`). */
1121static 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
1135static 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). */
1142static 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 */
1167static 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. */
1178static 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 */
1189struct VariableRowFields
1190{
1191 QString name;
1192 QString value;
1193 QString type;
1194 bool canExpand = false;
1195 QString childPath;
1196};
1197
1198static 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 */
1215static 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"). */
1257static 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 */
1274static 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). */
1292static 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). */
1323static 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
1357static 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). */
1368static 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 */
1409static 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
1437static 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
1455static 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. */
1482static 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
1506using 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 */
1521static 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
1566static 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
1575static 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
1589static 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). */
1608static 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 */
1635class 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 */
1677class 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). */
1786class 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). */
1802class 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
1834static QStandardItem *
1835itemFromTreeIndex(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
1841class 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
1861QWidget *
1862WatchRootDelegate::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
1886void 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
1907void 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
1951enum class BreakpointEditMode : int
1952{
1953 Expression = 0,
1954 HitCount = 1,
1955 LogMessage = 2,
1956};
1957
1958struct 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
1966static 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 */
2055class 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. */
2251static 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. */
2308static 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. */
2378static 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
2396class 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
3186LuaDebuggerDialog::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
3587LuaDebuggerDialog::~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
3623void 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
4110void 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
4155LuaDebuggerDialog *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
4169void 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
4537void LuaDebuggerDialog::onContinue()
4538{
4539 resumeDebuggerAndExitLoop();
4540 updateWidgets();
4541}
4542
4543void 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
4607void LuaDebuggerDialog::onStepOver()
4608{
4609 runDebuggerStep(wslua_debugger_step_over);
4610}
4611
4612void LuaDebuggerDialog::onStepIn()
4613{
4614 runDebuggerStep(wslua_debugger_step_in);
4615}
4616
4617void LuaDebuggerDialog::onStepOut()
4618{
4619 runDebuggerStep(wslua_debugger_step_out);
4620}
4621
4622void 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
4655void 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
4665void 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
4723void 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
4732void 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
4752void LuaDebuggerDialog::installDescendantShortcutFilters()
4753{
4754 installEventFilter(this);
4755 for (QWidget *w : findChildren<QWidget *>())
4756 {
4757 w->installEventFilter(this);
4758 }
4759}
4760
4761void 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
4777bool 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
4986void 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
5017void 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
5310void 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
5410void 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
5425void 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
5454void 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
5584void 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
5618void 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
5643LuaDebuggerCodeView *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
5785LuaDebuggerCodeView *LuaDebuggerDialog::currentCodeView() const
5786{
5787 return qobject_cast<LuaDebuggerCodeView *>(
5788 ui->codeTabWidget->currentWidget());
5789}
5790
5791qint32 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
5808bool LuaDebuggerDialog::hasUnsavedChanges() const
5809{
5810 return unsavedOpenScriptTabCount() > 0;
5811}
5812
5813bool 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
5839void 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
5854bool 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
5881bool 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
5900void 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
5919void LuaDebuggerDialog::updateSaveActionState()
5920{
5921 LuaDebuggerCodeView *view = currentCodeView();
5922 ui->actionSaveFile->setEnabled(view && view->document()->isModified());
5923}
5924
5925void LuaDebuggerDialog::updateWindowModifiedState()
5926{
5927 setWindowModified(hasUnsavedChanges());
5928}
5929
5930void 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
5971void LuaDebuggerDialog::updateLuaEditorAuxFrames()
5972{
5973 QPlainTextEdit *ed = currentCodeView();
5974 ui->luaDebuggerFindFrame->setTargetEditor(ed);
5975 ui->luaDebuggerGoToLineFrame->setTargetEditor(ed);
5976}
5977
5978void LuaDebuggerDialog::onEditorFind()
5979{
5980 updateLuaEditorAuxFrames();
5981 showAccordionFrame(ui->luaDebuggerFindFrame, true);
5982}
5983
5984void LuaDebuggerDialog::onEditorGoToLine()
5985{
5986 updateLuaEditorAuxFrames();
5987 showAccordionFrame(ui->luaDebuggerGoToLineFrame, true);
5988}
5989
5990void LuaDebuggerDialog::onSaveFile()
5991{
5992 LuaDebuggerCodeView *view = currentCodeView();
5993 if (!view || !view->document()->isModified())
5994 {
5995 return;
5996 }
5997 saveCodeView(view);
5998 updateSaveActionState();
5999}
6000
6001void 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
6036void 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
6081void 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
6221void 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
6248void 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
6353void 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
6382bool 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
6437bool 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
6459void 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
6627void 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
6747void 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
6776void LuaDebuggerDialog::onMonospaceFontUpdated(const QFont &font)
6777{
6778 applyCodeEditorFonts(font);
6779}
6780
6781void LuaDebuggerDialog::onMainAppInitialized()
6782{
6783 applyMonospaceFonts();
6784}
6785
6786void LuaDebuggerDialog::onPreferencesChanged()
6787{
6788 applyCodeViewThemes();
6789 applyMonospaceFonts();
6790 refreshWatchDisplay();
6791}
6792
6793void 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
6814void 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 */
6838void 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 */
6909void 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 */
6931void 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
6941void 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
6977void 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 */
7007static 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
7016void 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
7053void 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
7074bool 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
7138QString 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
7167QStandardItem *
7168LuaDebuggerDialog::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
7201bool 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
7277void LuaDebuggerDialog::openInitialBreakpointFiles(
7278 const QVector<QString> &files)
7279{
7280 for (const QString &path : files)
7281 {
7282 loadFile(path);
7283 }
7284}
7285
7286void 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
7302void 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
7316void 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
7332void 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
7360void 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
7375void 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
7480void 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
7552void 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
7615void 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
7669void 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
7687void LuaDebuggerDialog::applyMonospaceFonts()
7688{
7689 applyCodeEditorFonts(effectiveMonospaceFont(true));
7690 applyMonospacePanelFonts();
7691}
7692
7693void 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)
7723static 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
7753void 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
7767void 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
7795QFont 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
7807QFont LuaDebuggerDialog::effectiveRegularFont() const
7808{
7809 if (mainApp && mainApp->isInitialized())
7810 {
7811 return mainApp->font();
7812 }
7813 return QGuiApplication::font();
7814}
7815
7816void 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
7841void 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
7848void 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
7877void 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
7894LuaDebuggerDialog::DebuggerUiStatus
7895LuaDebuggerDialog::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
7919void 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
7989void 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
8023void 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
8035void 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
8051void 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
8079void 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
8100void 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
8146void 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
8164void 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
8192void 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
8247void LuaDebuggerDialog::onEvalClear()
8248{
8249 evalInputEdit->clear();
8250 evalOutputEdit->clear();
8251}
8252
8253void 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
8287void 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
8366void 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
8409namespace
8410{
8411// NOLINTNEXTLINE(misc-no-recursion)
8412static 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
8437void 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
8469QList<QStandardItem *>
8470LuaDebuggerDialog::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
8494void 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
8508void 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
8554void 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
8668QStandardItem *
8669LuaDebuggerDialog::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
8689QStandardItem *
8690LuaDebuggerDialog::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
8718void 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
8741void 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
8778void 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
8828void 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
8868namespace
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 */
8876static 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
8887void 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
8916void 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
8937void 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
8976namespace
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 */
8983static 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 */
9015static 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
9050void 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
9107void 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
9128void 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
9182void 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
9225void 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
9247void 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
9274void 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
9326void 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
9375void 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
9437void 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
9507void 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
9569void 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
9678void 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
9782namespace
9783{
9784/** Subpath / variable-path key used to address @p item inside a watch root. */
9785static 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
9800void 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
9820void 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
9851QStringList 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
9867void LuaDebuggerDialog::recordWatchRootExpansion(const QString &rootSpec,
9868 bool expanded)
9869{
9870 recordTreeSectionRootExpansion(watchExpansion_, rootSpec, expanded);
9871}
9872
9873void LuaDebuggerDialog::recordWatchSubpathExpansion(const QString &rootSpec,
9874 const QString &key,
9875 bool expanded)
9876{
9877 recordTreeSectionSubpathExpansion(watchExpansion_, rootSpec, key, expanded);
9878}
9879
9880QStringList
9881LuaDebuggerDialog::watchExpandedSubpathsForSpec(const QString &rootSpec) const
9882{
9883 return treeSectionExpandedSubpaths(watchExpansion_, rootSpec);
9884}
9885
9886void 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
9920void 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
9975void 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
10001void 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
10046void 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
10069namespace
10070{
10071/** Pointers into the context menu built by buildWatchContextMenu(). */
10072struct 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 */
10092static 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
10142void 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
10241void 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
10304void 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
10345void 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
10384void 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
10418void 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
10430void 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
10449void LuaDebuggerDialog::addWatchFromSpec(const QString &watchSpec)
10450{
10451 insertNewWatchRow(watchSpec, false);
10452}
10453
10454void 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
10519void 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
10580void 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
10615void 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
10660void 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
10692void 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
10722void 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
10912void 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}