From cee701b44587a4b390994488bdb8245557798b0b Mon Sep 17 00:00:00 2001 From: simtel12 <4183233+simtel12@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:47:07 -0700 Subject: [PATCH 01/12] feat(config): add useeditorrawxmlwindow setting --- src/Genie.Core/Config/GenieConfig.cs | 9 +++- .../EditorRawXmlWindowConfigTests.cs | 49 +++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 tests/Genie.Core.Tests/EditorRawXmlWindowConfigTests.cs diff --git a/src/Genie.Core/Config/GenieConfig.cs b/src/Genie.Core/Config/GenieConfig.cs index 3decec0e..0cd16260 100644 --- a/src/Genie.Core/Config/GenieConfig.cs +++ b/src/Genie.Core/Config/GenieConfig.cs @@ -61,6 +61,11 @@ public GenieConfig(LocalDirectoryService localDirectory) /// layout is built — swapping renderers under a populated buffer is not /// worth the complexity, so changing it needs a restart. public bool UseEditorGameWindow { get; set; } + /// Render the Raw XML window with AvaloniaEdit instead of the + /// per-line ItemsControl. Same experimental contract as + /// — default off, read ONCE when the dock + /// layout is built, so changing it needs a restart. + public bool UseEditorRawXmlWindow { get; set; } public bool ShowSpellTimer { get; set; } = true; /// Built-in Experience tracker ($Skill.* / $TDPs globals + "Experience" /// dock panel). Default on. Was the external Plugin_EXPTrackerV5, now in Core. @@ -643,6 +648,7 @@ public bool Save(string fileName = "settings.cfg") ("aliases", EnableAliases.ToString()), ("scrollbacklines", ScrollbackLines.ToString()), ("useeditorgamewindow", UseEditorGameWindow.ToString()), + ("useeditorrawxmlwindow", UseEditorRawXmlWindow.ToString()), ("spelltimer", ShowSpellTimer.ToString()), ("showexperience", ShowExperience.ToString()), ("experiencedensity", ExperienceDensity.ToString()), @@ -775,7 +781,7 @@ public bool Save(string fileName = "settings.cfg") { ("Connection", new[] { "activitytimeout", "classicconnect", "conndebug", "connectscript", "flagscheck", "frontend", "reconnect" }), ("Lich", new[] { "lichautolaunch", "lichruby", "lichpath", "lichargs", "lichstartpause", "lichdebug" }), - ("Window / Input", new[] { "alwaysontop", "ignoreclosealert", "keepinputtext", "sizeinputtogame", "scrollbacklines", "useeditorgamewindow" }), + ("Window / Input", new[] { "alwaysontop", "ignoreclosealert", "keepinputtext", "sizeinputtogame", "scrollbacklines", "useeditorgamewindow", "useeditorrawxmlwindow" }), ("Display / Parser", new[] { "spelltimer", "showexperience", "experiencedensity", "experiencetrackgain", "experienceg4layout", "experienceconfigbar", "showtimetracker", "prompt", "promptbreak", "promptforce", "condensed", "monstercountignorelist", "monsterbold", "parsegameonly", "roundtimeoffset", "showlinks", "showimages", "weblinksafety", "injuriespoll", "injurieslayout" }), ("Master Toggles", new[] { "highlights", "triggers", "substitutes", "gags", "aliases" }), ("Scripting", new[] { "scriptchar", "separatorchar", "commandchar", "mycommandchar", "triggeroninput", "warnrawvars", "scripttimeout", "maxgosubdepth", "abortdupescript", "ignorescriptwarnings", "scriptextension", "editor" }), @@ -821,6 +827,7 @@ public IReadOnlyList SetSetting(string key, string value = "", bool show case "aliases": EnableAliases = ToBool(value); Notify(ConfigFieldUpdated.MasterToggles); break; case "scrollbacklines": ScrollbackLines = Math.Clamp(UtilityCore.StringToInteger(value), 100, 100000); break; case "useeditorgamewindow": UseEditorGameWindow = ToBool(value); break; + case "useeditorrawxmlwindow": UseEditorRawXmlWindow = ToBool(value); break; case "spelltimer": ShowSpellTimer = ToBool(value); Notify(ConfigFieldUpdated.Trackers); break; case "showexperience": ShowExperience = ToBool(value); Notify(ConfigFieldUpdated.Trackers); break; case "experiencedensity": ExperienceDensity = Math.Clamp(UtilityCore.StringToInteger(value), 0, 4); Notify(ConfigFieldUpdated.Trackers); break; diff --git a/tests/Genie.Core.Tests/EditorRawXmlWindowConfigTests.cs b/tests/Genie.Core.Tests/EditorRawXmlWindowConfigTests.cs new file mode 100644 index 00000000..a0cd1eeb --- /dev/null +++ b/tests/Genie.Core.Tests/EditorRawXmlWindowConfigTests.cs @@ -0,0 +1,49 @@ +using System; +using System.Linq; +using Genie.Core.Config; +using Genie.Core.Runtime; +using Xunit; + +namespace Genie.Core.Tests; + +/// +/// #config useeditorrawxmlwindow — the flag that swaps the Raw XML +/// window onto the experimental AvaloniaEdit renderer. Same contract as +/// useeditorgamewindow (see EditorGameWindowConfigTests): parses, +/// persists, and reports like every other boolean, and is off unless the +/// user turned it on. The renderer swap itself lives in Genie.App and is +/// not reachable from these tests. +/// +public class EditorRawXmlWindowConfigTests +{ + private static GenieConfig NewConfig() => + new(new LocalDirectoryService("Genie5Test", AppContext.BaseDirectory)); + + [Fact] + public void DefaultsOff() + { + Assert.False(NewConfig().UseEditorRawXmlWindow); + } + + [Theory] + [InlineData("True", true)] + [InlineData("on", true)] + [InlineData("False", false)] + [InlineData("off", false)] + [InlineData("", false)] // unset / unparseable stays on the shipped renderer + [InlineData("banana", false)] + public void RoundTrips(string input, bool expected) + { + var cfg = NewConfig(); + cfg.SetSetting("useeditorrawxmlwindow", input, showException: false); + Assert.Equal(expected, cfg.UseEditorRawXmlWindow); + Assert.Equal(expected.ToString(), cfg.GetSetting("useeditorrawxmlwindow")); + } + + [Fact] + public void IsListedForConfigDisplay() + { + Assert.Contains(NewConfig().ToConfigPairs(), p => p.Key == "useeditorrawxmlwindow"); + Assert.Contains(GenieConfig.ConfigCategories.SelectMany(c => c.Keys), k => k == "useeditorrawxmlwindow"); + } +} From ef69337485a0440e10efbcf157c4f1ed52ee8fd1 Mon Sep 17 00:00:00 2001 From: simtel12 <4183233+simtel12@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:50:37 -0700 Subject: [PATCH 02/12] feat(config): add useeditorstreamwindow setting --- src/Genie.Core/Config/GenieConfig.cs | 10 +++- .../EditorStreamWindowConfigTests.cs | 49 +++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 tests/Genie.Core.Tests/EditorStreamWindowConfigTests.cs diff --git a/src/Genie.Core/Config/GenieConfig.cs b/src/Genie.Core/Config/GenieConfig.cs index 0cd16260..b90b505c 100644 --- a/src/Genie.Core/Config/GenieConfig.cs +++ b/src/Genie.Core/Config/GenieConfig.cs @@ -66,6 +66,12 @@ public GenieConfig(LocalDirectoryService localDirectory) /// — default off, read ONCE when the dock /// layout is built, so changing it needs a restart. public bool UseEditorRawXmlWindow { get; set; } + /// Render every Stream window (Logons, Talk, Whispers, ...) with + /// AvaloniaEdit instead of the per-line ItemsControl. One flag governs all + /// 12 StreamTool instances. Same experimental contract as + /// — default off, read ONCE when the dock + /// layout is built, so changing it needs a restart. + public bool UseEditorStreamWindow { get; set; } public bool ShowSpellTimer { get; set; } = true; /// Built-in Experience tracker ($Skill.* / $TDPs globals + "Experience" /// dock panel). Default on. Was the external Plugin_EXPTrackerV5, now in Core. @@ -649,6 +655,7 @@ public bool Save(string fileName = "settings.cfg") ("scrollbacklines", ScrollbackLines.ToString()), ("useeditorgamewindow", UseEditorGameWindow.ToString()), ("useeditorrawxmlwindow", UseEditorRawXmlWindow.ToString()), + ("useeditorstreamwindow", UseEditorStreamWindow.ToString()), ("spelltimer", ShowSpellTimer.ToString()), ("showexperience", ShowExperience.ToString()), ("experiencedensity", ExperienceDensity.ToString()), @@ -781,7 +788,7 @@ public bool Save(string fileName = "settings.cfg") { ("Connection", new[] { "activitytimeout", "classicconnect", "conndebug", "connectscript", "flagscheck", "frontend", "reconnect" }), ("Lich", new[] { "lichautolaunch", "lichruby", "lichpath", "lichargs", "lichstartpause", "lichdebug" }), - ("Window / Input", new[] { "alwaysontop", "ignoreclosealert", "keepinputtext", "sizeinputtogame", "scrollbacklines", "useeditorgamewindow", "useeditorrawxmlwindow" }), + ("Window / Input", new[] { "alwaysontop", "ignoreclosealert", "keepinputtext", "sizeinputtogame", "scrollbacklines", "useeditorgamewindow", "useeditorrawxmlwindow", "useeditorstreamwindow" }), ("Display / Parser", new[] { "spelltimer", "showexperience", "experiencedensity", "experiencetrackgain", "experienceg4layout", "experienceconfigbar", "showtimetracker", "prompt", "promptbreak", "promptforce", "condensed", "monstercountignorelist", "monsterbold", "parsegameonly", "roundtimeoffset", "showlinks", "showimages", "weblinksafety", "injuriespoll", "injurieslayout" }), ("Master Toggles", new[] { "highlights", "triggers", "substitutes", "gags", "aliases" }), ("Scripting", new[] { "scriptchar", "separatorchar", "commandchar", "mycommandchar", "triggeroninput", "warnrawvars", "scripttimeout", "maxgosubdepth", "abortdupescript", "ignorescriptwarnings", "scriptextension", "editor" }), @@ -828,6 +835,7 @@ public IReadOnlyList SetSetting(string key, string value = "", bool show case "scrollbacklines": ScrollbackLines = Math.Clamp(UtilityCore.StringToInteger(value), 100, 100000); break; case "useeditorgamewindow": UseEditorGameWindow = ToBool(value); break; case "useeditorrawxmlwindow": UseEditorRawXmlWindow = ToBool(value); break; + case "useeditorstreamwindow": UseEditorStreamWindow = ToBool(value); break; case "spelltimer": ShowSpellTimer = ToBool(value); Notify(ConfigFieldUpdated.Trackers); break; case "showexperience": ShowExperience = ToBool(value); Notify(ConfigFieldUpdated.Trackers); break; case "experiencedensity": ExperienceDensity = Math.Clamp(UtilityCore.StringToInteger(value), 0, 4); Notify(ConfigFieldUpdated.Trackers); break; diff --git a/tests/Genie.Core.Tests/EditorStreamWindowConfigTests.cs b/tests/Genie.Core.Tests/EditorStreamWindowConfigTests.cs new file mode 100644 index 00000000..b8b383ce --- /dev/null +++ b/tests/Genie.Core.Tests/EditorStreamWindowConfigTests.cs @@ -0,0 +1,49 @@ +using System; +using System.Linq; +using Genie.Core.Config; +using Genie.Core.Runtime; +using Xunit; + +namespace Genie.Core.Tests; + +/// +/// #config useeditorstreamwindow — the flag that swaps every Stream +/// window (Logons, Talk, Whispers, ...) onto the experimental AvaloniaEdit +/// renderer at once. Same contract as useeditorgamewindow (see +/// EditorGameWindowConfigTests): parses, persists, and reports like every +/// other boolean, and is off unless the user turned it on. The renderer swap +/// itself lives in Genie.App and is not reachable from these tests. +/// +public class EditorStreamWindowConfigTests +{ + private static GenieConfig NewConfig() => + new(new LocalDirectoryService("Genie5Test", AppContext.BaseDirectory)); + + [Fact] + public void DefaultsOff() + { + Assert.False(NewConfig().UseEditorStreamWindow); + } + + [Theory] + [InlineData("True", true)] + [InlineData("on", true)] + [InlineData("False", false)] + [InlineData("off", false)] + [InlineData("", false)] + [InlineData("banana", false)] + public void RoundTrips(string input, bool expected) + { + var cfg = NewConfig(); + cfg.SetSetting("useeditorstreamwindow", input, showException: false); + Assert.Equal(expected, cfg.UseEditorStreamWindow); + Assert.Equal(expected.ToString(), cfg.GetSetting("useeditorstreamwindow")); + } + + [Fact] + public void IsListedForConfigDisplay() + { + Assert.Contains(NewConfig().ToConfigPairs(), p => p.Key == "useeditorstreamwindow"); + Assert.Contains(GenieConfig.ConfigCategories.SelectMany(c => c.Keys), k => k == "useeditorstreamwindow"); + } +} From 416cdb53e89932304f5581bf2f77218b20011b25 Mon Sep 17 00:00:00 2001 From: simtel12 <4183233+simtel12@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:53:02 -0700 Subject: [PATCH 03/12] feat(app): seed UseEditorRawXmlWindow and UseEditorStreamWindow at startup --- .../ViewModels/MainWindowViewModel.cs | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/Genie.App/ViewModels/MainWindowViewModel.cs b/src/Genie.App/ViewModels/MainWindowViewModel.cs index 6e5795a1..48593be2 100644 --- a/src/Genie.App/ViewModels/MainWindowViewModel.cs +++ b/src/Genie.App/ViewModels/MainWindowViewModel.cs @@ -1082,6 +1082,23 @@ private void SyncAutoLogFromConfig() /// document. Changing the setting needs a restart. public bool UseEditorGameWindow { get; private set; } + /// Render the Raw XML window with AvaloniaEdit instead of the + /// per-line ItemsControl (#config useeditorrawxmlwindow, default + /// off). Same read-once-at-startup contract as + /// ; consumed by + /// when it creates the + /// Raw XML tool. Changing the setting needs a restart. + public bool UseEditorRawXmlWindow { get; private set; } + + /// Render every Stream window (Logons, Talk, Whispers, ...) with + /// AvaloniaEdit instead of the per-line ItemsControl (#config + /// useeditorstreamwindow, default off). One flag governs all 12 + /// instances. Same read-once-at-startup + /// contract as ; consumed by + /// when it creates each + /// stream tool. Changing the setting needs a restart. + public bool UseEditorStreamWindow { get; private set; } + public MainWindowViewModel() : this(null) { } public MainWindowViewModel(StartupOptions? startup) @@ -1130,7 +1147,9 @@ public MainWindowViewModel(StartupOptions? startup) { var startupConfig = new Genie.Core.Config.GenieConfig(dir); startupConfig.Load(); - UseEditorGameWindow = startupConfig.UseEditorGameWindow; + UseEditorGameWindow = startupConfig.UseEditorGameWindow; + UseEditorRawXmlWindow = startupConfig.UseEditorRawXmlWindow; + UseEditorStreamWindow = startupConfig.UseEditorStreamWindow; } catch (Exception ex) { From a90880a7ff606411b510417465c778282e9442a8 Mon Sep 17 00:00:00 2001 From: simtel12 <4183233+simtel12@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:57:44 -0700 Subject: [PATCH 04/12] refactor(rawxml): Lines becomes TextLine-based to match Game/Stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RawXmlViewModel.Lines is now ObservableCollection (plain, Color = StreamColor.Main) instead of ObservableCollection, matching GameTextViewModel.Lines/StreamBuffer.Lines so a later task can generalize the AvaloniaEdit renderer to Raw XML. Raw XML's own visual behavior is unchanged (still plain green, unstyled). Also updates the two GenieDockFactory.cs call sites (CopyAll, SaveAs) that fed rx.ViewModel.Lines directly into IEnumerable APIs — they now .Select(l => l.Text), matching the pattern already used for every other TextLine-backed tool in the same switch expressions. --- src/Genie.App/App.axaml | 4 ++-- src/Genie.App/Docking/GenieDockFactory.cs | 4 ++-- src/Genie.App/ViewModels/RawXmlViewModel.cs | 12 ++++++++---- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/Genie.App/App.axaml b/src/Genie.App/App.axaml index b0dbe9fb..8e3df2fd 100644 --- a/src/Genie.App/App.axaml +++ b/src/Genie.App/App.axaml @@ -1669,8 +1669,8 @@ FontSize="{Binding ToolFontSize}" Foreground="#7fc4a0" Margin="4"> - - + + diff --git a/src/Genie.App/Docking/GenieDockFactory.cs b/src/Genie.App/Docking/GenieDockFactory.cs index 1e56925a..a4355bc6 100644 --- a/src/Genie.App/Docking/GenieDockFactory.cs +++ b/src/Genie.App/Docking/GenieDockFactory.cs @@ -926,7 +926,7 @@ private WindowMenuModel BuildWindowMenu(string id, IDockable dockable) StreamTool st => ReactiveCommand.CreateFromTask( () => WindowClipboard.CopyLinesAsync(st.Buffer.Lines.Select(l => l.Text))), RawXmlTool rx => ReactiveCommand.CreateFromTask( - () => WindowClipboard.CopyLinesAsync(rx.ViewModel.Lines)), + () => WindowClipboard.CopyLinesAsync(rx.ViewModel.Lines.Select(l => l.Text))), PluginWindowTool pw => ReactiveCommand.CreateFromTask( () => WindowClipboard.CopyLinesAsync(pw.ViewModel.Lines.Select(l => l.Text))), BackpackTool bp => ReactiveCommand.CreateFromTask( @@ -955,7 +955,7 @@ private WindowMenuModel BuildWindowMenu(string id, IDockable dockable) BackpackTool bp => ReactiveCommand.CreateFromTask( () => WindowSaveAs.SaveLinesAsync(bp.Title, bp.ViewModel.Items.Select(l => l.Text))), RawXmlTool rx => ReactiveCommand.CreateFromTask( - () => WindowSaveAs.SaveLinesAsync(rx.Title, rx.ViewModel.Lines)), + () => WindowSaveAs.SaveLinesAsync(rx.Title, rx.ViewModel.Lines.Select(l => l.Text))), PluginWindowTool pw => ReactiveCommand.CreateFromTask( () => WindowSaveAs.SaveLinesAsync(pw.Title, pw.ViewModel.Lines.Select(l => l.Text))), RoomTool rm => ReactiveCommand.CreateFromTask( diff --git a/src/Genie.App/ViewModels/RawXmlViewModel.cs b/src/Genie.App/ViewModels/RawXmlViewModel.cs index d2d671b0..7267a717 100644 --- a/src/Genie.App/ViewModels/RawXmlViewModel.cs +++ b/src/Genie.App/ViewModels/RawXmlViewModel.cs @@ -30,9 +30,13 @@ public class RawXmlViewModel : ReactiveObject /// is generous but still finite. private const int MaxLines = 5000; - /// One raw line per row. Plain strings (not TextLine) — - /// this is a verbatim protocol dump, so no highlighting / inlines. - public ObservableCollection Lines { get; } = []; + /// One raw line per row, as a plain TextLine (Color = + /// StreamColor.Main, no Links/BoldSpans/PresetSpans) — this is a verbatim + /// protocol dump, so no highlighting/inlines happen. Unified with + /// GameTextViewModel/StreamBuffer's shape so the AvaloniaEdit renderer + /// (GameTextEditor) can bind against any of the three without a special + /// case (#274). + public ObservableCollection Lines { get; } = []; public ReactiveCommand ClearCommand { get; } @@ -70,7 +74,7 @@ private void AddChunk(string chunk) var line = raw.TrimEnd('\r'); if (line.Length == 0) continue; - Lines.Add(line); + Lines.Add(new TextLine(line, StreamColor.Main)); if (Lines.Count > MaxLines) Lines.RemoveAt(0); } From 75cee7af4fa73e5ad8f73070b80ff3a0223a448b Mon Sep 17 00:00:00 2001 From: simtel12 <4183233+simtel12@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:01:26 -0700 Subject: [PATCH 05/12] feat(controls): add ITextEditorHost contract for the shared AvaloniaEdit renderer --- src/Genie.App/Controls/ITextEditorHost.cs | 49 +++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 src/Genie.App/Controls/ITextEditorHost.cs diff --git a/src/Genie.App/Controls/ITextEditorHost.cs b/src/Genie.App/Controls/ITextEditorHost.cs new file mode 100644 index 00000000..966103b7 --- /dev/null +++ b/src/Genie.App/Controls/ITextEditorHost.cs @@ -0,0 +1,49 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using Avalonia.Controls.Primitives; +using Avalonia.Media; +using Genie.App.Docking; +using Genie.App.ViewModels; + +namespace Genie.App.Controls; + +/// +/// Contract the AvaloniaEdit-backed control needs +/// from whatever dock tool or document hosts it. Game +/// (), Stream +/// (), and Raw XML +/// () all implement this instead of the +/// control depending on any one concrete host type. +/// +public interface ITextEditorHost : INotifyPropertyChanged +{ + /// The buffered lines to render — one document line per entry, + /// the same source of truth the legacy ItemsControl renderer reads. + ObservableCollection Lines { get; } + + FontFamily ToolFontFamily { get; } + double ToolFontSize { get; } + + /// Null means "inherit the global game colour" (Game, Stream); + /// hosts with a fixed colour (Raw XML) always return a brush. + IBrush? ToolForeground { get; } + + TextWrapping ToolTextWrapping { get; } + ScrollBarVisibility ToolHScroll { get; } + + /// "Pause Scrolling" window-menu state. Settable so the control + /// can resume it on toggle. + bool IsScrollPaused { get; set; } + + /// Null disables the in-window Find bar entirely (Raw XML). + FindInWindowModel? Find { get; } + + /// Run highlight-rule colorizing () + /// over each line. On for Game/Stream; off for Raw XML — a verbatim + /// protocol dump with no rule matching. + bool EnableColorizing { get; } + + /// Detect and render clickable links (). + /// On for Game/Stream; off for Raw XML. + bool EnableLinks { get; } +} From 2ffb510760c2fece88674afb5826acb8a530e6c3 Mon Sep 17 00:00:00 2001 From: simtel12 <4183233+simtel12@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:04:00 -0700 Subject: [PATCH 06/12] feat(docking): GameTextDocument implements ITextEditorHost --- src/Genie.App/Docking/GameTextDocument.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Genie.App/Docking/GameTextDocument.cs b/src/Genie.App/Docking/GameTextDocument.cs index 9200d88f..7a319ed4 100644 --- a/src/Genie.App/Docking/GameTextDocument.cs +++ b/src/Genie.App/Docking/GameTextDocument.cs @@ -1,3 +1,4 @@ +using System.Collections.ObjectModel; using Avalonia; using Avalonia.Media; using Dock.Model.Mvvm.Controls; @@ -7,10 +8,14 @@ namespace Genie.App.Docking; -public class GameTextDocument : Document, IWindowMenuHost, IFindHost +public class GameTextDocument : Document, IWindowMenuHost, IFindHost, ITextEditorHost { public GameTextViewModel ViewModel { get; } + public ObservableCollection Lines => ViewModel.Lines; + public bool EnableColorizing => true; + public bool EnableLinks => true; + /// In-window Find bar state (#120). The overlay in the game-text /// template binds to this; Ctrl+F / the window menu opens it. public FindInWindowModel Find { get; } From 5c47f0c3336cd2a719c7f84a3dc654cdf23bf5e7 Mon Sep 17 00:00:00 2001 From: simtel12 <4183233+simtel12@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:07:13 -0700 Subject: [PATCH 07/12] feat(docking): StreamTool implements ITextEditorHost, add EditorStreamTool --- src/Genie.App/Docking/StreamTool.cs | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/Genie.App/Docking/StreamTool.cs b/src/Genie.App/Docking/StreamTool.cs index 3140f77c..8afa94c1 100644 --- a/src/Genie.App/Docking/StreamTool.cs +++ b/src/Genie.App/Docking/StreamTool.cs @@ -1,3 +1,4 @@ +using System.Collections.ObjectModel; using Avalonia; using Avalonia.Media; using Dock.Model.Mvvm.Controls; @@ -7,10 +8,14 @@ namespace Genie.App.Docking; -public class StreamTool : Tool, IWindowMenuHost, IFindHost +public class StreamTool : Tool, IWindowMenuHost, IFindHost, ITextEditorHost { public StreamBuffer Buffer { get; } + public ObservableCollection Lines => Buffer.Lines; + public bool EnableColorizing => true; + public bool EnableLinks => true; + /// In-window Find bar state (#120). public FindInWindowModel Find { get; } @@ -83,3 +88,19 @@ private void ApplySettings(WindowSettings s) : Avalonia.Controls.Primitives.ScrollBarVisibility.Auto; } } + +/// +/// A Stream window (Logons, Talk, Whispers, ...) rendered by +/// (AvaloniaEdit) instead of the +/// per-line ItemsControl. Created by in +/// place of a plain when +/// GenieConfig.UseEditorStreamWindow is on — one flag governs every +/// Stream instance, since they all share this one class. Experimental; +/// default off. Same type-based renderer selection as +/// — see that type's doc comment for why. +/// +public sealed class EditorStreamTool : StreamTool +{ + public EditorStreamTool(StreamBuffer buffer, WindowSettings? settings = null) + : base(buffer, settings) { } +} From 94ffe34e8f9c7f628f6668d1873611e57b233bbe Mon Sep 17 00:00:00 2001 From: simtel12 <4183233+simtel12@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:10:31 -0700 Subject: [PATCH 08/12] feat(docking): RawXmlTool implements ITextEditorHost, add EditorRawXmlTool --- src/Genie.App/Docking/RawXmlTool.cs | 38 ++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/src/Genie.App/Docking/RawXmlTool.cs b/src/Genie.App/Docking/RawXmlTool.cs index ec764831..423e2779 100644 --- a/src/Genie.App/Docking/RawXmlTool.cs +++ b/src/Genie.App/Docking/RawXmlTool.cs @@ -1,3 +1,4 @@ +using System.Collections.ObjectModel; using Avalonia.Media; using Dock.Model.Mvvm.Controls; using Genie.App.Controls; @@ -12,7 +13,7 @@ namespace Genie.App.Docking; /// Window → Raw XML. A dev/debug panel, grouped beside the other utility tabs /// (Scripts / Scene) in the default layout. /// -public class RawXmlTool : Tool, IWindowMenuHost +public class RawXmlTool : Tool, IWindowMenuHost, ITextEditorHost { public RawXmlViewModel ViewModel { get; } @@ -30,6 +31,27 @@ public class RawXmlTool : Tool, IWindowMenuHost private double _toolFontSize = 11; public double ToolFontSize { get => _toolFontSize; private set => SetProperty(ref _toolFontSize, value); } + // ── ITextEditorHost (consumed only when useeditorrawxmlwindow is on) ──────── + // Raw XML stays exactly as minimal under the editor renderer as it is under + // the legacy one: a fixed colour (not a resolvable per-window + // ToolForeground like Game/Stream get), no word-wrap toggle (NoWrap + + // horizontal scroll, matching the legacy "long tags stay on one line" + // behavior), no Find, no highlighting/links. + private static readonly IBrush RawXmlForeground = new SolidColorBrush(Color.Parse("#7fc4a0")); + + public ObservableCollection Lines => ViewModel.Lines; + public IBrush? ToolForeground => RawXmlForeground; + public TextWrapping ToolTextWrapping => TextWrapping.NoWrap; + public Avalonia.Controls.Primitives.ScrollBarVisibility ToolHScroll + => Avalonia.Controls.Primitives.ScrollBarVisibility.Auto; + + private bool _isScrollPaused; + public bool IsScrollPaused { get => _isScrollPaused; set => SetProperty(ref _isScrollPaused, value); } + + public FindInWindowModel? Find => null; + public bool EnableColorizing => false; + public bool EnableLinks => false; + public RawXmlTool(RawXmlViewModel vm, WindowSettings? settings = null) { ViewModel = vm; @@ -50,3 +72,17 @@ private void ApplySettings(WindowSettings s) ToolFontSize = WindowSettingsResolver.ResolveFontSize(s.FontSize); } } + +/// +/// The Raw XML window rendered by +/// (AvaloniaEdit) instead of the per-line ItemsControl. Created by +/// in place of a plain +/// when GenieConfig.UseEditorRawXmlWindow is on. Experimental; default +/// off. Same type-based renderer selection as +/// — see that type's doc comment for why. +/// +public sealed class EditorRawXmlTool : RawXmlTool +{ + public EditorRawXmlTool(RawXmlViewModel vm, WindowSettings? settings = null) + : base(vm, settings) { } +} From 2aec7a4378d62afed724a32da2f55af872ff6308 Mon Sep 17 00:00:00 2001 From: simtel12 <4183233+simtel12@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:13:53 -0700 Subject: [PATCH 09/12] refactor(controls): generalize GameTextEditor to ITextEditorHost --- src/Genie.App/Controls/GameTextEditor.cs | 63 +++++++++++++++--------- 1 file changed, 41 insertions(+), 22 deletions(-) diff --git a/src/Genie.App/Controls/GameTextEditor.cs b/src/Genie.App/Controls/GameTextEditor.cs index 3d4782de..9963a675 100644 --- a/src/Genie.App/Controls/GameTextEditor.cs +++ b/src/Genie.App/Controls/GameTextEditor.cs @@ -20,10 +20,12 @@ namespace Genie.App.Controls; /// -/// The experimental AvaloniaEdit-backed renderer for the main Game window -/// (#config useeditorgamewindow on, default off). It hosts a single -/// read-only and mirrors -/// — still the source of truth — into its +/// The experimental AvaloniaEdit-backed renderer shared by the Game, Raw XML, +/// and Stream windows (#config useeditorgamewindow / +/// useeditorrawxmlwindow / useeditorstreamwindow, all default +/// off). It hosts a single read-only and mirrors +/// whatever it is bound to — +/// is still the source of truth — into its /// document, one buffered line per document line. /// /// Only the rendering moves. Timestamping, the Name-List-Only filter, @@ -31,9 +33,14 @@ namespace Genie.App.Controls; /// view-model and are untouched by which renderer is active. /// /// Selected out of the legacy path by type, not by a visibility toggle: -/// gets its own DataTemplate, so with -/// the flag off this control is never constructed and the legacy subtree is -/// exactly what it always was. +/// , , and +/// each get their own DataTemplate, so +/// with a flag off this control is never constructed for that window type and +/// the legacy subtree is exactly what it always was. Colorizing (highlight +/// rules) and link generation are opt-in per host +/// ( / +/// ) — on for Game/Stream, off for +/// Raw XML, which stays a plain verbatim dump. /// public sealed class GameTextEditor : UserControl { @@ -46,9 +53,9 @@ public sealed class GameTextEditor : UserControl private readonly TextDocument _document = new(); private readonly List _entries = []; - private GameTextDocument? _host; - private GameTextViewModel? _vm; + private ITextEditorHost? _host; private FindInWindowModel? _find; + private bool _renderersConfigured; private bool _atBottom = true; private bool _paused; @@ -110,8 +117,9 @@ public GameTextEditor() area.CaretBrush = Brushes.Transparent; // read-only view: no caret area.ContextFlyout = null; area.TextView.ContextFlyout = null; - area.TextView.LineTransformers.Add(new GameTextColorizer(EntryAt)); - area.TextView.ElementGenerators.Add(new GameLinkGenerator(EntryAt, area)); + // Colorizing/link-generation are opt-in per host (EnableColorizing / + // EnableLinks) — added lazily in Subscribe(), once, the first time a + // host resolves. DataContext isn't available yet in the constructor. // The ScrollViewer normally exists by TemplateApplied; LayoutUpdated is the // belt-and-braces retry (it unhooks itself the moment the lookup succeeds) @@ -160,15 +168,27 @@ private void Subscribe() { if (_host is not null) return; // already wired if (this.GetVisualRoot() is null) return; // wired on attach instead - if (DataContext is not GameTextDocument host) return; + if (DataContext is not ITextEditorHost host) return; _host = host; - _vm = host.ViewModel; _find = host.Find; - host.PropertyChanged += OnHostPropertyChanged; - _vm.Lines.CollectionChanged += OnLinesChanged; - _find.JumpRequested += OnFindJump; + // Decided once per control instance, the first time a host resolves — + // a given DataTemplate instance always binds to the same concrete + // host type for its lifetime (visual-tree re-parenting re-attaches the + // same host; it never swaps Game for Raw XML under one control). + if (!_renderersConfigured) + { + _renderersConfigured = true; + if (host.EnableColorizing) + _editor.TextArea.TextView.LineTransformers.Add(new GameTextColorizer(EntryAt)); + if (host.EnableLinks) + _editor.TextArea.TextView.ElementGenerators.Add(new GameLinkGenerator(EntryAt, _editor.TextArea)); + } + + host.PropertyChanged += OnHostPropertyChanged; + host.Lines.CollectionChanged += OnLinesChanged; + if (_find is not null) _find.JumpRequested += OnFindJump; ApplyHostSettings(); _paused = host.IsScrollPaused; @@ -178,10 +198,9 @@ private void Subscribe() private void Unsubscribe() { if (_host is not null) _host.PropertyChanged -= OnHostPropertyChanged; - if (_vm is not null) _vm.Lines.CollectionChanged -= OnLinesChanged; + if (_host is not null) _host.Lines.CollectionChanged -= OnLinesChanged; if (_find is not null) _find.JumpRequested -= OnFindJump; _host = null; - _vm = null; _find = null; } @@ -190,7 +209,7 @@ private void OnHostPropertyChanged(object? sender, PropertyChangedEventArgs e) if (_host is null) return; switch (e.PropertyName) { - case nameof(GameTextDocument.IsScrollPaused): + case nameof(ITextEditorHost.IsScrollPaused): SetPaused(_host.IsScrollPaused); break; default: @@ -366,16 +385,16 @@ private void RemoveHead(int count) private void RebuildAll() { _entries.Clear(); - if (_vm is null || _vm.Lines.Count == 0) + if (_host is null || _host.Lines.Count == 0) { _document.Text = ""; return; } var sb = new System.Text.StringBuilder(); - for (var i = 0; i < _vm.Lines.Count; i++) + for (var i = 0; i < _host.Lines.Count; i++) { - var line = _vm.Lines[i]; + var line = _host.Lines[i]; _entries.Add(new GameLineEntry(line)); if (i > 0) sb.Append('\n'); sb.Append(Flatten(line.Text)); From 0ff85f7af6cf47a1781794839ec4c9f68fa79cae Mon Sep 17 00:00:00 2001 From: simtel12 <4183233+simtel12@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:17:49 -0700 Subject: [PATCH 10/12] feat(app): add MainWindowViewModel dataDirectoryOverride test seam --- .../ViewModels/MainWindowViewModel.cs | 18 ++++++++-- .../DataDirectoryOverrideTests.cs | 35 +++++++++++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) create mode 100644 tests/Genie.App.Tests/DataDirectoryOverrideTests.cs diff --git a/src/Genie.App/ViewModels/MainWindowViewModel.cs b/src/Genie.App/ViewModels/MainWindowViewModel.cs index 48593be2..75fdff5f 100644 --- a/src/Genie.App/ViewModels/MainWindowViewModel.cs +++ b/src/Genie.App/ViewModels/MainWindowViewModel.cs @@ -1099,9 +1099,19 @@ private void SyncAutoLogFromConfig() /// stream tool. Changing the setting needs a restart. public bool UseEditorStreamWindow { get; private set; } - public MainWindowViewModel() : this(null) { } - - public MainWindowViewModel(StartupOptions? startup) + public MainWindowViewModel() : this(null, null) { } + + public MainWindowViewModel(StartupOptions? startup) : this(startup, null) { } + + /// Test-only entry point: + /// points the whole data root at an isolated directory instead of letting + /// discover the real per-user Genie5 + /// AppData folder — everything below (Profiles.Load, Display.Load, the + /// Maps migration, ...) then reads/writes under it instead. Null (every + /// real caller) keeps normal discovery. Mirrors + /// 's own dataDirectoryOverride + /// parameter. + public MainWindowViewModel(StartupOptions? startup, string? dataDirectoryOverride) { Startup = startup; @@ -1109,6 +1119,8 @@ public MainWindowViewModel(StartupOptions? startup) // LocalDirectoryService honors portable mode (Config\ next to the exe) // and XDG / AppSupport paths on Linux / macOS. var dir = new LocalDirectoryService("Genie5", AppContext.BaseDirectory); + if (!string.IsNullOrWhiteSpace(dataDirectoryOverride)) + dir.UseExplicitRoot(dataDirectoryOverride); _configDir = dir.Current.ValidateDirectory("Config"); _profilesPath = Path.Combine(_configDir, "profiles.json"); _displayPath = Path.Combine(_configDir, "display.json"); diff --git a/tests/Genie.App.Tests/DataDirectoryOverrideTests.cs b/tests/Genie.App.Tests/DataDirectoryOverrideTests.cs new file mode 100644 index 00000000..782f8e31 --- /dev/null +++ b/tests/Genie.App.Tests/DataDirectoryOverrideTests.cs @@ -0,0 +1,35 @@ +using System; +using System.IO; +using Genie.App.ViewModels; +using Xunit; + +namespace Genie.App.Tests; + +/// +/// has no dataDirectoryOverride seam +/// today (unlike , which already has one — +/// see MapperConfigSyncTests.Harness), so every test that constructs it would +/// otherwise touch the real per-user Genie5 AppData folder: Profiles.Load, +/// Display.Load, and a one-time Maps-folder migration all run in the +/// constructor. This confirms the override actually confines that I/O to an +/// isolated directory instead. +/// +public class DataDirectoryOverrideTests +{ + [Fact] + public void DataDirectoryOverride_confines_construction_file_io_to_the_given_directory() + { + var dir = Path.Combine(Path.GetTempPath(), "genie_app_tests_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + _ = new MainWindowViewModel(startup: null, dataDirectoryOverride: dir); + + Assert.True(Directory.Exists(Path.Combine(dir, "Config"))); + } + finally + { + try { Directory.Delete(dir, recursive: true); } catch { /* best effort */ } + } + } +} From 6e145642c724d1e54ef717792062fe3d816adb57 Mon Sep 17 00:00:00 2001 From: simtel12 <4183233+simtel12@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:30:22 -0700 Subject: [PATCH 11/12] feat(docking): factory selects editor subtypes for Raw XML and Stream --- src/Genie.App/Docking/GenieDockFactory.cs | 78 ++++++---- .../DockFactoryEditorWindowTests.cs | 138 ++++++++++++++++++ 2 files changed, 190 insertions(+), 26 deletions(-) create mode 100644 tests/Genie.App.Tests/DockFactoryEditorWindowTests.cs diff --git a/src/Genie.App/Docking/GenieDockFactory.cs b/src/Genie.App/Docking/GenieDockFactory.cs index a4355bc6..503494e2 100644 --- a/src/Genie.App/Docking/GenieDockFactory.cs +++ b/src/Genie.App/Docking/GenieDockFactory.cs @@ -159,6 +159,32 @@ private GameTextDocument NewGameText(Genie.Core.Layout.WindowSettingsStore ws) : new GameTextDocument(_vm.GameText, settings); } + /// Render the Raw XML window via when + /// #config useeditorrawxmlwindow is on. Same read-once, type-based + /// selection as . + private RawXmlTool NewRawXmlTool(Genie.Core.Layout.WindowSettingsStore ws) + { + var settings = ws.Get("raw-xml"); + return _vm.UseEditorRawXmlWindow + ? new EditorRawXmlTool(_vm.RawXml, settings) + : new RawXmlTool(_vm.RawXml, settings); + } + + /// Render every Stream window via + /// when #config useeditorstreamwindow is on — one flag governs all + /// 12 instances. varies (Logons, Talk, ...); the + /// id derivation (buffer.Name.ToLowerInvariant()) matches + /// 's own constructor exactly, so the same + /// entry resolves + /// either way. + private StreamTool NewStreamTool(StreamBuffer buffer, Genie.Core.Layout.WindowSettingsStore ws) + { + var settings = ws.Get(buffer.Name.ToLowerInvariant()); + return _vm.UseEditorStreamWindow + ? new EditorStreamTool(buffer, settings) + : new StreamTool(buffer, settings); + } + public override IRootDock CreateLayout() { // Wire the host-window locator. Dock.Avalonia's FloatDockable silently @@ -193,18 +219,18 @@ public override IRootDock CreateLayout() 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 familiar = new StreamTool (_vm.StreamTabs.Familiar, ws.Get("familiar")); - var death = new StreamTool (_vm.StreamTabs.Death, ws.Get("death")); - var assess = new StreamTool (_vm.StreamTabs.Assess, ws.Get("assess")); - var atmospherics = new StreamTool (_vm.StreamTabs.Atmospherics, ws.Get("atmospherics")); - var ooc = new StreamTool (_vm.StreamTabs.Ooc, ws.Get("ooc")); - var log = new StreamTool (_vm.StreamTabs.Log, ws.Get("log")); - var itemlog = new StreamTool (_vm.StreamTabs.ItemLog, ws.Get("itemlog")); + var logons = NewStreamTool(_vm.StreamTabs.Logons, ws); + var talk = NewStreamTool(_vm.StreamTabs.Talk, ws); + var whispers = NewStreamTool(_vm.StreamTabs.Whispers, ws); + var thoughts = NewStreamTool(_vm.StreamTabs.Thoughts, ws); + var combat = NewStreamTool(_vm.StreamTabs.Combat, ws); + var familiar = NewStreamTool(_vm.StreamTabs.Familiar, ws); + var death = NewStreamTool(_vm.StreamTabs.Death, ws); + var assess = NewStreamTool(_vm.StreamTabs.Assess, ws); + var atmospherics = NewStreamTool(_vm.StreamTabs.Atmospherics, ws); + var ooc = NewStreamTool(_vm.StreamTabs.Ooc, ws); + var log = NewStreamTool(_vm.StreamTabs.Log, ws); + var itemlog = NewStreamTool(_vm.StreamTabs.ItemLog, ws); var experience = new ExperienceTool(_vm.Experience, ws.Get("experience")); var analytics = new AnalyticsTool (_vm.Analytics, ws.Get("analytics")); var activeSpells = new ActiveSpellsTool(_vm.ActiveSpells, ws.Get("active-spells")); @@ -214,7 +240,7 @@ public override IRootDock CreateLayout() var scene = new SceneTool (_vm.Scene, ws.Get("scene")); var mobs = new MobsTool (_vm.Mobs, ws.Get("mobs")); var players = new PlayersTool (_vm.Players, ws.Get("players")); - var rawXml = new RawXmlTool (_vm.RawXml, ws.Get("raw-xml")); + var rawXml = NewRawXmlTool(ws); var injuries = new InjuriesTool (_vm.Injuries, ws.Get("injuries")); // ── Default ship layout — three vertical columns ───────────────── @@ -456,18 +482,18 @@ public IRootDock CreateMdiLayout(IReadOnlyCollection? visibleIds = null) 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 familiar = new StreamTool (_vm.StreamTabs.Familiar, ws.Get("familiar")); - var death = new StreamTool (_vm.StreamTabs.Death, ws.Get("death")); - var assess = new StreamTool (_vm.StreamTabs.Assess, ws.Get("assess")); - var atmospherics = new StreamTool (_vm.StreamTabs.Atmospherics, ws.Get("atmospherics")); - var ooc = new StreamTool (_vm.StreamTabs.Ooc, ws.Get("ooc")); - var log = new StreamTool (_vm.StreamTabs.Log, ws.Get("log")); - var itemlog = new StreamTool (_vm.StreamTabs.ItemLog, ws.Get("itemlog")); + var logons = NewStreamTool(_vm.StreamTabs.Logons, ws); + var talk = NewStreamTool(_vm.StreamTabs.Talk, ws); + var whispers = NewStreamTool(_vm.StreamTabs.Whispers, ws); + var thoughts = NewStreamTool(_vm.StreamTabs.Thoughts, ws); + var combat = NewStreamTool(_vm.StreamTabs.Combat, ws); + var familiar = NewStreamTool(_vm.StreamTabs.Familiar, ws); + var death = NewStreamTool(_vm.StreamTabs.Death, ws); + var assess = NewStreamTool(_vm.StreamTabs.Assess, ws); + var atmospherics = NewStreamTool(_vm.StreamTabs.Atmospherics, ws); + var ooc = NewStreamTool(_vm.StreamTabs.Ooc, ws); + var log = NewStreamTool(_vm.StreamTabs.Log, ws); + var itemlog = NewStreamTool(_vm.StreamTabs.ItemLog, ws); var experience = new ExperienceTool (_vm.Experience, ws.Get("experience")); var analytics = new AnalyticsTool (_vm.Analytics, ws.Get("analytics")); var activeSpells = new ActiveSpellsTool(_vm.ActiveSpells, ws.Get("active-spells")); @@ -476,7 +502,7 @@ public IRootDock CreateMdiLayout(IReadOnlyCollection? visibleIds = null) var scene = new SceneTool (_vm.Scene, ws.Get("scene")); var mobs = new MobsTool (_vm.Mobs, ws.Get("mobs")); var players = new PlayersTool (_vm.Players, ws.Get("players")); - var rawXml = new RawXmlTool (_vm.RawXml, ws.Get("raw-xml")); + var rawXml = NewRawXmlTool(ws); var injuries = new InjuriesTool (_vm.Injuries, ws.Get("injuries")); // Every MDI panel in canonical order, paired with its id. diff --git a/tests/Genie.App.Tests/DockFactoryEditorWindowTests.cs b/tests/Genie.App.Tests/DockFactoryEditorWindowTests.cs new file mode 100644 index 00000000..5cf86f8b --- /dev/null +++ b/tests/Genie.App.Tests/DockFactoryEditorWindowTests.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections; +using System.IO; +using System.Reflection; +using Dock.Model.Core; +using Genie.App.Docking; +using Genie.App.ViewModels; +using Xunit; + +namespace Genie.App.Tests; + +/// +/// Covers the mechanism useeditorrawxmlwindow / useeditorstreamwindow +/// actually introduce: a factory-level type swap that applies to every window +/// instance of a type at once (all 12 Stream tools share one flag). +/// +/// Runs with no live Avalonia +/// Application — confirmed safe by spike: +/// 's constructor only touches +/// Application.Current through null-conditional guards, and +/// CreateLayout only builds Dock.Model POCOs, never an actual +/// Window. This is coverage the existing useeditorgamewindow +/// flag doesn't have today. +/// +/// Each test points at an isolated +/// temp directory via its Task 10 dataDirectoryOverride seam instead of +/// letting it discover the real per-user Genie5 AppData folder. +/// +public class DockFactoryEditorWindowTests +{ + private static readonly string[] StreamIds = + [ + "logons", "talk", "whispers", "thoughts", "combat", "familiar", + "death", "assess", "atmospherics", "ooc", "log", "itemlog", + ]; + + private static void SetFlag(MainWindowViewModel vm, string propertyName, bool value) => + typeof(MainWindowViewModel).GetProperty(propertyName)!.SetValue(vm, value); + + private static IDockable? FindById(IDockable root, string id) + { + if (root.Id == id) return root; + if (root is IDock dock && dock.VisibleDockables is not null) + foreach (var child in dock.VisibleDockables) + { + var found = FindById(child, id); + if (found is not null) return found; + } + return null; + } + + /// + /// Deviation from the plan brief's test, documented in task-11-report.md: + /// Raw XML, Atmospherics, and OOC are "hidden by default" — the factory + /// constructs and registers them in its private _tools lookup for + /// the Window menu, but never attaches them to any dock's + /// , so alone + /// can never reach them (pre-existing behavior; not introduced by this + /// feature). Falls back to the factory's private tool registry via + /// reflection — the same registry the Window-menu "re-open" toggles read + /// from — matching this test's existing reflection-based access to + /// private members (see ). + /// + private static IDockable ResolveDockable(GenieDockFactory factory, IDockable root, string id) + { + var found = FindById(root, id); + if (found is not null) return found; + + var toolsField = typeof(GenieDockFactory) + .GetField("_tools", BindingFlags.NonPublic | BindingFlags.Instance)!; + var tools = (IDictionary)toolsField.GetValue(factory)!; + Assert.True(tools.Contains(id), $"'{id}' was not found in the dock tree or the tool registry."); + var entry = tools[id]!; + return (IDockable)entry.GetType().GetField("Item1")!.GetValue(entry)!; + } + + private sealed class Harness : IDisposable + { + public MainWindowViewModel Vm { get; } + private readonly string _dir; + + public Harness() + { + _dir = Path.Combine(Path.GetTempPath(), "genie_app_tests_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_dir); + Vm = new MainWindowViewModel(startup: null, dataDirectoryOverride: _dir); + } + + public void Dispose() + { + try { Directory.Delete(_dir, recursive: true); } catch { /* best effort */ } + } + } + + [Fact] + public void Raw_xml_stays_on_the_legacy_type_by_default() + { + using var h = new Harness(); + var factory = new GenieDockFactory(h.Vm); + var root = factory.CreateLayout(); + + Assert.IsType(ResolveDockable(factory, root, "raw-xml")); + } + + [Fact] + public void Raw_xml_switches_to_the_editor_type_when_the_flag_is_on() + { + using var h = new Harness(); + SetFlag(h.Vm, nameof(MainWindowViewModel.UseEditorRawXmlWindow), true); + var factory = new GenieDockFactory(h.Vm); + var root = factory.CreateLayout(); + + Assert.IsType(ResolveDockable(factory, root, "raw-xml")); + } + + [Fact] + public void Stream_windows_stay_on_the_legacy_type_by_default() + { + using var h = new Harness(); + var factory = new GenieDockFactory(h.Vm); + var root = factory.CreateLayout(); + + foreach (var id in StreamIds) + Assert.IsType(ResolveDockable(factory, root, id)); + } + + [Fact] + public void All_twelve_stream_windows_switch_to_the_editor_type_when_the_flag_is_on() + { + using var h = new Harness(); + SetFlag(h.Vm, nameof(MainWindowViewModel.UseEditorStreamWindow), true); + var factory = new GenieDockFactory(h.Vm); + var root = factory.CreateLayout(); + + foreach (var id in StreamIds) + Assert.IsType(ResolveDockable(factory, root, id)); + } +} From ea1d19951cd79b0203b8584b028c9ec64ba48f90 Mon Sep 17 00:00:00 2001 From: simtel12 <4183233+simtel12@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:36:22 -0700 Subject: [PATCH 12/12] feat(app): add AvaloniaEdit DataTemplates for Raw XML and Stream windows --- src/Genie.App/App.axaml | 79 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/src/Genie.App/App.axaml b/src/Genie.App/App.axaml index 8e3df2fd..706078fd 100644 --- a/src/Genie.App/App.axaml +++ b/src/Genie.App/App.axaml @@ -1295,6 +1295,59 @@ + + + + + + +