diff --git a/Directory.Packages.props b/Directory.Packages.props
index 1c49a52c..bf4bede5 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -36,6 +36,7 @@
+
diff --git a/src/Spectron.Debugger/Breakpoints/BreakpointHandler.cs b/src/Spectron.Debugger/Breakpoints/BreakpointHandler.cs
index 24808abc..46d5b8c8 100644
--- a/src/Spectron.Debugger/Breakpoints/BreakpointHandler.cs
+++ b/src/Spectron.Debugger/Breakpoints/BreakpointHandler.cs
@@ -69,7 +69,7 @@ private void BeforeInstruction(Word pc)
BreakpointHit?.Invoke(this, new BreakpointHitEventArgs(_previousAddress));
}
- private void MemoryOnMemoryUpdated(Word address)
+ private void MemoryOnMemoryUpdated(Word address, byte value)
{
IsBreakpointHit = BreakpointManager.IsMemoryBreakpointHit(address, _emulator.Memory);
diff --git a/src/Spectron.Debugger/Controls/Hex/DefaultAsciiFormatter.cs b/src/Spectron.Debugger/Controls/Hex/DefaultAsciiFormatter.cs
new file mode 100644
index 00000000..ea275ef5
--- /dev/null
+++ b/src/Spectron.Debugger/Controls/Hex/DefaultAsciiFormatter.cs
@@ -0,0 +1,6 @@
+namespace OldBit.Spectron.Debugger.Controls.Hex;
+
+public sealed class DefaultAsciiFormatter : IAsciiFormatter
+{
+ public char Format(byte b) => b is >= 32 and <= 126 ? (char)b : '.';
+}
\ No newline at end of file
diff --git a/src/Spectron.Debugger/Controls/Hex/HexCellClickedEventArgs.cs b/src/Spectron.Debugger/Controls/Hex/HexCellClickedEventArgs.cs
new file mode 100644
index 00000000..d4556a80
--- /dev/null
+++ b/src/Spectron.Debugger/Controls/Hex/HexCellClickedEventArgs.cs
@@ -0,0 +1,10 @@
+namespace OldBit.Spectron.Debugger.Controls.Hex;
+
+internal sealed class HexCellClickedEventArgs(int rowIndex, int position, bool isShiftPressed) : EventArgs
+{
+ internal int Position { get; } = position;
+
+ internal int RowIndex { get; } = rowIndex;
+
+ internal bool IsShiftPressed { get; } = isShiftPressed;
+}
diff --git a/src/Spectron.Debugger/Controls/Hex/HexViewer.axaml b/src/Spectron.Debugger/Controls/Hex/HexViewer.axaml
new file mode 100644
index 00000000..b07af558
--- /dev/null
+++ b/src/Spectron.Debugger/Controls/Hex/HexViewer.axaml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Spectron.Debugger/Controls/Hex/HexViewer.cs b/src/Spectron.Debugger/Controls/Hex/HexViewer.cs
new file mode 100644
index 00000000..fb0be09c
--- /dev/null
+++ b/src/Spectron.Debugger/Controls/Hex/HexViewer.cs
@@ -0,0 +1,513 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Media;
+
+namespace OldBit.Spectron.Debugger.Controls.Hex;
+
+public class HexViewer : ContentControl
+{
+ public static readonly StyledProperty RowHeightProperty =
+ AvaloniaProperty.Register(nameof(RowHeight), defaultValue: 20);
+
+ public static readonly StyledProperty BytesPerRowProperty =
+ AvaloniaProperty.Register(nameof(BytesPerRow), defaultValue: 16, inherits: true);
+
+ public static readonly StyledProperty IsOffsetVisibleProperty =
+ AvaloniaProperty.Register(nameof(IsOffsetVisible), true, inherits: true);
+
+ public static readonly StyledProperty IsHeaderVisibleProperty =
+ AvaloniaProperty.Register(nameof(IsHeaderVisible), defaultValue: true);
+
+ public static readonly StyledProperty GroupSizeProperty =
+ AvaloniaProperty.Register(nameof(GroupSize), defaultValue: 8);
+
+ public static readonly DirectProperty DataProperty =
+ AvaloniaProperty.RegisterDirect(
+ nameof(Data),
+ getter: o => o.Data,
+ setter: (o, v) => o.Data = v,
+ unsetValue: []);
+
+ internal static readonly StyledProperty TypefaceProperty =
+ AvaloniaProperty.Register(nameof(Typeface), inherits: true);
+
+ internal static readonly StyledProperty RowTextBuilderProperty =
+ AvaloniaProperty.Register(nameof(RowTextBuilder), inherits: true);
+
+ public static readonly StyledProperty IsMultiSelectProperty =
+ AvaloniaProperty.Register(nameof(IsMultiSelect), defaultValue: true);
+
+ public static readonly StyledProperty AsciiFormatterProperty =
+ AvaloniaProperty.Register(nameof(AsciiFormatter), new DefaultAsciiFormatter());
+
+ public static readonly StyledProperty SelectionProperty =
+ AvaloniaProperty.Register(nameof(Selection), Selection.Empty, inherits: true);
+
+ public int RowHeight
+ {
+ get => GetValue(RowHeightProperty);
+ set => SetValue(RowHeightProperty, value);
+ }
+
+ public int BytesPerRow
+ {
+ get => GetValue(BytesPerRowProperty);
+ set => SetValue(BytesPerRowProperty, value);
+ }
+
+ public bool IsOffsetVisible
+ {
+ get => GetValue(IsOffsetVisibleProperty);
+ set => SetValue(IsOffsetVisibleProperty, value);
+ }
+
+ public bool IsHeaderVisible
+ {
+ get => GetValue(IsHeaderVisibleProperty);
+ set => SetValue(IsHeaderVisibleProperty, value);
+ }
+
+ public int GroupSize
+ {
+ get => GetValue(GroupSizeProperty);
+ set => SetValue(GroupSizeProperty, value);
+ }
+
+ public bool IsMultiSelect
+ {
+ get => GetValue(IsMultiSelectProperty);
+ set => SetValue(IsMultiSelectProperty, value);
+ }
+
+ public IAsciiFormatter AsciiFormatter
+ {
+ get => GetValue(AsciiFormatterProperty);
+ set => SetValue(AsciiFormatterProperty, value);
+ }
+
+ public Selection Selection
+ {
+ get => GetValue(SelectionProperty);
+ set => SetValue(SelectionProperty, value);
+ }
+
+ public byte[] Data
+ {
+ get;
+ set
+ {
+ if (value.Length == Data.Length)
+ {
+ Array.Copy(value, Data, value.Length);
+ _hexPanel.InvalidateVisual();
+ }
+ else
+ {
+ if (!SetAndRaise(DataProperty, ref field, value))
+ {
+ return;
+ }
+
+ Selection = Selection.Empty;
+ _hexPanel.Clear();
+
+ UpdateView();
+ }
+ }
+ } = [];
+
+ internal Typeface Typeface
+ {
+ get => GetValue(TypefaceProperty);
+ private set => SetValue(TypefaceProperty, value);
+ }
+
+ internal RowTextBuilder RowTextBuilder
+ {
+ get => GetValue(RowTextBuilderProperty);
+ set => SetValue(RowTextBuilderProperty, value);
+ }
+
+ private readonly HexViewerHeader _header;
+ private readonly ScrollViewer _scrollViewer;
+ private readonly HexViewerPanel _hexPanel;
+
+ private int _caretPosition = -1;
+ private int _selectionAnchor = -1;
+
+ private int CurrentRowIndex => Selection.Start / BytesPerRow;
+ private int VisibleRowCount => (int)(_scrollViewer.Viewport.Height / RowHeight);
+
+ private int PageHeight => VisibleRowCount * RowHeight;
+ private int PageSize => VisibleRowCount * BytesPerRow;
+
+ internal double RowWidth { get; private set; }
+
+ public HexViewer()
+ {
+ Focusable = true;
+
+ AffectsMeasure(RowHeightProperty);
+ AffectsMeasure(BytesPerRowProperty);
+
+ _header = new HexViewerHeader()
+ {
+ Height = RowHeight,
+ };
+ _hexPanel = new HexViewerPanel(this);
+
+ _scrollViewer = new ScrollViewer()
+ {
+ HorizontalScrollBarVisibility = ScrollBarVisibility.Auto,
+ VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
+ Content = _hexPanel,
+ };
+
+ HorizontalContentAlignment = HorizontalAlignment.Stretch;
+ VerticalContentAlignment = VerticalAlignment.Stretch;
+
+ var headerHost = new Border
+ {
+ ClipToBounds = true,
+ Child = _header
+ };
+
+ var dockPanel = new DockPanel();
+ dockPanel.Children.Add(headerHost);
+ dockPanel.Children.Add(_scrollViewer);
+ DockPanel.SetDock(headerHost, Dock.Top);
+
+ this.GetObservable(FontFamilyProperty).Subscribe(_ => Invalidate());
+ this.GetObservable(FontSizeProperty).Subscribe(_ => Invalidate());
+ this.GetObservable(FontStyleProperty).Subscribe(_ => Invalidate());
+ this.GetObservable(FontStretchProperty).Subscribe(_ => Invalidate());
+ this.GetObservable(IsOffsetVisibleProperty).Subscribe(_ => Invalidate());
+ this.GetObservable(BytesPerRowProperty).Subscribe(_ => Invalidate());
+ this.GetObservable(AsciiFormatterProperty).Subscribe(_ => Invalidate());
+ this.GetObservable(IsHeaderVisibleProperty).Subscribe(_ => _header.IsVisible = IsHeaderVisible);
+
+ _scrollViewer.GetObservable(ScrollViewer.OffsetProperty).Subscribe(offset =>
+ {
+ _header.RenderTransform = new TranslateTransform(-offset.X, 0);
+ UpdateView();
+ });
+ _scrollViewer.GetObservable(ScrollViewer.ViewportProperty).Subscribe(_ => UpdateView());
+
+ Content = dockPanel;
+ }
+
+ public void UpdateValues(int offset, params byte[] values)
+ {
+ var rowIndices = new HashSet();
+
+ for (var i = 0; i < values.Length; i++)
+ {
+ if (offset + i >= Data.Length)
+ {
+ break;
+ }
+
+ Data[offset + i] = values[i];
+
+ var rowIndex = offset / BytesPerRow;
+ rowIndices.Add(rowIndex);
+ }
+
+ foreach (var rowIndex in rowIndices)
+ {
+ _hexPanel.InvalidateRow(rowIndex);
+ }
+ }
+
+ public void Select(int start, int length = 1)
+ {
+ Selection = new Selection(start, start + length - 1);
+
+ _caretPosition = Selection.End;
+ _selectionAnchor = Selection.Start;
+
+ var rowIndex = Selection.Start / BytesPerRow;
+
+ if (!IsRowVisible(rowIndex))
+ {
+ ScrollToRow(rowIndex);
+ }
+ }
+
+ protected override void OnKeyDown(KeyEventArgs e)
+ {
+ switch (e.Key)
+ {
+ case Key.Left:
+ MoveLeft(e.KeyModifiers.HasFlag(KeyModifiers.Shift), offset: 1);
+ break;
+
+ case Key.Right:
+ MoveRight(e.KeyModifiers.HasFlag(KeyModifiers.Shift), offset: 1);
+ break;
+
+ case Key.Up:
+ MoveLeft(e.KeyModifiers.HasFlag(KeyModifiers.Shift), offset: BytesPerRow);
+
+ if (!IsRowVisible(CurrentRowIndex))
+ {
+ ScrollToRow(CurrentRowIndex);
+ }
+ break;
+
+ case Key.Down:
+ MoveRight(e.KeyModifiers.HasFlag(KeyModifiers.Shift), offset: BytesPerRow);
+
+ if (!IsRowVisible(CurrentRowIndex))
+ {
+ var viewportRows = (int)(_scrollViewer.Viewport.Height / RowHeight);
+ var targetTopRow = CurrentRowIndex - Math.Max(0, viewportRows) + 1;
+
+ ScrollToRow(targetTopRow);
+ }
+ break;
+
+ case Key.Home:
+ _caretPosition = 0;
+
+ if (!e.KeyModifiers.HasFlag(KeyModifiers.Shift))
+ {
+ Selection = new Selection(_caretPosition, _caretPosition);
+ _selectionAnchor = _caretPosition;
+ }
+ else
+ {
+ Selection = new Selection(_caretPosition, Selection.End);
+ }
+
+ _scrollViewer.ScrollToHome();
+ break;
+
+ case Key.End:
+ _caretPosition = Data.Length - 1;
+
+ if (!e.KeyModifiers.HasFlag(KeyModifiers.Shift))
+ {
+ Selection = new Selection(_caretPosition, _caretPosition);
+ _selectionAnchor = _caretPosition;
+ }
+ else
+ {
+ Selection = new Selection(Selection.Start, _caretPosition);
+ }
+
+ _scrollViewer.ScrollToEnd();
+ break;
+
+ case Key.PageUp:
+ MoveLeft(e.KeyModifiers.HasFlag(KeyModifiers.Shift), offset: PageSize);
+
+ _scrollViewer.Offset = new Vector(_scrollViewer.Offset.X, _scrollViewer.Offset.Y - PageHeight);
+ break;
+
+ case Key.PageDown:
+ MoveRight(e.KeyModifiers.HasFlag(KeyModifiers.Shift), offset: PageSize);
+
+ _scrollViewer.Offset = new Vector(_scrollViewer.Offset.X, _scrollViewer.Offset.Y + PageHeight);
+ break;
+
+ default:
+ return;
+ }
+ }
+
+ private void MoveLeft(bool isShiftPressed, int offset)
+ {
+ if (_caretPosition == 0)
+ {
+ return;
+ }
+
+ _caretPosition = _caretPosition >= offset ? _caretPosition - offset : 0;
+
+ if (!IsMultiSelect || !isShiftPressed)
+ {
+ Selection = new Selection(_caretPosition, _caretPosition);
+ _selectionAnchor = _caretPosition;
+ }
+ else
+ {
+ var start = Selection.Start;
+ var end = Selection.End;
+
+ if (_caretPosition == Selection.End - offset && Selection.Length > 1)
+ {
+ end = _caretPosition;
+ }
+
+ if (_caretPosition < Selection.Start)
+ {
+ start = _caretPosition;
+ }
+
+ Selection = new Selection(start, end);
+ }
+ }
+
+ private void MoveRight(bool isShiftPressed, int offset)
+ {
+ _caretPosition = _caretPosition < Data.Length - offset ? _caretPosition + offset : Data.Length - 1;
+
+ if (!IsMultiSelect || !isShiftPressed)
+ {
+ Selection = new Selection(_caretPosition, _caretPosition);
+ _selectionAnchor = _caretPosition;
+ }
+ else
+ {
+ var start = Selection.Start;
+ var end = Selection.End;
+
+ if (_caretPosition == Selection.Start + offset && Selection.Length > 1)
+ {
+ start = _caretPosition;
+ }
+
+ if (_caretPosition > Selection.End)
+ {
+ end = _caretPosition;
+ }
+
+ Selection = new Selection(start, end);
+ }
+ }
+
+
+ private HexViewerRow CreateRow(int rowIndex)
+ {
+ var startOffset = rowIndex * BytesPerRow;
+ var endOffset = startOffset + BytesPerRow;
+
+ if (endOffset > Data.Length)
+ {
+ endOffset = Data.Length;
+ }
+
+ var row = new HexViewerRow
+ {
+ RowIndex = rowIndex,
+ Offset = startOffset,
+ Data = new ArraySegment(Data, startOffset, endOffset - startOffset),
+ Height = RowHeight,
+ Width = RowWidth
+ };
+
+ row.CellClicked += (_, e) => { HandleCellClicked(e); };
+
+ return row;
+ }
+
+ private void HandleCellClicked(HexCellClickedEventArgs e)
+ {
+ var targetPosition = e.RowIndex * BytesPerRow + e.Position;
+
+ if (!IsMultiSelect || !e.IsShiftPressed || _caretPosition < 0)
+ {
+ _caretPosition = targetPosition;
+ Selection = new Selection(_caretPosition, _caretPosition);
+ _selectionAnchor = _caretPosition;
+ }
+ else
+ {
+ int selectionAnchor;
+
+ if (Selection.Length > 0)
+ {
+ if (targetPosition < Selection.Start)
+ {
+ selectionAnchor = Selection.End;
+ }
+ else if (targetPosition > Selection.End)
+ {
+ selectionAnchor = Selection.Start;
+ }
+ else
+ {
+ selectionAnchor = _selectionAnchor >= 0 ? _selectionAnchor : _caretPosition;
+ }
+ }
+ else
+ {
+ selectionAnchor = _selectionAnchor >= 0 ? _selectionAnchor : _caretPosition;
+ }
+
+ _caretPosition = targetPosition;
+ Selection = new Selection(
+ Math.Min(selectionAnchor, targetPosition),
+ Math.Max(selectionAnchor, targetPosition));
+ }
+ }
+
+ private void UpdateView()
+ {
+ var offset = _scrollViewer.Offset.Y;
+ var viewportHeight = _scrollViewer.Viewport.Height;
+
+ var startIndex = Math.Max(0, (int)(offset/ RowHeight));
+ var endIndex = Math.Min(
+ (Data.Length + BytesPerRow - 1) / BytesPerRow,
+ (int)((offset + viewportHeight) / RowHeight) + 2);
+
+ if (Data.Length == 0)
+ {
+ _hexPanel.Clear();
+ return;
+ }
+
+ _hexPanel.RemoveNotVisibleRows(startIndex, endIndex);
+
+ for (var rowIndex = startIndex; rowIndex < endIndex; rowIndex++)
+ {
+ if (_hexPanel.ContainsRow(rowIndex))
+ {
+ continue;
+ }
+
+ var row = CreateRow(rowIndex);
+ _hexPanel.Add(row);
+ }
+
+ _hexPanel.InvalidateArrange();
+ }
+
+ private bool IsRowVisible(int rowIndex)
+ {
+ var rowTop = rowIndex * RowHeight;
+ var rowBottom = rowTop + RowHeight;
+
+ var viewportTop = _scrollViewer.Offset.Y;
+ var viewportBottom = viewportTop + _scrollViewer.Viewport.Height;
+
+ return rowTop >= viewportTop && rowBottom <= viewportBottom;
+ }
+
+ private void UpdateTypeface()
+ {
+ Typeface = new Typeface(FontFamily, FontStyle, FontWeight, FontStretch);
+
+ var textLength = RowTextBuilder.CalculateTotalLength(IsOffsetVisible, GroupSize, BytesPerRow);
+ var formattedText = HexViewerRow.CreateFormattedText("X", Typeface, FontSize, Foreground);
+
+ RowWidth = textLength * formattedText.Width;
+ RowTextBuilder = new RowTextBuilder(AsciiFormatter, IsOffsetVisible, GroupSize, BytesPerRow, formattedText.Width);
+ }
+
+ private void Invalidate()
+ {
+ UpdateTypeface();
+
+ _hexPanel.InvalidateVisual();
+ _header.InvalidateVisual();
+ }
+
+ private void ScrollToRow(int rowIndex) => _scrollViewer.Offset =
+ new Vector(_scrollViewer.Offset.X, rowIndex * RowHeight);
+}
diff --git a/src/Spectron.Debugger/Controls/Hex/HexViewerHeader.cs b/src/Spectron.Debugger/Controls/Hex/HexViewerHeader.cs
new file mode 100644
index 00000000..a4236faf
--- /dev/null
+++ b/src/Spectron.Debugger/Controls/Hex/HexViewerHeader.cs
@@ -0,0 +1,52 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Media;
+
+namespace OldBit.Spectron.Debugger.Controls.Hex;
+
+public sealed class HexViewerHeader : Control
+{
+ internal static readonly StyledProperty ForegroundProperty =
+ TemplatedControl.ForegroundProperty.AddOwner();
+
+ private static readonly StyledProperty FontSizeProperty =
+ TemplatedControl.FontSizeProperty.AddOwner();
+
+ private static readonly StyledProperty TypefaceProperty =
+ HexViewer.TypefaceProperty.AddOwner();
+
+ private static readonly StyledProperty RowTextBuilderProperty =
+ HexViewer.RowTextBuilderProperty.AddOwner();
+
+ public IBrush? Foreground
+ {
+ get => GetValue(ForegroundProperty);
+ set => SetValue(ForegroundProperty, value);
+ }
+
+ private double FontSize => GetValue(FontSizeProperty);
+
+ private Typeface Typeface => GetValue(TypefaceProperty);
+
+ private RowTextBuilder RowTextBuilder => GetValue(RowTextBuilderProperty);
+
+ public override void Render(DrawingContext context)
+ {
+ var formattedText = GetFormattedText();
+ var rect = new Rect(new Size(formattedText.Width, Bounds.Height));
+
+ context.FillRectangle(Brushes.Transparent, rect);
+
+ var y = (Bounds.Height - formattedText.Height) / 2;
+ context.DrawText(formattedText, new Point(0, y));
+ }
+
+ private FormattedText GetFormattedText()
+ {
+ var text = RowTextBuilder.BuildHeader();
+ var formattedText = HexViewerRow.CreateFormattedText(text, Typeface, FontSize, Foreground);
+
+ return formattedText;
+ }
+}
\ No newline at end of file
diff --git a/src/Spectron.Debugger/Controls/Hex/HexViewerPanel.cs b/src/Spectron.Debugger/Controls/Hex/HexViewerPanel.cs
new file mode 100644
index 00000000..f7124c12
--- /dev/null
+++ b/src/Spectron.Debugger/Controls/Hex/HexViewerPanel.cs
@@ -0,0 +1,73 @@
+using Avalonia;
+using Avalonia.Controls;
+
+namespace OldBit.Spectron.Debugger.Controls.Hex;
+
+internal class HexViewerPanel(HexViewer viewer) : Panel
+{
+ private readonly Dictionary _visibleRows = [];
+
+ internal void Add(HexViewerRow row)
+ {
+ Children.Add(row);
+ _visibleRows.Add(row.RowIndex, row);
+ }
+
+ internal bool ContainsRow(int rowIndex) => _visibleRows.ContainsKey(rowIndex);
+
+ internal void Clear()
+ {
+ _visibleRows.Clear();
+ Children.Clear();
+ }
+
+ internal void RemoveNotVisibleRows(int startIndex, int endIndex)
+ {
+ var removeIndices = _visibleRows.Where(row => row.Key > endIndex || row.Key < startIndex)
+ .Select(row => row.Key)
+ .ToList();
+
+ foreach (var index in removeIndices)
+ {
+ Children.Remove(_visibleRows[index]);
+ _visibleRows.Remove(index);
+ }
+ }
+
+ internal new void InvalidateVisual()
+ {
+ foreach (var row in _visibleRows.Values)
+ {
+ row.InvalidateVisual();
+ }
+
+ base.InvalidateVisual();
+ }
+
+ internal void InvalidateRow(int rowIndex)
+ {
+ if (_visibleRows.TryGetValue(rowIndex, out var row))
+ {
+ row.InvalidateVisual();
+ }
+ }
+
+ protected override Size MeasureOverride(Size availableSize)
+ {
+ var rowCount = (viewer.Data.Length + viewer.BytesPerRow - 1) / viewer.BytesPerRow;
+ double totalHeight = rowCount * viewer.RowHeight;
+
+ return new Size(viewer.RowWidth, totalHeight);
+ }
+
+ protected override Size ArrangeOverride(Size finalSize)
+ {
+ foreach (var (rowIndex, row) in _visibleRows)
+ {
+ var rect = new Rect(0, rowIndex * viewer.RowHeight, finalSize.Width, viewer.RowHeight);
+ row.Arrange(rect);
+ }
+
+ return finalSize;
+ }
+}
\ No newline at end of file
diff --git a/src/Spectron.Debugger/Controls/Hex/HexViewerRow.cs b/src/Spectron.Debugger/Controls/Hex/HexViewerRow.cs
new file mode 100644
index 00000000..87c91355
--- /dev/null
+++ b/src/Spectron.Debugger/Controls/Hex/HexViewerRow.cs
@@ -0,0 +1,164 @@
+using System.Globalization;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Media;
+
+namespace OldBit.Spectron.Debugger.Controls.Hex;
+
+public sealed class HexViewerRow : Control
+{
+ public static readonly StyledProperty AlternatingRowBackgroundProperty =
+ AvaloniaProperty.Register(nameof(AlternatingRowBackground));
+
+ public static readonly StyledProperty SelectedBackgroundProperty =
+ AvaloniaProperty.Register(nameof(SelectedBackground));
+
+ public static readonly StyledProperty ForegroundProperty =
+ TemplatedControl.ForegroundProperty.AddOwner();
+
+ public static readonly StyledProperty OffsetForegroundProperty =
+ AvaloniaProperty.Register(nameof(OffsetForeground));
+
+ private static readonly StyledProperty BytesPerRowProperty =
+ HexViewer.BytesPerRowProperty.AddOwner();
+
+ private static readonly StyledProperty FontSizeProperty =
+ TemplatedControl.FontSizeProperty.AddOwner();
+
+ private static readonly StyledProperty TypefaceProperty =
+ HexViewer.TypefaceProperty.AddOwner();
+
+ private static readonly StyledProperty RowTextBuilderProperty =
+ HexViewer.RowTextBuilderProperty.AddOwner();
+
+ public static readonly StyledProperty SelectionProperty =
+ HexViewer.SelectionProperty.AddOwner();
+
+ public IBrush? AlternatingRowBackground
+ {
+ get => GetValue(AlternatingRowBackgroundProperty);
+ set => SetValue(AlternatingRowBackgroundProperty, value);
+ }
+
+ public IBrush? SelectedBackground
+ {
+ get => GetValue(SelectedBackgroundProperty);
+ set => SetValue(SelectedBackgroundProperty, value);
+ }
+
+ public IBrush? Foreground
+ {
+ get => GetValue(ForegroundProperty);
+ set => SetValue(ForegroundProperty, value);
+ }
+
+ public IBrush? OffsetForeground
+ {
+ get => GetValue(OffsetForegroundProperty);
+ set => SetValue(OffsetForegroundProperty, value);
+ }
+
+ public Selection Selection => GetValue(SelectionProperty);
+
+ private int BytesPerRow => GetValue(BytesPerRowProperty);
+
+ private double FontSize => GetValue(FontSizeProperty);
+
+ private Typeface Typeface => GetValue(TypefaceProperty);
+
+ private RowTextBuilder RowTextBuilder => GetValue(RowTextBuilderProperty);
+
+ private int RowStartIndex => RowIndex * BytesPerRow;
+ private int RowEndIndex => (RowIndex + 1) * BytesPerRow - 1;
+
+ public required int RowIndex { get; init; }
+ public required int Offset { get; init; }
+ public required ArraySegment Data { get; init; }
+
+ internal event EventHandler? CellClicked;
+
+ internal HexViewerRow()
+ {
+ HorizontalAlignment = HorizontalAlignment.Left;
+ VerticalAlignment = VerticalAlignment.Center;
+
+ this.GetObservable(TypefaceProperty).Subscribe(_ => InvalidateVisual());
+ this.GetObservable(SelectionProperty).Subscribe(_ => InvalidateVisual());
+ }
+
+ public override void Render(DrawingContext context)
+ {
+ var background = AlternatingRowBackground != null && RowIndex % 2 == 0 ? AlternatingRowBackground : null;
+ var formattedText = GetFormattedText();
+ var rect = new Rect(new Size(formattedText.Width, Bounds.Height));
+
+ context.FillRectangle(background ?? Brushes.Transparent, rect);
+
+ foreach (var selectedIndex in GetSelectedIndexes())
+ {
+ var layout = RowTextBuilder.GetLayout(selectedIndex);
+ rect = new Rect(layout.Position * RowTextBuilder.CharWidth - 4, 1, layout.Width * RowTextBuilder.CharWidth + 8, Height - 2);
+ context.DrawRectangle(SelectedBackground, null, rect);
+
+ layout = RowTextBuilder.GetLayout(selectedIndex + BytesPerRow);
+ rect = new Rect(layout.Position * RowTextBuilder.CharWidth, 1, layout.Width * RowTextBuilder.CharWidth, Height - 2);
+ context.DrawRectangle(SelectedBackground, null, rect);
+ }
+
+ if (RowTextBuilder.IsOffsetVisible)
+ {
+ formattedText.SetForegroundBrush(OffsetForeground, 0, 5);
+ }
+
+ var y = (Bounds.Height - formattedText.Height) / 2;
+ context.DrawText(formattedText, new Point(0, y));
+ }
+
+ protected override void OnPointerPressed(PointerPressedEventArgs e)
+ {
+ if (!e.Properties.IsLeftButtonPressed)
+ {
+ return;
+ }
+
+ var x = e.GetPosition(this).X;
+ var cellIndex = RowTextBuilder.GetIndexFromPosition(x);
+
+ if (cellIndex == null)
+ {
+ return;
+ }
+
+ var isShiftPressed = e.KeyModifiers.HasFlag(KeyModifiers.Shift);
+ CellClicked?.Invoke(this, new HexCellClickedEventArgs(RowIndex, cellIndex.Value, isShiftPressed));
+ }
+
+ internal static FormattedText CreateFormattedText(string text, Typeface typeface, double fontSize, IBrush? foreground) => new(
+ text,
+ CultureInfo.CurrentCulture,
+ FlowDirection.LeftToRight,
+ typeface,
+ fontSize,
+ foreground);
+
+ private FormattedText GetFormattedText()
+ {
+ var text = RowTextBuilder.Build(Data, Offset);
+
+ return CreateFormattedText(text, Typeface, FontSize, Foreground);
+ }
+
+ private IEnumerable GetSelectedIndexes()
+ {
+ for (var i = Selection.Start; i <= Selection.End; i++)
+ {
+ if (i >= RowStartIndex && i <= RowEndIndex)
+ {
+ yield return i - RowStartIndex;
+ }
+ }
+ }
+}
diff --git a/src/Spectron.Debugger/Controls/Hex/IAsciiFormatter.cs b/src/Spectron.Debugger/Controls/Hex/IAsciiFormatter.cs
new file mode 100644
index 00000000..016c02cc
--- /dev/null
+++ b/src/Spectron.Debugger/Controls/Hex/IAsciiFormatter.cs
@@ -0,0 +1,6 @@
+namespace OldBit.Spectron.Debugger.Controls.Hex;
+
+public interface IAsciiFormatter
+{
+ char Format(byte b);
+}
\ No newline at end of file
diff --git a/src/Spectron.Debugger/Controls/Hex/RowTextBuilder.cs b/src/Spectron.Debugger/Controls/Hex/RowTextBuilder.cs
new file mode 100644
index 00000000..028ec698
--- /dev/null
+++ b/src/Spectron.Debugger/Controls/Hex/RowTextBuilder.cs
@@ -0,0 +1,148 @@
+using System.Text;
+
+namespace OldBit.Spectron.Debugger.Controls.Hex;
+
+internal record RowTextLayout(int Position, int Width);
+
+internal sealed class RowTextBuilder
+{
+ private readonly IAsciiFormatter _asciiFormatter;
+ private readonly int _groupSize;
+ private readonly int _bytesPerRow;
+ private readonly RowTextLayout[] _layout;
+
+ internal double CharWidth { get; }
+ internal bool IsOffsetVisible { get; }
+
+ public RowTextBuilder(
+ IAsciiFormatter asciiFormatter,
+ bool isOffsetVisible,
+ int groupSize,
+ int bytesPerRow,
+ double charWidth)
+ {
+ IsOffsetVisible = isOffsetVisible;
+ _asciiFormatter = asciiFormatter;
+ _groupSize = groupSize;
+ _bytesPerRow = bytesPerRow;
+ CharWidth = charWidth;
+
+ _layout = CreateLayout().ToArray();
+ }
+
+ internal string Build(ReadOnlySpan data, int offset)
+ {
+ var hex = new StringBuilder();
+ var ascii = new StringBuilder();
+
+ var address = IsOffsetVisible ? $"{offset:X4}: " : string.Empty;
+ hex.Append(address);
+
+ for (var i = 0; i < _bytesPerRow; i++)
+ {
+ if (i > 0)
+ {
+ hex.Append(' ');
+ }
+
+ if (IsGroupSpacer(i))
+ {
+ hex.Append(' ');
+ }
+
+ hex.Append(i >= data.Length ? " " : data[i].ToString("X2"));
+ ascii.Append(i >= data.Length ? ' ' : _asciiFormatter.Format(data[i]));
+ }
+
+ hex.Append(" ");
+ hex.Append(ascii);
+
+ return hex.ToString();
+ }
+
+ internal string BuildHeader()
+ {
+ var hex = new StringBuilder();
+
+ var address = IsOffsetVisible ? " " : string.Empty;
+ hex.Append(address);
+
+ for (var i = 0; i < _bytesPerRow; i++)
+ {
+ if (i > 0)
+ {
+ hex.Append(' ');
+ }
+
+ if (IsGroupSpacer(i))
+ {
+ hex.Append(' ');
+ }
+
+ hex.Append(i.ToString("X2"));
+ }
+
+ return hex.ToString();
+ }
+
+ internal int? GetIndexFromPosition(double x)
+ {
+ var item = _layout
+ .Select((value, index) => new { Item = value, Index = index })
+ .FirstOrDefault(p =>
+ x > p.Item.Position * CharWidth &&
+ x < (p.Item.Position + p.Item.Width) * CharWidth);
+
+ return item?.Index % _bytesPerRow;
+ }
+
+ internal RowTextLayout GetLayout(int index) => _layout[index];
+
+ internal static int CalculateTotalLength(bool isOffsetVisible, int groupSize, int bytesPerRow)
+ {
+ // Offset "F000: "
+ var length = isOffsetVisible ? 6 : 0;
+
+ // Hex bytes
+ length += bytesPerRow * 3;
+
+ // Group extra gap
+ if (groupSize > 1)
+ {
+ length += bytesPerRow / groupSize - 1;
+ }
+
+ // Before ASCII gap
+ length += 2;
+
+ // ASCII "ABCDEFGHIJKLMNOP"
+ length += bytesPerRow;
+
+ return length;
+ }
+
+ private IEnumerable CreateLayout()
+ {
+ var position = IsOffsetVisible ? 6 : 0;
+
+ for (var i = 0; i < _bytesPerRow; i++)
+ {
+ if (IsGroupSpacer(i))
+ {
+ position += 1;
+ }
+
+ yield return new RowTextLayout (position, 2);
+ position += 3;
+ }
+
+ position += 1;
+
+ for (var i = 0; i < _bytesPerRow; i++)
+ {
+ yield return new RowTextLayout (position++, 1);
+ }
+ }
+
+ private bool IsGroupSpacer(int index) => _groupSize > 0 & index > 0 && index % _groupSize == 0;
+}
\ No newline at end of file
diff --git a/src/Spectron.Debugger/Controls/Hex/Selection.cs b/src/Spectron.Debugger/Controls/Hex/Selection.cs
new file mode 100644
index 00000000..09f33298
--- /dev/null
+++ b/src/Spectron.Debugger/Controls/Hex/Selection.cs
@@ -0,0 +1,11 @@
+namespace OldBit.Spectron.Debugger.Controls.Hex;
+
+public sealed record Selection(int Start, int End)
+{
+ public static Selection Empty { get; } = new(-1, -1);
+
+ public int Start { get; } = Start;
+ public int End { get; } = End;
+
+ public int Length => End - Start + 1;
+}
\ No newline at end of file
diff --git a/src/Spectron.Debugger/Controls/HexViewer.axaml b/src/Spectron.Debugger/Controls/HexViewer.axaml
deleted file mode 100644
index 036a04e2..00000000
--- a/src/Spectron.Debugger/Controls/HexViewer.axaml
+++ /dev/null
@@ -1,113 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/Spectron.Debugger/Controls/HexViewer.axaml.cs b/src/Spectron.Debugger/Controls/HexViewer.axaml.cs
deleted file mode 100644
index 9eb1f93c..00000000
--- a/src/Spectron.Debugger/Controls/HexViewer.axaml.cs
+++ /dev/null
@@ -1,49 +0,0 @@
-using Avalonia;
-using Avalonia.Controls.Primitives;
-
-namespace OldBit.Spectron.Debugger.Controls;
-
-public record HexViewerDataRow(int Address, HexViewerCell[] Cells);
-
-public class HexViewer : TemplatedControl
-{
- private const int DefaultCellHeight = 25;
- private const int DefaultCellWight = 25;
- private const int DefaultBytesPerRow = 16;
-
- public static readonly StyledProperty CellHeightProperty =
- AvaloniaProperty.Register(nameof(CellHeight), DefaultCellHeight);
-
- public static readonly StyledProperty CellWidthProperty =
- AvaloniaProperty.Register(nameof(CellWidth), DefaultCellWight);
-
- public static readonly StyledProperty> DataProperty =
- AvaloniaProperty.Register>(nameof(Data), []);
-
- private static readonly StyledProperty BytesPerRowProperty =
- AvaloniaProperty.Register(nameof(BytesPerRow), DefaultBytesPerRow, validate: i => i > 0);
-
- public double CellHeight
- {
- get => GetValue(CellHeightProperty);
- set => SetValue(CellHeightProperty, value);
- }
-
- public double CellWidth
- {
- get => GetValue(CellWidthProperty);
- set => SetValue(CellWidthProperty, value);
- }
-
- public IEnumerable Data
- {
- get => GetValue(DataProperty);
- set => SetValue(DataProperty, value);
- }
-
- public int BytesPerRow
- {
- get => GetValue(BytesPerRowProperty);
- set => SetValue(BytesPerRowProperty, value);
- }
-}
\ No newline at end of file
diff --git a/src/Spectron.Debugger/Controls/HexViewerCell.cs b/src/Spectron.Debugger/Controls/HexViewerCell.cs
deleted file mode 100644
index 5110cf22..00000000
--- a/src/Spectron.Debugger/Controls/HexViewerCell.cs
+++ /dev/null
@@ -1,15 +0,0 @@
-using CommunityToolkit.Mvvm.ComponentModel;
-
-namespace OldBit.Spectron.Debugger.Controls;
-
-public partial class HexViewerCell : ObservableObject
-{
- [ObservableProperty]
- private byte _value;
-
- [ObservableProperty]
- private bool _isSelected;
-
- public int RowIndex { get; init; }
- public int ColumnIndex { get;init; }
-}
\ No newline at end of file
diff --git a/src/Spectron.Debugger/Controls/Memory.axaml b/src/Spectron.Debugger/Controls/Memory.axaml
index b85f94a1..9926b57b 100644
--- a/src/Spectron.Debugger/Controls/Memory.axaml
+++ b/src/Spectron.Debugger/Controls/Memory.axaml
@@ -2,8 +2,8 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
- xmlns:controls="clr-namespace:OldBit.Spectron.Debugger.Controls"
xmlns:viewModels="clr-namespace:OldBit.Spectron.Debugger.ViewModels"
+ xmlns:hex="clr-namespace:OldBit.Spectron.Debugger.Controls.Hex"
x:DataType="viewModels:MemoryViewModel"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="OldBit.Spectron.Debugger.Controls.Memory">
@@ -19,10 +19,24 @@
-
+ GroupSize="0"
+ IsOffsetVisible="True"
+ IsHeaderVisible="False"
+ BytesPerRow="8">
+
+
+
+
+
diff --git a/src/Spectron.Debugger/Controls/Memory.axaml.cs b/src/Spectron.Debugger/Controls/Memory.axaml.cs
index e110eebd..e4a30c74 100644
--- a/src/Spectron.Debugger/Controls/Memory.axaml.cs
+++ b/src/Spectron.Debugger/Controls/Memory.axaml.cs
@@ -1,8 +1,23 @@
using Avalonia.Controls;
+using Avalonia.Threading;
+using OldBit.Spectron.Debugger.ViewModels;
namespace OldBit.Spectron.Debugger.Controls;
public partial class Memory : UserControl
{
public Memory() => InitializeComponent();
+
+ protected override void OnDataContextChanged(EventArgs e)
+ {
+ if (DataContext is not MemoryViewModel viewModel)
+ {
+ return;
+ }
+
+ viewModel.OnMemoryUpdated = (address, value) =>
+ {
+ Dispatcher.UIThread.Post(() => MemoryView.UpdateValues(address, value));
+ };
+ }
}
\ No newline at end of file
diff --git a/src/Spectron.Debugger/Converters/Hex.cs b/src/Spectron.Debugger/Converters/Hex.cs
new file mode 100644
index 00000000..3e717c5a
--- /dev/null
+++ b/src/Spectron.Debugger/Converters/Hex.cs
@@ -0,0 +1,49 @@
+using System.Globalization;
+using System.Numerics;
+
+namespace OldBit.Spectron.Debugger.Converters;
+
+internal static class Hex
+{
+ internal static bool TryParse(string input, out T value, bool preferDecimal = false) where T : IBinaryInteger
+ {
+ input = input.Trim();
+
+ if (preferDecimal && T.TryParse(input, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var result))
+ {
+ value = result;
+ return true;
+ }
+
+ var hex = TrimHexIndicator(input);
+
+ if (T.TryParse(hex, NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out result))
+ {
+ value = result;
+ return true;
+ }
+
+ value = T.Zero;
+ return false;
+ }
+
+ private static string TrimHexIndicator(string input)
+ {
+ if (input.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
+ {
+ return input[2..];
+ }
+
+ if (input.StartsWith('$') || input.StartsWith('#'))
+ {
+ return input[1..];
+ }
+
+ if (input.EndsWith("h", StringComparison.OrdinalIgnoreCase))
+ {
+ return input[..^1];
+ }
+
+ return input;
+ }
+}
\ No newline at end of file
diff --git a/src/Spectron.Debugger/Converters/HexViewerDataConverter.cs b/src/Spectron.Debugger/Converters/HexViewerDataConverter.cs
deleted file mode 100644
index 2cd662b8..00000000
--- a/src/Spectron.Debugger/Converters/HexViewerDataConverter.cs
+++ /dev/null
@@ -1,32 +0,0 @@
-using System.Globalization;
-using Avalonia.Data.Converters;
-using OldBit.Spectron.Debugger.Controls;
-using OldBit.Spectron.Debugger.Extensions;
-
-namespace OldBit.Spectron.Debugger.Converters;
-
-public class HexViewerDataConverter : IMultiValueConverter
-{
- public object? Convert(IList