Skip to content

Windowed (MDI) document mode with Genie 4-style window decorations#52

Merged
monil2233 merged 8 commits into
GenieClient:mainfrom
dylb0t:feature/windowed-mode
Jun 6, 2026
Merged

Windowed (MDI) document mode with Genie 4-style window decorations#52
monil2233 merged 8 commits into
GenieClient:mainfrom
dylb0t:feature/windowed-mode

Conversation

@dylb0t
Copy link
Copy Markdown
Collaborator

@dylb0t dylb0t commented Jun 6, 2026

Pull Request

Summary

Adds an optional windowed (MDI) document mode — every panel becomes a
free-floating, skinned child window inside the main window (à la Genie 4),
alongside the existing tabbed/docked layout. Toggle via Window → Windowed
Mode (MDI)
. Ported from the dylb0t Genie5 prototype (GPL-3.0).

Highlights:

  • Free-floating panels in a single MDI DocumentDock; 9-slice skinned window
    decoration (Themes/MdiDocumentWindowSkin.axaml + skin_* assets).
  • Persists the mode choice, each window's position/size/state, and which
    windows are open/closed
    , as long as saved in Layouts.
  • New first-class Log (speech/conversation mirror + #echo >log) and
    ItemLog (itemLog stream + #echo >itemlog) panels.
  • Main-window size/position now persists across restarts.

Type of Change

  • Bug fix
  • New feature
  • Documentation update
  • Plugin update
  • Map update
  • Build/tooling change
  • Other

Related Issue

Closes #

Testing

  • dotnet build -c Release clean; tabbed + windowed modes both launch with no
    XAML/Dock errors (verified after rebasing onto current main).
  • Manually verified by @dylb0t on macOS: toggle modes; run scripts (Script Bar);
    text wraps + follows; save/load a windowed layout; resize + restart (size
    restored); close windows + restart (stay closed, re-open from Window menu);
    Log/ItemLog titles correct.
  • Runtime-only caveat: the macOS render output was verified manually; CI
    exercises build on all platforms.
  • Regression testing suggested on other platforms

Checklist

  • [ x ] I kept this pull request focused and reasonably scoped
  • [ x ] I tested the change where practical
  • [ x ] I updated documentation if needed
  • [ x ] I understand this contribution will be distributed under the repository’s existing license

Dependency change (please note)

MDI requires Dock ≥ 11.3.9, and Dock.Model.ReactiveUI at that version pulls
ReactiveUI ≥ 22.2.1, which would break ReactiveUI.Fody across every
view-model. To avoid an app-wide ReactiveUI migration, the dock layer moves
to Dock.Model.Mvvm 11.3.11.22 (CommunityToolkit-based, no ReactiveUI dep);
the app's own view-models keep ReactiveUI 20 + Fody untouched. Avalonia core
goes 11.3.6 → 11.3.11 (Avalonia.ReactiveUI stays 11.3.9, still satisfied).
No behaviour change to the existing tabbed layout.

Included fixes (surfaced during testing on the 11.3.11 bump)

  • {ReflectionBinding} for $parent VM-command bindings — 11.3.11 broke the
    old ((vm:Type)DataContext) cast in lazily-built templates (crashed when the
    Script Bar / Edit-Exit dialog realized).
  • Text now wraps + auto-scroll follows in MDI (the host measures content with
    infinite size; pin the scroller's width/height).
  • Saving a layout no longer crashes on a NaN dock proportion (auto-sized docks).

Attribution

Window skin, status/compass icon set, and Log/ItemLog windows derive from
@dylb0t's prototype (GPL-3.0); see CREDITS.md.

dylb0t added 8 commits June 5, 2026 21:11
Adds an optional windowed mode where every panel becomes a free-floating,
skinned child window inside the main window (à la Genie 4), alongside the
existing tabbed/docked layout. Toggle via Window → "Windowed Mode (MDI)";
the choice and each window's geometry persist across restarts.

Framework groundwork
  - Dock layer moved from Dock.Model.ReactiveUI to Dock.Model.Mvvm 11.3.11.22
    (CommunityToolkit.Mvvm-based). MDI requires Dock >= 11.3.9, and the
    ReactiveUI dock model at that version pulls ReactiveUI >= 22.2.1, which
    would break ReactiveUI.Fody across every view-model. The MVVM dock model
    has no ReactiveUI dependency, so the app's own VMs keep ReactiveUI 20 +
    Fody untouched; only the dock Tool/Document/Factory classes use the MVVM
    base (the 3 that used Fody [Reactive] now use SetProperty).
  - Avalonia core 11.3.6 -> 11.3.11 (Dock 11.3.11.x requires it);
    Avalonia.ReactiveUI stays at 11.3.9 (its latest), which still satisfies
    core >= 11.3.9.

Windowed mode
  - DisplaySettings.WindowedMode (persisted in display.json).
  - GenieDockFactory.CreateMdiLayout()/BuildMdiLayout(): all panels in a single
    MDI DocumentDock; panel VMs, DataTemplates, Window-menu registry and float
    plumbing are shared with the tabbed path.
  - MainWindowViewModel honors the mode on startup and ToggleWindowedModeCommand
    rebuilds the layout live.

Window decorations (ported from dylb0t's Genie5 prototype, GPL-3.0)
  - Themes/MdiDocumentWindowSkin.axaml: 9-slice skinned MDI window chrome
    (bitmap borders, compact title bar, min/max/close, resize handles) +
    Assets/skin_*.{bmp,png}. Merged in App.axaml to override the default
    MdiDocumentWindow theme.

Per-window geometry persistence
  - Settings/MdiLayoutStore (mdi-layout.json) + GenieDockFactory
    Capture/ApplyMdiBounds via IMdiDocument (Tool and Document both implement
    it). Captured on mode-toggle-out and on window close; restored when the
    MDI layout is built.

Verified: clean `dotnet build -c Release`; tabbed and windowed modes both
launch and initialise (skin + geometry restore) with no XAML/Dock errors.
Ports the dedicated Log and ItemLog windows from the dylb0t prototype as
proper stream panels (rendered by the existing StreamTool template, shown in
both the tabbed and windowed/MDI layouts, toggleable from the Window menu):

- Log — consolidated conversation feed; mirrors the Talk + Whispers speech
  streams. Also the target for `#echo >log`.
- ItemLog — item/loot log; fed by the server `itemLog` stream and by
  `#echo >itemlog`.

Wiring: StreamTabs gains Log + ItemLog buffers (+ speech-mirror / itemLog
routing); GenieDockFactory registers both panels in CreateLayout and
CreateMdiLayout; reserved-window list, visibility bools, toggle commands,
SetVisibilityBool/RefreshVisibilityBools, and the EchoToWindow seam route
`#echo >log` / `>itemlog` to the built-in panels rather than auto-creating a
plugin window. Verified: clean build; tabbed and windowed modes both launch
with the panels present and no errors.
…11.3.11)

Three item-template bindings reached a window/dialog command via a runtime
type cast — `$parent[ItemsControl].((vm:SomeViewModel)DataContext).Cmd`. Under
the Avalonia 11.3.11 bump (pulled in for Dock 11.3.11.x / windowed mode) the
runtime XAML type resolver fails to resolve the `vm:` type inside a lazily
built ItemTemplate, throwing "Unable to resolve type vm:MainWindowViewModel"
and crashing the app the first time the template realised — e.g. the Script
Bar appearing when a script starts (hit by running tradeshard.cmd), or the
Edit-Exit dialog's skill-requirement list.

Switch these to {ReflectionBinding $parent[ItemsControl].DataContext.Cmd} —
resolved by reflection on the live object, with no compile-time type name to
look up. Affects the Script Bar Edit/Stop buttons and EditExitDialog's
Remove-skill button.

Verified by forcing the Script Bar to realise: the type-resolution exception
no longer occurs and the template builds cleanly.
In windowed (MDI) mode the document host measures content with infinite
width, so the line-text ScrollViewers never got a finite width to wrap
against — long lines (e.g. movement/arrival spam) ran past the right edge
instead of wrapping. Same root cause the dylb0t prototype fixed when it
added MDI: pin the scroll container to its host-given width.

Bind each text ScrollViewer's Width to its parent Grid's arranged width
(Bounds.Width) — the declarative equivalent of the prototype's
SizeChanged width-pin. Applies to the game-text, stream (logons/talk/
whispers/thoughts/combat/log/itemlog), and backpack panels; gives the
SelectableTextBlock TextWrapping="Wrap" a finite width even when the MDI
host measures with infinity.

Verified: clean build; tabbed and windowed modes both launch and stay up
with no layout errors (3x MDI runs).
Dock leaves a ProportionalDock's Proportion at double.NaN to mean "auto /
equal share". The DockTree snapshot in a SavedLayout captures that value
verbatim, and System.Text.Json's default options reject NaN/Infinity —
so "Save Layout" threw ArgumentException ("…infinity cannot be written as
valid JSON") and crashed. The windowed-mode (MDI) layout makes this easy to
hit since its docks use auto proportions, but the default tabbed layout also
has auto-proportioned containers.

- SavedLayout JSON options: NumberHandling = AllowNamedFloatingPointLiterals,
  so NaN serializes as "NaN" and round-trips (auto-sized docks stay auto).
  Shared options cover both ToJson and FromJson.
- Harden GenieDockFactory.CaptureMdiBounds to persist only finite rects, so a
  never-realised/minimised window can't write NaN/Infinity into
  mdi-layout.json (which uses default, throwing options).

Verified: AllowNamedFloatingPointLiterals serializes double.NaN as "NaN"
without the exception that default options raise.
…titles

Four issues found while using windowed (MDI) mode:

1. Text scrolled past the window and didn't follow the newest line. Root
   cause: the MDI host measures content with infinite *height* too — I'd only
   pinned width (for wrapping), so the ScrollViewer's viewport was bogus and
   never actually scrolled. Pin Height as well (mirroring the dylb0t
   prototype's both-dimensions pin) on the game/stream/backpack scrollers, and
   make AutoScroll use ScrollViewer.ScrollToEnd() (robust to mid-update
   extent) instead of computing Offset from Extent - Viewport.

2. Main-window size/position now persists across restarts. New
   Settings/WindowBoundsStore (window.json); MainWindow restores it in the
   ctor (before first show, no flash) and saves on close. Maximized state is
   recorded and re-applied; off-screen positions are guarded.

3. Saving a layout in windowed mode reopened as tabbed. SavedLayout now
   carries WindowedMode (+ per-layout MdiBounds); CaptureCurrentLayout records
   them and ApplyLayout switches document mode before rebuilding, restoring the
   floating-window geometry.

4. Log and ItemLog tabs/windows had blank titles — those ids were never
   registered in WindowSettingsStore, so ws.Get returned the empty-title
   Fallback which clobbered the StreamTool's buffer-name title. Register
   "log"/"itemlog" with titles "Log"/"ItemLog".

Verified: clean build; tabbed + windowed modes launch (incl. window-bounds
restore) with no errors.
On restart (and on toggling back to windowed mode) the MDI layout was rebuilt
with the full default-visible set and bounds applied on top, so any window the
user had closed reappeared.

Now the set of OPEN windows is part of the persisted state:
- CaptureMdiBounds iterates the live MDI dock's VisibleDockables (only open
  windows) instead of the full _tools registry, so a closed panel's stale
  bounds aren't recorded — the saved geometry keys ARE the open set.
- CreateMdiLayout takes an optional visible-id set; BuildMdiLayout passes the
  saved geometry's keys, so restore opens exactly the windows that were open.
  All panels stay registered so closed ones can still be re-opened from the
  Window menu.

Verified: clean build; launching windowed mode with a saved set of just two
windows builds with only those (no errors).
…layouts

Previously the main-window size/position (window.json), the windowed-mode
on/off choice (display.json), and per-window MDI geometry (mdi-layout.json)
were auto-saved on close and auto-restored on every launch. That coupled
"how the app looked last time" to startup, independent of the named layout
profiles.

Decouple them so they ride exclusively on saved layouts:

- Startup always builds the default tabbed layout. WindowedMode is now
  [JsonIgnore] (session/layout state, not persisted) so fresh launches open
  tabbed at the XAML default size.
- Window geometry (size/position/maximized) is captured into SavedLayout and
  restored on layout load, via a CaptureWindowGeometry/ApplyWindowGeometry
  bridge the View wires to the VM. A HasWindowGeometry flag keeps older
  layouts (no geometry) from snapping the window to defaults.
- The Window-menu mode toggle remembers MDI window positions in-memory for
  the session only (no disk write).
- Removed the on-close auto-save (PersistMdiGeometryIfWindowed + SaveWindowBounds)
  and the startup auto-restore.
- Deleted WindowBoundsStore (window.json) and MdiLayoutStore (mdi-layout.json);
  kept the MdiWindowBounds record (now in its own file).

Net effect: window size / MDI on-off / MDI positions load only when a layout
profile is loaded (Layout menu, or per-profile / global default on connect)
and save only when a layout is saved.
@monil2233 monil2233 merged commit 60ed4d0 into GenieClient:main Jun 6, 2026
4 checks passed
monil2233 added a commit that referenced this pull request Jun 6, 2026
Mapper editor + pan + smarter labels (#53) and windowed (MDI) document
mode (#52) since alpha.3.4.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants