Commit graph

548 commits

Author SHA1 Message Date
Vadim Peretokin
7bb20fa2ce
add: widget state getters for titles, stylesheets, tooltips and scroll bars (#9645)
#### Brief overview of PR changes/additions

- Seven state getters that are the inverse of setters we already ship:
`getUserWindowTitle`, `getUserWindowStyleSheet`, `getCmdLineStyleSheet`,
`getLabelToolTip`, `getScrollBarVisible`, `getMapWindowTitle` and
`getMapWidgetGeometry`
- Each returns nil plus a message when the window, label or map widget
it names does not exist, reusing the matching setter's wording so the
pair reports the same problems the same way
- 39 specs added to the existing `UI_spec.lua` and `Mapper_spec.lua`

#### Motivation for adding to Mudlet

#9630's audit left 11 Geyser/UI rows untestable purely because the state
those functions set could not be read back; this tranche unblocks them
exactly as #9528's getters unblocked the geometry specs. Scripts get the
same readback symmetry as a side effect.

#### Other info (issues closed, discussion etc)

One deliberate behaviour change: `enableScrollBar`/`disableScrollBar`
now record what they were asked for, so `getScrollBarVisible` answers
for a profile that is not the front tab (whose whole console Mudlet
hides) instead of reporting every background profile's scroll bar as
gone. Wiki pages for the seven functions to follow in Area 51.

**Test case:** busted 1868 passed / 0 failed / 0 errors / 17 pending
(baseline 1829), green twice on the same isolated profile, plus ctest;
31 of the new specs verified by breaking the getters - wrong return
values fail 20, making the not-found branches succeed fails 7, and
dropping the empty-name and nil handling fails 8 more.

Assisted-by: Claude:claude-opus-5
2026-08-05 06:49:23 +02:00
Vadim Peretokin
fba66d283d
fix: event handlers no longer wipe the caller's Lua stack (#9621)
#### Brief overview of PR changes/additions
- Each Lua-glue function that runs user code (event handlers,
trigger/alias/script bodies, callbacks, the GMCP/MSSP/MXP table
builders) now records its entry stack level and `lua_settop()`s back to
it, and reads its result and error object relative to its own frame
instead of at absolute index 1 (where, mid-dispatch, the caller's
arguments live).
- 17 sites converted across `TLuaInterpreter.cpp` /
`TLuaInterpreterMapper.cpp`; three inferred entry levels
(`callReference`, `parseJSON`, `parseMSSP`, whose callers pre-push for
them) carry a `Q_ASSERT_X`. Left alone: `formatLuaCode` (separate
indenter state) and `initLuaGlobals`/`setupLanguageData` (run while the
state is being built).
- Called from the event loop, entry gettop is 0, so `settop(entry)` is
the old wipe exactly. One deliberate exception: a script that returned
nothing used to be read with `lua_isboolean(L, 0)` - a stale-slot read
in Lua 5.1 - and is now deterministically false.

#### Motivation for adding to Mudlet
These functions finished with `lua_pop(L, lua_gettop(L))`, clearing the
whole shared per-profile Lua stack rather than their own frame. Because
they run synchronously from inside other Lua API C functions, the wipe
destroyed the caller's arguments and pending return values - #9590
shipped as an instance (`ttsSetVoiceByName` returning stack garbage).
This removes the class rather than patching call sites one by one.

#### Other info (issues closed, discussion etc)
Root cause of #9590 (already patched at its call site in #9595); this
makes that ordering safe everywhere.
**Test case:** with neither fix the suite reproduces #9590 verbatim (2
failures, `Passed in: function: 0x...`); with only the class fix and
`ttsSetVoiceByName` still pushing before it raises, the suite is green.
A new `Trigger_spec` case pins the frame-relative reads generically (a
1-fire trigger fires 3 times without them). Busted 1821/0/0 twice (17
pendings are the HTTP fixture), ctest 65/66 (`TKeySequenceEditTest` is
the known bare-Xvfb flake).

Assisted-by: Claude:claude-opus-5
2026-08-04 10:10:18 +02:00
Vadim Peretokin
44da9ec237 fix: setTextFormat leak, createLabel return convention, windowType scrollbox
setTextFormat built a QVector<int> and a QString before validating its
arguments, so every lua_error() on the type paths longjmped past their
destructors. createLabel's two helpers held QStrings across the same kind
of raise. Both now build owning objects only once nothing else can raise.

createLabel discarded its helpers' return count and always returned 1, so a
failed call handed Lua just the message string - truthy, so the failure was
undetectable. It now returns the documented false + message.

windowType gained a scroll box branch, and its not-found message now names
every type it checks.

UI_spec.lua flips the specs that documented the old behaviour and covers
setTextFormat's argument-type errors, which the leak used to block.

Fixes #9576
Fixes #9577
Fixes #9578

Assisted-by: Claude:claude-opus-5
2026-08-02 20:11:00 +02:00
Vadim Peretokin
0f9bc0a4be
infrastructure: decouple the mapper engine from UI dialogs (#9513)
#### Brief overview of PR changes/additions
- Removes all raw Qt Widgets usage from the map engine: `TMap.{h,cpp}`
no longer owns a `QProgressDialog` (and drops a dead `QFileDialog`
include), and `XMLimport.{h,cpp}` no longer pulls in `QApplication` (the
clipboard read now uses `QGuiApplication::clipboard()`, which lives in
Qt Gui).
- The standalone map-progress dialog (shown when the mapper is not
visible, for map download / XML import and JSON export/import) is now
driven by Qt signals carrying pre-translated payloads; the frontend
(`TMainConsole`) owns the actual `QProgressDialog`, and a user cancel
returns to the engine through `TMap::slot_mapProgressDialogCancelled()`.
- Adds `MapProgressDialogSeamTest` covering the transfer-progress state
machine, a JSON export/import round trip driving the new signals, a
mid-import cancel delivered through the seam (the highest-risk change,
since the JSON reader used to poll `QProgressDialog::wasCanceled()`
synchronously), and an XML map import re-entered from inside a running
JSON operation.

#### Motivation for adding to Mudlet
Second concrete step of the re-scoped libmudlet plan (a Qt-Widgets-free
`mudlet_core` for headless use, testability and WASM). It copies the
seam template established in #9507: core emits a pre-translated payload
-> frontend owns the widget -> a callback slot returns the answer. The
Qt Widgets dependency audit (`cmake/audit-core-widgets.sh`) drops from
**151 to 147** offending files; `TMap.cpp`, `TMap.h`, `XMLimport.cpp`
and `XMLimport.h` are all now clean. The mapper-owned inline progress
path (`dlgMapper`/`T2DMap`, Mudlet's own widgets) is deliberately
untouched here - those move wholesale in the later target-split phase.

#### Other info (issues closed, discussion etc)
Part of #8681 / #9011. Existing translations are unaffected: every
progress string keeps its `TMap` `tr()` context, so current translations
carry straight over. Two new strings do arrive, both with `//:`
translator comments - the warnings shown when a map download or an XML
map import is refused because a JSON import/export is already running.
The JSON dialog stays non-modal and the download/import dialog keeps its
modeless styling, each applied by the frontend. The engine keeps its own
`mMapProgressStandalone` / `mMapProgressCancelRequested` /
`mMapProgressStandaloneMaximum` state to replace the widget read-backs
it used to do (`!= nullptr`, `wasCanceled()`, `maximum()`). If a map
operation ever reaches the engine before a console is wired (checked via
`isSignalConnected`), `TMap::warnIfMapProgressUnwired()` logs a loud
`qWarning` rather than silently running with no progress UI.

It also closes a latent null-dereference that exists on `development`
today. With the mapper visible a map download takes the inline-progress
path, leaving `mpProgressDialog` null - so a JSON export started
meanwhile sails past the `if (mpProgressDialog)` "already in progress"
check and creates a dialog of its own. When the download then finishes
inside the `processEvents()` pump the export is running,
`clearTransferProgress()` deletes and nulls *that* dialog, and the
export's next `incrementJsonProgressDialog()` dereferences null. The
engine now records whose dialog is up (`mMapProgressIsTransfer`) so a
transfer only ever closes its own, and `importMap()` refuses to start
while a JSON operation holds the progress - the mirror of the guard
`downloadMap()` has.

Two review-driven details worth flagging: the frontend only wires the
dialog's cancel to the engine when the operation is actually cancelable,
so a non-cancelable local XML import no longer turns a window-close into
a spurious "Map download was canceled" message; and the standalone
download/import dialog is now parented to the console (like the JSON one
always was, and like #9507's package-download dialog), so it centres on
and dies with the profile window. The three `#include <QApplication>`
additions to `Host.cpp` / `dlgTriggerEditor.cpp` /
`dlgConnectionProfiles.cpp` replace the transitive include they used to
get from `XMLimport.h`; all three are already Qt Widgets consumers, so
the audit count is unaffected.

Assisted-by: Claude:claude-opus-4-8
Assisted-by: Claude:claude-opus-5

**Test case:** With a mapper window open, use a game that supports map
download (or call `downloadMap()`) and confirm the progress dialog
shows, updates, and its Abort cancels the download. Then with the mapper
window closed, run `exportJsonMap()` and `importJsonMap()` on a large
map and confirm the non-modal JSON progress dialog appears, updates its
Areas/Rooms/Labels counts, and that clicking Abort during an import
stops it with an "aborted by user" result. Load a local XML map
(Settings -> Map -> load) and confirm closing its progress window does
not print a "Map download was canceled" line. Everything should behave
exactly as on `development`.

#### Demo (before & after)


https://github.com/user-attachments/assets/f1c62580-2d03-4e5b-a6ee-f6e2b78d113d
2026-08-02 15:33:07 +02:00
Vadim Peretokin
1f00cd0ad7
infrastructure: decouple profile management (Host) from UI dialogs (#9514)
#### Brief overview of PR changes/additions
- Removes all raw Qt Widgets usage from `Host.{h,cpp}` so the
`mudlet_core` Qt Widgets audit (`cmake/audit-core-widgets.sh`, added in
#9508) drops both files to zero: the offending-file count goes from 158
to 156 and both move to the "Clean files" list. (The committed
report/baseline are intentionally not regenerated here, to avoid
conflicts with sibling wave-2 PRs.)
- Follows the seam pattern established by #9507: the core (`Host`) emits
Qt signals carrying already-translated `tr()` strings, and the frontend
(`TMainConsole`/`mudlet`) owns the actual widgets.
- The dockable map widget (`mpDockableMapWidget`, a `QDockWidget`) moved
from `Host` to the profile's own `TMainConsole`.
`TMainConsole::createMapperDock()` constructs it and the console's
destructor disposes of it. `Host` still drives it through
`mpConsole->mpDockableMapWidget` (an already out-of-scope pointer per
the split plan) but no longer names any Qt Widgets type. The external
accessors in `mudlet.cpp`/`TDetachedWindow.cpp` gained an `mpConsole &&`
null-guard.
- The mapping-script reminder and package-unpacking progress dialogs are
now shown by the frontend in response to
`signal_showMapperScriptReminder` / `signal_showUnpackingProgress` /
`signal_hideUnpackingProgress`, wired up in
`mudlet::addConsoleForNewHost`.
- `TDockWidget` now sets its own dock features (moved out of
`Host::openWindow`); `Host::setBorders` uses
`QCoreApplication::sendEvent`; and the user-window scrollbar is hidden
via `TConsole::setScrollBarVisible()` instead of reaching into the raw
`QScrollBar`.
- Adds `HostWidgetDecouplingTest` (ephemeral port-0 stub + a real
profile, modelled on `TelnetTlsPromptTest`): verifies the map dock is
created and owned by the console, that `setMapperTitle` routes through
it, that the reminder dialog is raised, and that the unpacking dialog is
replaced then disposed (asserting the replaced dialog is destroyed, not
leaked). Two further tests cover the seams end to end: a real package
install has to reach the dialog through the `addConsoleForNewHost`
wiring, and closing a profile has to take the console-owned map dock
with it.

#### Motivation for adding to Mudlet
Continues the re-scoped libmudlet plan (a Qt Widgets-free `mudlet_core`
for headless use, testability and WASM). `Host` is the second concrete
extraction after `cTelnet` (#9507) and copies its template so later
extractions can follow the same shape.

#### Other info (issues closed, discussion etc)
Part of #8681 / #9011. Behavior-preserving: dialogs keep the same
modality/defaults and all strings stay in `Host`'s translation context,
so existing translations are unaffected. One intentional behaviour
change: failing to load the cosmetic unpacking/reminder `.ui` now warns
and no-ops instead of aborting the package install (a fire-and-forget
signal cannot fail the install back to `Host`), which is strictly
better. The dock's `deleteLater()` cleanup moved from `Host`'s
destructor to `TMainConsole`'s.

Reviewed with the code-reviewer and silent-failure-hunter agents; both
flagged a leak in the unpacking-dialog replace path (parentless dialog
`close()`d instead of `deleteLater()`d) and the missing destructor
cleanup, both fixed and now covered by the test. Expect the `mudlet.cpp`
`addConsoleForNewHost` wiring to conflict with sibling wave-2 PRs; that
is fine.

Assisted-by: Claude:claude-opus-4-8

**Test case:** Open a profile and open the mapper via the Map toolbar
button - it appears docked as before, and its title can be changed with
`setMapperTitle(...)`. Install a `.zip`/`.mpackage` from the package
manager (a normal package, not a module-from-UI and not a script/quiet
install) and confirm the "Unpacking..." progress dialog shows and then
closes. On a profile with no mapper script, open the mapper and confirm
the "you have no mapper script" reminder dialog appears and its link
opens the mapping scripts page.



#### Demo (before & after)

Parity check that the moved dialog/dock flows behave identically before
(development) and after this PR: the package-install "Unpacking..."
dialog and the dockable map widget.


https://github.com/user-attachments/assets/41579b67-7de1-463b-b642-bd7660b52431
2026-08-02 13:04:21 +02:00
Vadim Peretokin
1227bc3778
add: getWindowGeometry(), windowVisible() and getLabelText() functions (#9528)
#### Brief overview of PR changes/additions
- `getWindowGeometry(name)` returns x, y, width, height for any window
element - the exact inverse of moveWindow()/resizeWindow()
- `windowVisible(name)` returns effective visibility (a child in a
hidden user window reports false)
- `getLabelText(name)` returns the text shown on a label
- 25 specs appended to UI_spec.lua

#### Motivation for adding to Mudlet
Long-requested readback symmetry: scripts (and now tests) can finally
query window state they could previously only set.

#### Other info (issues closed, discussion etc)
Part of the Lua API test-coverage program (Wave 0); unlocks ~100
previously untestable functions. Wiki text drafted, to be added to Area
51 once merged.

**Test case:** `createLabel` + `moveWindow`/`resizeWindow`, then
`getWindowGeometry` returns the same values; `hideWindow` flips
`windowVisible` to false; `echo` to a label, `getLabelText` returns it.

Awaiting build/test by a maintainer; squash-merge with:
Assisted-by: Claude:claude-opus-4-8
(Signed-off-by to be added at squash after testing)
2026-07-30 12:04:54 +02:00
Vadim Peretokin
1cf8a59ba2
fix: prevent a rare crash while saving a profile that uses modules (#9559)
#### Brief overview of PR changes/additions
- Move all of `Host::saveProfile()`'s module-save bookkeeping to the
main thread: the module XML documents are now built and their writers
registered up front (`prepareModuleSaves()`), and the background task
only does file I/O (`writeModuleFiles()`: back up, serialize the
prepared documents, update zips).
- The shared `writers`/`modulesToWrite`/`mModulesToSync` containers, and
`Host::xmlSaved()`, are now only ever touched on the main thread; the
background task no longer reads the live trigger/timer/script lists
while building a module document either.
- Each module writer is owned solely by `writers` and dropped on the
main thread, so its `XMLexport` (a main-thread `QObject`) is always
destroyed on its own thread; `profileSaveFinished` is now always emitted
on the main thread.

#### Motivation for adding to Mudlet
Concurrently mutating these implicitly-shared Qt containers from both
the main thread and the QtConcurrent pool thread is undefined behaviour
and matches a real heap-corruption crash cluster.

#### Other info (issues closed, discussion etc)
- This is the pre-existing module-save data race documented in-code by
#9557 (which fixed the profile-save half and explicitly deferred module
writing to its own PR). Same crash signature: `STATUS_HEAP_CORRUPTION`,
Sentry cluster MUDLET-32 / MUDLET-2S / MUDLET-48, frames in lua51 /
libpugixml / Qt6Core.
- The approach mirrors the existing profile-save path (build the pugixml
document on the main thread, serialize it on a background thread) and
reuses the `pendingXmlSaveFutures()` snapshot pattern. Module
serialization, backups, zip updates and the wait-for-save semantics
(`waitForProfileSave`, `currentlySavingProfile`, single-in-flight-save
gating) are preserved.
- Known-benign ordering: the completion handler clears
`mWritingHostAndModules` and emits `profileSaveFinished` before running
`reloadModules()`. There is no current `profileSaveFinished` handler
that re-enters `saveProfile()`; a future one must not, as it would
repopulate `mModulesToSync` that `reloadModules()` then consumes.
- Data races are non-deterministic and the existing save tests
synchronise via `waitForProfileSave()`, so this cannot be exhibited by a
functional test. Correctness rests on every access to the four shared
members now being single-threaded; a ThreadSanitizer build runs the save
path with no data race reported in this code.

**Test case:**
1. In a profile with a module that has "sync" enabled, edit the module
and save the profile (or close the profile) repeatedly - the module
still serialises to disk correctly and syncs to other open profiles.
2. `ctest -R "ResetProfileTest|ProfileRoundTripTest|MapRoundTripTest"`
in an ASan (default Debug) build - all pass.


Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Vadim Peretokin <vadim.peretokin@mudlet.org>
2026-07-30 12:02:53 +02:00
Vadim Peretokin
41f61e9698
Fix: crash when a button uninstalls its own package (#9558)
#### Brief overview of PR changes/additions

A toolbar or menu button whose Lua script calls `uninstallPackage()` on
its own package used to crash Mudlet. `ActionUnit::uninstall()` deleted
the package's buttons immediately, but the clicked button is still
inside `TAction::execute()`, which reads `this->mpHost` (to restore
command-line focus) *after* the script returns - a heap-use-after-free.

This applies the same processing-depth deferral already used for
triggers/aliases/keys and timers/scripts: while a button's script is
running, `uninstall()` deactivates and queues that package's actions
instead of deleting them, and the delete happens later in `doCleanup()`
once `execute()` has unwound. `ActionUnit` was the last object unit
still deleting immediately. The queued deletes are flushed by the button
dispatchers once the click returns and by the existing per-line /
periodic `doCleanup()` calls in `Host`, and a seen-set guards against a
double free from a re-entrant uninstall.

#### Motivation for adding to Mudlet

It is a hard crash reachable with a completely normal setup - a
"reload/update this package" button that reinstalls itself is a common
pattern.

#### Other info (issues closed, discussion etc)

Completes the self-uninstall use-after-free hardening across all object
units - a follow-up to #9557 (timers/scripts) and #9383
(triggers/aliases/keys); actions were the one remaining unguarded unit.
The crash is reported in Sentry as MUDLET-32 / MUDLET-2S / MUDLET-48.

**Test case:**

`test/functional_tests/ActionSelfUninstallTest.cpp` (Qt Test, built with
AddressSanitizer) builds a synthetic package whose toolbar button
uninstalls its own package, gives it a real toolbar widget, invokes the
button, and asserts no crash, the package removed, and the deferred
action freed cleanly with no double free. It trips a heap-use-after-free
in `TAction::execute()` on the pre-fix code and is ASan-clean with the
fix.

To reproduce by hand:
1. Create a package containing one button whose script is
`uninstallPackage("<that package's name>")`.
2. Click the button.
3. Before this PR: Mudlet crashes. After: the package uninstalls cleanly
and focus returns to the command line.



Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Vadim Peretokin <vadim.peretokin@mudlet.org>
2026-07-30 12:02:32 +02:00
Vadim Peretokin
d4376326bb
fix: stop crashes while saving and profiles losing their triggers (#9557)
#### Brief overview of PR changes/additions
- Profile loading now only considers real `*.xml` saves: an empty
QSaveFile temporary left behind by a crash during a save can no longer
be loaded as "the profile", which made a profile open with its
connection settings intact but every trigger/script seemingly gone.
Affected profiles heal themselves on next load by falling back to the
newest real save.
- Packages that uninstall themselves from their own timer script or
event-handler script (a common auto-updater pattern) no longer free the
very objects still executing: `TimerUnit`/`ScriptUnit` uninstall now
defers deletion while `TTimer::execute()` / `Host::raiseEvent()` are on
the call stack, completing the #9337/#9383 fix that already covered
triggers/aliases/keys. Deferred timer deletes are flushed before the
queued post-uninstall save runs, so removed items cannot be serialized
back into the profile.
- `Host::saveProfile()`'s background module task no longer reads
`writers`/`saveFutures` concurrently with the main thread (data race in
the profile save path).

#### Motivation for adding to Mudlet
Fixes a real-world heap-corruption crash cluster (Sentry MUDLET-32 /
MUDLET-2S / MUDLET-48: `STATUS_HEAP_CORRUPTION` on 4.21.0/4.21.1, frames
touching lua51/Qt6Core/libpugixml, breadcrumbs showing package uninstall
activity around saves) and the profile data loss it caused.

#### Other info (issues closed, discussion etc)
Root cause of the crashes: #9111 (in the 4.20.1 → 4.21.0 window) changed
the `*Unit::uninstall()` methods from unregister-only to immediate
`delete`. A package script calling `uninstallPackage()` on its own
package then freed objects still on the call stack - use-after-free that
poisons the heap, typically detected slightly later during the
background save serialization (hence the pugixml/lua frames, aborts
mid-save, and zero-byte `....xml.XXXXXX` QSaveFile leftovers in
`current/`). #9383 fixed the trigger/alias/key cases; this completes
timers and scripts, which reproduce under ASan on current development
(heap-use-after-free in `Tree<TScript>::isActive()` /
`TTimer::execute()`).

Data-loss mechanism (generic): a crash mid-save leaves a 0-byte
QSaveFile temporary as the newest file in `current/`;
`mudlet::loadProfile()` picked the newest file of any name, tried to
load the empty temp, and the profile opened "gutted" (connection details
live in separate files and survived). Verified end-to-end with affected
profile data and covered by a synthetic regression test.

Both new functional tests fail on pre-fix code
(`PackageSelfUninstallTest` trips ASan heap-use-after-free;
`ProfileLoadTempFileTest` reproduces the data loss) and pass with the
fix; full functional suite green (24/24).

Known remaining (pre-existing) issue documented in-code at
`Host::pendingXmlSaveFutures()`: module writing still touches `writers`
from the background task for profiles that use modules; fixing that
properly means moving module serialization back to the main thread and
deserves its own PR.

**Test case:**
1. Create a package containing a timer or event-handler script that
calls `uninstallPackage()` on its own package, and let it fire - no
crash, package cleanly removed, next save does not resurrect it.
2. Simulate an interrupted save: place an empty file named like
`2026-01-01#12-00-00.xml.AbCdEf` in a profile's `current/` folder with
the newest timestamp - the profile still loads the newest real save with
all triggers intact, and the temporary no longer appears in Connect →
Options → Profile history.
3. `ctest -R "ProfileLoadTempFileTest|PackageSelfUninstallTest"` in an
ASan (default Debug) build.


Assisted-by: Claude:claude-fable-5
Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Vadim Peretokin <vadim.peretokin@mudlet.org>
2026-07-30 10:49:10 +02:00
Vadim Peretokin
9007603096
infrastructure: add waitForEvent test helper for async Lua API tests (#9527)
#### Brief overview of PR changes/additions
- New test-mode-only `waitForEvent(eventName, timeoutMs)`: pumps a
nested event loop so busted specs can observe timers, network and Mudlet
events (inert outside MUDLET_TEST_MODE)
- Event args are snapshotted into the Lua registry so tables survive the
event's own cleanup
- 13 specs in MudletBusted_spec.lua covering firing, arg round-trips,
timeouts, filtering and nested waits

#### Motivation for adding to Mudlet
Busted blocks the event loop, so nothing asynchronous was testable; this
is the keystone for timer/HTTP/media/MMCP test coverage.

#### Other info (issues closed, discussion etc)
Part of the Lua API test-coverage program (Wave 0). Sabotage-verified:
7/13 specs fail when the capture hook is disabled.

**Test case:** build, then run the busted suite (all green);
waitForEvent specs exercise a real tempTimer firing through the helper.

Awaiting build/test by a maintainer; squash-merge with:
Assisted-by: Claude:claude-opus-4-8
(Signed-off-by to be added at squash after testing)
2026-07-29 19:36:28 +02:00
Vadim Peretokin
775e223405
infrastructure: clean vestigial widget includes from the Lua engine files (#9526)
#### Brief overview of PR changes/additions

Step 1 of the libmudlet Wave 3 de-widgeting work: a cleanup +
correctness pass over the `TLuaInterpreter*` engine files.

- Delete the dead `#include <edbee/texteditorwidget.h>` from
`TLuaInterpreter.h` (no `edbee`/`TextEditorWidget` use anywhere in the
TLuaInterpreter family).
- Drop the copy-pasted, unused `QFileDialog`/`QTableWidget`/`QToolTip`
include block from the seven `TLuaInterpreter*` files carrying it,
keeping only the includes each file actually uses.
`TLuaInterpreterMudletObjects.cpp` keeps `QFileDialog` (used by
`invokeFileDialog`); `TLuaInterpreter.cpp` gains an explicit
`<QApplication>` for its remaining `QApplication::alert`, which the
removed headers had been supplying transitively.
- Retarget two Widgets-free statics for audit accuracy:
`QApplication::clipboard()` -> `QGuiApplication::clipboard()` and
`QApplication::applicationPid()` -> `QCoreApplication::applicationPid()`
(both are inherited statics, so behaviour is identical).
- Fix a null-path bug: `Host::echoWindow` and `Host::pasteWindow`
returned `-1` (which promotes to `true`) when the console is null, a
silent success-lie on a torn-down profile. They now return `false`,
matching the existing missing-window path, so the checked `echo` caller
yields the honest `nil + message` instead of a false success.

#### Motivation for adding to Mudlet

Part of the incremental libmudlet refactor (#8681, #9011) that drives
raw Qt Widgets coupling out of `mudlet_core`. This removes 5 files from
the `cmake/audit-core-widgets.sh` Widgets-dependent set (156 -> 151)
with no behaviour change on the GUI path, and folds in an independent
correctness fix for the `-1`->`true` null-path pattern.

#### Other info (issues closed, discussion etc)

Relates to #8681 and #9011. Closes no issues.

**Test case:**

1. Build (this was verified with Qt 6.12.0, Ninja, ASan) and run the C++
suite:
`ctest --output-on-failure` -> 48/49 pass. The only failure is
`TKeySequenceEditTest`, a known pre-existing headless-focus failure
(`qWaitForWindowActive` under Xvfb), unrelated to these changes.
2. `bash cmake/audit-core-widgets.sh --summary` reports 151 files depend
on Qt Widgets (was 156); the five now-clean files are
`TLuaInterpreterUI/Mapper/Networking/Discord/TextToSpeech.cpp`.
3. Lua sanity for the retargeted statics: `getClipboardText()`,
`setClipboardText("hi")`, and `getProcessID()` behave exactly as before.
4. Null-path fix: `echo` targeting a window that does not exist returns
the honest `nil + "console/label '...' does not exist"` rather than
reporting success. (The null-console branch it fixes is reachable when a
profile's main console has been torn down.)

Assisted-by: Claude:claude-opus-4-8
2026-07-28 16:26:02 +02:00
elements-of-boredom
ae6b017c81
add: full-window background image/gradient support (#9394)
#### Brief overview of PR changes/additions
- Updates `setBackgroundImage(target, imageLocation, [mode],
[fullWindow])` / `resetBackgroundImage(target, [fullWindow])` Lua
functions to support painting a background image or gradient behind the
entire main console window, independent of `main`'s own size/position.
- adds a new `cover` mode for true aspect-preserving scale-to-fill;
gradients are supported via the existing `style` mode using Qt's native
QSS gradient syntax
(`qlineargradient`/`qradialgradient`/`qconicalgradient`).
- Unlike previous `setBackgroundImage("main", ...)`, the background
stays fixed and uncropped when `main` is resized via `setBorderSizes()`
or side panels are added.

#### Motivation for adding to Mudlet
Lets players and package authors theme the whole Mudlet window with a
persistent image or gradient, without hacky miniconsole/image-slicing
workarounds that break whenever borders or panel layout change.

#### Other info (issues closed, discussion etc)
Closes #9392

**Test case:** 
`setBorderSizes(80,80,80,80)` then 
`setBackgroundImage("main", getMudletHomeDir().."/pathto/asset.png",
"cover", true)`

Image fills the border margin around `main`, uncropped, and stays fixed
through further `setBorderSizes()` changes. Combine with
`setBackgroundColor("main", 0,0,0,140)` for a tinted readable box.
`resetBackgroundImage("main", true)` removes it.

`setBackgroundImage("main","background: qlineargradient(x1:0, y1:0,
x2:0, y2:1, stop:0 #1a1a2e, stop:1 #16213e);", "style", true)` paints a
gradient behind "main"
`resetBackgroundImage("main", true)` removes it.

`setBackgroundImage("main", getMudletHomeDir().."/bgtest-assets/bg.png",
"border")` - Continues to add image as before

Example:
<img width="1482" height="846" alt="Screenshot 2026-07-08 at 2 59 17 PM"
src="https://github.com/user-attachments/assets/e2ec9835-a5c3-4f1a-a246-c45de6f80062"
/>
2026-07-27 22:14:58 +02:00
Morquin
4f259afa0a
Fix: character-at-a-time warning misfires on password prompts (#9391)
#### Brief overview of PR changes/additions
- Only warn about character-at-a-time mode (server ECHO +
SUPPRESS-GO-AHEAD) once that combination has outlived a submitted input
line, instead of the instant both options are negotiated.
- Detect it by behaviour: when a line is sent while ECHO+SGA are active,
arm a short one-shot timer; cancel it if the server releases ECHO (WONT
ECHO); only warn if ECHO+SGA are still active when the timer fires.

#### Motivation for adding to Mudlet
Servers that mask a password advertise SUPPRESS-GO-AHEAD up front and
take over ECHO transiently (WILL ECHO ... WONT ECHO) around the masked
field, which #8825 misread as character-at-a-time mode - so the warning
appeared at the login/password prompt of a large class of well-behaved
servers.

#### Other info (issues closed, discussion etc)
Follow-up to #8825. Only the warning's trigger changes; the
`sysCharacterModeDetected` Lua event now fires at the same confirmed
point. Detection window is `CHARACTER_MODE_DETECT_MS` (3000 ms).

**Test case:** Connect to a server that masks the password via telnet
ECHO (e.g. any GoMud-based game) and log in - the "character-at-a-time
mode" warning no longer appears and password masking still works. A
genuine character-at-a-time server (ECHO+SGA kept active across
successive lines) still triggers the warning.

Try connecting to:
gomud.net port 33333 or willowdalemud.com port 1111

---------

Signed-off-by: Morquin <morquin@morquin.dk>
2026-07-27 09:47:44 +00:00
Vadim Peretokin
efc42fac05
infrastructure: harden the build for the minimum supported Qt version (#9510)
#### Brief overview of PR changes/additions

- Include QJson/QtCore headers directly in the 16 files that only
reached those types through transitive includes; headers forward-declare
where a reference suffices
- Pass the MMCP command bytes to QString::arg() as explicit QChar (wire
bytes unchanged)

#### Motivation for adding to Mudlet

An audit comment in #9011 reported that development does not build at
the declared Qt 6.8.2 lower bound; building against official Qt 6.8.2
binaries shows it actually builds cleanly today, but only via transitive
includes and arg() overload-set details that shift between Qt releases -
this PR makes both explicit so the lower bound keeps working.

#### Other info (issues closed, discussion etc)

Relates to #9011 (not closed by this PR).

Verification of the audit's two Qt 6.8.2 claims, done against official
Qt 6.8.2 gcc_64 binaries:

- Unmodified development configures and builds completely (387/387
targets, all functional tests link) at 6.8.2, so the reported QJson
include errors and MMCP arg() compile failures did not reproduce. This
branch builds green at 6.8.2 as well.
- On the arg() claim specifically: 6.8.2's qstring.h declares
`arg(char)` unconditionally, so the two live single-arg sites in
MMCPServer.cpp already compiled. The genuinely 6.8.2-problematic pattern
- multi-arg `.arg(char, ...)`, which on 6.8.2 resolves to `arg(char a,
int fieldWidth)` and would silently eat the second byte as a field width
- only occurs in commented-out code (MMCPServer.cpp around lines 534 and
1054). If that code is ever revived, it needs the QChar wrapping.
- MMCP byte-equivalence: a small harness compiled against Qt 6.12 shows
the old and new forms produce identical toLatin1() frames (0x06 for
TextGroup, 0xFF for End). On 6.8.2, `arg(char)` forwards to QLatin1Char
- the same Latin-1 mapping `QChar(char)` uses - so frames are
byte-identical there too.

Assisted-by: Claude:claude-opus-4-8
Assisted-by: Claude:claude-fable-5

**Test case:**

- Build against Qt 6.8.x: done for this PR with official 6.8.2 binaries
on Linux - full build green on this branch and on unmodified
development.
- At minimum: the current build stays green (CI covers newer Qt only).
2026-07-26 13:30:15 +02:00
Vadim Peretokin
3a9c6f2615
fix: profile reset no longer forgets which variables to save (#9484)
#### Brief overview of PR changes/additions
- Carry the Variables view's "save this variable" and "hidden by user"
sets across resetProfile(), instead of losing them with the rebuilt Lua
state
- Re-hide Mudlet's internal Lua API after reset (same call the normal
profile load uses), so the Variables view shows user variables only
- 3 regression tests including an end-to-end check that a saved variable
still lands in the profile XML after a reset

#### Motivation for adding to Mudlet
Since the #9430 crash fix, the first profile save after resetProfile()
silently stripped all user-saved variables from the profile, and the
Variables view flooded with hundreds of internal entries.

#### Other info (issues closed, discussion etc)
Follow-up to #9430; found by a post-merge QA audit and confirmed with a
failing test before the fix.

**Test case:** Mark a variable as saved in the Variables view, run `lua
resetProfile()`, save the profile - the variable survives a restart, and
the Variables view lists only user variables.

Assisted-by: Claude:claude-fable-5
2026-07-25 20:25:26 +02:00
Vadim Peretokin
cb402cc3e6
infrastructure: use std::chrono literals for time durations (#9493)
#### Brief overview of PR changes/additions

Convert raw millisecond integer literals at time-duration call sites to
`std::chrono` literals, and add `#include <chrono>` to each touched
translation unit. Examples:

- `QTimer::singleShot(0, ...)` → `QTimer::singleShot(0ms, ...)`
- `mpTimerReplay->setInterval(1000)` → `setInterval(1s)`
- `mPendingTimer.start(60000)` → `start(1min)`
- `QObject::startTimer(50)` → `startTimer(50ms)`
- `QTest::qWait(100)` → `QTest::qWait(100ms)`
- `QThread::msleep(10)` → `QThread::sleep(10ms)`

This is a semantics-preserving refactor - every duration is kept exactly
equal to before (e.g. `1000` ms becomes `1s`, `60000` ms becomes
`1min`). No behavioural change.

#### Motivation for adding to Mudlet

Chrono literals make time durations self-documenting and type-safe. `1s`
/ `100ms` read unambiguously where a bare `1000` / `100` forces the
reader to remember each API's unit, and the compiler now rejects unit
mismatches. Only genuine duration arguments were converted - loop
counts, scroll-line counts, sizes, ports and the like were deliberately
left as plain integers.

All targeted APIs provide `std::chrono` overloads in the minimum
supported Qt (6.8.2): `QTimer::singleShot`/`start`/`setInterval` (5.8),
`QObject::startTimer` (5.9), `QThread::sleep(std::chrono::nanoseconds)`
(6.6) and `QTest::qWait(std::chrono::milliseconds)` (6.7).

#### Other info (issues closed, discussion etc)

Test case: the full application builds cleanly and the entire functional
`ctest` suite passes. The only failing test is the known, pre-existing
`PasswordMigrationTest` LSan exit-leak (GTK3/fontconfig noise), which is
unrelated to this change.

Assisted-by: Claude:claude-opus-4-8
2026-07-25 20:24:31 +02:00
Vadim Peretokin
b649b60f0f
add: keyboard shortcuts to switch between game tabs (#9460)
#### Brief overview of PR changes/additions
- Ctrl+Tab / Ctrl+Shift+Tab cycle through open profile tabs (with
wraparound); Ctrl+1-9 jump straight to a tab. On macOS: physical
Ctrl+Tab and Cmd+1-9.
- All 11 shortcuts are remappable per profile in Preferences >
Shortcuts.
- A one-time dismissible callout points out the new shortcuts when a
second profile opens.
- Replaces the old command-line-only Ctrl+Tab handling, which never
worked on macOS and didn't move the tab bar highlight. The caret-mode
Ctrl+Tab accessibility toggle still takes precedence when selected.

#### Motivation for adding to Mudlet
Switching between open games needed the mouse - the long-standing ask in
#1160 - and macOS had no working way to do it at all (needs the
QShortcut path due to QTBUG-12232, fixed for shortcuts in Qt 6.7.1+).

#### Other info (issues closed, discussion etc)
Closes #1160

Needs a test on macOS before merge (physical Ctrl+Tab / Cmd+1-9 delivery
via Qt 6.8's performKeyEquivalent fix) - this was verified on Linux
only.

**Test case:** Open 3 profiles. Ctrl+Tab cycles forward and wraps,
Ctrl+Shift+Tab cycles backward, Ctrl+2 jumps to the second tab - the tab
bar highlight follows each switch. Remap "Next profile" in Preferences >
Shortcuts and confirm the new key works. With Preferences >
Accessibility caret shortcut set to Ctrl+Tab, Ctrl+Tab toggles caret
mode instead of switching.


https://github.com/user-attachments/assets/2f6a3ab7-1212-4d4a-9ad5-ed94dd0dc9b3
2026-07-20 22:46:05 +02:00
Vadim Peretokin
b23d6f788e
Infrastructure: fix else-after-return in codebase (#9096)
<!-- Keep the title short & concise so anyone non-technical can
understand it,
     the title appears in PTB changelogs -->
#### Brief overview of PR changes/additions
Remove unnecessary else/else-if after return, break, continue, and throw
statements - and in places where fixing them is more trouble than its
worth, added NOLINT.
#### Motivation for adding to Mudlet

https://clang.llvm.org/extra/clang-tidy/checks/readability/else-after-return.html,
so it doesn't pop up in PR reviews.
#### Other info (issues closed, discussion etc)

---------

Co-authored-by: Stephen Lyons <slysven@virginmedia.com>
Co-authored-by: Vadim Peretokin <vadi2@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-18 18:39:57 +02:00
Vadim Peretokin
8d4f110c28
fix: no more crash when viewing Variables after resetting a profile (#9430)
#### Brief overview of PR changes/additions
- `resetProfile()` replaces the Lua state, but `LuaInterface` kept a raw
pointer to the closed one; opening the editor's Variables view then read
freed memory and crashed
- Recreate the `LuaInterface` right after the state swap so the
Variables view works on the new state

#### Motivation for adding to Mudlet
Viewing variables after a profile reset reliably crashed Mudlet
(reported as a Windows CTD; reproduces on all platforms under ASAN).

#### Other info (issues closed, discussion etc)
Fixes #9382

**Test case:** open a profile, run `lua resetProfile()`, open the editor
and click Variables - the list populates instead of crashing.



https://github.com/user-attachments/assets/392992cf-a7d1-449d-a243-caa036a7cb75

Signed-off-by: Vadim Peretokin <vadim.peretokin@mudlet.org>
Co-authored-by: Vadim Peretokin <vadim.peretokin@mudlet.org>
2026-07-16 10:00:47 +02:00
Mike Conley
a97186ad5f
Add: sign in to supported games using your browser (e.g. Google, Discord, or the game's own account) (#9378)
#### Brief overview of PR changes/additions

Adds client-side support for the GMCP `Char.Login` v2 sign-in flow. When
a game offers it, Mudlet automates sign-in *around the game's own login
screen* — it renders no sign-in UI of its own:

- Hands off to the game's interactive screen with an empty
`Char.Login.Credentials {}` when nothing is stored, so the player picks
a provider (Google, Discord, GitHub, the game's own account, …) as text
on the game's own screen.
- Opens the sign-in URL the server pushes (`Char.Login.URL`) in the
system browser — but only after the player has acted on this connection,
never unprompted.
- Autofills a stored character name + password when the profile has
them.
- Persists and replays the server's reconnect token (`Char.Login.Token`
/ `Char.Login.Reconnect`) for instant, password-less reconnects, with a
"forget saved sign-in" control in Preferences → Connection.
- Resumes the *remembered* provider's browser sign-in without a menu
when a saved token has expired or been revoked (`Char.Login.Credentials
{account, provider}`), falling back to the interactive hand-off only
when no provider is remembered.
- Handles token rotation and multiple devices safely: overwrites the
saved token when the server rotates it, and if another running Mudlet
instance sharing the profile's keychain rotates the token mid-flight,
replays the fresh token instead of discarding it.
- For a game that is its own OpenID Provider over TLS, optionally runs
the client-driven PKCE flow end to end (`Char.Login.AuthCode`).

#### Motivation for adding to Mudlet

Modern games are moving to browser-based single sign-on; this lets
Mudlet players use those accounts directly, and reconnect without
re-entering anything — while classic character-name/password logins keep
working unchanged.

#### Other info (issues closed, discussion etc)

- Reworks the approach to resolve @vadi2's UX feedback
(https://github.com/Mudlet/Mudlet/pull/9373#issuecomment-4865669499):
there is **no client pop-up and no in-client chooser**. The game owns
the sign-in screen and offers the choice as text; Mudlet only automates
the mechanical hooks around it (autofill, open URL, save token, replay
token).
- Supersedes and closes #9373.
- Implements the revised v2 draft spec, inspired by #9354.
- Covered by new tests: `OAuthClientFlowTest` (OIDC discovery, PKCE,
loopback capture) and the `GMCPCharLoginTest` functional suite driving a
GMCP server stub through the full client flow.
- Try it out on StickMUD.



https://github.com/user-attachments/assets/566947f6-4f43-4bff-b98c-9328b7a40a2d

---------

Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-07-14 12:20:02 +00:00
Stephen Lyons
84751258ff
Infrastructure: rename ActionUnit::UpdateToolbar() to ActionUnit::UpdateAllToolbars() (#9359)
#### Brief overview of PR changes/additions
A rename to more accurately reflect that the method works on ALL
toolBars/easyButtonBars and not just a single one.

#### Motivation for adding to Mudlet
To improve nomenclature.

#### Other info (issues closed, discussion etc)
There should be no functional changes with this item.

---------

Signed-off-by: Stephen Lyons <slysven@virginmedia.com>
2026-06-27 00:25:01 +00:00
Vadim Peretokin
48a6d9e184
improve: make small area centering opt-in via Mudlet.ini (#9351)
<!-- Keep the title short & concise so anyone non-technical can
understand it,
     the title appears in PTB changelogs -->
#### Brief overview of PR changes/additions
The area auto-centering feature (#8766) - which centers the map on a
whole area when it fits entirely in the viewport - did not work well for
most users, who would rather have it off. Make it opt-in instead: it is
now disabled by default and enabled via the mapCenterSmallAreas key in
Mudlet.ini, following the autosaveIntervalMinutes pattern.

See also #8982.
#### Motivation for adding to Mudlet
Better player experience
#### Other info (issues closed, discussion etc)

Signed-off-by: Vadim Peretokin <vperetokin@hey.com>
2026-06-22 19:04:25 +02:00
John McKisson
a4f7829226
Fix: Regression of resetProfile handling of labels (#9255)
#### Brief overview of PR changes/additions

Fixes issue https://github.com/Mudlet/Mudlet/issues/9254 where a
mouseover event in labels placed in geyser containers throw errors after
a resetProfile due to lua_unref in the wrong lua state during the reset
process.

Unit test and fix researched and applied by by Claude Opus.

Draft to see if a unit test catches the error first before I add the
fix.

#### Motivation for adding to Mudlet

#### Other info (issues closed, discussion etc)
2026-05-08 06:29:22 +02:00
Nick Shearer
cea7b868ec
improve: improve memory safety by using smart pointers (#9239)
### Refactor: replace raw pointer ownership with smart pointers across
core subsystems

  #### Brief overview of PR changes/additions
Replaces raw pointer ownership patterns with `std::unique_ptr` and
`std::map`
  across several core subsystems:

- **Host**: `mStopWatchMap` (`QMap<int, stopWatch*>` → `std::map<int,
unique_ptr<stopWatch>>`), `profileShortcuts` (`QMap<QString,
QKeySequence*>` → `std::map<QString, unique_ptr<QKeySequence>>`).
Removes `qDeleteAll` in destructor and `delete mMMCPServer`.
  - **TMap**: `mpRoomDB` raw pointer → `unique_ptr`
  - **VarUnit**: `base` raw pointer → `unique_ptr`
- **TTrigger**: condition map storage converted to `unique_ptr`,
destructor simplified
- **discord**: handler and presence maps converted from raw pointer
`QMap` to `unique_ptr` + `std::map`
  - **Updater**: `mFeed` and `mUpdateDialog` converted to `unique_ptr`

  #### Motivation for adding to Mudlet
These patterns were identified as sources of memory leaks and potential
use-after-free bugs. Using smart pointers makes ownership explicit,
eliminates manual cleanup code, and ensures correct destruction even on
early-exit paths.

#### Other info (issues closed, discussion etc)

sorry this one is still pretty big, but most of the changes are the same
for each thing so reviewing them together probably makes sense. sadly
there isn't much to see here other than no slow uptick of heap size :-[

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 06:42:09 +02:00
Stephen Lyons
113218930f
Infrastructure: Swap out QtConcurrent module header for sub-module ones (#9246)
#### Brief overview of PR changes/additions
The Qt documentation for `QtConcurrent` points out:
> If you include the `<QtConcurrent>` header, the entire Qt Concurrent
module with the entire Qt Core module will be included, which may
increase compilation times and binary sizes. To use individual functions
from the QtConcurrent namespace, you can include more specific headers.
>
> The table below lists the functions in the QtConcurrent namespace and
their corresponding headers:

|Function|Header|
|--------|------|
|`QtConcurrent::run()`|`<QtConcurrentRun>`|
|`QtConcurrent::task()`| `<QtConcurrentTask>`|

|`QtConcurrent::filter()`,<br>`QtConcurrent::filtered()`,<br>`QtConcurrent::filteredReduced()`|`<QtConcurrentFilter>`|
|`QtConcurrent::map()`,<br>`QtConcurrent::mapped()`,<br>`QtConcurrent::mappedReduced()`|`<QtConcurrentMap>`|

#### Motivation for adding to Mudlet
To speed up the build a little by removing stuff that isn't needed.

#### Other info (issues closed, discussion etc)
In doing this I happened to start cleaning up a couple of header files
`T2DMap.h` and then `mudlet.h`, I then got into converting some
`#include`s into forward declarations in a "include-what-you-use" move.
This then rippled through into a (more than 10!) number of files but
should "improve" things.

Note that the ordering of `#include` in many files seems to be rather
haphazard and is due for some serious overhaul - I suggest that we
should actually declare an "official" style for this project so that
everyone knows what it is.

**During the CI/CB process I discovered that Linux and then MacOS builds
were failing because the file referred to by the `#include
<QtConcurrentTask>` header file was missing, yet was present on my local
PC when I was using the Qt framework from the On-line installer.
Initially I suspected a Debian (and then Devuan - as the packaged
version on my own machine also had this defect AND Ubuntu) package
problem; however it now seems to be an upstream Qt issue as the various
Qt versions & OS combinations suggest that Qt themselves fixed it for Qt
6.10:**
| OS     | QtVersion             | Missing header |
|--------|-----------------------|----------------|
| Windows| 6.11.0 package        |       No       |
| Devuan | 6.8.2 package         |      Yes       |
| Devuan | 6.10.0 online install |       No       |
| Ubuntu | 6.9.0 package         |      Yes       |
| Debian | 6.8.2 package         |      Yes       |
| MacOS  | 6.9.0 package         |      Yes       |

**To fix this I reverted to an `#include <qtconcurrenttask.h>` for Linux
and MacOS builds - although it would probably have been better to make
it conditional on the Qt Version instead...**

*I have reported this upstream to Debian - see:
https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=1135197*

---------

Signed-off-by: Stephen Lyons <slysven@virginmedia.com>
2026-04-29 13:35:29 +01:00
Mike Conley
3886eec794
Fix: stop scripted package installs from stealing window focus (#9236)
#### Brief overview of PR changes/additions

When a Lua script calls `installPackage()` or `installModule()`, Mudlet
no longer shows the brief "Unpacking…" pop-up dialog. The dialog still
appears for installs you start from the package manager UI.

#### Motivation for adding to Mudlet

The pop-up was raising and activating Mudlet's window every time a
script installed a package, pulling focus away from whatever you were
doing in another application. This was especially disruptive for package
authors using build tools like Muddler that auto-reinstall on save.

#### Other info (issues closed, discussion etc)

Closes #9170.
2026-04-29 06:57:14 +02:00
Nick Shearer
55cad851d9
fix: make FontManager track fonts per-profile (#9238)
#### Brief overview of PR changes/additions
  `FontManager::loadFont()` and `unloadFonts()` now take a `profileName`
parameter. Font registration keys are prefixed with the profile name, so
  two profiles loading a font file with the same filename are tracked
  independently. Adds two helpers: `fontAlreadyLoaded()` and
  `rememberFont()`. Call sites in `Host::installPackageFonts()` and
  `Host::uninstallPackage()` updated accordingly.

  #### Motivation for adding to Mudlet
Previously all profiles shared a single flat registry keyed on filename.
If two profiles loaded a font with the same name, unloading one
profile's
fonts could unload the other's copy too, leaving the second profile with
  missing fonts.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 09:38:11 +02:00
Vadim Peretokin
91c5bf18a7
Improve: inline map download dialog (#9204)
<!-- Keep the title short & concise so anyone non-technical can
understand it,
     the title appears in PTB changelogs -->
#### Brief overview of PR changes/additions
Inline map download dialog to remove an additional pop-up when the map
is opened.
#### Motivation for adding to Mudlet
Less pop-ups in desktop UX, the better
#### Other info (issues closed, discussion etc)
[Screencast from 2026-04-18
09-43-26.webm](https://github.com/user-attachments/assets/e5df051a-db20-47a9-b62d-6f59a15eb17b)

---------

Co-authored-by: Vadim Peretokin <vadi2@users.noreply.github.com>
Co-authored-by: Zooka <136661366+ZookaOnGit@users.noreply.github.com>
2026-04-25 13:02:24 +02:00
Stephen Lyons
8b0e62b334
Fix: Clazy warnings part 1 - range-loop-detach (#9195)
#### Brief overview of PR changes/additions
One of a series of PRs, each addressing a type of issue reported by
Clazy.

This is the: "C+11 range-loop might detach Qt container
[clazy-range-loop-detach]" one.

#### Motivation for adding to Mudlet
Remove warnings detected by the Clazy tool - either when explicitly run
on the Mudlet code-base or detected by the background scanner/analyser
that Qt Creator offers.

#### Other info (issues closed, discussion etc)
There was over 100 of this particular item - some of them also could
have `const` applied to the iterator.

---------

Signed-off-by: Stephen Lyons <slysven@virginmedia.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-04-16 14:30:33 +01:00
Vadim Peretokin
6ea87c5649
improve: Auto-switch code editor theme with app appearance (#9167)
#### Brief overview of PR changes/additions
Auto-switch the code editor theme when toggling between light and dark
mode - finds counterpart themes by name (e.g. "Solarized light" <->
"Solarized dark"), falls back to stored preference, then defaults
(Monokai/Mudlet).

#### Motivation for adding to Mudlet
Follow-up to #8897 - the editor theme is a separate option that should
follow the app appearance.

#### Other info (issues closed, discussion etc)
https://github.com/Mudlet/Mudlet/pull/8897#issuecomment-4188760804

**Test case:** Open Preferences, switch Appearance to Dark - editor
theme should auto-switch. Pick a paired theme like "Seafoam Pastel
Dark", switch to Light - should find "Seafoam Pastel Light". Toggle back
- should restore dark variant.
2026-04-16 05:43:22 +00:00
Vadim Peretokin
d3396ee5bb
Fix: several resetProfile() bugs (#9083)
<!-- Keep the title short & concise so anyone non-technical can
understand it,
     the title appears in PTB changelogs -->
#### Brief overview of PR changes/additions
1. Double reset guard (Host.cpp) — Added early return in
resetProfile_phase1() if mResetProfile is already true.
Without this, calling resetProfile() twice before the event loop
processes would schedule two phase2s, and the
second one would leak the Lua state (since initLuaGlobals() overwrites
pGlobalLua without closing the old one).
2. Anonymous event handlers cleared (Host.cpp) — Added
mAnonymousEventHandlerFunctions.clear() to
resetProfile_phase2(). This map was the only event-related map not
cleared during reset. After reset, it
contained stale function names pointing to functions that no longer
existed in the new Lua state, causing error
  spam when events were raised.
3. Media stopped on reset (Host.cpp + TMedia.h) — Added
mpMedia->stopAllMediaPlayers() call to
resetProfile_phase2(). Audio/music continued playing through profile
resets. Also moved stopAllMediaPlayers()
  from private to public in TMedia.h so Host can call it.

#### Motivation for adding to Mudlet
Bug fixes.
#### Other info (issues closed, discussion etc)
Builds on https://github.com/Mudlet/Mudlet/pull/9082, requires that to
be merged first.

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-04-13 18:55:09 +02:00
Vadim Peretokin
5800be63d2
add: telnets:// link support for secure TLS connections (#9153)
#### Brief overview of PR changes/additions
Adds `telnets://` URI scheme support so clicking secure telnet links
opens Mudlet with TLS enabled.

#### Motivation for adding to Mudlet
MUD games advertising TLS connections can provide clickable `telnets://`
links that connect securely, complementing the existing `telnet://`
support.

#### Other info (issues closed, discussion etc)
Builds on #8601. Registers `telnets://` as a protocol handler on
Windows, Linux, and macOS. Uses IANA-assigned default port 992 for
`telnets://` when no port is specified. Honors TLS intent for both new
and existing profiles.

**Test case:** Run Mudlet with `telnets://example.com:992` as a CLI
argument and verify the profile is created with TLS enabled. Also verify
`telnet://` links still work unchanged.
2026-04-12 17:37:37 +02:00
Vadim Peretokin
8fa2f580e1
Fix: review fixes for font handling rework (#9135)
<!-- Keep the title short & concise so anyone non-technical can
understand it,
     the title appears in PTB changelogs -->
#### Brief overview of PR changes/additions
Fix a few things from
4c0e4b764e,
see below for details.
#### Motivation for adding to Mudlet
QA improvements.
#### Other info (issues closed, discussion etc)

1. getAndClearTempDisplayFont() called .value() without checking if the
optional was populated. The only caller is TMainConsole::TMainConsole()
at line 57. If setDisplayFont() in the Host constructor returns early at
line 1129 (because averageCharWidth() == 0 for the font),
mTempDisplayFont is never set. Calling .value() on an empty optional is
UB - in a no-exceptions build (which Mudlet uses), this is a crash with
no error message. The sibling method getDisplayFont() at line 4942
already had the correct guard pattern. We replicated it.

2. setFont(mDisplayFontDetails.makeFont(), true) at line 1606 calls
refreshView() internally (line 1598). Then setFontName() called
refreshView() again at line 1607. Each refreshView() sets fonts on both
TTextEdit panes, recalculates screen dimensions (updateScreenView()),
and forces a full repaint (forceUpdate()). Every font name change was
doing all of this twice.

3. mpHost is a QPointer<Host> that becomes null when the Host is
destroyed. The code dereferenced mpHost->mpConsole without checking
mpHost first. During profile teardown, TConsole can outlive Host
briefly. Other methods in the same class (e.g. raiseFontChangeEvent() at
line 1555) correctly guard with if (!mpHost).

4. Every other field in TFontAttributes had a default member initializer
except mStyleStrategy. Both existing constructors set it, so it's safe
today. But if anyone adds a third constructor (or a default constructor)
without remembering to set this field, it's an uninitialized read - UB.
Now defaults to static_cast<QFont::StyleStrategy>(QFont::NoAntialias |
QFont::PreferQuality), matching the bool constructor's false-case
behavior.

5. The CentralDebugConsole branch in changeColors() previously set font
properties and applied them to both panes. After the font rework it
became empty, but the comment "// No-op now?" with a question mark
signals the author wasn't sure if this was correct. It is correct - font
is now managed via QWidget::font() inheritance and propagated through
updateConsolesFont(). Replaced with a definitive explanation.

6. The setFont() comment said "This *should* be overridding the (void)
QWidget::setFont(const QFont&) method but doesn't seem to be...!" -
implying the intent was to override and something is broken. In reality,
QWidget::setFont is non-virtual so it can never be overridden. The extra
bool forceChange parameter also gives it a different signature. This is
intentional method hiding, not a failed override. The confused comment
could mislead someone into "fixing" it. Replaced with an accurate
explanation of why the hiding is needed.

7. Various typos fixed: overridding, constuctor, QOject, accessng,
releated, inconsistant, Accomodate, TFontDetails, and a stale mKerning
comment.
2026-04-11 08:01:19 +02:00
John McKisson
2f41aa2136
Fix: Better handling of package-supplied fonts during unloading/reloading of packages (#9115)
#### Brief overview of PR changes/additions
- Remove package loaded font from loadedFontPaths so it can be
re-registered if the package is updated/reinstalled
- Associate package fonts with profile name + packageName so unloading a
package in one profile (which removes its package loaded font) does not
remove the font from Mudlet which was being used by another profile
using the same package (which hasn't been removed)

#### Motivation for adding to Mudlet
Addresses issue https://github.com/Mudlet/Mudlet/issues/7532
This also addresses the issue where if more than one profile had a
package loaded that provided a font, and that package is unloaded and
updated/reinstalled, other profiles using that package would have the
font removed.

#### Other info (issues closed, discussion etc)
@SlySven We had spoken briefly about this before.. This seems to work
but I'd definitely like your eyes on it!

---------

Co-authored-by: Vadim Peretokin <vperetokin@hey.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-04-11 08:00:21 +02:00
Morquin
3036e1f3d7
improve: Give players full control over Discord Rich Presence (#9116)
## Summary

Players had no clear way to control what Discord shows about their
Mudlet activity. The old checkbox in the connection pane only gated
server GMCP data but didn't prevent Discord from showing "Playing
Mudlet", and the privacy controls were confusing. This PR replaces all
of that with three straightforward modes via radio buttons in Profile
Preferences > Chat:

- **Show full game details (if supported)** - full game integration with
server-provided presence (default)
- **Show Mudlet only** - only shows "playing Mudlet", game server is not
told about Discord
- **Disabled** - Discord shows nothing about Mudlet

Players pick the mode that matches their comfort level, and the existing
privacy checkboxes (hide detail, hide state, etc.) remain available in
Game details mode for finer control.

### What changed

- **Three-mode radio buttons** in Profile Preferences > Chat with a
two-column layout (modes on the left, privacy controls on the right),
replacing the old connection-pane checkbox
- **Server-origin tracking** so privacy checkboxes only gate data sent
by the game server - Lua API calls always pass through (only Disabled
mode blocks Lua entirely)
- **Mid-session mode switching** via dynamic GMCP negotiation
(Core.Supports.Add/Remove + External.Discord.Hello/Get)
- **Deferred RPC init** - Discord RPC now starts when a profile loads,
not on app launch
- **Username restriction improvements** - takes effect immediately,
case-insensitive (Discord usernames are lowercase-only since 2023),
shuts down RPC when mismatched
- **Shows logged-in Discord user** in preferences next to the
restriction field, with a tooltip explaining the desktop app requirement
when not connected
- **Presence fix** - empty string fields now send nullptr so Discord
hides them instead of showing blanks
- **Memory leak fix** - presence allocations are now freed in the
destructor regardless of RPC state

### Cleanup

- Removed obsolete discriminator field
(`mRequiredDiscordUserDiscriminator`) - Discord removed discriminators
in 2023
- Removed dead code (`getDiscordUserDetails()`, never called)
- Restored `Discord_ClearPresence` function pointer for potential future
use
- Use proper `Host::DiscordOptionFlags` types instead of raw `int`
(thanks @SlySven)

### Known quirks

- The "Hide timer" checkbox correctly omits timestamps from presence
data, but Discord's client starts its own activity timer for any
presence without a timestamp - this is Discord client behavior outside
our control.
- The "Hide large icon" setting clears the image key, but some Discord
clients fall back to the application's default icon instead of hiding it
entirely.

### Test plan

- [ ] Open Profile Preferences > Chat tab
- [ ] Switch between the three radio button modes and verify Discord
presence updates accordingly
- [ ] In Game details mode, toggle privacy checkboxes and verify fields
are hidden/shown
- [ ] Use Lua API (e.g. `setDiscordDetail("test")`) in Mudlet only mode
- should work. Try in Disabled mode - should fail with error
- [ ] Set a username restriction and verify presence clears immediately
if mismatched
- [ ] Run unit tests: `cd build && ./test/DiscordTest`
- [ ] Run functional tests: `cd build && ctest -R TDiscordModeTest -V`

Closes #6967. Supersedes #7438.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: SlySven <slysven@virginmedia.com>
2026-04-08 13:33:09 +02:00
Vadim Peretokin
05a4937abc
Fix: memory leaks in sound player, dialogs (#9142)
<!-- Keep the title short & concise so anyone non-technical can
understand it,
     the title appears in PTB changelogs -->
#### Brief overview of PR changes/additions
Fixes memory leaks from playing sounds, opening the room exits dialog,
opening Preferences, right-clicking the console, connecting to MUDs with
compression, and viewing changelogs
#### Motivation for adding to Mudlet
Better memory management.
#### Other info (issues closed, discussion etc)

##### 3. TMediaPlayer destructor leaks TMediaPlaylist + QAudioOutput

```
playSoundFile() [TLuaInterpreterMedia.cpp:703]
  → TMedia::playMedia() [TMedia.cpp:47] → play() [TMedia.cpp:1485]
    → getMediaPlayer() [TMedia.cpp:1150]
      → make_shared<TMediaPlayer>(mpHost, mediaData) [TMedia.cpp:1193]
        → constructor: mPlaylist(new TMediaPlaylist)       ← LEAK #1 [TMedia.h:47]
        → constructor: setAudioOutput(new QAudioOutput())  ← LEAK #2 [TMedia.h:50]
    → updateMediaPlayerList() [TMedia.cpp:1102]
      → purgeStoppedMediaPlayers() [TMedia.cpp:1056] (when list > 25)
        → shared_ptr destroyed → ~TMediaPlayer() = default  ← NEVER DELETES [TMedia.h:52]
```

##### 6. dlgRoomExits TExit objects never freed

```
Right-click room → "Set exits..." [RoomContextMenuHandler.cpp:196]
  → T2DMap::slot_setExits() [T2DMap.cpp:4271]
    → new dlgRoomExits(...) [T2DMap.cpp:4278] + setAttribute(WA_DeleteOnClose) [T2DMap.cpp:4280]
      → init() [dlgRoomExits.cpp:279]
        → initExit() x12 [dlgRoomExits.cpp:1633-1775]
          → originalExits[dir] = makeExitFromControls(dir) [dlgRoomExits.cpp:1599]
            → new TExit() [dlgRoomExits.cpp:1968]  ← ALLOCATED
        → new TExit() per special exit [dlgRoomExits.cpp:1782] → originalSpecialExits[dir] [line 1848]
  User closes → ~dlgRoomExits() [dlgRoomExits.cpp:285] ← EMPTY, never deletes TExit objects
```

##### 7. dlgProfilePreferences QKeySequence* leak (no destructor)

```
mudlet::showOptionsDialog() [mudlet.cpp:3578]
  → new dlgProfilePreferences(this, pHost) [mudlet.cpp:3585]
  → setAttribute(WA_DeleteOnClose) [mudlet.cpp:3603]
    → constructor loop [dlgProfilePreferences.cpp:1394-1399]:
        new QKeySequence(*pHost->profileShortcuts.value(key)) [line 1398]
        currentShortcuts.insert(key, sequence) [line 1399]  ← 17 raw pointers stored
  User closes → WA_DeleteOnClose triggers delete
    → implicit ~dlgProfilePreferences() ← NO DESTRUCTOR EXISTS
      → QMap destroyed, pointer values NOT deleted
```

##### 10. ctelnet missing inflateEnd in destructor

```
Server sends WILL COMPRESS2 → processSocketData [ctelnet.cpp:4876-4880]
  → mNeedDecompression = true → initStreamDecompressor()
    → inflateInit(&mZstream) [ctelnet.cpp:4514]  ← allocates ~256KB

Connection drops → slot_socketDisconnected() [ctelnet.cpp:627]
  → mNeedDecompression = false [line 663]  ← NO inflateEnd()
  → reset() [line 664] ← NO inflateEnd()
  → ~cTelnet() [line 158] ← NO inflateEnd()
  (only inflateEnd is in decompressBuffer:4532, on Z_STREAM_END from server)
```

##### 11. ctelnet QNetworkReply leak on file error paths

```
slot_replyFinished() [ctelnet.cpp:1365]
  → reply->error() == NoError [line 1398]
    → file.open() fails [line 1400] → return ← LEAK (no reply->deleteLater())
    → file.commit() fails [line 1409] → return ← LEAK (no reply->deleteLater())
  (compare: error path at line 1393 correctly calls reply->deleteLater())
```

##### 15. TTextEdit QAction accumulation (5-10 per right-click)

```
Right-click on console → mouseReleaseEvent [TTextEdit.cpp:2287]
  → new QAction(tr("Copy"), this) [line 2374]      ← parented to TTextEdit, not QMenu
  → new QAction(tr("Copy HTML"), this) [line 2385]
  → new QAction(tr("Copy as image"), this) [line 2389]
  → new QAction(tr("Select all"), this) [line 2392]
  → new QAction(tr("Search on %1"), this) [line 2397]
  → (+ conditional actions at 2416, 2434, 2438, 2448, 2465)
  → popup = new QMenu(this) + WA_DeleteOnClose [line 2406-2407]
  → Menu closes → QMenu deleted, but QActions survive as TTextEdit children ← ACCUMULATE
```

##### 17. Updater changelog dialogs leaked

```
Help → Show changelog → Updater::showChangelog() [updater.cpp:202]
  → new dblsqd::UpdateDialog(feed, ...) ← no parent [line 202]
  → changelogDialog->show() [line 204] ← non-blocking, pointer lost at scope end
  (no WA_DeleteOnClose set; same at showFullChangelog:218)
```

##### 20. ModernGLWidget mTexCoordBuffer.destroy() missing

```
initializeGL() → setupBuffers() [modern_glwidget.cpp:146]
  → mTexCoordBuffer.create() [line 183]
~ModernGLWidget() → cleanup() [line 70]
  → mVertexBuffer.destroy()  [line 78] ✓
  → mColorBuffer.destroy()   [line 79] ✓
  → mNormalBuffer.destroy()  [line 80] ✓
  → mIndexBuffer.destroy()   [line 81] ✓
  → mInstanceBuffer.destroy() [line 82] ✓
  → mTexCoordBuffer.destroy() ← MISSING between lines 82 and 83
  → mVAO.destroy()           [line 83] ✓
```

##### 21. dlgComposer context menu + Hunspell suggestions

```
Right-click in composer → slot_contextMenu() [dlgComposer.cpp:226]
  → createStandardContextMenu() [line 228] ← returns new QMenu*, no parent
  → fillSpellCheckList() [line 233] → Hunspell_suggest() [line 331,335] ← allocates lists
  → popup->popup() [line 237] ← non-blocking, pointer lost
  ← QMenu leaked (no WA_DeleteOnClose, no deleteLater)
  ← If dismissed without selection: Hunspell_free_list() never called [only at line 468,472]
```

##### 22-24. Lower-impact (also in this branch)

```
dlgColorTrigger after exec() [dlgTriggerEditor.cpp:12469,12532]:
  → new dlgColorTrigger(this, ...) → exec() → no delete pD

Hunspell shared dictionary [mudlet.cpp:4768]:
  → mpHunspell_sharedDictionary = nullptr ← missing Hunspell_destroy() before

ExitsTreeWidget takeTopLevelItem [exitstreewidget.cpp:45]:
  → takeTopLevelItem(indexOfTopLevelItem(pItem)) ← return value discarded, never deleted
```

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-04-08 07:55:42 +02:00
Vadim Peretokin
62e7daaa63
Fix: crash when resetProfile() is called while downloads are in-progress (#9138)
<!-- Keep the title short & concise so anyone non-technical can
understand it,
     the title appears in PTB changelogs -->
#### Brief overview of PR changes/additions
esetProfile() now properly destoys the old Lua state, but it never
cancelled in-flight downloads. When a download was finished, it would
try and use the old Lua state that wasn't there anymore. Fix - cancel
downloads on resetProfile()


#### Motivation for adding to Mudlet
Fix crash introduced into development.
#### Other info (issues closed, discussion etc)
The new tests genuinely found a crashing issue!
2026-04-03 18:50:08 +02:00
Vadim Peretokin
3cc576ae3b
Fix: small memory leaks when closing/reopening profiles (#9110)
<!-- Keep the title short & concise so anyone non-technical can
understand it,
     the title appears in PTB changelogs -->
#### Brief overview of PR changes/additions

  1. Host::mStopWatchMap not cleaned in destructor

```
  createStopWatch() [TLuaInterpreterMudletObjects.cpp:256]
    → Host::createStopWatch() [Host.cpp:1410]
      → new stopWatch() [Host.cpp:1433] → mStopWatchMap.insert() [Host.cpp:1439]

  mudlet::closeHost() [mudlet.cpp:1970]
    → HostManager::deleteHost() [HostManager.cpp:31] → mHostPool.remove() [HostManager.cpp:42]
      → ~Host() [Host.cpp:418]
        → qDeleteAll(profileShortcuts)  ← PRESENT [Host.cpp:428]
        → qDeleteAll(mStopWatchMap)     ← MISSING
```

  2. Discord stale Host* map entries (dangling pointers)
```
  setDiscord*() functions → insert into QMap<Host*, ...> [discord.h:251-260] (10 maps)
  Profile close → mudlet::closeHost() [mudlet.cpp:1970]
    → mHostManager.deleteHost() [mudlet.cpp:2020] → Host destroyed
    ← Discord::resetData() NEVER called (not connected to signal_hostDestroyed)
    ← 10 maps retain entries keyed by dangling Host*
```
  3. MMCPServer + MMCPClient leak
```
  chatStartServer() → Host::initMMCPServer() [Host.cpp:3050]
    → mMMCPServer = new MMCPServer(this) [Host.cpp:3056]
      ← 'this' passed as arg only, NOT as QObject parent [MMCPServer.cpp:38]
      ← mMMCPServer is QPointer (non-owning) [Host.h:789]
  Profile close → ~Host() [Host.cpp:418] ← NEVER deletes mMMCPServer
    ← MMCPServer leaked with all connected MMCPClients
```
#### Motivation for adding to Mudlet
Addressing memory leaks
#### Other info (issues closed, discussion etc)
2026-03-26 06:55:19 +01:00
Mike Conley
2fa61a5de8
Fix: OSC 8 hyperlinks strip config/preset only when features are advertised (#9106)
#### Brief overview of PR changes/additions
OSC 8 hyperlinks now only strip `config` and `preset` query parameters
from web URLs when the corresponding features are actually advertised
via NEW-ENVIRON. Also fixes stripping when `=` is percent-encoded as
`%3D`.

#### Motivation for adding to Mudlet
Improves protocol correctness for the MUD server community. Servers
using only basic `send:` and `prompt:` schemes no longer have their web
URLs unexpectedly modified.

#### Other info (issues closed, discussion etc)
- Regression fix from #9073 for encoded `=` handling
- Added tests for encoded `=` variants
- `config` stripped when any config-using feature enabled (STYLE_BASIC,
STYLE_STATES, TOOLTIP, MENU, COMPACT, VISIBILITY, SELECTION, SPOILER,
DISABLED)
- `preset` stripped only when PRESETS feature is enabled

---

**Before**

<img width="467" height="119" alt="Screenshot 2026-03-22 at 2 07 41 AM"
src="https://github.com/user-attachments/assets/5662a8c5-2c9f-415c-9a49-31b653c9ed7e"
/>

**After**

<img width="497" height="126" alt="Screenshot 2026-03-22 at 2 42 42 AM"
src="https://github.com/user-attachments/assets/15d6afd5-a9e0-4faf-ab2f-31251ea3e633"
/>
2026-03-24 11:52:33 +00:00
Mike Conley
5b4871d54e
Improve: Add smooth pulsing effect for blinking text (#9104)
#### Brief overview of PR changes/additions

Adds a smooth pulsing animation for blinking text (SGR codes 5 and 6)
controlled by a new "Enable blinking text" checkbox in Settings >
Accessibility. When disabled, blinking text is shown in italics instead.
The pulse effect is also applied to HTML log exports using matching CSS
animations.

#### Motivation for adding to Mudlet

Blinking text is a standard terminal feature that MUD servers can send.
This adds an accessible, WCAG-compliant way to display it — the smooth
pulse avoids harsh on/off flashing while the checkbox gives users full
control to disable it.

#### Other info (issues closed, discussion etc)

- Lua API: `setConfig("enableBlinkText", true/false)` and
`getConfig("enableBlinkText")`
- WCAG 2.3.1 compliant: slow blink at 0.5 Hz, fast blink at 1 Hz (both
well under 3 flashes/second limit), with opacity floor of 0.4

---


https://github.com/user-attachments/assets/292aa7ef-0239-44dd-9d9c-8e5079a7e6e4
2026-03-24 12:30:08 +01:00
John McKisson
1e9ecebce1
Fix: Refactor and fix logic handling chat name change from GUI preferences dialog (#9025)
#### Brief overview of PR changes/additions

Refactor mChatName out of MMCPServer as the value is stored in Host
already (for profile saving), this simplifies syncing between the two.
Fix signaling so the connected clients are notified when chat name is
changed via GUI.
We're left with a double setText in
dlgProfilePreferences::slot_setMMCPChatName but this should be OK as
setText does not re-trigger the editingFinished signal.

#### Motivation for adding to Mudlet

#### Other info (issues closed, discussion etc)
2026-03-23 16:24:55 +01:00
Vadim Peretokin
842f470c4f
Fix: use correct variable for Discord starttime string parsing (#9100)
#### Brief overview of PR changes/additions

Fix Discord Rich Presence not showing the "Playing for..." elapsed time
when the MUD server sends the start timestamp as a string instead of a
number.

#### Motivation for adding to Mudlet

A copy-paste bug in `processGMCPDiscordStatus()` checked
`endTimeStamp.isString()` instead of `startTimeStamp.isString()`, making
the string-typed starttime path dead code. MUD servers sending starttime
as a string had the value silently dropped.

#### Other info (issues closed, discussion etc)

One-line fix: two variable name corrections on Host.cpp line 2943-2944.
2026-03-22 14:04:05 +01:00
Vadim Peretokin
2cf23b8664
fix: Fix QFutureWatcher memory leaks across the codebase (#9095)
#### Brief overview of PR changes/additions
A few places allocate a QFutureWatcher with new but don't clean it up
afterwards. Added deleteLater() to all of them: Host::saveProfile,
dlgConnectionProfiles::slot_copyProfile, dlgProfilePreferences theme
download handler, TLuaInterpreter::unzipAsync, and Updater (3 sites).

#### Motivation for adding to Mudlet
Small housekeeping fix. Each leaked watcher is ~230 bytes so the
practical impact is minimal, but it's good practice to clean up after
ourselves. The Host::saveProfile one is the most frequent since autosave
runs every 2 minutes.

#### Other info (issues closed, discussion etc)
Test case: review the one-line diffs, confirm deleteLater() is called
after the watcher's finished signal work is done.
2026-03-22 12:09:37 +01:00
Mike Conley
998714cbe1
Fix: clickable links losing their URL query parameters (#9073)
#### Brief overview of PR changes/additions
Clickable links (OSC 8 hyperlinks) that contain query parameters in the
URL now keep them intact. Previously, all query parameters were
stripped, breaking the link.

#### Motivation for adding to Mudlet
Links from games that include query parameters (e.g. `?id=2896`)
resolved to the wrong page because the parameters were lost.

#### Other info (issues closed, discussion etc)
Closes #9071
2026-03-21 06:58:22 +01:00
Stephen Lyons
301467792e
Fix: ansi_color table loading order (#9084)
#### Brief overview of PR changes/additions
(Re)load the Lua `color_table` before the loaded profile is
(re)compiled.

#### Motivation for adding to Mudlet
User/Packages that use the `color_table` did not have it available
because it was not set up until after they were reloaded and compile as
part of a profile reset.

#### Other info (issues closed, discussion etc)
This should close 9081.

---------

Signed-off-by: Stephen Lyons <slysven@virginmedia.com>
2026-03-20 18:48:11 +00:00
Morquin
bfba86fc75
Add embeddable TextEdit widget (Geyser.TextEdit) (#8986)
## Summary

Adds a new embeddable multi-line text editor widget - `Geyser.TextEdit`
- that can be placed inside any Geyser container, user window, or the
main display, just like a MiniConsole or CommandLine.

**The problem:** Mudlet has no way to embed a multi-line text input
area. `Geyser.CommandLine` is single-line only. `Geyser.MiniConsole` is
display-only. The old `dlgComposer` is a standalone dialog that can't be
embedded. This means there's no good way to write longer text in-game -
RP posts, mail, notes, long emotes, or speeches.

**The solution:** A new `Geyser.TextEdit` widget that provides a plain
text editor you can embed anywhere. It's backed by a new C++ class
(`TTextBox`) using Qt's `QPlainTextEdit`.

### Use cases

- **In-game mail composition** - Subject + Body fields in a resizable
window, sent via GMCP
- **RP text editor** - Write longer emotes, descriptions, or poses in a
dedicated editor before sending
- **Speech writer** - Compose multi-line speeches where each line gets
prepended with `say` and sent to the game line by line
- **Note taking** - Keep in-game notes in an editable text area
- **GMCP-driven text forms** - Games can request text input via GMCP and
receive structured responses
- **Builder/admin tools** - Room descriptions, help files, board posts

## Lua API

### Create and delete
```lua
-- Create a text edit (in main window or a user window)
createTextEdit("main", "myEditor", 50, 50, 400, 200)
createTextEdit("myUserWindow", "myEditor", 50, 50, 400, 200)

-- Delete it
deleteTextEdit("myEditor")
```

### Text content
```lua
setTextEditText("myEditor", "Hello\nWorld")
local text = getTextEditText("myEditor")  --> "Hello\nWorld"
clearTextEdit("myEditor")
```

### Properties
```lua
setTextEditReadOnly("myEditor", true)
setTextEditPlaceholder("myEditor", "Type your message here...")
setTextEditStyleSheet("myEditor", [[QPlainTextEdit { background: #1a1a2e; color: #e0e0e0; }]])
setTextEditFont("myEditor", "Bitstream Vera Sans Mono")
setTextEditFontSize("myEditor", 12)
setTextEditTabMovesFocus("myEditor", true)  -- Tab moves to next widget instead of inserting tab
```

### Standard window functions work too
```lua
moveWindow("myEditor", 100, 100)
resizeWindow("myEditor", 500, 300)
showWindow("myEditor")
hideWindow("myEditor")
```

### Widget type identification
```lua
windowType("myEditor")  --> "textedit"
```

## Geyser wrapper

```lua
local editor = Geyser.TextEdit:new({
  name = "myEditor",
  x = 10, y = 100,
  width = "80%", height = "60%",
}, myContainer)

editor:setText("Dear Gandalf,\n\nHere is your sword back.")
local text = editor:getText()
editor:setPlaceholder("Compose your message...")
editor:setReadOnly(false)
editor:setFontSize(12)
editor:setStyleSheet([[QPlainTextEdit { background: #1a1a2e; color: #e0e0e0; }]])
editor:setTabMovesFocus(true)
editor:clear()
editor:delete()
```

## Full example: Mail composer with GMCP

A complete mail composition window using `Adjustable.Container` with
Subject and Body fields:

```lua
GMCPEditor = GMCPEditor or {}

local styles = {
  subject = [[QPlainTextEdit { background-color: #1a1a2e; color: #e0e0e0; border: 1px solid #555; padding: 2px; } QScrollBar:vertical { width: 0px; }]],
  body = [[QPlainTextEdit { background-color: #1a1a2e; color: #e0e0e0; border: 1px solid #555; padding: 4px; }]],
  label = [[QLabel { color: #aaaaaa; padding-left: 10px; }]],
  button = [[QLabel { background-color: #333; color: #e0e0e0; border: 1px solid #555; qproperty-alignment: AlignCenter; } QLabel:hover { background-color: #555; }]],
  sendButton = [[QLabel { background-color: #2a5a3a; color: #e0e0e0; border: 1px solid #3a7a4a; qproperty-alignment: AlignCenter; } QLabel:hover { background-color: #3a7a4a; }]],
}

function GMCPEditor.open(options)
  options = options or {}
  local subject = options.subject or ""
  local body = options.body or ""
  local gmcpModule = options.gmcpModule or "Mail.Compose"

  if GMCPEditor.container then
    GMCPEditor.subject:setText(subject)
    GMCPEditor.body:setText(body)
    GMCPEditor.gmcpModule = gmcpModule
    GMCPEditor.container:show()
    return
  end

  GMCPEditor.gmcpModule = gmcpModule

  GMCPEditor.container = Adjustable.Container:new({
    name = "GMCPEditorContainer",
    x = "25%", y = "25%",
    width = "50%", height = "50%",
    titleText = "Compose Mail",
    titleFormat = "c11",
    autoLoad = false, autoSave = false,
  })

  GMCPEditor.subjectLabel = Geyser.Label:new({
    name = "GMCPEditorSubjectLabel",
    x = 5, y = 5, width = 70, height = 25,
    message = "Subject:",
  }, GMCPEditor.container)
  GMCPEditor.subjectLabel:setStyleSheet(styles.label)
  GMCPEditor.subjectLabel:setFontSize(12)

  GMCPEditor.subject = Geyser.TextEdit:new({
    name = "GMCPEditorSubject",
    x = 80, y = 5, width = "-5", height = 25,
  }, GMCPEditor.container)
  GMCPEditor.subject:setStyleSheet(styles.subject)
  GMCPEditor.subject:setFontSize(12)
  GMCPEditor.subject:setText(subject)
  GMCPEditor.subject:setTabMovesFocus(true)

  GMCPEditor.body = Geyser.TextEdit:new({
    name = "GMCPEditorBody",
    x = 5, y = 35, width = "-5", height = "-35",
  }, GMCPEditor.container)
  GMCPEditor.body:setStyleSheet(styles.body)
  GMCPEditor.body:setFontSize(12)
  GMCPEditor.body:setText(body)
  GMCPEditor.body:setPlaceholder("Compose your message here...")

  GMCPEditor.sendBtn = Geyser.Label:new({
    name = "GMCPEditorSendBtn",
    x = "-145", y = "-30", width = 65, height = 25,
    message = "Send", clickCallback = "GMCPEditor.send",
  }, GMCPEditor.container)
  GMCPEditor.sendBtn:setStyleSheet(styles.sendButton)
  GMCPEditor.sendBtn:setFontSize(12)

  GMCPEditor.cancelBtn = Geyser.Label:new({
    name = "GMCPEditorCancelBtn",
    x = "-75", y = "-30", width = 65, height = 25,
    message = "Cancel", clickCallback = "GMCPEditor.close",
  }, GMCPEditor.container)
  GMCPEditor.cancelBtn:setStyleSheet(styles.button)
  GMCPEditor.cancelBtn:setFontSize(12)
end

function GMCPEditor.send()
  if not GMCPEditor.container then return end
  local data = {
    subject = GMCPEditor.subject:getText(),
    body = GMCPEditor.body:getText(),
  }
  sendGMCP(GMCPEditor.gmcpModule .. " " .. yajl.to_string(data))
  GMCPEditor.close()
end

function GMCPEditor.close()
  if GMCPEditor.container then
    GMCPEditor.container:hide()
  end
end

function composeMail(subject, body, gmcpModule)
  GMCPEditor.open({
    subject = subject or "",
    body = body or "",
    gmcpModule = gmcpModule or "Mail.Compose",
  })
end
```

Usage: `composeMail()` or `composeMail("Re: Quest rewards", "Thanks for
the info!")`

## Full example: Speech/RP text sender

Write a multi-line speech in an editor, then send each line to the game
with a command prefix. Uses `sendAll()` with a delay between lines to
prevent the game from concatenating rapid input.

```lua
function openSpeechEditor(prefix)
  prefix = prefix or "say"

  if speechEditor then speechEditor.container:show() return end
  speechEditor = {}

  speechEditor.container = Adjustable.Container:new({
    name = "SpeechEditorContainer",
    x = "30%", y = "30%", width = "40%", height = "40%",
    titleText = "Speech Editor",
    titleFormat = "c11",
    autoLoad = false, autoSave = false,
  })

  speechEditor.body = Geyser.TextEdit:new({
    name = "SpeechEditorBody",
    x = 5, y = 5, width = "-5", height = "-35",
  }, speechEditor.container)
  speechEditor.body:setStyleSheet([[QPlainTextEdit { background-color: #1a1a2e; color: #e0e0e0; border: 1px solid #555; padding: 4px; }]])
  speechEditor.body:setFontSize(12)
  speechEditor.body:setPlaceholder("Write your speech here...\nEach line will be sent as: " .. prefix .. " <line>")

  speechEditor.sendBtn = Geyser.Label:new({
    name = "SpeechEditorSendBtn",
    x = "-145", y = "-30", width = 65, height = 25,
    message = "Send", clickCallback = "sendSpeech",
  }, speechEditor.container)
  speechEditor.sendBtn:setStyleSheet([[QLabel { background-color: #2a5a3a; color: #e0e0e0; border: 1px solid #3a7a4a; } QLabel:hover { background-color: #3a7a4a; }]])
  speechEditor.sendBtn:setFontSize(12)

  speechEditor.cancelBtn = Geyser.Label:new({
    name = "SpeechEditorCancelBtn",
    x = "-75", y = "-30", width = 65, height = 25,
    message = "Cancel",
    clickCallback = "closeSpeechEditor",
  }, speechEditor.container)
  speechEditor.cancelBtn:setStyleSheet([[QLabel { background-color: #333; color: #e0e0e0; border: 1px solid #555; } QLabel:hover { background-color: #555; }]])
  speechEditor.cancelBtn:setFontSize(12)

  speechEditor.prefix = prefix
end

function sendSpeech()
  if not speechEditor then return end
  local text = speechEditor.body:getText()
  local prefix = speechEditor.prefix
  local lines = {}
  for line in text:gmatch("[^\n]+") do
    lines[#lines + 1] = prefix .. " " .. line
  end
  if #lines > 0 then
    sendAll(0.3, unpack(lines))
  end
  speechEditor.body:clear()
  speechEditor.container:hide()
end

function closeSpeechEditor()
  if speechEditor then speechEditor.container:hide() end
end
```

Usage:
```lua
openSpeechEditor("say")      -- each line sent as: say <line>
openSpeechEditor("emote")    -- each line sent as: emote <line>
openSpeechEditor("tell bob")  -- each line sent as: tell bob <line>
```

## Changes

- **New files:** `TTextBox.h/cpp` (C++ widget), `GeyserTextEdit.lua`
(Geyser wrapper), `TextEdit_spec.lua` (Lua tests)
- **Modified:** `TMainConsole.h/cpp` (widget map + create/delete),
`Host.cpp` (window function support + windowType),
`TLuaInterpreter.h/cpp` (Lua API registration), `TLuaInterpreterUI.cpp`
(Lua API implementation), `LuaGlobal.lua` (Geyser loader),
`CMakeLists.txt` (build)
- **11 new Lua functions:** `createTextEdit`, `deleteTextEdit`,
`getTextEditText`, `setTextEditText`, `clearTextEdit`,
`setTextEditReadOnly`, `setTextEditPlaceholder`,
`setTextEditStyleSheet`, `setTextEditFont`, `setTextEditFontSize`,
`setTextEditTabMovesFocus`

## Test plan

- [x] Build compiles cleanly on all platforms
- [x] `createTextEdit`/`deleteTextEdit` work in main window and user
windows
- [x] Text get/set/clear operations work correctly
- [x] Placeholder text displays and updates properly
- [x] Read-only mode prevents editing
- [x] Stylesheet, font, and font size changes apply correctly
- [x] Tab focus navigation works between TextEdit widgets
- [x] `moveWindow`/`resizeWindow`/`showWindow`/`hideWindow` work with
TextEdit
- [x] `windowType()` returns "textedit" for TextEdit widgets
- [x] Geyser.TextEdit wrapper works in containers and
Adjustable.Container
- [x] Widget cleanup on profile close (no crashes)
- [x] Lua tests pass (`TextEdit_spec.lua`)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 19:13:38 +01:00
Vadim Peretokin
3f5e2f3623
Fix: Separate user and MXP borders so reconnect doesn't reset user borders (#9016)
<!-- Keep the title short & concise so anyone non-technical can
understand it,
     the title appears in PTB changelogs -->
#### Brief overview of PR changes/additions
TMxpFrameManager::resetAllFrames() was zeroing Host::mBorders on
reconnect, wiping user-configured borders. Split into mUserBorders and
mMxpBorders with additive effective borders. MXP callers use
setMxpBorders(), all others use setUserBorders(). setBorders() is now
private.
#### Motivation for adding to Mudlet


Fixes #8871
#### Other info (issues closed, discussion etc)

Co-authored-by: Vadim Peretokin <vadi2@users.noreply.github.com>
2026-03-09 09:26:29 +01:00
John McKisson
b15dddae4f
Add: MudMaster Chat Protocol (MMCP) (#7765)
Brief overview of PR changes/additions
Add MMCP Client and Server classes, Lua scriptability, and options for
the MudMaster Chat Protocol

Motivation for adding to Mudlet
This is a feature largely requested by players of the Medievia MUD, it
allows peer to peer client communication integrated directly into the
main console.

Other info (issues closed, discussion etc)
Starting a new PR as the original PR 7155 was unintentionally
closed/deleted from my local disk

---------

Co-authored-by: Tim Johnson <29287358+atari2600tim@users.noreply.github.com>
Co-authored-by: Stephen Lyons <slysven@virginmedia.com>
Co-authored-by: Vadim Peretokin <vperetokin@hey.com>
2026-03-06 18:24:16 +01:00
Mike Conley
f782a2c143
Add: blinking/flashing text support (#8983)
## Summary
Adds support for SGR codes 5 (slow blink) and 6 (rapid blink/flash) text
attributes.

## Implementation Details

### Blink Timer Architecture
- Global blink timer in `mudlet` singleton runs at 200ms interval (2.5
Hz, WCAG 2.3.1 compliant - under 3 Hz limit)
- TTextEdit widgets register/unregister as blink clients
- Timer only runs when at least one client needs it
- Uses 4-state counter per ISO/IEC 8613-6:1994 to create two speeds:
  - **Slow blink (SGR 5)**: &lt; 150 cycles/min (~1.25 Hz)
  - **Fast blink (SGR 6)**: &gt; 150 cycles/min (~2.5 Hz)

### Text Attributes
- New `TChar::AttributeFlags`: `Blink` and `FastBlink`
- SGR 5 sets `Blink`, SGR 6 sets `FastBlink`
- SGR 25 clears both flags

### Rendering
- `TTextEdit::drawBackground()` skips drawing background for hidden
blink text
- `TTextEdit::drawForeground()` skips drawing foreground for hidden
blink text
- When blinking is disabled, blink text renders as italics instead

### User Preference
- Per-profile `enableBlinkText` setting (disabled by default for
accessibility)
- Checkbox in Settings → Accessibility tab
- Lua API: `getConfig("enableBlinkText")` /
`setConfig("enableBlinkText", bool)`
- Saved/loaded in profile XML

### Lua API
- `getTextFormat()` reports blinking as `"none"`, `"slow"`, or `"fast"`
- `setTextFormat()` accepts optional blink parameter: `"none"`,
`"slow"`, or `"fast"`

## Testing
To test blinking text, connect to a game that sends SGR 5/6 codes, or
use:
```lua
echo("\27[5mSlow blink\27[0m \27[6mFast blink\27[0m\n")
```

## Checklist
- [x] Blink timer starts/stops based on client registration
- [x] Slow and fast blink speeds are visually distinct
- [x] Preference toggles blinking on/off per profile
- [x] Fallback to italics when blinking disabled
- [x] WCAG 2.3.1 compliant (2.5 Hz, under 3 Hz limit)
- [x] Default is disabled for accessibility considerations
- [x]
[`setTextFormat()`](https://wiki.mudlet.org/w/Area_51#setTextFormat.2C_PR_.238983)
supports blink mode parameter

---


https://github.com/user-attachments/assets/25f63605-7b90-40c3-963f-53889e41328d

---------

Co-authored-by: Stephen Lyons <slysven@virginmedia.com>
2026-03-04 14:04:45 +01:00
Mike Conley
eabdf1b7e7
Fix: Crash when removing default MUD profiles from welcome window (#8916)
#### Brief overview of PR changes/additions

Fixes a crash that occurred when users tried to remove the default MUD
profiles from the welcome window on Linux.

#### Motivation for adding to Mudlet

Users on Linux were experiencing crashes when clicking the remove button
on the default game profiles in the connection dialog. This made it
difficult to clean up the profile list.

#### Other info (issues closed, discussion etc)

Closes #8907

The fix ensures that password cleanup operations happen one at a time
instead of simultaneously, and adds safety checks to handle cases where
the dialog might close during the cleanup process.
2026-02-09 11:44:01 +01:00