diff --git a/src/Genie.App/App.axaml b/src/Genie.App/App.axaml
index b0dbe9fb..706078fd 100644
--- a/src/Genie.App/App.axaml
+++ b/src/Genie.App/App.axaml
@@ -1295,6 +1295,59 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -1643,6 +1696,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -1669,8 +1748,8 @@
FontSize="{Binding ToolFontSize}"
Foreground="#7fc4a0" Margin="4">
-
-
+
+
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));
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; }
+}
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; }
diff --git a/src/Genie.App/Docking/GenieDockFactory.cs b/src/Genie.App/Docking/GenieDockFactory.cs
index 1e56925a..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.
@@ -926,7 +952,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 +981,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/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) { }
+}
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) { }
+}
diff --git a/src/Genie.App/ViewModels/MainWindowViewModel.cs b/src/Genie.App/ViewModels/MainWindowViewModel.cs
index 6e5795a1..75fdff5f 100644
--- a/src/Genie.App/ViewModels/MainWindowViewModel.cs
+++ b/src/Genie.App/ViewModels/MainWindowViewModel.cs
@@ -1082,9 +1082,36 @@ private void SyncAutoLogFromConfig()
/// document. Changing the setting needs a restart.
public bool UseEditorGameWindow { get; private set; }
- public MainWindowViewModel() : this(null) { }
-
- public MainWindowViewModel(StartupOptions? startup)
+ /// 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, 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;
@@ -1092,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");
@@ -1130,7 +1159,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)
{
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);
}
diff --git a/src/Genie.Core/Config/GenieConfig.cs b/src/Genie.Core/Config/GenieConfig.cs
index 3decec0e..b90b505c 100644
--- a/src/Genie.Core/Config/GenieConfig.cs
+++ b/src/Genie.Core/Config/GenieConfig.cs
@@ -61,6 +61,17 @@ 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; }
+ /// 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.
@@ -643,6 +654,8 @@ public bool Save(string fileName = "settings.cfg")
("aliases", EnableAliases.ToString()),
("scrollbacklines", ScrollbackLines.ToString()),
("useeditorgamewindow", UseEditorGameWindow.ToString()),
+ ("useeditorrawxmlwindow", UseEditorRawXmlWindow.ToString()),
+ ("useeditorstreamwindow", UseEditorStreamWindow.ToString()),
("spelltimer", ShowSpellTimer.ToString()),
("showexperience", ShowExperience.ToString()),
("experiencedensity", ExperienceDensity.ToString()),
@@ -775,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" }),
+ ("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" }),
@@ -821,6 +834,8 @@ 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 "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.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 */ }
+ }
+ }
+}
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));
+ }
+}
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");
+ }
+}
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");
+ }
+}