Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion src/Genie.App/App.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@
x:Class="Genie.App.App"
RequestedThemeVariant="Dark">

<!-- Genie 4-style skinned chrome for windowed (MDI) child windows. Overrides
the default MdiDocumentWindow ControlTheme from DockFluentTheme, so it
must be merged here in Application.Resources. -->
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceInclude Source="avares://Genie/Themes/MdiDocumentWindowSkin.axaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>

<Application.Styles>
<FluentTheme />
<DockFluentTheme />
Expand Down Expand Up @@ -71,6 +82,8 @@
<ScrollViewer x:Name="gameScroll"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto"
Width="{Binding $parent[Grid].Bounds.Width}"
Height="{Binding $parent[Grid].Bounds.Height}"
controls:AutoScrollBehavior.ItemsSource="{Binding ViewModel.Lines}">
<ItemsControl ItemsSource="{Binding ViewModel.Lines}"
FontFamily="{Binding ToolFontFamily}"
Expand Down Expand Up @@ -499,7 +512,9 @@
<Grid RowDefinitions="*" Background="{Binding ToolBackground}">
<ScrollViewer x:Name="backpackScroll"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
VerticalScrollBarVisibility="Auto"
Width="{Binding $parent[Grid].Bounds.Width}"
Height="{Binding $parent[Grid].Bounds.Height}">
<ItemsControl ItemsSource="{Binding ViewModel.Items}"
FontFamily="{Binding ToolFontFamily}"
FontSize="{Binding ToolFontSize}"
Expand All @@ -523,6 +538,8 @@
<ScrollViewer x:Name="streamScroll"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto"
Width="{Binding $parent[Grid].Bounds.Width}"
Height="{Binding $parent[Grid].Bounds.Height}"
controls:AutoScrollBehavior.ItemsSource="{Binding Buffer.Lines}">
<ItemsControl ItemsSource="{Binding Buffer.Lines}"
FontFamily="{Binding ToolFontFamily}"
Expand Down
Binary file added src/Genie.App/Assets/skin_bottom.bmp
Binary file not shown.
Binary file added src/Genie.App/Assets/skin_bottomleft.bmp
Binary file not shown.
Binary file added src/Genie.App/Assets/skin_bottomright.bmp
Binary file not shown.
Binary file added src/Genie.App/Assets/skin_left.bmp
Binary file not shown.
Binary file added src/Genie.App/Assets/skin_right.bmp
Binary file not shown.
Binary file added src/Genie.App/Assets/skin_top.bmp
Binary file not shown.
Binary file added src/Genie.App/Assets/skin_topleft.bmp
Binary file not shown.
Binary file added src/Genie.App/Assets/skin_topleft.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/Genie.App/Assets/skin_topright.bmp
Binary file not shown.
Binary file added src/Genie.App/Assets/skin_topright.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 5 additions & 2 deletions src/Genie.App/Controls/AutoScrollBehavior.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,11 @@ private void OnScrollChanged(object? sender, ScrollChangedEventArgs e)

public void ScrollToBottom()
{
var max = Math.Max(0, _sv.Extent.Height - _sv.Viewport.Height);
_sv.Offset = _sv.Offset.WithY(max);
// ScrollToEnd() uses the ScrollViewer's own up-to-date extent, so it
// lands on the last line even when extent/viewport are mid-update
// (right after a line is added, or after an MDI relayout) — more
// robust than computing Offset from Extent - Viewport.
_sv.ScrollToEnd();
}

private void JumpToBottom()
Expand Down
21 changes: 15 additions & 6 deletions src/Genie.App/Docking/BackpackTool.cs
Original file line number Diff line number Diff line change
@@ -1,21 +1,30 @@
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;

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)
{
Expand Down
2 changes: 1 addition & 1 deletion src/Genie.App/Docking/ExperienceTool.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down
18 changes: 12 additions & 6 deletions src/Genie.App/Docking/GameTextDocument.cs
Original file line number Diff line number Diff line change
@@ -1,28 +1,34 @@
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;

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].

/// <summary>Per-window foreground brush. Null falls through to the global GameBrush.</summary>
[Reactive] public IBrush? ToolForeground { get; private set; }
private IBrush? _toolForeground;
public IBrush? ToolForeground { get => _toolForeground; private set => SetProperty(ref _toolForeground, value); }

/// <summary>Per-window background brush. Null = transparent (default).</summary>
[Reactive] public IBrush? ToolBackground { get; private set; }
private IBrush? _toolBackground;
public IBrush? ToolBackground { get => _toolBackground; private set => SetProperty(ref _toolBackground, value); }

/// <summary>Per-window font family override.</summary>
[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); }

/// <summary>Per-window font size override.</summary>
[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)
{
Expand Down
193 changes: 190 additions & 3 deletions src/Genie.App/Docking/GenieDockFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -47,6 +47,11 @@ private readonly record struct DockHome(
/// </summary>
private IRootDock? _root;

/// <summary>The live MDI <see cref="DocumentDock"/> while in windowed mode
/// (null in tabbed mode). Its <c>VisibleDockables</c> is the set of windows
/// currently OPEN — used to persist/restore which windows the user closed.</summary>
private DocumentDock? _mdiDock;

/// <summary>
/// "Last known location" for every registered tool. Captures enough
/// state to fully reconstruct the tool's home, even when its parent
Expand Down Expand Up @@ -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 ─────────────────
Expand Down Expand Up @@ -231,7 +238,7 @@ public override IRootDock CreateLayout()
Id = "streams",
Alignment = Alignment.Bottom,
Proportion = 0.65,
VisibleDockables = CreateList<IDockable>(logons, talk, whispers, thoughts, combat),
VisibleDockables = CreateList<IDockable>(logons, talk, whispers, thoughts, combat, log, itemlog),
ActiveDockable = combat // matches screenshot default — Combat tab active
};

Expand Down Expand Up @@ -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);
Expand All @@ -327,6 +336,184 @@ public override IRootDock CreateLayout()
return root;
}

/// <summary>
/// Windowed (MDI) layout — every panel is a free-floating child window
/// inside a single MDI <see cref="DocumentDock"/> (Genie 4 "windowed
/// mode"). Requires Dock 11.3.9+ (<see cref="DocumentLayoutMode.Mdi"/>).
/// Panel instances and their DataTemplates are identical to
/// <see cref="CreateLayout"/>; only the container differs, so the Window
/// menu, per-window settings, and float/visibility plumbing all carry over.
/// </summary>
/// <param name="visibleIds">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.</param>
public IRootDock CreateMdiLayout(IReadOnlyCollection<string>? 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<string, Func<IHostWindow?>>
{
[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<string>(
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<IDockable>(visibleDockables),
ActiveDockable = active,
};
_mdiDock = mdiDock;

var rootLayout = new ProportionalDock
{
Id = "root-layout",
Orientation = Orientation.Horizontal,
IsCollapsable = false,
VisibleDockables = CreateList<IDockable>(mdiDock),
};

var root = CreateRootDock();
root.Id = "root";
root.IsCollapsable = false;
root.VisibleDockables = CreateList<IDockable>(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;
}

/// <summary>Build + initialise the MDI layout, ready to assign to the
/// DockControl. MDI counterpart of <see cref="BuildDefaultLayout"/>.
/// <paramref name="savedBounds"/> (if any) restores each child window's
/// last position/size/state.</summary>
public IRootDock BuildMdiLayout(
IReadOnlyDictionary<string, Settings.MdiWindowBounds>? 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.

/// <summary>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.</summary>
public Dictionary<string, Settings.MdiWindowBounds> CaptureMdiBounds()
{
var result = new Dictionary<string, Settings.MdiWindowBounds>();
// 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;
}

/// <summary>Restore saved MDI geometry onto the freshly built panels.</summary>
public void ApplyMdiBounds(IReadOnlyDictionary<string, Settings.MdiWindowBounds> 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<Dock.Model.Core.MdiWindowState>(b.State, out var st))
mdi.MdiState = st;
}
}
}

// ── Layout snapshot (full-tree round-trip) ─────────────────────────────

/// <summary>
Expand Down
2 changes: 1 addition & 1 deletion src/Genie.App/Docking/MapperTool.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using Dock.Model.ReactiveUI.Controls;
using Dock.Model.Mvvm.Controls;
using Genie.App.ViewModels;
using Genie.Core.Layout;

Expand Down
Loading
Loading