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 @@