Windowed (MDI) document mode with Genie 4-style window decorations#52
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
DocumentDock; 9-slice skinned windowdecoration (
Themes/MdiDocumentWindowSkin.axaml+skin_*assets).windows are open/closed, as long as saved in Layouts.
#echo >log) andItemLog (
itemLogstream +#echo >itemlog) panels.Type of Change
Related Issue
Closes #
Testing
dotnet build -c Releaseclean; tabbed + windowed modes both launch with noXAML/Dock errors (verified after rebasing onto current
main).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.
exercises build on all platforms.
Checklist
Dependency change (please note)
MDI requires Dock ≥ 11.3.9, and
Dock.Model.ReactiveUIat that version pullsReactiveUI ≥ 22.2.1, which would break
ReactiveUI.Fodyacross everyview-model. To avoid an app-wide ReactiveUI migration, the dock layer moves
to
Dock.Model.Mvvm11.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.ReactiveUIstays 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$parentVM-command bindings — 11.3.11 broke theold
((vm:Type)DataContext)cast in lazily-built templates (crashed when theScript Bar / Edit-Exit dialog realized).
infinite size; pin the scroller's width/height).
Attribution
Window skin, status/compass icon set, and Log/ItemLog windows derive from
@dylb0t's prototype (GPL-3.0); see CREDITS.md.