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
83 changes: 81 additions & 2 deletions src/Genie.App/App.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -1295,6 +1295,59 @@
</Grid>
</DataTemplate>

<!-- ── Stream windows (experimental AvaloniaEdit renderer) ────────────── -->
<!-- MUST stay ABOVE the StreamTool template below — see the
EditorGameTextDocument comment for why. Only reached when
#config useeditorstreamwindow is on — GenieDockFactory constructs the
subtype only then, so with the flag off (the default) this template
matches nothing and the legacy subtree below is built exactly as it
always was. One flag, one template, all 12 Stream instances. -->
<DataTemplate DataType="docking:EditorStreamTool">
<Grid Background="{Binding ToolBackground}">
<controls:GameTextEditor x:Name="streamEditor"
Width="{Binding $parent[Grid].Bounds.Width}"
Height="{Binding $parent[Grid].Bounds.Height}"/>

<Button HorizontalAlignment="Right" VerticalAlignment="Bottom"
Margin="0,0,20,8"
IsVisible="{Binding #streamEditor.IsScrolledUp}"
Command="{Binding #streamEditor.JumpToBottomCommand}"
Content="↓ Bottom" FontSize="11" Padding="8,4" Opacity="0.85"/>

<Border HorizontalAlignment="Right" VerticalAlignment="Top"
Margin="0,4,20,0" Padding="4,2" CornerRadius="3"
Background="{DynamicResource Theme.ToolbarBg}"
BorderBrush="{DynamicResource Theme.Border}" BorderThickness="1"
IsVisible="{Binding Find.IsOpen}">
<StackPanel Orientation="Horizontal" Spacing="4">
<TextBox Text="{Binding Find.FindText}" Watermark="Find…"
AutomationProperties.Name="Find in window"
Width="160" FontSize="11" MinHeight="0" Padding="4,2"
controls:FindInWindow.BoxModel="{Binding Find}"/>
<TextBlock Text="{Binding Find.Status}" FontSize="10"
Foreground="{DynamicResource Theme.TextMuted}"
VerticalAlignment="Center"/>
<Button Content="▲" FontSize="10" Padding="4,1" MinHeight="0" MinWidth="0"
AutomationProperties.Name="Find previous"
AutomationProperties.HelpText="Older match (Enter)"
Command="{Binding Find.OlderCommand}"
ToolTip.Tip="Older match (Enter)"/>
<Button Content="▼" FontSize="10" Padding="4,1" MinHeight="0" MinWidth="0"
AutomationProperties.Name="Find next"
AutomationProperties.HelpText="Newer match (Shift+Enter)"
Command="{Binding Find.NewerCommand}"
ToolTip.Tip="Newer match (Shift+Enter)"/>
<Button Content="✕" FontSize="10" Padding="4,1" MinHeight="0" MinWidth="0"
Background="Transparent" BorderThickness="0"
AutomationProperties.Name="Close find bar"
AutomationProperties.HelpText="Close (Esc)"
Command="{Binding Find.CloseCommand}"
ToolTip.Tip="Close (Esc)"/>
</StackPanel>
</Border>
</Grid>
</DataTemplate>

<!-- ── Streams (shared template for all 5 stream tools) ──────────────── -->
<DataTemplate DataType="docking:StreamTool">
<Grid Background="{Binding ToolBackground}">
Expand Down Expand Up @@ -1643,6 +1696,32 @@
<views:ScriptManagerView DataContext="{Binding ViewModel}"/>
</DataTemplate>

<!-- ── Raw XML window (experimental AvaloniaEdit renderer) ────────────── -->
<!-- MUST stay ABOVE the RawXmlTool template below — see the
EditorGameTextDocument comment for why. Only reached when
#config useeditorrawxmlwindow is on. Stays as minimal as the legacy
template: fixed green foreground, no Find, no word-wrap toggle — only
the rendering control underneath changes. -->
<DataTemplate DataType="docking:EditorRawXmlTool">
<Grid RowDefinitions="Auto,*" Margin="4">

<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="6"
Margin="0,0,0,4">
<Button Content="Clear" Command="{Binding ViewModel.ClearCommand}"
FontSize="11" Padding="8,2"
ToolTip.Tip="Clear the buffer"/>
<TextBlock Text="Live raw server XML — local view only."
Foreground="{DynamicResource Theme.SectionHeader}" FontSize="11" VerticalAlignment="Center"/>
</StackPanel>

<Border Grid.Row="1" Background="{DynamicResource Theme.PanelBgDeep}" BorderBrush="{DynamicResource Theme.Border}"
BorderThickness="1" CornerRadius="3">
<controls:GameTextEditor Width="{Binding $parent[Border].Bounds.Width}"
Height="{Binding $parent[Border].Bounds.Height}"/>
</Border>
</Grid>
</DataTemplate>

<!-- ── Raw XML window (#14) — verbatim live server stream ─────────────── -->
<!-- Read-only protocol dump for parser development / "where did that come -->
<!-- from?" debugging. Capped rolling buffer, auto-scroll, default hidden. -->
Expand All @@ -1669,8 +1748,8 @@
FontSize="{Binding ToolFontSize}"
Foreground="#7fc4a0" Margin="4">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="x:String">
<SelectableTextBlock Text="{Binding}"/>
<DataTemplate DataType="vm:TextLine">
<SelectableTextBlock Text="{Binding Text}"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
Expand Down
63 changes: 41 additions & 22 deletions src/Genie.App/Controls/GameTextEditor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,27 @@
namespace Genie.App.Controls;

/// <summary>
/// The experimental AvaloniaEdit-backed renderer for the main Game window
/// (<c>#config useeditorgamewindow on</c>, default off). It hosts a single
/// read-only <see cref="TextEditor"/> and mirrors
/// <see cref="GameTextViewModel.Lines"/> — still the source of truth — into its
/// The experimental AvaloniaEdit-backed renderer shared by the Game, Raw XML,
/// and Stream windows (<c>#config useeditorgamewindow</c> /
/// <c>useeditorrawxmlwindow</c> / <c>useeditorstreamwindow</c>, all default
/// off). It hosts a single read-only <see cref="TextEditor"/> and mirrors
/// whatever <see cref="ITextEditorHost"/> it is bound to —
/// <see cref="ITextEditorHost.Lines"/> is still the source of truth — into its
/// document, one buffered line per document line.
///
/// <para>Only the <i>rendering</i> moves. Timestamping, the Name-List-Only filter,
/// gags/substitutes, session logging, Copy All and Save As all stay on the
/// view-model and are untouched by which renderer is active.</para>
///
/// <para>Selected out of the legacy path by type, not by a visibility toggle:
/// <see cref="EditorGameTextDocument"/> gets its own <c>DataTemplate</c>, so with
/// the flag off this control is never constructed and the legacy subtree is
/// exactly what it always was.</para>
/// <see cref="EditorGameTextDocument"/>, <see cref="EditorRawXmlTool"/>, and
/// <see cref="EditorStreamTool"/> each get their own <c>DataTemplate</c>, 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
/// (<see cref="ITextEditorHost.EnableColorizing"/> /
/// <see cref="ITextEditorHost.EnableLinks"/>) — on for Game/Stream, off for
/// Raw XML, which stays a plain verbatim dump.</para>
/// </summary>
public sealed class GameTextEditor : UserControl
{
Expand All @@ -46,9 +53,9 @@ public sealed class GameTextEditor : UserControl
private readonly TextDocument _document = new();
private readonly List<GameLineEntry> _entries = [];

private GameTextDocument? _host;
private GameTextViewModel? _vm;
private ITextEditorHost? _host;
private FindInWindowModel? _find;
private bool _renderersConfigured;

private bool _atBottom = true;
private bool _paused;
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}

Expand All @@ -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:
Expand Down Expand Up @@ -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));
Expand Down
49 changes: 49 additions & 0 deletions src/Genie.App/Controls/ITextEditorHost.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Contract the AvaloniaEdit-backed <see cref="GameTextEditor"/> control needs
/// from whatever dock tool or document hosts it. Game
/// (<see cref="Docking.GameTextDocument"/>), Stream
/// (<see cref="Docking.StreamTool"/>), and Raw XML
/// (<see cref="Docking.RawXmlTool"/>) all implement this instead of the
/// control depending on any one concrete host type.
/// </summary>
public interface ITextEditorHost : INotifyPropertyChanged
{
/// <summary>The buffered lines to render — one document line per entry,
/// the same source of truth the legacy ItemsControl renderer reads.</summary>
ObservableCollection<TextLine> Lines { get; }

FontFamily ToolFontFamily { get; }
double ToolFontSize { get; }

/// <summary>Null means "inherit the global game colour" (Game, Stream);
/// hosts with a fixed colour (Raw XML) always return a brush.</summary>
IBrush? ToolForeground { get; }

TextWrapping ToolTextWrapping { get; }
ScrollBarVisibility ToolHScroll { get; }

/// <summary>"Pause Scrolling" window-menu state. Settable so the control
/// can resume it on toggle.</summary>
bool IsScrollPaused { get; set; }

/// <summary>Null disables the in-window Find bar entirely (Raw XML).</summary>
FindInWindowModel? Find { get; }

/// <summary>Run highlight-rule colorizing (<see cref="GameTextColorizer"/>)
/// over each line. On for Game/Stream; off for Raw XML — a verbatim
/// protocol dump with no rule matching.</summary>
bool EnableColorizing { get; }

/// <summary>Detect and render clickable links (<see cref="GameLinkGenerator"/>).
/// On for Game/Stream; off for Raw XML.</summary>
bool EnableLinks { get; }
}
7 changes: 6 additions & 1 deletion src/Genie.App/Docking/GameTextDocument.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Collections.ObjectModel;
using Avalonia;
using Avalonia.Media;
using Dock.Model.Mvvm.Controls;
Expand All @@ -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<TextLine> Lines => ViewModel.Lines;
public bool EnableColorizing => true;
public bool EnableLinks => true;

/// <summary>In-window Find bar state (#120). The overlay in the game-text
/// template binds to this; Ctrl+F / the window menu opens it.</summary>
public FindInWindowModel Find { get; }
Expand Down
Loading
Loading