From a4dcc97b653ab03b84de2eb9c3029dcbb8527ea3 Mon Sep 17 00:00:00 2001 From: dylb0t Date: Tue, 2 Jun 2026 23:14:44 -0700 Subject: [PATCH 1/8] feat: windowed (MDI) document mode with Genie 4-style window chrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/Genie.App/App.axaml | 11 + src/Genie.App/Assets/skin_bottom.bmp | Bin 0 -> 664 bytes src/Genie.App/Assets/skin_bottomleft.bmp | Bin 0 -> 104 bytes src/Genie.App/Assets/skin_bottomright.bmp | Bin 0 -> 104 bytes src/Genie.App/Assets/skin_left.bmp | Bin 0 -> 416 bytes src/Genie.App/Assets/skin_right.bmp | Bin 0 -> 416 bytes src/Genie.App/Assets/skin_top.bmp | Bin 0 -> 2792 bytes src/Genie.App/Assets/skin_topleft.bmp | Bin 0 -> 1352 bytes src/Genie.App/Assets/skin_topleft.png | Bin 0 -> 415 bytes src/Genie.App/Assets/skin_topright.bmp | Bin 0 -> 1064 bytes src/Genie.App/Assets/skin_topright.png | Bin 0 -> 419 bytes src/Genie.App/Docking/BackpackTool.cs | 21 +- src/Genie.App/Docking/ExperienceTool.cs | 2 +- src/Genie.App/Docking/GameTextDocument.cs | 18 +- src/Genie.App/Docking/GenieDockFactory.cs | 140 +++++- src/Genie.App/Docking/MapperTool.cs | 2 +- src/Genie.App/Docking/PluginWindowTool.cs | 4 +- src/Genie.App/Docking/RoomTool.cs | 2 +- src/Genie.App/Docking/ScriptsTool.cs | 2 +- src/Genie.App/Docking/StreamTool.cs | 20 +- src/Genie.App/Docking/VitalsTool.cs | 2 +- src/Genie.App/Genie.App.csproj | 34 +- src/Genie.App/Settings/DisplaySettings.cs | 8 + src/Genie.App/Settings/MdiLayoutStore.cs | 44 ++ .../Themes/MdiDocumentWindowSkin.axaml | 404 ++++++++++++++++++ .../ViewModels/MainWindowViewModel.cs | 42 +- src/Genie.App/Views/MainWindow.axaml | 5 + src/Genie.App/Views/MainWindow.axaml.cs | 3 + 28 files changed, 727 insertions(+), 37 deletions(-) create mode 100644 src/Genie.App/Assets/skin_bottom.bmp create mode 100644 src/Genie.App/Assets/skin_bottomleft.bmp create mode 100644 src/Genie.App/Assets/skin_bottomright.bmp create mode 100644 src/Genie.App/Assets/skin_left.bmp create mode 100644 src/Genie.App/Assets/skin_right.bmp create mode 100644 src/Genie.App/Assets/skin_top.bmp create mode 100644 src/Genie.App/Assets/skin_topleft.bmp create mode 100644 src/Genie.App/Assets/skin_topleft.png create mode 100644 src/Genie.App/Assets/skin_topright.bmp create mode 100644 src/Genie.App/Assets/skin_topright.png create mode 100644 src/Genie.App/Settings/MdiLayoutStore.cs create mode 100644 src/Genie.App/Themes/MdiDocumentWindowSkin.axaml diff --git a/src/Genie.App/App.axaml b/src/Genie.App/App.axaml index 4b483d5..8d88a3e 100644 --- a/src/Genie.App/App.axaml +++ b/src/Genie.App/App.axaml @@ -7,6 +7,17 @@ x:Class="Genie.App.App" RequestedThemeVariant="Dark"> + + + + + + + + + diff --git a/src/Genie.App/Assets/skin_bottom.bmp b/src/Genie.App/Assets/skin_bottom.bmp new file mode 100644 index 0000000000000000000000000000000000000000..f229237c7fc93a4e38e68f050cd593052cc44668 GIT binary patch literal 664 zcmZ?rox#Ka24+A~1Bi`)m<5O#86Fz$83j04N1yW`F^l&BevVzyJVBQ3>z> literal 0 HcmV?d00001 diff --git a/src/Genie.App/Assets/skin_bottomright.bmp b/src/Genie.App/Assets/skin_bottomright.bmp new file mode 100644 index 0000000000000000000000000000000000000000..8f7560c20ace819b8f74f92d6dc3dc501740fbb7 GIT binary patch literal 104 zcmZ?r&0v54Ga#h_#4J$E$RGihFanZ7+z<>FKqEY104SaT20#HIn~RGJ$b+yM7yw{6 B3Ge^_ literal 0 HcmV?d00001 diff --git a/src/Genie.App/Assets/skin_left.bmp b/src/Genie.App/Assets/skin_left.bmp new file mode 100644 index 0000000000000000000000000000000000000000..2bb8f856ebe3617b567b606afa396342c2f751d2 GIT binary patch literal 416 zcmZ?rUBJiy24+A~1Bh9GSPqC886ngM%X@Bg50vbCgZfaA05n E08b%9c>n+a literal 0 HcmV?d00001 diff --git a/src/Genie.App/Assets/skin_top.bmp b/src/Genie.App/Assets/skin_top.bmp new file mode 100644 index 0000000000000000000000000000000000000000..daf7bdf6997a0789c9ebab2eea56ea703b2eb68e GIT binary patch literal 2792 zcmd7UyA8rX5Jk~LYBDEK0vTT+BqB%%fYe>M0~#Ww0?MHb4|WspNcRP^4ea&#YmAPy zt7=!Z)y%S%PrbMEsqs+LMB)~SdnC@0I7DI{iA^N7k=RG#6p2eD9+BuG(MRGHiEkv{ Kkyu3f|5^u;NFi(h literal 0 HcmV?d00001 diff --git a/src/Genie.App/Assets/skin_topleft.bmp b/src/Genie.App/Assets/skin_topleft.bmp new file mode 100644 index 0000000000000000000000000000000000000000..6d7bddc054404315d30cb6d8901d23de08b00813 GIT binary patch literal 1352 zcmbW0xoZMJ9ED$s6q+uDwkcD2yWXhripP2(79!YK*a#wsh=P@cjfIVcg@u)1Wnp9E ze`fYgTo963SO>mgc4qeDw{K>@)bCe_rfB`A#`~QYd}t%>SzCX9`$bILyL>*6Uatp> z#UjOOwZaIh=pngW4%uw>U$|xjJzlRD^?DuEY89nY357!855-~;M$n@u3Tm|);_*15 z(WsP2Bmx=qghC-yDi!$se)xR8r=TYs4x?Ny!|is%;c!TCI-QU~4{K#EtVOnu^DdW5 z3O&q)83uzv={`DGuMB!psT5RIMJyJB$Kw(349!4KI-M5zBoYY(0s#SQGXp)DOh%kL zeaU3!04kH_fu`=-%7 z{4WNB0Vb0P*6TG^s}&ZD1?KZPrqd~Avza0E=q}!AzPI1+aXcPzI-PJnpK-ZdaJ^m) r(LK70-#8o&v0N^(*=(@gZn4|#a5x+cdGzRGx*nU&W`o^sHw5?r=Jyy2 literal 0 HcmV?d00001 diff --git a/src/Genie.App/Assets/skin_topleft.png b/src/Genie.App/Assets/skin_topleft.png new file mode 100644 index 0000000000000000000000000000000000000000..ab7bf403beb68ef054fc53589a15d7ced59f6421 GIT binary patch literal 415 zcmV;Q0bu@#P)?9ra1q%?XiiG~PT8d`z~BqG7dp{1dvp`oEkaB^^I@tyrH=Rd@~ z+|B_LK9lf0@;k<2pC9}tNfMIDq|nE}e!s{0e8%l|!~K581ak*UJI1YtRrxPs85_*SLs})Ss#Nlwj zcDs#?BI$G*i^T%#^%~3N67%^yG75D%9n5AkjK^d2`+W=sgUBePX&PQ^Yl z0_?_`%_fS)A}N(hkx;1BYGF7WqF%2fo6VA3E*A-f8jS{R!^&)Uo=hggMWJf7N`vL| zd8AS)F3}+rs#Gep5i85G%q2R6LgjLqu3k|T`l@eGs8A@-^|P&NwHg-_!d)*M^xrVT6J?i*fNij@T8ge;i}&JRP)v>5Rka ze~d<>cDqgL1Bd%RU?Ze@y-wZ}hsU27hSBYI9mgpY3gq31Q7)IO)v9gVin8wlW4FS{ zvfSx(G)*J#T8vySm(S-d%PN&hNpruA{Ke5A_A%xi$(MhDak{BM35w@QmL4x z*=#n+LlqHJRjt)($k%GM$iqDf0t5G;>pFReDgu(>5?t5CUqjxr82x^KI2`tRJv4~C z7cqR__dIVf7(n{O!(KVeX0sp&=JWYtv52B*I-QQkW8^z{?;Li!-FCZ8l4P^l#Bq!o qXl5$W_ue?H*Xz}4wOlU4Fq}*#lxcs$^kwXlLnf0+_x>^t9%EnkBpA{F literal 0 HcmV?d00001 diff --git a/src/Genie.App/Assets/skin_topright.png b/src/Genie.App/Assets/skin_topright.png new file mode 100644 index 0000000000000000000000000000000000000000..0183cf10fefb0b0f7ca022f4c61e02838db640d4 GIT binary patch literal 419 zcmV;U0bKrxP)gTrQAtI-PJl9^rW&cDo(s^Z94t^ZA5~$K!$f z{f^u1hU@i;^ZAU;W`l0G`&+hNuOVZ#TJgv2c8mRfkHg`B<#LJHY=(Zn|B^Y5165qt z#bU9*WHNb)EX#r_VHrBW!BO1wD|vM3&pLzP%8hC-o$PNxIIFoY-?jY1U)m?opFip8Q3QBjp} zIE+Lh!NKQpIU%B=Dxpw_C!>tA*{l$e`5ThSB%eSglMx~sO_h8;&)LxWbzK)C3QR_` z*?fbt>GgUT397C* N002ovPDHLkV1j2c#N7Y@ literal 0 HcmV?d00001 diff --git a/src/Genie.App/Docking/BackpackTool.cs b/src/Genie.App/Docking/BackpackTool.cs index ace9c8d..7b84dbb 100644 --- a/src/Genie.App/Docking/BackpackTool.cs +++ b/src/Genie.App/Docking/BackpackTool.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,20 @@ public class BackpackTool : Tool { public InventoryViewModel ViewModel { 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. Backed by SetProperty (the Dock.Model.Mvvm + // base derives from CommunityToolkit's ObservableObject) rather than Fody + // [Reactive], since this Tool no longer derives from a ReactiveObject. + 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 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..8b716f6 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; @@ -327,6 +327,142 @@ 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. + /// + public IRootDock CreateMdiLayout() + { + // 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 experience = new ExperienceTool (_vm.Experience, ws.Get("experience")); + + // Default-visible set mirrors the tabbed layout (Vitals + Experience + // stay registered-but-hidden — the Status Bar / plugin cover them). + var mdiDock = new DocumentDock + { + Id = "mdi", + Title = "Windows", + IsCollapsable = false, + CanCreateDocument = false, + LayoutMode = DocumentLayoutMode.Mdi, + VisibleDockables = CreateList( + gameText, room, mapper, backpack, + logons, talk, whispers, thoughts, combat), + ActiveDockable = gameText, + }; + + 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 — every panel re-opens + // into the single MDI dock. No nested home docks in MDI mode. + _tools.Clear(); + foreach (var d in new IDockable[] + { gameText, room, mapper, backpack, + logons, talk, whispers, thoughts, combat, + vitals, experience }) + _tools[d.Id!] = (d, 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) + { + var root = CreateMdiLayout(); + 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(); + foreach (var (_, (dockable, _)) in _tools) + { + if (dockable is Dock.Model.Controls.IMdiDocument mdi && + dockable.Id is { Length: > 0 } id) + { + var r = mdi.MdiBounds; + if (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..e1e2a73 100644 --- a/src/Genie.App/Settings/DisplaySettings.cs +++ b/src/Genie.App/Settings/DisplaySettings.cs @@ -50,6 +50,14 @@ 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. Persisted so the choice survives restarts; the + /// per-window positions/sizes are saved separately in the layout snapshot. + /// + [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/MdiLayoutStore.cs b/src/Genie.App/Settings/MdiLayoutStore.cs new file mode 100644 index 0000000..e445679 --- /dev/null +++ b/src/Genie.App/Settings/MdiLayoutStore.cs @@ -0,0 +1,44 @@ +using System.Text.Json; + +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). +/// +public sealed record MdiWindowBounds( + double X, double Y, double Width, double Height, string State); + +/// +/// JSON load/save for per-window MDI geometry (mdi-layout.json). Mirrors +/// the rest of the Settings stores: best-effort, never throws into the caller — +/// a missing/corrupt file just yields an empty map so windowed mode falls back +/// to Dock's default cascade positions. +/// +public static class MdiLayoutStore +{ + private static readonly JsonSerializerOptions Json = new() { WriteIndented = true }; + + public static Dictionary Load(string path) + { + if (!File.Exists(path)) return new(); + try + { + return JsonSerializer.Deserialize>( + File.ReadAllText(path), Json) ?? new(); + } + catch { return new(); } + } + + public static void Save(string path, IReadOnlyDictionary bounds) + { + try + { + var dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + File.WriteAllText(path, JsonSerializer.Serialize(bounds, Json)); + } + catch { /* best-effort — geometry persistence is a convenience, not critical */ } + } +} 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..5dff685 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. @@ -313,6 +314,7 @@ public class MainWindowViewModel : ReactiveObject, IActivatableViewModel private readonly string _profilesPath; private readonly string _displayPath; private readonly string _pathsPath; + private readonly string _mdiLayoutPath; private readonly string _configDir; private readonly string _defaultMapsDir; @@ -366,6 +368,7 @@ public MainWindowViewModel(StartupOptions? startup) _profilesPath = Path.Combine(_configDir, "profiles.json"); _displayPath = Path.Combine(_configDir, "display.json"); _pathsPath = Path.Combine(_configDir, "paths.json"); + _mdiLayoutPath = Path.Combine(_configDir, "mdi-layout.json"); // Recordings live as a sibling to Config (not under it) — same parent // dir, so {AppData}/Genie5/{Config, Logs, Maps, Profiles}/ is the layout. _logsDir = Path.Combine(Path.GetDirectoryName(_configDir)!, "Logs"); @@ -531,7 +534,11 @@ public MainWindowViewModel(StartupOptions? startup) }; DockFactory = factory; - DockLayout = factory.BuildDefaultLayout(); + // Honor the persisted windowed-mode choice on startup, restoring each + // child window's saved geometry when windowed. + DockLayout = Display.WindowedMode + ? factory.BuildMdiLayout(Settings.MdiLayoutStore.Load(_mdiLayoutPath)) + : factory.BuildDefaultLayout(); // Wire the Mapper's "pop out" button. Done here (after factory exists) // because the VM doesn't carry a factory reference itself. @@ -788,6 +795,26 @@ 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 first + // so it's restored next time MDI is enabled. + if (Display.WindowedMode) + Settings.MdiLayoutStore.Save(_mdiLayoutPath, factory.CaptureMdiBounds()); + Display.WindowedMode = !Display.WindowedMode; + Display.Save(_displayPath); + DockLayout = Display.WindowedMode + ? factory.BuildMdiLayout(Settings.MdiLayoutStore.Load(_mdiLayoutPath)) + : factory.BuildDefaultLayout(); + RefreshVisibilityBools(); + GameText.AddSystemLine($"[layout] {(Display.WindowedMode ? "windowed (MDI)" : "tabbed")} mode"); + }); + ToggleGuildInTitleCommand = ReactiveCommand.Create(() => { Display.ShowGuildInTitle = !Display.ShowGuildInTitle; @@ -968,6 +995,19 @@ public MainWindowViewModel(StartupOptions? startup) /// public void SaveProfiles() => Profiles.Save(_profilesPath); + /// + /// If windowed (MDI) mode is active, capture each child window's current + /// position/size/state to mdi-layout.json. Called from the main + /// window's close handler so the windowed layout survives a restart. + /// No-op in tabbed mode. + /// + public void PersistMdiGeometryIfWindowed() + { + if (!Display.WindowedMode) return; + if (DockFactory is GenieDockFactory factory) + Settings.MdiLayoutStore.Save(_mdiLayoutPath, factory.CaptureMdiBounds()); + } + /// /// File → Maps Directory... Opens a native folder picker, persists the /// choice, and re-points the Mapper VM at the new directory. Designed to diff --git a/src/Genie.App/Views/MainWindow.axaml b/src/Genie.App/Views/MainWindow.axaml index 89dd048..b30c71c 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"/> + Date: Tue, 2 Jun 2026 23:29:52 -0700 Subject: [PATCH 2/8] feat(windows): add first-class Log and ItemLog panels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/Genie.App/Docking/GenieDockFactory.cs | 12 ++++++-- .../ViewModels/MainWindowViewModel.cs | 23 +++++++++++++- .../ViewModels/StreamTabsViewModel.cs | 30 ++++++++++++++----- src/Genie.App/Views/MainWindow.axaml | 10 +++++++ 4 files changed, 64 insertions(+), 11 deletions(-) diff --git a/src/Genie.App/Docking/GenieDockFactory.cs b/src/Genie.App/Docking/GenieDockFactory.cs index 8b716f6..8719f4e 100644 --- a/src/Genie.App/Docking/GenieDockFactory.cs +++ b/src/Genie.App/Docking/GenieDockFactory.cs @@ -174,6 +174,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 +233,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 +304,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); @@ -360,6 +364,8 @@ public IRootDock CreateMdiLayout() 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-visible set mirrors the tabbed layout (Vitals + Experience @@ -373,7 +379,7 @@ public IRootDock CreateMdiLayout() LayoutMode = DocumentLayoutMode.Mdi, VisibleDockables = CreateList( gameText, room, mapper, backpack, - logons, talk, whispers, thoughts, combat), + logons, talk, whispers, thoughts, combat, log, itemlog), ActiveDockable = gameText, }; @@ -398,7 +404,7 @@ public IRootDock CreateMdiLayout() _tools.Clear(); foreach (var d in new IDockable[] { gameText, room, mapper, backpack, - logons, talk, whispers, thoughts, combat, + logons, talk, whispers, thoughts, combat, log, itemlog, vitals, experience }) _tools[d.Id!] = (d, mdiDock.Id); _dockHomes.Clear(); diff --git a/src/Genie.App/ViewModels/MainWindowViewModel.cs b/src/Genie.App/ViewModels/MainWindowViewModel.cs index 5dff685..118d62f 100644 --- a/src/Genie.App/ViewModels/MainWindowViewModel.cs +++ b/src/Genie.App/ViewModels/MainWindowViewModel.cs @@ -262,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; } @@ -275,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 ────────────────────────────────────────────────────────────────── @@ -952,15 +956,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( @@ -1534,6 +1541,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 ─────────────────────────────────────────────── @@ -1556,6 +1565,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) @@ -1584,6 +1594,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(() => { @@ -1814,6 +1833,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) 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/MainWindow.axaml b/src/Genie.App/Views/MainWindow.axaml index b30c71c..f7eee04 100644 --- a/src/Genie.App/Views/MainWindow.axaml +++ b/src/Genie.App/Views/MainWindow.axaml @@ -219,6 +219,16 @@ ToggleType="CheckBox" IsChecked="{Binding CombatVisible, Mode=OneWay}" Command="{Binding ToggleCombatCommand}"/> + + From 7ed9ded4d6f499827f404fe2f585197caa340fa0 Mon Sep 17 00:00:00 2001 From: dylb0t Date: Wed, 3 Jun 2026 10:49:53 -0700 Subject: [PATCH 3/8] fix: use ReflectionBinding for $parent VM-command bindings (Avalonia 11.3.11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/Genie.App/Views/EditExitDialog.axaml | 2 +- src/Genie.App/Views/MainWindow.axaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) 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 @@ 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; diff --git a/src/Genie.App/Settings/WindowBoundsStore.cs b/src/Genie.App/Settings/WindowBoundsStore.cs new file mode 100644 index 0000000..f4e759d --- /dev/null +++ b/src/Genie.App/Settings/WindowBoundsStore.cs @@ -0,0 +1,41 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Genie.App.Settings; + +/// +/// Main-window geometry persisted across restarts. Position/size in DIPs; +/// wins over size on restore (we just re-maximize). +/// +public sealed record WindowBounds(double Width, double Height, int X, int Y, bool Maximized); + +/// +/// JSON load/save for the main window's size/position (window.json). +/// Best-effort — a missing/corrupt file just means "use the default size". +/// +public static class WindowBoundsStore +{ + private static readonly JsonSerializerOptions Json = new() + { + WriteIndented = true, + NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals, + }; + + public static WindowBounds? Load(string path) + { + if (!File.Exists(path)) return null; + try { return JsonSerializer.Deserialize(File.ReadAllText(path), Json); } + catch { return null; } + } + + public static void Save(string path, WindowBounds bounds) + { + try + { + var dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + File.WriteAllText(path, JsonSerializer.Serialize(bounds, Json)); + } + catch { /* best-effort */ } + } +} diff --git a/src/Genie.App/ViewModels/MainWindowViewModel.cs b/src/Genie.App/ViewModels/MainWindowViewModel.cs index 118d62f..3c32b3b 100644 --- a/src/Genie.App/ViewModels/MainWindowViewModel.cs +++ b/src/Genie.App/ViewModels/MainWindowViewModel.cs @@ -487,6 +487,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"); @@ -2185,6 +2187,7 @@ private Settings.SavedLayout CaptureCurrentLayout() ShowEchoText = Display.ShowEchoText, ShowScriptText = Display.ShowScriptText, MapBackgroundHex = Display.MapBackgroundHex, + WindowedMode = Display.WindowedMode, }; // Visible-tool list — walk the dock factory's known tools and @@ -2204,6 +2207,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; } @@ -2228,11 +2237,25 @@ 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); 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 global + // mdi-layout.json. + var bounds = layout.MdiBounds is { Count: > 0 } + ? layout.MdiBounds + : Settings.MdiLayoutStore.Load(_mdiLayoutPath); + 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, @@ -2252,6 +2275,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/Views/MainWindow.axaml.cs b/src/Genie.App/Views/MainWindow.axaml.cs index a8dc13e..34ff40c 100644 --- a/src/Genie.App/Views/MainWindow.axaml.cs +++ b/src/Genie.App/Views/MainWindow.axaml.cs @@ -5,17 +5,54 @@ using Avalonia.ReactiveUI; using Avalonia.VisualTree; using Genie.App.Controls; +using Genie.App.Settings; using Genie.App.ViewModels; +using Genie.Core.Runtime; using ReactiveUI; namespace Genie.App.Views; public partial class MainWindow : ReactiveWindow { + /// Path to the persisted main-window geometry (window.json under + /// the app's Config dir). Null only if the dir couldn't be resolved. + private readonly string? _windowBoundsPath; + public MainWindow() { InitializeComponent(); + // ── Restore the saved window size/position (before first show, so + // there's no resize flash). Self-contained here rather than via the + // VM because the VM's DataContext isn't set until after construction. + try + { + var cfgDir = new LocalDirectoryService("Genie5", AppContext.BaseDirectory) + .Current.ValidateDirectory("Config"); + _windowBoundsPath = System.IO.Path.Combine(cfgDir, "window.json"); + + if (WindowBoundsStore.Load(_windowBoundsPath) is { } b) + { + if (b.Maximized) + { + WindowState = WindowState.Maximized; + } + else + { + if (b.Width >= 400) Width = b.Width; + if (b.Height >= 300) Height = b.Height; + // Only restore position if it looks sane (guards against a + // window stranded off-screen after a monitor change). + if (b.X > -50 && b.Y > -50 && b.X < 20000 && b.Y < 20000) + { + WindowStartupLocation = WindowStartupLocation.Manual; + Position = new PixelPoint(b.X, b.Y); + } + } + } + } + catch { /* fall back to the XAML default size */ } + // 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 @@ -209,9 +246,11 @@ private void OnPluginsMenuOpened(object? sender, RoutedEventArgs e) protected override void OnClosing(WindowClosingEventArgs e) { base.OnClosing(e); - // Capture windowed-mode geometry on every close attempt (idempotent; - // harmless if the close is later cancelled by the connection prompt). + // Capture windowed-mode geometry + the main-window size on every close + // attempt (idempotent; harmless if the close is later cancelled by the + // connection prompt). ViewModel?.PersistMdiGeometryIfWindowed(); + SaveWindowBounds(); if (e.Cancel) return; // something upstream already vetoed if (_closeConfirmed) return; // second pass after user said Yes if (ViewModel?.IsConnected != true) return; @@ -249,6 +288,22 @@ private async void ShowConfirmAndMaybeReclose() Close(); } + /// Persist the current window size/position for next launch. Uses + /// Bounds (the live size) rather than Width/Height, which go stale after an + /// OS-driven resize. When maximized we record the flag and re-maximize on + /// restore, so the captured size is unused in that case. + private void SaveWindowBounds() + { + if (_windowBoundsPath is null) return; + try + { + var maximized = WindowState == WindowState.Maximized; + WindowBoundsStore.Save(_windowBoundsPath, new WindowBounds( + Bounds.Width, Bounds.Height, Position.X, Position.Y, maximized)); + } + catch { /* best-effort */ } + } + private void CommandInput_KeyDown(object? sender, KeyEventArgs e) { if (ViewModel?.Command is not { } cmd) return; From 855599a27fa43b7978889d8acf57e521a2fc143a Mon Sep 17 00:00:00 2001 From: dylb0t Date: Thu, 4 Jun 2026 22:03:03 -0700 Subject: [PATCH 7/8] fix: windowed mode remembers which windows were closed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- src/Genie.App/Docking/GenieDockFactory.cs | 72 ++++++++++++++++++----- 1 file changed, 56 insertions(+), 16 deletions(-) diff --git a/src/Genie.App/Docking/GenieDockFactory.cs b/src/Genie.App/Docking/GenieDockFactory.cs index 0d39060..d80e2fe 100644 --- a/src/Genie.App/Docking/GenieDockFactory.cs +++ b/src/Genie.App/Docking/GenieDockFactory.cs @@ -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 @@ -339,7 +344,10 @@ public override IRootDock CreateLayout() /// ; only the container differs, so the Window /// menu, per-window settings, and float/visibility plumbing all carry over. /// - public IRootDock CreateMdiLayout() + /// 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 @@ -368,8 +376,34 @@ public IRootDock CreateMdiLayout() var itemlog = new StreamTool (_vm.StreamTabs.ItemLog, ws.Get("itemlog")); var experience = new ExperienceTool (_vm.Experience, ws.Get("experience")); - // Default-visible set mirrors the tabbed layout (Vitals + Experience - // stay registered-but-hidden — the Status Bar / plugin cover them). + // 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", @@ -377,11 +411,10 @@ public IRootDock CreateMdiLayout() IsCollapsable = false, CanCreateDocument = false, LayoutMode = DocumentLayoutMode.Mdi, - VisibleDockables = CreateList( - gameText, room, mapper, backpack, - logons, talk, whispers, thoughts, combat, log, itemlog), - ActiveDockable = gameText, + VisibleDockables = CreateList(visibleDockables), + ActiveDockable = active, }; + _mdiDock = mdiDock; var rootLayout = new ProportionalDock { @@ -399,14 +432,13 @@ public IRootDock CreateMdiLayout() root.DefaultDockable = rootLayout; _root = root; - // Registry for Window-menu visibility toggles — every panel re-opens - // into the single MDI dock. No nested home docks in MDI mode. + // 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 d in new IDockable[] - { gameText, room, mapper, backpack, - logons, talk, whispers, thoughts, combat, log, itemlog, - vitals, experience }) - _tools[d.Id!] = (d, mdiDock.Id); + foreach (var p in panels) + _tools[p.Id] = (p.Dockable, mdiDock.Id); _dockHomes.Clear(); foreach (var (id, tool) in _pluginWindowTools) @@ -422,7 +454,10 @@ public IRootDock CreateMdiLayout() public IRootDock BuildMdiLayout( IReadOnlyDictionary? savedBounds = null) { - var root = CreateMdiLayout(); + // 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(); @@ -439,7 +474,12 @@ public IRootDock BuildMdiLayout( public Dictionary CaptureMdiBounds() { var result = new Dictionary(); - foreach (var (_, (dockable, _)) in _tools) + // 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) From 288bcc818de27429df4ecf327727569045bc07a6 Mon Sep 17 00:00:00 2001 From: dylb0t Date: Fri, 5 Jun 2026 17:02:09 -0700 Subject: [PATCH 8/8] refactor(windows): window geometry + MDI mode persist only via saved 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. --- src/Genie.App/Docking/GenieDockFactory.cs | 4 +- src/Genie.App/Settings/DisplaySettings.cs | 11 ++- src/Genie.App/Settings/MdiLayoutStore.cs | 44 --------- src/Genie.App/Settings/MdiWindowBounds.cs | 15 ++++ src/Genie.App/Settings/SavedLayout.cs | 15 ++++ src/Genie.App/Settings/WindowBoundsStore.cs | 41 --------- .../ViewModels/MainWindowViewModel.cs | 81 +++++++++++------ src/Genie.App/Views/MainWindow.axaml.cs | 90 +++++++------------ 8 files changed, 127 insertions(+), 174 deletions(-) delete mode 100644 src/Genie.App/Settings/MdiLayoutStore.cs create mode 100644 src/Genie.App/Settings/MdiWindowBounds.cs delete mode 100644 src/Genie.App/Settings/WindowBoundsStore.cs diff --git a/src/Genie.App/Docking/GenieDockFactory.cs b/src/Genie.App/Docking/GenieDockFactory.cs index d80e2fe..d5f8bdd 100644 --- a/src/Genie.App/Docking/GenieDockFactory.cs +++ b/src/Genie.App/Docking/GenieDockFactory.cs @@ -485,9 +485,9 @@ public IRootDock BuildMdiLayout( dockable.Id is { Length: > 0 } id) { var r = mdi.MdiBounds; - // Only persist real, finite rects — a window that was never + // Only capture real, finite rects — a window that was never // realised (or is minimised) can report NaN/Infinity bounds, - // which would crash MdiLayoutStore's JSON write. + // 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) diff --git a/src/Genie.App/Settings/DisplaySettings.cs b/src/Genie.App/Settings/DisplaySettings.cs index e1e2a73..3099d3c 100644 --- a/src/Genie.App/Settings/DisplaySettings.cs +++ b/src/Genie.App/Settings/DisplaySettings.cs @@ -53,9 +53,16 @@ public sealed class DisplaySettings : ReactiveObject /// /// 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. Persisted so the choice survives restarts; the - /// per-window positions/sizes are saved separately in the layout snapshot. + /// 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; } /// diff --git a/src/Genie.App/Settings/MdiLayoutStore.cs b/src/Genie.App/Settings/MdiLayoutStore.cs deleted file mode 100644 index e445679..0000000 --- a/src/Genie.App/Settings/MdiLayoutStore.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System.Text.Json; - -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). -/// -public sealed record MdiWindowBounds( - double X, double Y, double Width, double Height, string State); - -/// -/// JSON load/save for per-window MDI geometry (mdi-layout.json). Mirrors -/// the rest of the Settings stores: best-effort, never throws into the caller — -/// a missing/corrupt file just yields an empty map so windowed mode falls back -/// to Dock's default cascade positions. -/// -public static class MdiLayoutStore -{ - private static readonly JsonSerializerOptions Json = new() { WriteIndented = true }; - - public static Dictionary Load(string path) - { - if (!File.Exists(path)) return new(); - try - { - return JsonSerializer.Deserialize>( - File.ReadAllText(path), Json) ?? new(); - } - catch { return new(); } - } - - public static void Save(string path, IReadOnlyDictionary bounds) - { - try - { - var dir = Path.GetDirectoryName(path); - if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); - File.WriteAllText(path, JsonSerializer.Serialize(bounds, Json)); - } - catch { /* best-effort — geometry persistence is a convenience, not critical */ } - } -} 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 3710491..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 diff --git a/src/Genie.App/Settings/WindowBoundsStore.cs b/src/Genie.App/Settings/WindowBoundsStore.cs deleted file mode 100644 index f4e759d..0000000 --- a/src/Genie.App/Settings/WindowBoundsStore.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace Genie.App.Settings; - -/// -/// Main-window geometry persisted across restarts. Position/size in DIPs; -/// wins over size on restore (we just re-maximize). -/// -public sealed record WindowBounds(double Width, double Height, int X, int Y, bool Maximized); - -/// -/// JSON load/save for the main window's size/position (window.json). -/// Best-effort — a missing/corrupt file just means "use the default size". -/// -public static class WindowBoundsStore -{ - private static readonly JsonSerializerOptions Json = new() - { - WriteIndented = true, - NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals, - }; - - public static WindowBounds? Load(string path) - { - if (!File.Exists(path)) return null; - try { return JsonSerializer.Deserialize(File.ReadAllText(path), Json); } - catch { return null; } - } - - public static void Save(string path, WindowBounds bounds) - { - try - { - var dir = Path.GetDirectoryName(path); - if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); - File.WriteAllText(path, JsonSerializer.Serialize(bounds, Json)); - } - catch { /* best-effort */ } - } -} diff --git a/src/Genie.App/ViewModels/MainWindowViewModel.cs b/src/Genie.App/ViewModels/MainWindowViewModel.cs index 3c32b3b..4343b09 100644 --- a/src/Genie.App/ViewModels/MainWindowViewModel.cs +++ b/src/Genie.App/ViewModels/MainWindowViewModel.cs @@ -318,10 +318,27 @@ public class MainWindowViewModel : ReactiveObject, IActivatableViewModel private readonly string _profilesPath; private readonly string _displayPath; private readonly string _pathsPath; - private readonly string _mdiLayoutPath; 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 @@ -372,7 +389,6 @@ public MainWindowViewModel(StartupOptions? startup) _profilesPath = Path.Combine(_configDir, "profiles.json"); _displayPath = Path.Combine(_configDir, "display.json"); _pathsPath = Path.Combine(_configDir, "paths.json"); - _mdiLayoutPath = Path.Combine(_configDir, "mdi-layout.json"); // Recordings live as a sibling to Config (not under it) — same parent // dir, so {AppData}/Genie5/{Config, Logs, Maps, Profiles}/ is the layout. _logsDir = Path.Combine(Path.GetDirectoryName(_configDir)!, "Logs"); @@ -540,11 +556,12 @@ public MainWindowViewModel(StartupOptions? startup) }; DockFactory = factory; - // Honor the persisted windowed-mode choice on startup, restoring each - // child window's saved geometry when windowed. - DockLayout = Display.WindowedMode - ? factory.BuildMdiLayout(Settings.MdiLayoutStore.Load(_mdiLayoutPath)) - : factory.BuildDefaultLayout(); + // 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) // because the VM doesn't carry a factory reference itself. @@ -808,14 +825,15 @@ public MainWindowViewModel(StartupOptions? startup) ToggleWindowedModeCommand = ReactiveCommand.Create(() => { if (DockFactory is not GenieDockFactory factory) return; - // Leaving windowed mode — capture the current window geometry first - // so it's restored next time MDI is enabled. + // 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) - Settings.MdiLayoutStore.Save(_mdiLayoutPath, factory.CaptureMdiBounds()); + _mdiBoundsCache = factory.CaptureMdiBounds(); Display.WindowedMode = !Display.WindowedMode; - Display.Save(_displayPath); DockLayout = Display.WindowedMode - ? factory.BuildMdiLayout(Settings.MdiLayoutStore.Load(_mdiLayoutPath)) + ? factory.BuildMdiLayout(_mdiBoundsCache) : factory.BuildDefaultLayout(); RefreshVisibilityBools(); GameText.AddSystemLine($"[layout] {(Display.WindowedMode ? "windowed (MDI)" : "tabbed")} mode"); @@ -1004,19 +1022,6 @@ public MainWindowViewModel(StartupOptions? startup) /// public void SaveProfiles() => Profiles.Save(_profilesPath); - /// - /// If windowed (MDI) mode is active, capture each child window's current - /// position/size/state to mdi-layout.json. Called from the main - /// window's close handler so the windowed layout survives a restart. - /// No-op in tabbed mode. - /// - public void PersistMdiGeometryIfWindowed() - { - if (!Display.WindowedMode) return; - if (DockFactory is GenieDockFactory factory) - Settings.MdiLayoutStore.Save(_mdiLayoutPath, factory.CaptureMdiBounds()); - } - /// /// File → Maps Directory... Opens a native folder picker, persists the /// choice, and re-points the Mapper VM at the new directory. Designed to @@ -2190,6 +2195,19 @@ private Settings.SavedLayout CaptureCurrentLayout() 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 @@ -2242,17 +2260,24 @@ private void ApplyLayout(Settings.SavedLayout layout) 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.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 global - // mdi-layout.json. + // 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 - : Settings.MdiLayoutStore.Load(_mdiLayoutPath); + : _mdiBoundsCache; DockLayout = factory.BuildMdiLayout(bounds); } else if (layout.DockTree is not null) diff --git a/src/Genie.App/Views/MainWindow.axaml.cs b/src/Genie.App/Views/MainWindow.axaml.cs index 34ff40c..cb35eb7 100644 --- a/src/Genie.App/Views/MainWindow.axaml.cs +++ b/src/Genie.App/Views/MainWindow.axaml.cs @@ -5,53 +5,21 @@ using Avalonia.ReactiveUI; using Avalonia.VisualTree; using Genie.App.Controls; -using Genie.App.Settings; using Genie.App.ViewModels; -using Genie.Core.Runtime; using ReactiveUI; namespace Genie.App.Views; public partial class MainWindow : ReactiveWindow { - /// Path to the persisted main-window geometry (window.json under - /// the app's Config dir). Null only if the dir couldn't be resolved. - private readonly string? _windowBoundsPath; - public MainWindow() { InitializeComponent(); - // ── Restore the saved window size/position (before first show, so - // there's no resize flash). Self-contained here rather than via the - // VM because the VM's DataContext isn't set until after construction. - try - { - var cfgDir = new LocalDirectoryService("Genie5", AppContext.BaseDirectory) - .Current.ValidateDirectory("Config"); - _windowBoundsPath = System.IO.Path.Combine(cfgDir, "window.json"); - - if (WindowBoundsStore.Load(_windowBoundsPath) is { } b) - { - if (b.Maximized) - { - WindowState = WindowState.Maximized; - } - else - { - if (b.Width >= 400) Width = b.Width; - if (b.Height >= 300) Height = b.Height; - // Only restore position if it looks sane (guards against a - // window stranded off-screen after a monitor change). - if (b.X > -50 && b.Y > -50 && b.X < 20000 && b.Y < 20000) - { - WindowStartupLocation = WindowStartupLocation.Manual; - Position = new PixelPoint(b.X, b.Y); - } - } - } - } - catch { /* fall back to the XAML default size */ } + // 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 @@ -86,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 @@ -246,11 +241,8 @@ private void OnPluginsMenuOpened(object? sender, RoutedEventArgs e) protected override void OnClosing(WindowClosingEventArgs e) { base.OnClosing(e); - // Capture windowed-mode geometry + the main-window size on every close - // attempt (idempotent; harmless if the close is later cancelled by the - // connection prompt). - ViewModel?.PersistMdiGeometryIfWindowed(); - SaveWindowBounds(); + // 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; @@ -288,22 +280,6 @@ private async void ShowConfirmAndMaybeReclose() Close(); } - /// Persist the current window size/position for next launch. Uses - /// Bounds (the live size) rather than Width/Height, which go stale after an - /// OS-driven resize. When maximized we record the flag and re-maximize on - /// restore, so the captured size is unused in that case. - private void SaveWindowBounds() - { - if (_windowBoundsPath is null) return; - try - { - var maximized = WindowState == WindowState.Maximized; - WindowBoundsStore.Save(_windowBoundsPath, new WindowBounds( - Bounds.Width, Bounds.Height, Position.X, Position.Y, maximized)); - } - catch { /* best-effort */ } - } - private void CommandInput_KeyDown(object? sender, KeyEventArgs e) { if (ViewModel?.Command is not { } cmd) return;