diff --git a/src/HexManiac.Core/ViewModels/Selection.cs b/src/HexManiac.Core/ViewModels/Selection.cs index 21329a61..99204ff6 100644 --- a/src/HexManiac.Core/ViewModels/Selection.cs +++ b/src/HexManiac.Core/ViewModels/Selection.cs @@ -8,11 +8,13 @@ using System.Linq; using System.Windows.Input; namespace HavenSoft.HexManiac.Core.ViewModels { + public delegate (Point start, Point end) GetSelectionSpan(Point p); + public class Selection : ViewModelCore { private const int DefaultPreferredWidth = 0x10; private readonly IDataModel model; - + private readonly GetSelectionSpan getSpan; private readonly StubCommand moveSelectionStart = new StubCommand(), moveSelectionEnd = new StubCommand(), @@ -26,7 +28,10 @@ namespace HavenSoft.HexManiac.Core.ViewModels { private int preferredWidth = DefaultPreferredWidth, maxWidth = 4; - private Point selectionStart, selectionEnd; + private Point rawSelectionStart; // the actual click point + private Point selectionStart; // the calculated selection start, which may differ depending on the SelectionSpan + private Point rawSelectionEnd; // the actual release point + private Point selectionEnd; // the calculated selection end, which may differ depending on the SelectionSpan public Point SelectionStart { get => selectionStart; @@ -37,12 +42,14 @@ namespace HavenSoft.HexManiac.Core.ViewModels { if (selectionStart.Equals(value)) return; if (!Scroll.ScrollToPoint(ref value)) { - PreviewSelectionStartChanged?.Invoke(this, selectionStart); + PreviewSelectionStartChanged?.Invoke(this, rawSelectionStart); } - if (TryUpdate(ref selectionStart, value)) { - SelectionEnd = selectionStart; - } + rawSelectionStart = value; + rawSelectionEnd = value; + var (start, end) = getSpan(rawSelectionStart); + TryUpdate(ref selectionStart, start); + TryUpdate(ref selectionEnd, end, nameof(SelectionEnd)); } } @@ -52,8 +59,35 @@ namespace HavenSoft.HexManiac.Core.ViewModels { var index = Scroll.ViewPointToDataIndex(value); value = Scroll.DataIndexToViewPoint(index.LimitToRange(0, Scroll.DataLength)); + if (selectionEnd.Equals(value)) return; + Scroll.ScrollToPoint(ref value); - TryUpdate(ref selectionEnd, value); + + rawSelectionEnd = value; + var startIndex = Scroll.ViewPointToDataIndex(rawSelectionStart); + var endIndex = Scroll.ViewPointToDataIndex(rawSelectionEnd); + + // case 1: start/end are the same + if (startIndex == endIndex) { + var (start, end) = getSpan(rawSelectionStart); + TryUpdate(ref selectionStart, start, nameof(SelectionStart)); + TryUpdate(ref selectionEnd, end); + return; + } + + // case 2: start < end + if (startIndex < endIndex) { + TryUpdate(ref selectionStart, getSpan(rawSelectionStart).start, nameof(SelectionStart)); + TryUpdate(ref selectionEnd, getSpan(rawSelectionEnd).end); + return; + } + + // case 3: start > end + if (startIndex > endIndex) { + TryUpdate(ref selectionEnd, getSpan(rawSelectionEnd).start); + TryUpdate(ref selectionStart, getSpan(rawSelectionStart).end, nameof(SelectionStart)); + return; + } } } @@ -80,8 +114,9 @@ namespace HavenSoft.HexManiac.Core.ViewModels { /// public event EventHandler PreviewSelectionStartChanged; - public Selection(ScrollRegion scrollRegion, IDataModel model) { + public Selection(ScrollRegion scrollRegion, IDataModel model, GetSelectionSpan getSpan = null) { this.model = model; + this.getSpan = getSpan ?? GetDefaultSelectionSpan; Scroll = scrollRegion; Scroll.ScrollChanged += (sender, e) => ShiftSelectionFromScroll(e); @@ -152,13 +187,17 @@ namespace HavenSoft.HexManiac.Core.ViewModels { /// public void ChangeWidth(int newWidth) { maxWidth = newWidth; + var rawStart = Scroll.ViewPointToDataIndex(rawSelectionStart); + var rawEnd = Scroll.ViewPointToDataIndex(rawSelectionEnd); var start = Scroll.ViewPointToDataIndex(selectionStart); var end = Scroll.ViewPointToDataIndex(selectionEnd); Scroll.Width = CoerceWidth(newWidth); - TryUpdate(ref selectionStart, Scroll.DataIndexToViewPoint(start)); - TryUpdate(ref selectionEnd, Scroll.DataIndexToViewPoint(end)); + rawSelectionStart = Scroll.DataIndexToViewPoint(rawStart); + rawSelectionEnd = Scroll.DataIndexToViewPoint(rawEnd); + selectionStart = Scroll.DataIndexToViewPoint(start); + selectionEnd = Scroll.DataIndexToViewPoint(end); } public void GotoAddress(int address) { @@ -171,6 +210,8 @@ namespace HavenSoft.HexManiac.Core.ViewModels { GotoAddressHelper(address); } + private static (Point start, Point end) GetDefaultSelectionSpan(Point p) => (p, p); + private void GotoAddressHelper(int address) { var destinationRun = model.GetNextRun(address) as ArrayRun; var destinationIsArray = destinationRun != null; @@ -201,16 +242,23 @@ namespace HavenSoft.HexManiac.Core.ViewModels { /// Nothing in this method notifies because any amount of scrolling means we already need a complete redraw. /// private void ShiftSelectionFromScroll(int distance) { + var rawStart = Scroll.ViewPointToDataIndex(rawSelectionStart); + var rawEnd = Scroll.ViewPointToDataIndex(rawSelectionEnd); var start = Scroll.ViewPointToDataIndex(selectionStart); var end = Scroll.ViewPointToDataIndex(selectionEnd); + rawStart -= distance; + rawEnd -= distance; start -= distance; end -= distance; + rawSelectionStart = Scroll.DataIndexToViewPoint(rawStart); + rawSelectionEnd = Scroll.DataIndexToViewPoint(rawEnd); selectionStart = Scroll.DataIndexToViewPoint(start); selectionEnd = Scroll.DataIndexToViewPoint(end); } + // TODO update these two methods to work with rawSelectionStart / rawSelectionEnd private void MoveSelectionStartExecuted(Direction direction) { Point dif; if (direction == Direction.PageUp) { @@ -220,7 +268,15 @@ namespace HavenSoft.HexManiac.Core.ViewModels { } else { dif = ScrollRegion.DirectionToDif[direction]; } - SelectionStart = SelectionEnd + dif; + + var (start, end) = getSpan(rawSelectionEnd); + if (dif.X < 0 || dif.Y < 0) { + // start from the _front_ of selectionEnd + SelectionStart = start + dif; + } else { + // start from the _back_ of selectionEnd + SelectionStart = end + dif; + } } private void MoveSelectionEndExecuted(Direction direction) { @@ -232,7 +288,15 @@ namespace HavenSoft.HexManiac.Core.ViewModels { } else { dif = ScrollRegion.DirectionToDif[direction]; } - SelectionEnd += dif; + + var (start, end) = getSpan(rawSelectionEnd); + if (dif.X < 0 || dif.Y < 0) { + // start from the _front_ of selectionEnd + SelectionEnd = start + dif; + } else { + // start from the _back_ of selectionEnd + SelectionEnd = end + dif; + } } private int CoerceWidth(int width) { diff --git a/src/HexManiac.Core/ViewModels/ViewPort.cs b/src/HexManiac.Core/ViewModels/ViewPort.cs index 86fabcb8..f40990fe 100644 --- a/src/HexManiac.Core/ViewModels/ViewPort.cs +++ b/src/HexManiac.Core/ViewModels/ViewPort.cs @@ -353,7 +353,7 @@ namespace HavenSoft.HexManiac.Core.ViewModels { scroll = new ScrollRegion(model.TryGetUsefulHeader) { DataLength = Model.Count }; scroll.PropertyChanged += ScrollPropertyChanged; - selection = new Selection(scroll, Model); + selection = new Selection(scroll, Model, GetSelectionSpan); selection.PropertyChanged += SelectionPropertyChanged; selection.PreviewSelectionStartChanged += ClearActiveEditBeforeSelectionChanges; selection.OnError += (sender, e) => OnError?.Invoke(this, e); @@ -505,6 +505,10 @@ namespace HavenSoft.HexManiac.Core.ViewModels { RefreshBackingData(); } + // TODO rethink backspace handling + // should it be changing just _before_ the selection, + // or should it be changing the _current_ selection? + run = Model.GetNextRun(index - 1); if (run is PCSRun pcs) { for (int i = index - 1; i < run.Start + run.Length; i++) history.CurrentChange.ChangeData(Model, i, 0xFF); @@ -520,6 +524,7 @@ namespace HavenSoft.HexManiac.Core.ViewModels { RefreshBackingData(); SilentScroll(index - 1); } else { + // TODO throw new NotImplementedException(); } } else if (run.Start <= index - 1 && run.Start + run.Length > index - 1) { @@ -529,11 +534,17 @@ namespace HavenSoft.HexManiac.Core.ViewModels { element = currentView[SelectionStart.X, SelectionStart.Y]; element.Format.Visit(cellToText, element.Value); var text = cellToText.Result; + + var editLength = 1; + if (element.Format is Pointer pointer) editLength = 4; + if (element.Format is Integer integer) editLength = integer.Length; + for (int i = 0; i < run.Length; i++) { var p = scroll.DataIndexToViewPoint(run.Start + i); string editString = i == 0 ? text.Substring(0, text.Length - 1) : string.Empty; - currentView[p.X, p.Y] = new HexElement(currentView[p.X, p.Y].Value, currentView[p.X, p.Y].Format.Edit(editString)); + currentView[p.X, p.Y] = new HexElement(currentView[p.X, p.Y].Value, new UnderEdit(currentView[p.X, p.Y].Format, editString, editLength)); } + } else { SelectionStart = scroll.DataIndexToViewPoint(index - 1); element = currentView[SelectionStart.X, SelectionStart.Y]; @@ -978,6 +989,22 @@ namespace HavenSoft.HexManiac.Core.ViewModels { return false; } + private (Point start, Point end) GetSelectionSpan(Point p) { + var index = scroll.ViewPointToDataIndex(p); + var run = Model.GetNextRun(index); + if (run.Start > index) return (p, p); + + if (run is PointerRun) return (scroll.DataIndexToViewPoint(run.Start), scroll.DataIndexToViewPoint(run.Start + run.Length - 1)); + if (!(run is ArrayRun array)) return (p, p); + + var offset = array.ConvertByteOffsetToArrayOffset(index); + if (array.ElementContent[offset.SegmentIndex].Type == ElementContentType.Pointer || array.ElementContent[offset.SegmentIndex].Type == ElementContentType.Integer) { + return (scroll.DataIndexToViewPoint(offset.SegmentStart), scroll.DataIndexToViewPoint(offset.SegmentStart + array.ElementContent[offset.SegmentIndex].Length - 1)); + } + + return (p, p); + } + private bool TryCoerceSelectionToStartOfElement(ref Point point, ref HexElement element) { var format = element.Format; var (position, length) = (-1, -1); diff --git a/src/HexManiac.Tests/PointerModelTests.cs b/src/HexManiac.Tests/PointerModelTests.cs index cb8aeca8..39369a40 100644 --- a/src/HexManiac.Tests/PointerModelTests.cs +++ b/src/HexManiac.Tests/PointerModelTests.cs @@ -308,7 +308,7 @@ namespace HavenSoft.HexManiac.Tests { viewPort.Edit("01 02 03 04"); // 2x4 characters to clear viewPort.Edit("<000020>"); // 8 characters to clear viewPort.Edit("<000030>"); // 8 characters to clear - viewPort.SelectionStart = new Point(10, 1); + viewPort.SelectionStart = new Point(12, 1); // just after <000030> for (int i = 0; i < 21; i++) viewPort.Edit(ConsoleKey.Backspace); // should clear both pointers (16) and 2 bytes (4) viewPort.MoveSelectionStart.Execute(Direction.Up); @@ -330,8 +330,9 @@ namespace HavenSoft.HexManiac.Tests { viewPort.SelectionStart = new Point(0, 1); viewPort.Edit("<000020>"); viewPort.Edit("<000030>"); - viewPort.SelectionStart = new Point(2, 1); + viewPort.SelectionStart = new Point(2, 1); // note that this will select all four bytes of <000020> viewPort.Clear.Execute(); // this should clear the data and formatting of the first pointer + viewPort.SelectionStart = new Point(2, 1); // this selects just the one byte viewPort.Edit("<000040>"); // this should remove the second pointer Assert.Equal(0xFF, viewPort[0, 1].Value); diff --git a/src/HexManiac.Tests/ViewPortCursorTests.cs b/src/HexManiac.Tests/ViewPortCursorTests.cs index 4fa3c881..111afaac 100644 --- a/src/HexManiac.Tests/ViewPortCursorTests.cs +++ b/src/HexManiac.Tests/ViewPortCursorTests.cs @@ -226,5 +226,126 @@ namespace HavenSoft.HexManiac.Tests { Assert.True(viewPort.IsSelected(new Point(3, 0))); Assert.False(viewPort.IsSelected(new Point(4, 0))); } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + public void SelectingAnyOfAPointerSelectsAllOfAPointer(int index) { + var data = new byte[0x200]; + var model = new PokemonModel(data); + var viewPort = new ViewPort("file.txt", model) { Width = 0x10, Height = 0x10 }; + + viewPort.Edit("<000100>"); + viewPort.SelectionStart = new Point(index, 0); + + Assert.True(viewPort.IsSelected(new Point(0, 0))); + Assert.True(viewPort.IsSelected(new Point(1, 0))); + Assert.True(viewPort.IsSelected(new Point(2, 0))); + Assert.True(viewPort.IsSelected(new Point(3, 0))); + } + + [Fact] + public void SelectLeftSelectsWholePointer() { + var data = new byte[0x200]; + var model = new PokemonModel(data); + var viewPort = new ViewPort("file.txt", model) { Width = 0x10, Height = 0x10 }; + + viewPort.SelectionStart = new Point(4, 0); + viewPort.Edit("<000100>"); + viewPort.SelectionStart = new Point(8, 0); + viewPort.MoveSelectionStart.Execute(Direction.Left); + + Assert.True(viewPort.IsSelected(new Point(4, 0))); + Assert.True(viewPort.IsSelected(new Point(5, 0))); + Assert.True(viewPort.IsSelected(new Point(6, 0))); + Assert.True(viewPort.IsSelected(new Point(7, 0))); + } + + [Fact] + public void SelectRightSelectsWholePointer() { + var data = new byte[0x200]; + var model = new PokemonModel(data); + var viewPort = new ViewPort("file.txt", model) { Width = 0x10, Height = 0x10 }; + + viewPort.SelectionStart = new Point(4, 0); + viewPort.Edit("<000100>"); + viewPort.SelectionStart = new Point(3, 0); + viewPort.MoveSelectionStart.Execute(Direction.Right); + + Assert.True(viewPort.IsSelected(new Point(4, 0))); + Assert.True(viewPort.IsSelected(new Point(5, 0))); + Assert.True(viewPort.IsSelected(new Point(6, 0))); + Assert.True(viewPort.IsSelected(new Point(7, 0))); + } + + [Fact] + public void SelectUpSelectsWholePointer() { + var data = new byte[0x200]; + var model = new PokemonModel(data); + var viewPort = new ViewPort("file.txt", model) { Width = 0x10, Height = 0x10 }; + + viewPort.SelectionStart = new Point(4, 0); + viewPort.Edit("<000100>"); + viewPort.SelectionStart = new Point(5, 1); + viewPort.MoveSelectionStart.Execute(Direction.Up); + + Assert.True(viewPort.IsSelected(new Point(4, 0))); + Assert.True(viewPort.IsSelected(new Point(5, 0))); + Assert.True(viewPort.IsSelected(new Point(6, 0))); + Assert.True(viewPort.IsSelected(new Point(7, 0))); + } + + [Fact] + public void SelectDownSelectsWholePointer() { + var data = new byte[0x200]; + var model = new PokemonModel(data); + var viewPort = new ViewPort("file.txt", model) { Width = 0x10, Height = 0x10 }; + + viewPort.SelectionStart = new Point(4, 1); + viewPort.Edit("<000100>"); + viewPort.SelectionStart = new Point(5, 0); + viewPort.MoveSelectionStart.Execute(Direction.Down); + + Assert.True(viewPort.IsSelected(new Point(4, 1))); + Assert.True(viewPort.IsSelected(new Point(5, 1))); + Assert.True(viewPort.IsSelected(new Point(6, 1))); + Assert.True(viewPort.IsSelected(new Point(7, 1))); + } + + [Fact] + public void HighlightLeftSelectsWholePointer() { + var data = new byte[0x200]; + var model = new PokemonModel(data); + var viewPort = new ViewPort("file.txt", model) { Width = 0x10, Height = 0x10 }; + + viewPort.SelectionStart = new Point(4, 1); + viewPort.Edit("<000100>"); + viewPort.SelectionStart = new Point(6, 1); + viewPort.SelectionEnd = new Point(2, 1); + + Assert.True(viewPort.IsSelected(new Point(4, 1))); + Assert.True(viewPort.IsSelected(new Point(5, 1))); + Assert.True(viewPort.IsSelected(new Point(6, 1))); + Assert.True(viewPort.IsSelected(new Point(7, 1))); + } + + [Fact] + public void HighlightRightSelectsWholePointer() { + var data = new byte[0x200]; + var model = new PokemonModel(data); + var viewPort = new ViewPort("file.txt", model) { Width = 0x10, Height = 0x10 }; + + viewPort.SelectionStart = new Point(4, 1); + viewPort.Edit("<000100>"); + viewPort.SelectionStart = new Point(6, 1); + viewPort.SelectionEnd = new Point(10, 1); + + Assert.True(viewPort.IsSelected(new Point(4, 1))); + Assert.True(viewPort.IsSelected(new Point(5, 1))); + Assert.True(viewPort.IsSelected(new Point(6, 1))); + Assert.True(viewPort.IsSelected(new Point(7, 1))); + } } }