diff --git a/src/Genie.App/App.axaml b/src/Genie.App/App.axaml
index 4b483d5..cd7d925 100644
--- a/src/Genie.App/App.axaml
+++ b/src/Genie.App/App.axaml
@@ -7,6 +7,17 @@
x:Class="Genie.App.App"
RequestedThemeVariant="Dark">
+
+
+
+
+
+
+
+
+
@@ -71,6 +82,8 @@
+ VerticalScrollBarVisibility="Auto"
+ Width="{Binding $parent[Grid].Bounds.Width}"
+ Height="{Binding $parent[Grid].Bounds.Height}">
_toolForeground; private set => SetProperty(ref _toolForeground, value); }
+
+ private IBrush? _toolBackground;
+ public IBrush? ToolBackground { get => _toolBackground; private set => SetProperty(ref _toolBackground, value); }
+
+ private FontFamily _toolFontFamily = new("Cascadia Mono,Consolas,Courier New,monospace");
+ public FontFamily ToolFontFamily { get => _toolFontFamily; private set => SetProperty(ref _toolFontFamily, value); }
+
+ private double _toolFontSize = 11;
+ public double ToolFontSize { get => _toolFontSize; private set => SetProperty(ref _toolFontSize, value); }
public BackpackTool(InventoryViewModel vm, WindowSettings? settings = null)
{
diff --git a/src/Genie.App/Docking/ExperienceTool.cs b/src/Genie.App/Docking/ExperienceTool.cs
index 462e2a1..429d01f 100644
--- a/src/Genie.App/Docking/ExperienceTool.cs
+++ b/src/Genie.App/Docking/ExperienceTool.cs
@@ -1,5 +1,5 @@
using Avalonia.Media;
-using Dock.Model.ReactiveUI.Controls;
+using Dock.Model.Mvvm.Controls;
using Genie.App.ViewModels;
using Genie.Core.Layout;
diff --git a/src/Genie.App/Docking/GameTextDocument.cs b/src/Genie.App/Docking/GameTextDocument.cs
index a2dc840..c2b3d67 100644
--- a/src/Genie.App/Docking/GameTextDocument.cs
+++ b/src/Genie.App/Docking/GameTextDocument.cs
@@ -1,10 +1,9 @@
using Avalonia;
using Avalonia.Media;
-using Dock.Model.ReactiveUI.Controls;
+using Dock.Model.Mvvm.Controls;
using Genie.App.Controls;
using Genie.App.ViewModels;
using Genie.Core.Layout;
-using ReactiveUI.Fody.Helpers;
namespace Genie.App.Docking;
@@ -12,17 +11,24 @@ public class GameTextDocument : Document
{
public GameTextViewModel ViewModel { get; }
+ // Per-window appearance overrides. SetProperty-backed (Dock.Model.Mvvm base
+ // is a CommunityToolkit ObservableObject) instead of Fody [Reactive].
+
/// Per-window foreground brush. Null falls through to the global GameBrush.
- [Reactive] public IBrush? ToolForeground { get; private set; }
+ private IBrush? _toolForeground;
+ public IBrush? ToolForeground { get => _toolForeground; private set => SetProperty(ref _toolForeground, value); }
/// Per-window background brush. Null = transparent (default).
- [Reactive] public IBrush? ToolBackground { get; private set; }
+ private IBrush? _toolBackground;
+ public IBrush? ToolBackground { get => _toolBackground; private set => SetProperty(ref _toolBackground, value); }
/// Per-window font family override.
- [Reactive] public FontFamily ToolFontFamily { get; private set; } = new("Cascadia Mono,Consolas,Courier New,monospace");
+ private FontFamily _toolFontFamily = new("Cascadia Mono,Consolas,Courier New,monospace");
+ public FontFamily ToolFontFamily { get => _toolFontFamily; private set => SetProperty(ref _toolFontFamily, value); }
/// Per-window font size override.
- [Reactive] public double ToolFontSize { get; private set; } = 13;
+ private double _toolFontSize = 13;
+ public double ToolFontSize { get => _toolFontSize; private set => SetProperty(ref _toolFontSize, value); }
public GameTextDocument(GameTextViewModel vm, WindowSettings? settings = null)
{
diff --git a/src/Genie.App/Docking/GenieDockFactory.cs b/src/Genie.App/Docking/GenieDockFactory.cs
index 5494211..d5f8bdd 100644
--- a/src/Genie.App/Docking/GenieDockFactory.cs
+++ b/src/Genie.App/Docking/GenieDockFactory.cs
@@ -3,8 +3,8 @@
using Dock.Avalonia.Controls;
using Dock.Model.Controls;
using Dock.Model.Core;
-using Dock.Model.ReactiveUI;
-using Dock.Model.ReactiveUI.Controls;
+using Dock.Model.Mvvm;
+using Dock.Model.Mvvm.Controls;
using Genie.App.ViewModels;
namespace Genie.App.Docking;
@@ -47,6 +47,11 @@ private readonly record struct DockHome(
///
private IRootDock? _root;
+ /// The live MDI while in windowed mode
+ /// (null in tabbed mode). Its VisibleDockables is the set of windows
+ /// currently OPEN — used to persist/restore which windows the user closed.
+ private DocumentDock? _mdiDock;
+
///
/// "Last known location" for every registered tool. Captures enough
/// state to fully reconstruct the tool's home, even when its parent
@@ -174,6 +179,8 @@ public override IRootDock CreateLayout()
var whispers = new StreamTool (_vm.StreamTabs.Whispers, ws.Get("whispers"));
var thoughts = new StreamTool (_vm.StreamTabs.Thoughts, ws.Get("thoughts"));
var combat = new StreamTool (_vm.StreamTabs.Combat, ws.Get("combat"));
+ var log = new StreamTool (_vm.StreamTabs.Log, ws.Get("log"));
+ var itemlog = new StreamTool (_vm.StreamTabs.ItemLog, ws.Get("itemlog"));
var experience = new ExperienceTool(_vm.Experience, ws.Get("experience"));
// ── Default ship layout — three vertical columns ─────────────────
@@ -231,7 +238,7 @@ public override IRootDock CreateLayout()
Id = "streams",
Alignment = Alignment.Bottom,
Proportion = 0.65,
- VisibleDockables = CreateList(logons, talk, whispers, thoughts, combat),
+ VisibleDockables = CreateList(logons, talk, whispers, thoughts, combat, log, itemlog),
ActiveDockable = combat // matches screenshot default — Combat tab active
};
@@ -302,6 +309,8 @@ public override IRootDock CreateLayout()
_tools[whispers.Id] = (whispers, streamDock.Id);
_tools[thoughts.Id] = (thoughts, streamDock.Id);
_tools[combat.Id] = (combat, streamDock.Id);
+ _tools[log.Id] = (log, streamDock.Id);
+ _tools[itemlog.Id] = (itemlog, streamDock.Id);
// Experience: registered but hidden by default (like Vitals) — re-opens
// beside the Backpack via Window → Experience. The plugin fills it.
_tools[experience.Id] = (experience, backpackDock.Id);
@@ -327,6 +336,184 @@ public override IRootDock CreateLayout()
return root;
}
+ ///
+ /// Windowed (MDI) layout — every panel is a free-floating child window
+ /// inside a single MDI (Genie 4 "windowed
+ /// mode"). Requires Dock 11.3.9+ ().
+ /// Panel instances and their DataTemplates are identical to
+ /// ; only the container differs, so the Window
+ /// menu, per-window settings, and float/visibility plumbing all carry over.
+ ///
+ /// Panel ids to open as windows. Null = the
+ /// default set. When restoring a saved layout this is the set that was open
+ /// when it was saved, so windows the user had closed stay closed.
+ public IRootDock CreateMdiLayout(IReadOnlyCollection? visibleIds = null)
+ {
+ // Host-window locator is harmless in MDI mode (children are in-window,
+ // not OS windows) but a panel can still be floated out to a real
+ // window, so keep it wired exactly as the tabbed path does.
+ HostWindowLocator = new Dictionary>
+ {
+ [nameof(IDockWindow)] = () => new HostWindow
+ {
+ Background = new SolidColorBrush(Color.FromRgb(0x1f, 0x1f, 0x1f)),
+ TransparencyLevelHint = new[] { WindowTransparencyLevel.None },
+ }
+ };
+
+ var ws = _vm.WindowSettings;
+ var gameText = new GameTextDocument(_vm.GameText, ws.Get("game-text"));
+ var vitals = new VitalsTool (_vm.Vitals, ws.Get("vitals"));
+ var room = new RoomTool (_vm.Room, ws.Get("room"));
+ var backpack = new BackpackTool (_vm.Inventory, ws.Get("backpack"));
+ var mapper = new MapperTool (_vm.Mapper, ws.Get("mapper"));
+ var logons = new StreamTool (_vm.StreamTabs.Logons, ws.Get("logons"));
+ var talk = new StreamTool (_vm.StreamTabs.Talk, ws.Get("talk"));
+ var whispers = new StreamTool (_vm.StreamTabs.Whispers, ws.Get("whispers"));
+ var thoughts = new StreamTool (_vm.StreamTabs.Thoughts, ws.Get("thoughts"));
+ var combat = new StreamTool (_vm.StreamTabs.Combat, ws.Get("combat"));
+ var log = new StreamTool (_vm.StreamTabs.Log, ws.Get("log"));
+ var itemlog = new StreamTool (_vm.StreamTabs.ItemLog, ws.Get("itemlog"));
+ var experience = new ExperienceTool (_vm.Experience, ws.Get("experience"));
+
+ // Every MDI panel in canonical order, paired with its id.
+ var panels = new (string Id, IDockable Dockable)[]
+ {
+ ("game-text", gameText), ("room", room), ("mapper", mapper), ("backpack", backpack),
+ ("logons", logons), ("talk", talk), ("whispers", whispers), ("thoughts", thoughts),
+ ("combat", combat), ("log", log), ("itemlog", itemlog),
+ ("vitals", vitals), ("experience", experience),
+ };
+
+ // Which panels open as windows. Default mirrors the tabbed layout
+ // (Vitals + Experience stay registered-but-hidden).
+ var defaultVisible = new[]
+ {
+ "game-text", "room", "mapper", "backpack",
+ "logons", "talk", "whispers", "thoughts", "combat", "log", "itemlog",
+ };
+ var show = new HashSet(
+ visibleIds is { Count: > 0 } ? visibleIds : defaultVisible,
+ StringComparer.OrdinalIgnoreCase);
+
+ var visibleDockables = panels.Where(p => show.Contains(p.Id))
+ .Select(p => p.Dockable).ToArray();
+ if (visibleDockables.Length == 0) // never leave the MDI dock empty
+ visibleDockables = new IDockable[] { gameText };
+ var active = visibleDockables.FirstOrDefault(
+ d => string.Equals(d.Id, "game-text", StringComparison.OrdinalIgnoreCase))
+ ?? visibleDockables[0];
+
+ var mdiDock = new DocumentDock
+ {
+ Id = "mdi",
+ Title = "Windows",
+ IsCollapsable = false,
+ CanCreateDocument = false,
+ LayoutMode = DocumentLayoutMode.Mdi,
+ VisibleDockables = CreateList(visibleDockables),
+ ActiveDockable = active,
+ };
+ _mdiDock = mdiDock;
+
+ var rootLayout = new ProportionalDock
+ {
+ Id = "root-layout",
+ Orientation = Orientation.Horizontal,
+ IsCollapsable = false,
+ VisibleDockables = CreateList(mdiDock),
+ };
+
+ var root = CreateRootDock();
+ root.Id = "root";
+ root.IsCollapsable = false;
+ root.VisibleDockables = CreateList(rootLayout);
+ root.ActiveDockable = rootLayout;
+ root.DefaultDockable = rootLayout;
+ _root = root;
+
+ // Registry for Window-menu visibility toggles — ALL panels are
+ // registered (so a closed/hidden one can be re-opened from the menu),
+ // even though only the `show` set starts visible. No nested home docks
+ // in MDI mode.
+ _tools.Clear();
+ foreach (var p in panels)
+ _tools[p.Id] = (p.Dockable, mdiDock.Id);
+ _dockHomes.Clear();
+
+ foreach (var (id, tool) in _pluginWindowTools)
+ _tools[id] = (tool, mdiDock.Id);
+
+ return root;
+ }
+
+ /// Build + initialise the MDI layout, ready to assign to the
+ /// DockControl. MDI counterpart of .
+ /// (if any) restores each child window's
+ /// last position/size/state.
+ public IRootDock BuildMdiLayout(
+ IReadOnlyDictionary? savedBounds = null)
+ {
+ // The saved geometry keys are exactly the windows that were open, so
+ // restore opens only those (closed windows stay closed).
+ var visibleIds = savedBounds is { Count: > 0 } ? savedBounds.Keys.ToList() : null;
+ var root = CreateMdiLayout(visibleIds);
+ InitLayout(root);
+ if (savedBounds is { Count: > 0 }) ApplyMdiBounds(savedBounds);
+ CaptureAllPositions();
+ return root;
+ }
+
+ // ── Windowed-mode (MDI) per-window geometry ────────────────────────────
+ // Every dockable in Dock.Model.Mvvm (Tool AND Document) implements
+ // IMdiDocument, so each floating panel carries its own MdiBounds/MdiState.
+
+ /// Read the current MDI geometry of every panel that has a
+ /// non-empty rect. Called before leaving windowed mode and on app close
+ /// so positions survive restarts.
+ public Dictionary CaptureMdiBounds()
+ {
+ var result = new Dictionary();
+ // Iterate the MDI dock's live VisibleDockables — i.e. only the windows
+ // currently OPEN. A panel the user closed is no longer here, so it
+ // isn't recorded and won't be re-opened on restore. (Iterating _tools
+ // would also capture closed panels' stale bounds.)
+ if (_mdiDock?.VisibleDockables is not { } open) return result;
+ foreach (var dockable in open)
+ {
+ if (dockable is Dock.Model.Controls.IMdiDocument mdi &&
+ dockable.Id is { Length: > 0 } id)
+ {
+ var r = mdi.MdiBounds;
+ // Only capture real, finite rects — a window that was never
+ // realised (or is minimised) can report NaN/Infinity bounds,
+ // which would crash the layout's JSON write.
+ if (double.IsFinite(r.X) && double.IsFinite(r.Y) &&
+ double.IsFinite(r.Width) && double.IsFinite(r.Height) &&
+ r.Width > 0 && r.Height > 0)
+ result[id] = new Settings.MdiWindowBounds(
+ r.X, r.Y, r.Width, r.Height, mdi.MdiState.ToString());
+ }
+ }
+ return result;
+ }
+
+ /// Restore saved MDI geometry onto the freshly built panels.
+ public void ApplyMdiBounds(IReadOnlyDictionary bounds)
+ {
+ foreach (var (_, (dockable, _)) in _tools)
+ {
+ if (dockable is Dock.Model.Controls.IMdiDocument mdi &&
+ dockable.Id is { Length: > 0 } id &&
+ bounds.TryGetValue(id, out var b))
+ {
+ mdi.MdiBounds = new Dock.Model.Core.DockRect(b.X, b.Y, b.Width, b.Height);
+ if (Enum.TryParse(b.State, out var st))
+ mdi.MdiState = st;
+ }
+ }
+ }
+
// ── Layout snapshot (full-tree round-trip) ─────────────────────────────
///
diff --git a/src/Genie.App/Docking/MapperTool.cs b/src/Genie.App/Docking/MapperTool.cs
index f603370..1c2e0cb 100644
--- a/src/Genie.App/Docking/MapperTool.cs
+++ b/src/Genie.App/Docking/MapperTool.cs
@@ -1,4 +1,4 @@
-using Dock.Model.ReactiveUI.Controls;
+using Dock.Model.Mvvm.Controls;
using Genie.App.ViewModels;
using Genie.Core.Layout;
diff --git a/src/Genie.App/Docking/PluginWindowTool.cs b/src/Genie.App/Docking/PluginWindowTool.cs
index 1bfe5de..64d02f1 100644
--- a/src/Genie.App/Docking/PluginWindowTool.cs
+++ b/src/Genie.App/Docking/PluginWindowTool.cs
@@ -1,6 +1,6 @@
using System;
using Avalonia.Media;
-using Dock.Model.ReactiveUI.Controls;
+using Dock.Model.Mvvm.Controls;
using Genie.App.ViewModels;
using ReactiveUI;
@@ -12,7 +12,7 @@ namespace Genie.App.Docking;
/// title. Monospaced so plugins that render column-aligned text (inventory
/// trees, tables) line up — same treatment as the Experience panel.
///
-/// The dock is the
+/// The dock is the
/// canonical pluginwin:<name> key (see
/// ), so the panel round-trips
/// through saved layouts and the Window menu's visibility toggles just like a
diff --git a/src/Genie.App/Docking/RoomTool.cs b/src/Genie.App/Docking/RoomTool.cs
index d56228d..b352ccb 100644
--- a/src/Genie.App/Docking/RoomTool.cs
+++ b/src/Genie.App/Docking/RoomTool.cs
@@ -1,4 +1,4 @@
-using Dock.Model.ReactiveUI.Controls;
+using Dock.Model.Mvvm.Controls;
using Genie.App.ViewModels;
using Genie.Core.Layout;
diff --git a/src/Genie.App/Docking/ScriptsTool.cs b/src/Genie.App/Docking/ScriptsTool.cs
index dd46e8e..cdaf1a2 100644
--- a/src/Genie.App/Docking/ScriptsTool.cs
+++ b/src/Genie.App/Docking/ScriptsTool.cs
@@ -1,4 +1,4 @@
-using Dock.Model.ReactiveUI.Controls;
+using Dock.Model.Mvvm.Controls;
using Genie.App.ViewModels;
using Genie.Core.Layout;
diff --git a/src/Genie.App/Docking/StreamTool.cs b/src/Genie.App/Docking/StreamTool.cs
index afeda8f..56dd119 100644
--- a/src/Genie.App/Docking/StreamTool.cs
+++ b/src/Genie.App/Docking/StreamTool.cs
@@ -1,10 +1,9 @@
using Avalonia;
using Avalonia.Media;
-using Dock.Model.ReactiveUI.Controls;
+using Dock.Model.Mvvm.Controls;
using Genie.App.Controls;
using Genie.App.ViewModels;
using Genie.Core.Layout;
-using ReactiveUI.Fody.Helpers;
namespace Genie.App.Docking;
@@ -12,10 +11,19 @@ public class StreamTool : Tool
{
public StreamBuffer Buffer { get; }
- [Reactive] public IBrush? ToolForeground { get; private set; }
- [Reactive] public IBrush? ToolBackground { get; private set; }
- [Reactive] public FontFamily ToolFontFamily { get; private set; } = new("Cascadia Mono,Consolas,Courier New,monospace");
- [Reactive] public double ToolFontSize { get; private set; } = 11;
+ // Per-window appearance overrides. SetProperty-backed (Dock.Model.Mvvm base
+ // is a CommunityToolkit ObservableObject) instead of Fody [Reactive].
+ private IBrush? _toolForeground;
+ public IBrush? ToolForeground { get => _toolForeground; private set => SetProperty(ref _toolForeground, value); }
+
+ private IBrush? _toolBackground;
+ public IBrush? ToolBackground { get => _toolBackground; private set => SetProperty(ref _toolBackground, value); }
+
+ private FontFamily _toolFontFamily = new("Cascadia Mono,Consolas,Courier New,monospace");
+ public FontFamily ToolFontFamily { get => _toolFontFamily; private set => SetProperty(ref _toolFontFamily, value); }
+
+ private double _toolFontSize = 11;
+ public double ToolFontSize { get => _toolFontSize; private set => SetProperty(ref _toolFontSize, value); }
public StreamTool(StreamBuffer buffer, WindowSettings? settings = null)
{
diff --git a/src/Genie.App/Docking/VitalsTool.cs b/src/Genie.App/Docking/VitalsTool.cs
index 53bce89..05fc4f1 100644
--- a/src/Genie.App/Docking/VitalsTool.cs
+++ b/src/Genie.App/Docking/VitalsTool.cs
@@ -1,5 +1,5 @@
using Genie.Core.Layout;
-using Dock.Model.ReactiveUI.Controls;
+using Dock.Model.Mvvm.Controls;
using Genie.App.ViewModels;
namespace Genie.App.Docking;
diff --git a/src/Genie.App/Genie.App.csproj b/src/Genie.App/Genie.App.csproj
index 1bc92d2..abed558 100644
--- a/src/Genie.App/Genie.App.csproj
+++ b/src/Genie.App/Genie.App.csproj
@@ -41,15 +41,27 @@
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Genie.App/Settings/DisplaySettings.cs b/src/Genie.App/Settings/DisplaySettings.cs
index d84a528..3099d3c 100644
--- a/src/Genie.App/Settings/DisplaySettings.cs
+++ b/src/Genie.App/Settings/DisplaySettings.cs
@@ -50,6 +50,21 @@ public sealed class DisplaySettings : ReactiveObject
///
[Reactive] public bool ShowStatusBar { get; set; } = true;
+ ///
+ /// Windowed (MDI) document mode — every panel becomes a free-floating
+ /// child window inside the main window, à la Genie 4, instead of the
+ /// tabbed/docked layout.
+ ///
+ /// Deliberately : the mode is NOT auto-persisted
+ /// across restarts. It's session/layout state only — fresh launches start
+ /// tabbed, and the windowed choice (with its per-window geometry) rides on
+ /// a saved instead. Loading a layout sets this;
+ /// the Window-menu toggle flips it for the current session.
+ ///
+ ///
+ [JsonIgnore]
+ [Reactive] public bool WindowedMode { get; set; }
+
///
/// Name of the global layout preset auto-applied on connect when the
/// connected profile has no DefaultLayoutName of its own (and for
diff --git a/src/Genie.App/Settings/MdiWindowBounds.cs b/src/Genie.App/Settings/MdiWindowBounds.cs
new file mode 100644
index 0000000..01cf85d
--- /dev/null
+++ b/src/Genie.App/Settings/MdiWindowBounds.cs
@@ -0,0 +1,15 @@
+namespace Genie.App.Settings;
+
+///
+/// One windowed-mode (MDI) child window's saved geometry, keyed by the
+/// dockable's Id. Coordinates are in Dock's MDI coordinate space (the same
+/// values Dock writes to IMdiDocument.MdiBounds).
+///
+/// Persisted only as part of a (); it is also held transiently in memory
+/// across a within-session mode toggle. There is no standalone on-disk store —
+/// windowed geometry never auto-persists across restarts on its own.
+///
+///
+public sealed record MdiWindowBounds(
+ double X, double Y, double Width, double Height, string State);
diff --git a/src/Genie.App/Settings/SavedLayout.cs b/src/Genie.App/Settings/SavedLayout.cs
index d70593c..c63ccc0 100644
--- a/src/Genie.App/Settings/SavedLayout.cs
+++ b/src/Genie.App/Settings/SavedLayout.cs
@@ -41,6 +41,21 @@ public sealed class SavedLayout
public double WindowWidth { get; set; } = 1280;
public double WindowHeight { get; set; } = 800;
+ /// Main-window position (DIPs). Only meaningful when
+ /// is true.
+ public int WindowX { get; set; }
+ public int WindowY { get; set; }
+
+ /// Whether the main window was maximized when the layout was saved.
+ /// Wins over size on restore (we just re-maximize).
+ public bool WindowMaximized { get; set; }
+
+ /// True once a layout has actually captured the main-window
+ /// geometry. Layouts saved before window geometry rode on the profile leave
+ /// this false, so applying them leaves the current window size untouched
+ /// instead of snapping to the 1280×800 defaults at (0,0).
+ public bool HasWindowGeometry { get; set; }
+
// ── Dock-tool visibility ───────────────────────────────────────────
/// String IDs of every tool that should be visible in the
@@ -59,6 +74,16 @@ public sealed class SavedLayout
/// feature; those fall back to .
public Docking.DockNodeSnapshot? DockTree { get; set; }
+ /// Whether this layout was saved in windowed (MDI) document mode.
+ /// On load, the app switches to that mode before rebuilding — so a layout
+ /// saved in windowed mode reopens windowed, not tabbed.
+ public bool WindowedMode { get; set; }
+
+ /// Per-window MDI geometry (position/size/state), keyed by panel
+ /// id. Only populated for layouts; restores each
+ /// floating window where it was when the layout was saved.
+ public Dictionary? MdiBounds { get; set; }
+
// ── Cross-cutting display flags ────────────────────────────────────
public bool HandsStripVisible { get; set; } = true;
@@ -82,6 +107,13 @@ public sealed class SavedLayout
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
+ // Dock leaves a ProportionalDock's Proportion at double.NaN to mean
+ // "auto / equal share". The DockTree snapshot captures that verbatim,
+ // and System.Text.Json rejects NaN/Infinity unless told otherwise —
+ // which crashed "Save Layout" (the snapshot legitimately contains NaN).
+ // Allow the named literals so NaN round-trips and auto-sized docks stay
+ // auto-sized; used for both serialize and deserialize (shared options).
+ NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals,
};
public string ToJson() => JsonSerializer.Serialize(this, JsonOpts);
diff --git a/src/Genie.App/Themes/MdiDocumentWindowSkin.axaml b/src/Genie.App/Themes/MdiDocumentWindowSkin.axaml
new file mode 100644
index 0000000..402928c
--- /dev/null
+++ b/src/Genie.App/Themes/MdiDocumentWindowSkin.axaml
@@ -0,0 +1,404 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Genie.App/ViewModels/MainWindowViewModel.cs b/src/Genie.App/ViewModels/MainWindowViewModel.cs
index fca3643..4343b09 100644
--- a/src/Genie.App/ViewModels/MainWindowViewModel.cs
+++ b/src/Genie.App/ViewModels/MainWindowViewModel.cs
@@ -176,6 +176,7 @@ public class MainWindowViewModel : ReactiveObject, IActivatableViewModel
public Interaction ShowLayoutSavePrompt { get; } = new();
public ReactiveCommand ToggleStatusBarCommand { get; }
+ public ReactiveCommand ToggleWindowedModeCommand{ get; }
public ReactiveCommand ToggleGuildInTitleCommand{ get; }
public ReactiveCommand ToggleHandsBarCommand { get; }
/// Window → Hands Strip Position → Top. Snaps the strip to the top of the window.
@@ -261,6 +262,8 @@ public class MainWindowViewModel : ReactiveObject, IActivatableViewModel
[Reactive] public bool WhispersVisible { get; private set; } = true;
[Reactive] public bool ThoughtsVisible { get; private set; } = true;
[Reactive] public bool CombatVisible { get; private set; } = true;
+ [Reactive] public bool LogVisible { get; private set; } = true;
+ [Reactive] public bool ItemLogVisible { get; private set; } = true;
// ── Toggle commands (one per dockable) ───────────────────────────────────
public ReactiveCommand ToggleGameCommand { get; }
@@ -274,6 +277,8 @@ public class MainWindowViewModel : ReactiveObject, IActivatableViewModel
public ReactiveCommand ToggleWhispersCommand { get; }
public ReactiveCommand ToggleThoughtsCommand { get; }
public ReactiveCommand ToggleCombatCommand { get; }
+ public ReactiveCommand ToggleLogCommand { get; }
+ public ReactiveCommand ToggleItemLogCommand { get; }
// ── Core ──────────────────────────────────────────────────────────────────
@@ -316,6 +321,24 @@ public class MainWindowViewModel : ReactiveObject, IActivatableViewModel
private readonly string _configDir;
private readonly string _defaultMapsDir;
+ ///
+ /// In-memory MDI geometry held across a Window-menu mode toggle within a
+ /// single session. Captured when leaving windowed mode so toggling back
+ /// restores the floating windows where they were. Deliberately NOT
+ /// persisted to disk — windowed geometry only survives a restart via a
+ /// saved layout ().
+ ///
+ private Dictionary? _mdiBoundsCache;
+
+ // ── Main-window geometry bridge (set by the View) ───────────────────────
+ /// Set by MainWindow: returns the live window geometry so a
+ /// layout save can capture it. Null until the view wires it up.
+ public Func<(double Width, double Height, int X, int Y, bool Maximized)>? CaptureWindowGeometry { get; set; }
+
+ /// Set by MainWindow: applies geometry from a loaded layout
+ /// to the main window. Null until the view wires it up.
+ public Action? ApplyWindowGeometry { get; set; }
+
///
/// Directory the writes raw-XML captures to
/// (`{AppData}/Genie5/Logs/`). Exposed via
@@ -480,6 +503,8 @@ public MainWindowViewModel(StartupOptions? startup)
WindowSettings.Register("whispers", "Whispers");
WindowSettings.Register("thoughts", "Thoughts");
WindowSettings.Register("combat", "Combat");
+ WindowSettings.Register("log", "Log");
+ WindowSettings.Register("itemlog", "ItemLog");
WindowSettings.Register("mapper", "Mapper");
WindowSettings.Register("experience", "Experience");
@@ -531,6 +556,11 @@ public MainWindowViewModel(StartupOptions? startup)
};
DockFactory = factory;
+ // Always start in the built-in tabbed layout. The document mode,
+ // window geometry, and MDI window positions are NOT auto-restored from
+ // the last session — they only load when a layout profile is applied
+ // (the Layout menu, or the per-profile / global default layout on
+ // connect via ApplyDefaultLayoutForConnect).
DockLayout = factory.BuildDefaultLayout();
// Wire the Mapper's "pop out" button. Done here (after factory exists)
@@ -788,6 +818,27 @@ public MainWindowViewModel(StartupOptions? startup)
Display.Save(_displayPath);
});
+ // Switch between tabbed/docked and windowed (MDI) document modes.
+ // Rebuilds the dock layout from scratch — the panel view-models are
+ // reused, only the container tree changes — then re-syncs the
+ // Window-menu check marks against the freshly built tree.
+ ToggleWindowedModeCommand = ReactiveCommand.Create(() =>
+ {
+ if (DockFactory is not GenieDockFactory factory) return;
+ // Leaving windowed mode — capture the current window geometry into
+ // the in-memory cache so toggling back restores positions within
+ // this session. (Not written to disk; restart-persistence is via a
+ // saved layout only.)
+ if (Display.WindowedMode)
+ _mdiBoundsCache = factory.CaptureMdiBounds();
+ Display.WindowedMode = !Display.WindowedMode;
+ DockLayout = Display.WindowedMode
+ ? factory.BuildMdiLayout(_mdiBoundsCache)
+ : factory.BuildDefaultLayout();
+ RefreshVisibilityBools();
+ GameText.AddSystemLine($"[layout] {(Display.WindowedMode ? "windowed (MDI)" : "tabbed")} mode");
+ });
+
ToggleGuildInTitleCommand = ReactiveCommand.Create(() =>
{
Display.ShowGuildInTitle = !Display.ShowGuildInTitle;
@@ -925,15 +976,18 @@ public MainWindowViewModel(StartupOptions? startup)
ToggleWhispersCommand = MakeToggleCommand("whispers", v => WhispersVisible = v);
ToggleThoughtsCommand = MakeToggleCommand("thoughts", v => ThoughtsVisible = v);
ToggleCombatCommand = MakeToggleCommand("combat", v => CombatVisible = v);
+ ToggleLogCommand = MakeToggleCommand("log", v => LogVisible = v);
+ ToggleItemLogCommand = MakeToggleCommand("itemlog", v => ItemLogVisible = v);
ResetLayoutCommand = ReactiveCommand.Create(() =>
{
if (DockFactory is not GenieDockFactory factory) return;
- foreach (var id in new[] { "game-text", "vitals", "room", "backpack", "mapper", "logons", "talk", "whispers", "thoughts", "combat" })
+ foreach (var id in new[] { "game-text", "vitals", "room", "backpack", "mapper", "logons", "talk", "whispers", "thoughts", "combat", "log", "itemlog" })
factory.SetToolVisibility(id, true);
GameVisible = VitalsVisible = RoomVisible = BackpackVisible = MapperVisible = true;
LogonsVisible = TalkVisible = WhispersVisible = ThoughtsVisible = CombatVisible = true;
+ LogVisible = ItemLogVisible = true;
});
DisconnectCommand = ReactiveCommand.CreateFromTask(
@@ -1494,6 +1548,8 @@ public void RefreshVisibilityBools()
SetVisibilityBool("whispers", factory.IsToolVisible("whispers"));
SetVisibilityBool("thoughts", factory.IsToolVisible("thoughts"));
SetVisibilityBool("combat", factory.IsToolVisible("combat"));
+ SetVisibilityBool("log", factory.IsToolVisible("log"));
+ SetVisibilityBool("itemlog", factory.IsToolVisible("itemlog"));
}
// ── Plugin-created windows ───────────────────────────────────────────────
@@ -1516,6 +1572,7 @@ public void RefreshVisibilityBools()
"experience", "main", "game", "game-text", "room", "vitals",
"backpack", "mapper", "scripts",
"logons", "talk", "whispers", "thoughts", "combat",
+ "log", "itemlog",
};
private static bool IsReservedWindow(string? name)
@@ -1544,6 +1601,15 @@ private void AttachPluginWindows(GenieCore core)
// not re-open it on every line if the user closed it.
core.EchoToWindow += (text, window, _) =>
{
+ // First-class log windows: route #echo >log / >itemlog to the
+ // built-in stream panels instead of auto-creating a plugin window.
+ var w = window?.Trim().ToLowerInvariant();
+ if (w is "log" or "itemlog")
+ {
+ Avalonia.Threading.Dispatcher.UIThread.Post(() =>
+ (w == "log" ? StreamTabs.Log : StreamTabs.ItemLog).Add(text));
+ return;
+ }
if (IsReservedWindow(window)) return;
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
@@ -1774,6 +1840,8 @@ private void SetVisibilityBool(string id, bool visible)
case "whispers": ForceSet(visible, v => WhispersVisible = v, () => WhispersVisible); break;
case "thoughts": ForceSet(visible, v => ThoughtsVisible = v, () => ThoughtsVisible); break;
case "combat": ForceSet(visible, v => CombatVisible = v, () => CombatVisible); break;
+ case "log": ForceSet(visible, v => LogVisible = v, () => LogVisible); break;
+ case "itemlog": ForceSet(visible, v => ItemLogVisible = v, () => ItemLogVisible); break;
}
static void ForceSet(bool target, Action set, Func get)
@@ -2124,8 +2192,22 @@ private Settings.SavedLayout CaptureCurrentLayout()
ShowEchoText = Display.ShowEchoText,
ShowScriptText = Display.ShowScriptText,
MapBackgroundHex = Display.MapBackgroundHex,
+ WindowedMode = Display.WindowedMode,
};
+ // Main-window geometry — captured from the View (if it has wired the
+ // bridge) so window size/position/maximized ride on the layout profile.
+ if (CaptureWindowGeometry is { } capture)
+ {
+ var g = capture();
+ layout.WindowWidth = g.Width;
+ layout.WindowHeight = g.Height;
+ layout.WindowX = g.X;
+ layout.WindowY = g.Y;
+ layout.WindowMaximized = g.Maximized;
+ layout.HasWindowGeometry = true;
+ }
+
// Visible-tool list — walk the dock factory's known tools and
// record which ones are currently in the dock tree. The factory
// knows the canonical set; checking IsToolVisible per id gives
@@ -2143,6 +2225,12 @@ private Settings.SavedLayout CaptureCurrentLayout()
// alignments, active tabs, container structure), so loading the
// layout restores the visual arrangement, not just visibility.
layout.DockTree = factory.CaptureLayout();
+
+ // In windowed mode the tree snapshot isn't the arrangement — the
+ // per-window MDI geometry is. Capture it so the layout reopens with
+ // each floating window where it was.
+ if (Display.WindowedMode)
+ layout.MdiBounds = factory.CaptureMdiBounds();
}
return layout;
}
@@ -2167,11 +2255,32 @@ private void ApplyLayout(Settings.SavedLayout layout)
Display.ShowScriptText = layout.ShowScriptText;
if (!string.IsNullOrWhiteSpace(layout.MapBackgroundHex))
Display.MapBackgroundHex = layout.MapBackgroundHex;
+ // Switch document mode to match the saved layout BEFORE rebuilding the
+ // dock, so a layout saved in windowed mode reopens windowed (not tabbed).
+ Display.WindowedMode = layout.WindowedMode;
Display.Save(_displayPath);
+ // Restore the main-window geometry from the layout (only when the
+ // layout actually captured it — older layouts leave the window as-is).
+ if (layout.HasWindowGeometry)
+ ApplyWindowGeometry?.Invoke(
+ layout.WindowWidth, layout.WindowHeight,
+ layout.WindowX, layout.WindowY, layout.WindowMaximized);
+
if (DockFactory is Docking.GenieDockFactory factory)
{
- if (layout.DockTree is not null)
+ if (layout.WindowedMode)
+ {
+ // Windowed (MDI): the dock-tree snapshot doesn't capture MDI
+ // arrangement — the per-window geometry does. Prefer the
+ // layout's own saved geometry, falling back to the in-session
+ // cache (null is fine — BuildMdiLayout cascades from defaults).
+ var bounds = layout.MdiBounds is { Count: > 0 }
+ ? layout.MdiBounds
+ : _mdiBoundsCache;
+ DockLayout = factory.BuildMdiLayout(bounds);
+ }
+ else if (layout.DockTree is not null)
{
// Authoritative path: rebuild the whole tree from the snapshot
// and swap it into the bound DockControl. Restores proportions,
@@ -2191,6 +2300,7 @@ private void ApplyLayout(Settings.SavedLayout layout)
foreach (var id in factory.ToolIds)
factory.SetToolVisibility(id, wanted.Contains(id));
}
+ RefreshVisibilityBools();
}
}
diff --git a/src/Genie.App/ViewModels/StreamTabsViewModel.cs b/src/Genie.App/ViewModels/StreamTabsViewModel.cs
index 14abc54..99faa27 100644
--- a/src/Genie.App/ViewModels/StreamTabsViewModel.cs
+++ b/src/Genie.App/ViewModels/StreamTabsViewModel.cs
@@ -14,7 +14,16 @@ public class StreamTabsViewModel : ReactiveObject
public StreamBuffer Thoughts { get; } = new("Thoughts");
public StreamBuffer Combat { get; } = new("Combat");
- public IReadOnlyList All => [Logons, Talk, Whispers, Thoughts, Combat];
+ /// Consolidated conversation log — mirrors the speech streams
+ /// (talk / whispers), Genie 4 "Log" window parity. Also an #echo >log
+ /// target (wired in MainWindowViewModel).
+ public StreamBuffer Log { get; } = new("Log");
+
+ /// Item / loot log. Fed by the itemLog server stream and by
+ /// #echo >itemlog from scripts.
+ public StreamBuffer ItemLog { get; } = new("ItemLog");
+
+ public IReadOnlyList All => [Logons, Talk, Whispers, Thoughts, Combat, Log, ItemLog];
public void Attach(GenieCore core)
{
@@ -25,14 +34,21 @@ public void Attach(GenieCore core)
{
var buf = e.Stream switch
{
- "logons" => Logons,
- "talk" => Talk,
- "whispers" => Whispers,
- "thoughts" => Thoughts,
- "combat" => Combat,
- _ => null
+ "logons" => Logons,
+ "talk" => Talk,
+ "whispers" => Whispers,
+ "thoughts" => Thoughts,
+ "combat" => Combat,
+ "itemlog" or "itemLog" => ItemLog,
+ _ => null
};
buf?.Add(e.Text);
+
+ // The Log window is a consolidated conversation feed: mirror
+ // the speech streams into it (matches the Genie 4 / dylb0t
+ // prototype "Log" window).
+ if (e.Stream is "talk" or "whispers")
+ Log.Add(e.Text);
});
}
}
diff --git a/src/Genie.App/Views/EditExitDialog.axaml b/src/Genie.App/Views/EditExitDialog.axaml
index b83546f..cb91e07 100644
--- a/src/Genie.App/Views/EditExitDialog.axaml
+++ b/src/Genie.App/Views/EditExitDialog.axaml
@@ -64,7 +64,7 @@
diff --git a/src/Genie.App/Views/MainWindow.axaml b/src/Genie.App/Views/MainWindow.axaml
index 89dd048..c1927d1 100644
--- a/src/Genie.App/Views/MainWindow.axaml
+++ b/src/Genie.App/Views/MainWindow.axaml
@@ -130,6 +130,11 @@
IsChecked="{Binding Display.ShowStatusBar}"
Command="{Binding ToggleStatusBarCommand}"
ToolTip.Tip="Show/hide the Wrayth-style vitals strip at the bottom of the window"/>
+
+
+
@@ -943,14 +958,14 @@
Background="Transparent" BorderThickness="0"
Foreground="#80a8c0"
ToolTip.Tip="Edit this script in the configured editor"
- Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).ScriptBar.EditScriptCommand}"
+ Command="{ReflectionBinding $parent[ItemsControl].DataContext.ScriptBar.EditScriptCommand}"
CommandParameter="{Binding}"/>
diff --git a/src/Genie.App/Views/MainWindow.axaml.cs b/src/Genie.App/Views/MainWindow.axaml.cs
index aa72a15..cb35eb7 100644
--- a/src/Genie.App/Views/MainWindow.axaml.cs
+++ b/src/Genie.App/Views/MainWindow.axaml.cs
@@ -16,6 +16,11 @@ public MainWindow()
{
InitializeComponent();
+ // Main-window size/position is no longer auto-restored on startup — the
+ // app always opens at the XAML default size. Geometry now rides on a
+ // saved layout profile (captured/applied via the VM's
+ // CaptureWindowGeometry / ApplyWindowGeometry bridge, wired below).
+
// Ctrl+Right-Click on selected game text → append selection to the
// command bar. Window-level handler so the gesture works regardless of
// which docked panel currently shows the SelectableTextBlock (main game
@@ -49,6 +54,33 @@ public MainWindow()
this.WhenActivated(d =>
{
+ // Bridge the main-window geometry to the VM so a saved layout can
+ // capture/restore it (size, position, maximized). The VM owns no
+ // Window reference, so it calls back through these hooks.
+ ViewModel!.CaptureWindowGeometry = () =>
+ {
+ var maximized = WindowState == WindowState.Maximized;
+ return (Bounds.Width, Bounds.Height, Position.X, Position.Y, maximized);
+ };
+ ViewModel!.ApplyWindowGeometry = (w, h, x, y, maximized) =>
+ {
+ if (maximized)
+ {
+ WindowState = WindowState.Maximized;
+ return;
+ }
+ WindowState = WindowState.Normal;
+ if (w >= 400) Width = w;
+ if (h >= 300) Height = h;
+ // Only restore position if it looks sane (guards against a
+ // window stranded off-screen after a monitor change).
+ if (x > -50 && y > -50 && x < 20000 && y < 20000)
+ {
+ WindowStartupLocation = WindowStartupLocation.Manual;
+ Position = new PixelPoint(x, y);
+ }
+ };
+
d(ViewModel!.ShowConnectDialog.RegisterHandler(async ctx =>
{
// Pass the previous session's actual config so the dialog
@@ -209,6 +241,8 @@ private void OnPluginsMenuOpened(object? sender, RoutedEventArgs e)
protected override void OnClosing(WindowClosingEventArgs e)
{
base.OnClosing(e);
+ // Window geometry + windowed-mode layout are no longer auto-saved on
+ // close — they persist only when the user saves a layout profile.
if (e.Cancel) return; // something upstream already vetoed
if (_closeConfirmed) return; // second pass after user said Yes
if (ViewModel?.IsConnected != true) return;