diff --git a/Gen3Hex/Gen3Hex.csproj b/Gen3Hex/Gen3Hex.csproj index 377bcc49..a1eaa2cf 100644 --- a/Gen3Hex/Gen3Hex.csproj +++ b/Gen3Hex/Gen3Hex.csproj @@ -65,6 +65,7 @@ + MSBuild:Compile Designer diff --git a/Gen3Hex/View/App.xaml.cs b/Gen3Hex/View/App.xaml.cs index 27470f34..01ecb305 100644 --- a/Gen3Hex/View/App.xaml.cs +++ b/Gen3Hex/View/App.xaml.cs @@ -11,17 +11,19 @@ namespace HavenSoft.Gen3Hex.View { protected override void OnStartup(StartupEventArgs e) { base.OnStartup(e); var fileName = e.Args?.Length == 1 ? e.Args[0] : string.Empty; - var viewPort = GetViewPort(fileName); + var viewPort = GetViewModel(fileName); MainWindow = new MainWindow(viewPort); MainWindow.Show(); } - private ViewPort GetViewPort(string fileName) { - if (!File.Exists(fileName)) return new ViewPort(); + private EditorViewModel GetViewModel(string fileName) { + var editor = new EditorViewModel(new WindowsFileSystem()); + if (!File.Exists(fileName)) return editor; var bytes = File.ReadAllBytes(fileName); var loadedFile = new LoadedFile(fileName, bytes); - return new ViewPort(loadedFile); + editor.Add(new ViewPort(loadedFile)); + return editor; } } } diff --git a/Gen3Hex/View/HexContent.cs b/Gen3Hex/View/HexContent.cs index d0330782..ddb95639 100644 --- a/Gen3Hex/View/HexContent.cs +++ b/Gen3Hex/View/HexContent.cs @@ -1,5 +1,6 @@ using HavenSoft.Gen3Hex.Model; using HavenSoft.Gen3Hex.ViewModel; +using System; using System.Collections.Specialized; using System.ComponentModel; using System.Linq; @@ -107,6 +108,11 @@ namespace HavenSoft.Gen3Hex.View { ViewPort.SelectionEnd = ControlCoordinatesToModelCoordinates(e); } + protected override void OnMouseWheel(MouseWheelEventArgs e) { + base.OnMouseWheel(e); + ViewPort.ScrollValue -= Math.Sign(e.Delta); + } + protected override void OnRender(DrawingContext drawingContext) { base.OnRender(drawingContext); if (ViewPort == null) return; diff --git a/Gen3Hex/View/MainWindow.xaml b/Gen3Hex/View/MainWindow.xaml index f32e4ab1..0bd20b33 100644 --- a/Gen3Hex/View/MainWindow.xaml +++ b/Gen3Hex/View/MainWindow.xaml @@ -4,10 +4,51 @@ xmlns:hsg3hv="clr-namespace:HavenSoft.Gen3Hex.View" xmlns:solarized="clr-namespace:Solarized;assembly=HavenSoft" Title="Gen3Hex" Height="450" Width="800" Background="{solarized:Theme Background}"> + + + + + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Gen3Hex/View/MainWindow.xaml.cs b/Gen3Hex/View/MainWindow.xaml.cs index 3a0e7843..439d09ca 100644 --- a/Gen3Hex/View/MainWindow.xaml.cs +++ b/Gen3Hex/View/MainWindow.xaml.cs @@ -1,20 +1,26 @@ using HavenSoft.Gen3Hex.ViewModel; using System; -using System.Windows.Input; +using System.ComponentModel; namespace HavenSoft.Gen3Hex.View { public partial class MainWindow { - private readonly ViewPort viewPort; + public EditorViewModel ViewModel { get; } - public MainWindow(ViewPort viewModel) { + public MainWindow(EditorViewModel viewModel) { InitializeComponent(); - viewPort = viewModel; + ViewModel = viewModel; DataContext = viewModel; } - protected override void OnMouseWheel(MouseWheelEventArgs e) { - base.OnMouseWheel(e); - viewPort.ScrollValue -= Math.Sign(e.Delta); + protected override void OnClosing(CancelEventArgs e) { + base.OnClosing(e); + ViewModel.CloseAll.Execute(); + if (ViewModel.Count != 0) e.Cancel = true; + } + + private void ExitClicked(object sender, EventArgs e) { + ViewModel.CloseAll.Execute(); + if (ViewModel.Count == 0) Close(); } } } diff --git a/Gen3Hex/View/WindowsFileSystem.cs b/Gen3Hex/View/WindowsFileSystem.cs new file mode 100644 index 00000000..36aa771c --- /dev/null +++ b/Gen3Hex/View/WindowsFileSystem.cs @@ -0,0 +1,42 @@ +using HavenSoft.Gen3Hex.Model; +using Microsoft.Win32; +using System.IO; +using System.Linq; +using System.Windows; + +namespace HavenSoft.Gen3Hex.View { + public class WindowsFileSystem : IFileSystem { + public LoadedFile OpenFile(params string[] extensionOptions) { + var dialog = new OpenFileDialog { Filter = CreateFilterFromOptions(extensionOptions) }; + var result = dialog.ShowDialog(); + if (result != true) return null; + if (!File.Exists(dialog.FileName)) return null; + var data = File.ReadAllBytes(dialog.FileName); + return new LoadedFile(dialog.FileName, data); + } + + public string RequestNewName(string currentName, params string[] extensionOptions) { + var dialog = new SaveFileDialog { FileName = currentName, Filter = CreateFilterFromOptions(extensionOptions) }; + var result = dialog.ShowDialog(); + if (result != true) return null; + return dialog.FileName; + } + + public bool Save(LoadedFile file) { + if (!File.Exists(file.Name)) return false; + File.WriteAllBytes(file.Name, file.Contents); + return true; + } + + public bool? TrySavePrompt(LoadedFile file) { + var result = MessageBox.Show($"Would you like to save {file.Name}?", Application.Current.MainWindow.Title, MessageBoxButton.YesNoCancel); + if (result == MessageBoxResult.Cancel) return null; + if (result == MessageBoxResult.No) return false; + return Save(file); + } + + private static string CreateFilterFromOptions(string[] extensionOptions) { + return string.Join(",", extensionOptions.Select(option => $"*.{option}")); + } + } +} diff --git a/Gen3Hex/ViewModel/EditorViewModel.cs b/Gen3Hex/ViewModel/EditorViewModel.cs index 9d6699de..189f0cdb 100644 --- a/Gen3Hex/ViewModel/EditorViewModel.cs +++ b/Gen3Hex/ViewModel/EditorViewModel.cs @@ -11,19 +11,19 @@ namespace HavenSoft.Gen3Hex.ViewModel { private readonly IFileSystem fileSystem; private readonly List tabs; - private readonly StubCommand saveAll, closeAll, newCommand, open; + private readonly StubCommand newCommand, open, save, saveAs, saveAll, close, closeAll, undo, redo; private int selectedIndex; - public ICommand Undo => SelectedTab?.Undo; - public ICommand Redo => SelectedTab?.Redo; - public ICommand Save => SelectedTab?.Save; - public ICommand SaveAs => SelectedTab?.SaveAs; - public ICommand Close => SelectedTab?.Close; - public ICommand SaveAll => saveAll; - public ICommand CloseAll => closeAll; - public ICommand Open => open; public ICommand New => newCommand; + public ICommand Open => open; + public ICommand Save => save; + public ICommand SaveAs => saveAs; + public ICommand SaveAll => saveAll; + public ICommand Close => close; + public ICommand CloseAll => closeAll; + public ICommand Undo => undo; + public ICommand Redo => redo; #region Collection Properties @@ -36,15 +36,10 @@ namespace HavenSoft.Gen3Hex.ViewModel { public int SelectedIndex { get => selectedIndex; set { - if (!TryUpdate(ref selectedIndex, value)) return; - var commandsToRefresh = new List { - nameof(Undo), - nameof(Redo), - nameof(Save), - nameof(SaveAs), - nameof(Close), - }; - commandsToRefresh.ForEach(NotifyPropertyChanged); + StopListeningToCommandsFromCurrentTab(); + bool updated = TryUpdate(ref selectedIndex, value); + StartListeningToCommandsFromCurrentTab(); + if (!updated) return; } } @@ -57,17 +52,9 @@ namespace HavenSoft.Gen3Hex.ViewModel { tabs = new List(); selectedIndex = -1; - saveAll = new StubCommand { - CanExecute = arg => tabs.Any(tab => tab.Save.CanExecute(fileSystem)), - Execute = arg => tabs.ForEach(tab => { - if (tab.Save.CanExecute(fileSystem)) { - tab.Save.Execute(fileSystem); - } - }), - }; - closeAll = new StubCommand { - CanExecute = arg => tabs.Any(tab => tab.Close.CanExecute(fileSystem)), - Execute = arg => tabs.ToList().ForEach(tab => tab.Close.Execute(fileSystem)), // ToList -> because closing a tab modifies the original list + newCommand = new StubCommand { + CanExecute = arg => true, + Execute = arg => Add(new ViewPort()), }; open = new StubCommand { CanExecute = arg => true, @@ -79,10 +66,27 @@ namespace HavenSoft.Gen3Hex.ViewModel { Add(tab); }, }; - newCommand = new StubCommand { - CanExecute = arg => true, - Execute = arg => Add(new ViewPort()), + save = CreateWrapperFor(tab => tab.Save); + saveAs = CreateWrapperFor(tab => tab.SaveAs); + saveAll = new StubCommand { + CanExecute = arg => tabs.Any(tab => tab.Save.CanExecute(fileSystem)), + Execute = arg => tabs.ForEach(tab => { + if (tab.Save.CanExecute(fileSystem)) { + tab.Save.Execute(fileSystem); + } + }), }; + close = CreateWrapperFor(tab => tab.Close); + closeAll = new StubCommand { + CanExecute = arg => tabs.Any(tab => tab.Close.CanExecute(fileSystem)), + Execute = arg => tabs.ToList().ForEach(tab => { // ToList -> because closing a tab modifies the original list + if (tab.Close.CanExecute(fileSystem)) { + tab.Close.Execute(fileSystem); + } + }), + }; + undo = CreateWrapperFor(tab => tab.Undo); + redo = CreateWrapperFor(tab => tab.Redo); } public void Add(ITabContent content) { @@ -103,20 +107,81 @@ namespace HavenSoft.Gen3Hex.ViewModel { IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator(); + private StubCommand CreateWrapperFor(Func commandGetter) { + var command = new StubCommand { + CanExecute = arg => { + if (SelectedIndex < 0) return false; + var innerCommand = commandGetter(tabs[SelectedIndex]); + if (innerCommand == null) return false; + return innerCommand.CanExecute(fileSystem); + }, + Execute = arg => { + var innerCommand = commandGetter(tabs[SelectedIndex]); + if (innerCommand == null) return; + innerCommand.Execute(fileSystem); + } + }; + + return command; + } + private void RemoveTab(object sender, EventArgs e) { var tab = (ITabContent)sender; if (!tabs.Contains(tab)) throw new InvalidOperationException("Cannot remove tab, because tab is not currently in editor."); var index = tabs.IndexOf(tab); - tabs.Remove(tab); - tab.Closed -= RemoveTab; - CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, tab)); - if (index < SelectedIndex) { - selectedIndex--; - } else if (index == SelectedIndex) { - SelectedIndex = Math.Max(0, selectedIndex - 1); + + if (index == SelectedIndex) { + StopListeningToCommandsFromCurrentTab(); + tabs.Remove(tab); + tab.Closed -= RemoveTab; + if (selectedIndex == tabs.Count) TryUpdate(ref selectedIndex, tabs.Count - 1, nameof(SelectedIndex)); + CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, tab, index)); + StartListeningToCommandsFromCurrentTab(); + return; } - if (tabs.Count == 0) SelectedIndex = -1; + if (index < SelectedIndex) selectedIndex--; + + tabs.Remove(tab); + tab.Closed -= RemoveTab; + CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, tab, index)); } + + private void StartListeningToCommandsFromCurrentTab() { + var commandsToRefresh = new List { + undo, + redo, + save, + saveAs, + close, + }; + commandsToRefresh.ForEach(command => command.CanExecuteChanged.Invoke(command, EventArgs.Empty)); + + if (selectedIndex == -1) return; + var tab = tabs[selectedIndex]; + + if (tab.Save != null) tab.Save.CanExecuteChanged += RaiseSaveCanExecutedChanged; + if (tab.SaveAs != null) tab.SaveAs.CanExecuteChanged += RaiseSaveAsCanExecutedChanged; + if (tab.Close != null) tab.Close.CanExecuteChanged += RaiseCloseCanExecutedChanged; + if (tab.Undo != null) tab.Undo.CanExecuteChanged += RaiseUndoCanExecutedChanged; + if (tab.Redo != null) tab.Redo.CanExecuteChanged += RaiseRedoCanExecutedChanged; + } + + private void StopListeningToCommandsFromCurrentTab() { + if (selectedIndex == -1) return; + var tab = tabs[selectedIndex]; + + if (tab.Save != null) tab.Save.CanExecuteChanged -= RaiseSaveCanExecutedChanged; + if (tab.SaveAs != null) tab.SaveAs.CanExecuteChanged -= RaiseSaveAsCanExecutedChanged; + if (tab.Close != null) tab.Close.CanExecuteChanged -= RaiseCloseCanExecutedChanged; + if (tab.Undo != null) tab.Undo.CanExecuteChanged -= RaiseUndoCanExecutedChanged; + if (tab.Redo != null) tab.Redo.CanExecuteChanged -= RaiseRedoCanExecutedChanged; + } + + private void RaiseSaveCanExecutedChanged(object sender, EventArgs e) => save.CanExecuteChanged.Invoke(this, e); + private void RaiseSaveAsCanExecutedChanged(object sender, EventArgs e) => saveAs.CanExecuteChanged.Invoke(this, e); + private void RaiseCloseCanExecutedChanged(object sender, EventArgs e) => close.CanExecuteChanged.Invoke(this, e); + private void RaiseUndoCanExecutedChanged(object sender, EventArgs e) => undo.CanExecuteChanged.Invoke(this, e); + private void RaiseRedoCanExecutedChanged(object sender, EventArgs e) => redo.CanExecuteChanged.Invoke(this, e); } } diff --git a/Gen3Hex/ViewModel/ScrollRegion.cs b/Gen3Hex/ViewModel/ScrollRegion.cs index eacc1991..397292a1 100644 --- a/Gen3Hex/ViewModel/ScrollRegion.cs +++ b/Gen3Hex/ViewModel/ScrollRegion.cs @@ -31,14 +31,14 @@ namespace HavenSoft.Gen3Hex.ViewModel { public int Width { get => width; set { - if (TryUpdate(ref width, value)) UpdateScrollRange(); + if (TryUpdate(ref width, value.LimitToRange(4, int.MaxValue))) UpdateScrollRange(); } } public int Height { get => height; set { - if (TryUpdate(ref height, value)) UpdateScrollRange(); + if (TryUpdate(ref height, value.LimitToRange(4, int.MaxValue))) UpdateScrollRange(); } } @@ -71,6 +71,8 @@ namespace HavenSoft.Gen3Hex.ViewModel { public event EventHandler ScrollChanged; public ScrollRegion() { + width = 4; + height = 4; scroll = new StubCommand { CanExecute = args => dataLength > 0, Execute = args => ScrollExecuted((Direction)args), diff --git a/Gen3Hex/ViewModel/ViewPort.cs b/Gen3Hex/ViewModel/ViewPort.cs index 6f2ff084..b3d9074b 100644 --- a/Gen3Hex/ViewModel/ViewPort.cs +++ b/Gen3Hex/ViewModel/ViewPort.cs @@ -165,6 +165,8 @@ namespace HavenSoft.Gen3Hex.ViewModel { CanExecute = arg => true, Execute = arg => CloseExecuted((IFileSystem)arg), }; + + RefreshBackingData(); } public bool IsSelected(Point point) => selection.IsSelected(point); diff --git a/HexTests/GeneralAppTests.cs b/HexTests/GeneralAppTests.cs index 102143a7..321497da 100644 --- a/HexTests/GeneralAppTests.cs +++ b/HexTests/GeneralAppTests.cs @@ -59,7 +59,7 @@ namespace HavenSoft.HexTests { Assert.Equal(1, executeCounter); editor.SelectedIndex = 0; - Assert.Null(editor.Undo); + Assert.False(editor.Undo.CanExecute(null)); } [Fact] @@ -168,6 +168,75 @@ namespace HavenSoft.HexTests { Assert.Equal(0, editor.Count); } + [Fact] + public void SaveCommandsPassInFileSystemAsParameter() { + int count = 0; + void checkIfArgIsFileSystem(object arg) { if (arg is IFileSystem) count++; } + bool canExecuteWrapper(object arg) { checkIfArgIsFileSystem(arg); return true; } + var save = new StubCommand { CanExecute = canExecuteWrapper, Execute = checkIfArgIsFileSystem }; + + editor.Add(new StubTabContent { Save = save, SaveAs = save }); + editor.Add(new StubTabContent { Save = save, SaveAs = save }); + editor.Add(new StubTabContent { Save = save, SaveAs = save }); + + editor.Save.Execute(); // once + editor.SaveAs.Execute(); // once + editor.SaveAll.Execute(); // 6 times, since SaveAll should also check CanExecute + + Assert.Equal(8, count); + } + + [Fact] + public void CloseCommandsPassInFileSystemAsParameter() { + int count = 0; + void checkIfArgIsFileSystem(object arg) { if (arg is IFileSystem) count++; } + bool canExecuteWrapper(object arg) { checkIfArgIsFileSystem(arg); return true; } + var close = new StubCommand { CanExecute = canExecuteWrapper, Execute = checkIfArgIsFileSystem }; + + editor.Add(new StubTabContent { Close = close }); + editor.Add(new StubTabContent { Close = close }); + editor.Add(new StubTabContent { Close = close }); + + editor.Close.Execute(); // once + editor.CloseAll.Execute(); // 6 times, since SaveAll should also check CanExecute + + Assert.Equal(7, count); + } + + [Fact] + public void EditorNotifiesCanExecuteChangedOnTabChange() { + int count = 0; + editor.Save.CanExecuteChanged += (sender, e) => count++; + editor.SaveAs.CanExecuteChanged += (sender, e) => count++; + editor.Close.CanExecuteChanged += (sender, e) => count++; + editor.Undo.CanExecuteChanged += (sender, e) => count++; + editor.Redo.CanExecuteChanged += (sender, e) => count++; + var tab = new StubTabContent(); + tab.Close = new StubCommand { CanExecute = arg => true, Execute = arg => tab.Closed.Invoke(tab, EventArgs.Empty) }; + + editor.Add(tab); + Assert.Equal(5, count); + + count = 0; + editor.Close.Execute(); + Assert.Equal(5, count); + } + + [Fact] + public void EditorNotifiesWhenUndoCanExecuteChange() { + int count = 0; + var undo = new StubCommand(); + var tab = new StubTabContent { Undo = undo }; + editor.Add(tab); + editor.Undo.CanExecuteChanged += (sender, e) => count++; + + undo.CanExecute = arg => true; + undo.CanExecuteChanged.Invoke(undo, EventArgs.Empty); + + Assert.Equal(1, count); + } + + private StubTabContent CreateClosableTab() { var tab = new StubTabContent(); var close = new StubCommand { CanExecute = arg => true }; diff --git a/HexTests/ViewPortCursorTests.cs b/HexTests/ViewPortCursorTests.cs index ce814fb7..ce61f112 100644 --- a/HexTests/ViewPortCursorTests.cs +++ b/HexTests/ViewPortCursorTests.cs @@ -43,8 +43,8 @@ namespace HavenSoft.HexTests { [Fact] public void MovingCursorDownFromBottomRowScrolls() { var loadedFile = new LoadedFile("test", new byte[25]); - var viewPort = new ViewPort(loadedFile) { Width = 5, Height = 3 }; - viewPort.SelectionStart = new Point(0, 2); + var viewPort = new ViewPort(loadedFile) { Width = 5, Height = 4 }; + viewPort.SelectionStart = new Point(0, 3); viewPort.MoveSelectionStart.Execute(Direction.Down); diff --git a/HexTests/ViewPortScrollTests.cs b/HexTests/ViewPortScrollTests.cs index 10655843..34f301d4 100644 --- a/HexTests/ViewPortScrollTests.cs +++ b/HexTests/ViewPortScrollTests.cs @@ -96,8 +96,8 @@ namespace HavenSoft.HexTests { [Fact] public void ResizingCannotLeaveTotallyBlankLineAtTop() { - var loadedFile = new LoadedFile("test", new byte[25]); - var viewPort = new ViewPort(loadedFile) { Width = 5, Height = 5 }; + var loadedFile = new LoadedFile("test", new byte[36]); + var viewPort = new ViewPort(loadedFile) { Width = 6, Height = 6 }; viewPort.ScrollValue++; // scroll down one line viewPort.Width--; // decrease the width so that there is data 2 lines above