diff --git a/PettingZoo/Images/Dock.svg b/PettingZoo/Images/Dock.svg new file mode 100644 index 0000000..b1eabc4 --- /dev/null +++ b/PettingZoo/Images/Dock.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/PettingZoo/Images/Undock.svg b/PettingZoo/Images/Undock.svg new file mode 100644 index 0000000..cdc1afb --- /dev/null +++ b/PettingZoo/Images/Undock.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/PettingZoo/PettingZoo.csproj b/PettingZoo/PettingZoo.csproj index b227252..6e599ee 100644 --- a/PettingZoo/PettingZoo.csproj +++ b/PettingZoo/PettingZoo.csproj @@ -18,13 +18,16 @@ + + + @@ -42,6 +45,10 @@ + + + + True @@ -91,6 +98,11 @@ True True + + True + True + UndockedTabHostStrings.resx + @@ -130,6 +142,10 @@ SubscriberViewStrings.Designer.cs PublicResXFileCodeGenerator + + PublicResXFileCodeGenerator + UndockedTabHostStrings.Designer.cs + diff --git a/PettingZoo/TODO.md b/PettingZoo/TODO.md index c181109..450c51a 100644 --- a/PettingZoo/TODO.md +++ b/PettingZoo/TODO.md @@ -1,11 +1,10 @@ Must-have --------- +- Option to not save password in profiles Should-have ----------- -- Support undocking tabs (and redocking afterwards) -- Allow tab reordering - Save / load publisher messages (either as templates or to disk) - Export received messages to Tapeti JSON file / Tapeti.Cmd command-line diff --git a/PettingZoo/UI/Connection/ConnectionViewModel.cs b/PettingZoo/UI/Connection/ConnectionViewModel.cs index 55a02b8..37afe6e 100644 --- a/PettingZoo/UI/Connection/ConnectionViewModel.cs +++ b/PettingZoo/UI/Connection/ConnectionViewModel.cs @@ -235,7 +235,6 @@ namespace PettingZoo.UI.Connection private bool SaveCanExecute() { - // TODO check changes in parameters (excluding password) return SelectedStoredConnection != null && SelectedStoredConnection.Id != Guid.Empty && ValidConnection(false) && diff --git a/PettingZoo/UI/Main/ITabContainer.cs b/PettingZoo/UI/Main/ITabContainer.cs new file mode 100644 index 0000000..4591fda --- /dev/null +++ b/PettingZoo/UI/Main/ITabContainer.cs @@ -0,0 +1,8 @@ +namespace PettingZoo.UI.Main +{ + public interface ITabContainer + { + public double TabWidth { get; } + public double TabHeight { get; } + } +} diff --git a/PettingZoo/UI/Main/MainWindow.xaml b/PettingZoo/UI/Main/MainWindow.xaml index 6194a98..b0fa1a7 100644 --- a/PettingZoo/UI/Main/MainWindow.xaml +++ b/PettingZoo/UI/Main/MainWindow.xaml @@ -6,6 +6,7 @@ xmlns:main="clr-namespace:PettingZoo.UI.Main" xmlns:tab="clr-namespace:PettingZoo.UI.Tab" xmlns:svgc="http://sharpvectors.codeplex.com/svgc/" + xmlns:ui="clr-namespace:PettingZoo.UI" mc:Ignorable="d" d:DataContext="{d:DesignInstance main:DesignTimeMainWindowViewModel, IsDesignTimeCreatable=True}" Width="800" @@ -17,7 +18,11 @@ Closed="MainWindow_OnClosed"> + + + + + @@ -70,28 +81,39 @@ - + - - - - - - - - - - + + + + + + + + + + + + diff --git a/PettingZoo/UI/Main/MainWindow.xaml.cs b/PettingZoo/UI/Main/MainWindow.xaml.cs index 10221a7..ea1b01d 100644 --- a/PettingZoo/UI/Main/MainWindow.xaml.cs +++ b/PettingZoo/UI/Main/MainWindow.xaml.cs @@ -1,15 +1,20 @@ using System; +using System.Collections.ObjectModel; using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; using PettingZoo.Core.Connection; using PettingZoo.UI.Connection; using PettingZoo.UI.Subscribe; +using PettingZoo.UI.Tab; // TODO improve readability of the connection status (especially when connecting/disconnected) namespace PettingZoo.UI.Main { #pragma warning disable CA1001 // MainWindow can't be IDisposable, handled instead in OnDispatcherShutDownStarted - public partial class MainWindow + public partial class MainWindow : ITabContainer { private readonly MainWindowViewModel viewModel; @@ -18,7 +23,7 @@ namespace PettingZoo.UI.Main { WindowStartupLocation = WindowStartupLocation.CenterScreen; - viewModel = new MainWindowViewModel(connectionFactory, connectionDialog, subscribeDialog); + viewModel = new MainWindowViewModel(connectionFactory, connectionDialog, subscribeDialog, this); DataContext = viewModel; InitializeComponent(); @@ -43,6 +48,74 @@ namespace PettingZoo.UI.Main { var _ = Application.Current.Windows; } + + + private void TabItem_PreviewRightMouseDown(object sender, MouseButtonEventArgs e) + { + var tabItem = GetParent(e.OriginalSource); + if (tabItem == null) + return; + + var tabControl = GetParent(tabItem); + if (tabControl == null) + return; + + tabControl.SelectedItem = tabItem.DataContext; + } + + + private void TabItem_PreviewMouseMove(object sender, MouseEventArgs e) + { + if (e.Source is not TabItem tabItem) + return; + + if (Mouse.PrimaryDevice.LeftButton == MouseButtonState.Pressed) + DragDrop.DoDragDrop(tabItem, tabItem, DragDropEffects.All); + } + + + private void TabItem_Drop(object sender, DragEventArgs e) + { + var targetTab = GetParent(e.OriginalSource); + if (targetTab == null) + return; + + var sourceTab = (TabItem?)e.Data.GetData(typeof(TabItem)); + if (sourceTab == null || sourceTab == targetTab) + return; + + var tabControl = GetParent(targetTab); + if (tabControl?.ItemsSource is not ObservableCollection dataCollection) + return; + + if (sourceTab.DataContext is not ITab sourceData || targetTab.DataContext is not ITab targetData) + return; + + var sourceIndex = dataCollection.IndexOf(sourceData); + var targetIndex = dataCollection.IndexOf(targetData); + + dataCollection.Move(sourceIndex, targetIndex); + } + + + private static T? GetParent(object originalSource) where T : DependencyObject + { + var current = originalSource as DependencyObject; + + while (current != null) + { + if (current is T targetType) + return targetType; + + current = VisualTreeHelper.GetParent(current); + } + + return null; + } + + + public double TabWidth => SubscriberTabs.ActualWidth; + public double TabHeight => SubscriberTabs.ActualHeight; } #pragma warning restore CA1001 } diff --git a/PettingZoo/UI/Main/MainWindowStrings.Designer.cs b/PettingZoo/UI/Main/MainWindowStrings.Designer.cs index 8b3f52f..a167f44 100644 --- a/PettingZoo/UI/Main/MainWindowStrings.Designer.cs +++ b/PettingZoo/UI/Main/MainWindowStrings.Designer.cs @@ -19,7 +19,7 @@ namespace PettingZoo.UI.Main { // class via a tool like ResGen or Visual Studio. // To add or remove a member, edit your .ResX file then rerun ResGen // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "16.0.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] public class MainWindowStrings { @@ -96,6 +96,15 @@ namespace PettingZoo.UI.Main { } } + /// + /// Looks up a localized string similar to Undock tab. + /// + public static string CommandUndock { + get { + return ResourceManager.GetString("CommandUndock", resourceCulture); + } + } + /// /// Looks up a localized string similar to Close tab. /// @@ -105,6 +114,15 @@ namespace PettingZoo.UI.Main { } } + /// + /// Looks up a localized string similar to Undock. + /// + public static string ContextMenuUndockTab { + get { + return ResourceManager.GetString("ContextMenuUndockTab", resourceCulture); + } + } + /// /// Looks up a localized string similar to Non-persistent. /// @@ -159,6 +177,15 @@ namespace PettingZoo.UI.Main { } } + /// + /// Looks up a localized string similar to No open tabs. Click on New Publisher or New Subscriber to open a new tab.. + /// + public static string TabsEmptyText { + get { + return ResourceManager.GetString("TabsEmptyText", resourceCulture); + } + } + /// /// Looks up a localized string similar to Petting Zoo - a RabbitMQ live message viewer. /// diff --git a/PettingZoo/UI/Main/MainWindowStrings.resx b/PettingZoo/UI/Main/MainWindowStrings.resx index 6df2e25..6814dea 100644 --- a/PettingZoo/UI/Main/MainWindowStrings.resx +++ b/PettingZoo/UI/Main/MainWindowStrings.resx @@ -129,9 +129,15 @@ New Subscriber... + + Undock tab + Close tab + + Undock + Non-persistent @@ -150,6 +156,9 @@ Error: {0} + + No open tabs. Click on New Publisher or New Subscriber to open a new tab. + Petting Zoo - a RabbitMQ live message viewer diff --git a/PettingZoo/UI/Main/MainWindowViewModel.cs b/PettingZoo/UI/Main/MainWindowViewModel.cs index 5da1cdc..b16d70e 100644 --- a/PettingZoo/UI/Main/MainWindowViewModel.cs +++ b/PettingZoo/UI/Main/MainWindowViewModel.cs @@ -9,6 +9,7 @@ using PettingZoo.Core.Connection; using PettingZoo.UI.Connection; using PettingZoo.UI.Subscribe; using PettingZoo.UI.Tab; +using PettingZoo.UI.Tab.Undocked; namespace PettingZoo.UI.Main { @@ -17,18 +18,21 @@ namespace PettingZoo.UI.Main private readonly IConnectionFactory connectionFactory; private readonly IConnectionDialog connectionDialog; private readonly ISubscribeDialog subscribeDialog; + private readonly ITabContainer tabContainer; private readonly ITabFactory tabFactory; private SubscribeDialogParams? subscribeDialogParams; private IConnection? connection; private string connectionStatus; private ITab? activeTab; + private readonly Dictionary undockedTabs = new(); private readonly DelegateCommand connectCommand; private readonly DelegateCommand disconnectCommand; private readonly DelegateCommand publishCommand; private readonly DelegateCommand subscribeCommand; private readonly DelegateCommand closeTabCommand; + private readonly DelegateCommand undockTabCommand; public string ConnectionStatus @@ -60,6 +64,7 @@ namespace PettingZoo.UI.Main public ICommand PublishCommand => publishCommand; public ICommand SubscribeCommand => subscribeCommand; public ICommand CloseTabCommand => closeTabCommand; + public ICommand UndockTabCommand => undockTabCommand; public IEnumerable ToolbarCommands => ActiveTab is ITabToolbarCommands tabToolbarCommands ? tabToolbarCommands.ToolbarCommands @@ -68,13 +73,17 @@ namespace PettingZoo.UI.Main public Visibility ToolbarCommandsSeparatorVisibility => ToolbarCommands.Any() ? Visibility.Visible : Visibility.Collapsed; + public Visibility NoTabsVisibility => + Tabs.Count > 0 ? Visibility.Collapsed : Visibility.Visible; + public MainWindowViewModel(IConnectionFactory connectionFactory, IConnectionDialog connectionDialog, - ISubscribeDialog subscribeDialog) + ISubscribeDialog subscribeDialog, ITabContainer tabContainer) { this.connectionFactory = connectionFactory; this.connectionDialog = connectionDialog; this.subscribeDialog = subscribeDialog; + this.tabContainer = tabContainer; connectionStatus = GetConnectionStatus(null); @@ -83,9 +92,10 @@ namespace PettingZoo.UI.Main disconnectCommand = new DelegateCommand(DisconnectExecute, IsConnectedCanExecute); publishCommand = new DelegateCommand(PublishExecute, IsConnectedCanExecute); subscribeCommand = new DelegateCommand(SubscribeExecute, IsConnectedCanExecute); - closeTabCommand = new DelegateCommand(CloseTabExecute, CloseTabCanExecute); + closeTabCommand = new DelegateCommand(CloseTabExecute, HasActiveTabCanExecute); + undockTabCommand = new DelegateCommand(UndockTabExecute, HasActiveTabCanExecute); - tabFactory = new ViewTabFactory(this, closeTabCommand); + tabFactory = new ViewTabFactory(this); } @@ -125,7 +135,16 @@ namespace PettingZoo.UI.Main private async void DisconnectExecute() { Tabs.Clear(); - + + var capturedUndockedTabs = undockedTabs.ToList(); + undockedTabs.Clear(); + + foreach (var undockedTab in capturedUndockedTabs) + undockedTab.Value.Close(); + + RaisePropertyChanged(nameof(NoTabsVisibility)); + undockTabCommand.RaiseCanExecuteChanged(); + if (connection != null) { await connection.DisposeAsync(); @@ -170,24 +189,48 @@ namespace PettingZoo.UI.Main private void CloseTabExecute() { - if (ActiveTab == null) + RemoveActiveTab(); + } + + + private void UndockTabExecute() + { + var tab = RemoveActiveTab(); + if (tab == null) return; + var tabHostWindow = UndockedTabHostWindow.Create(this, tab, tabContainer.TabWidth, tabContainer.TabHeight); + undockedTabs.Add(tab, tabHostWindow); + + tabHostWindow.Show(); + } + + + private ITab? RemoveActiveTab() + { + if (ActiveTab == null) + return null; + var activeTabIndex = Tabs.IndexOf(ActiveTab); if (activeTabIndex == -1) - return; + return null; + var tab = Tabs[activeTabIndex]; Tabs.RemoveAt(activeTabIndex); - + if (activeTabIndex == Tabs.Count) activeTabIndex--; ActiveTab = activeTabIndex >= 0 ? Tabs[activeTabIndex] : null; closeTabCommand.RaiseCanExecuteChanged(); + undockTabCommand.RaiseCanExecuteChanged(); + RaisePropertyChanged(nameof(NoTabsVisibility)); + + return tab; } - - private bool CloseTabCanExecute() + + private bool HasActiveTabCanExecute() { return ActiveTab != null; } @@ -199,9 +242,25 @@ namespace PettingZoo.UI.Main ActiveTab = tab; closeTabCommand.RaiseCanExecuteChanged(); + undockTabCommand.RaiseCanExecuteChanged(); + RaisePropertyChanged(nameof(NoTabsVisibility)); } - + + public void DockTab(ITab tab) + { + if (undockedTabs.Remove(tab, out var tabHostWindow)) + tabHostWindow.Close(); + + AddTab(tab); + } + + public void UndockedTabClosed(ITab tab) + { + undockedTabs.Remove(tab); + } + + private void ConnectionChanged() { disconnectCommand.RaiseCanExecuteChanged(); @@ -232,7 +291,7 @@ namespace PettingZoo.UI.Main public class DesignTimeMainWindowViewModel : MainWindowViewModel { - public DesignTimeMainWindowViewModel() : base(null!, null!, null!) + public DesignTimeMainWindowViewModel() : base(null!, null!, null!, null!) { } } diff --git a/PettingZoo/UI/Tab/ITab.cs b/PettingZoo/UI/Tab/ITab.cs index 5e650f7..2448b1f 100644 --- a/PettingZoo/UI/Tab/ITab.cs +++ b/PettingZoo/UI/Tab/ITab.cs @@ -23,7 +23,6 @@ namespace PettingZoo.UI.Tab { string Title { get; } ContentControl Content { get; } - ICommand CloseTabCommand { get; } } diff --git a/PettingZoo/UI/Tab/ITabHost.cs b/PettingZoo/UI/Tab/ITabHost.cs index ec570df..e73ec07 100644 --- a/PettingZoo/UI/Tab/ITabHost.cs +++ b/PettingZoo/UI/Tab/ITabHost.cs @@ -3,5 +3,8 @@ public interface ITabHost { void AddTab(ITab tab); + + void DockTab(ITab tab); + void UndockedTabClosed(ITab tab); } } diff --git a/PettingZoo/UI/Tab/Publisher/PublisherView.xaml b/PettingZoo/UI/Tab/Publisher/PublisherView.xaml index 81d25cc..394ed57 100644 --- a/PettingZoo/UI/Tab/Publisher/PublisherView.xaml +++ b/PettingZoo/UI/Tab/Publisher/PublisherView.xaml @@ -10,54 +10,57 @@ d:DesignWidth="800" d:DataContext="{d:DesignInstance res:DesignTimePublisherViewModel, IsDesignTimeCreatable=True}" Background="White"> - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + diff --git a/PettingZoo/UI/Tab/Undocked/UndockedTabHostWindow.xaml.cs b/PettingZoo/UI/Tab/Undocked/UndockedTabHostWindow.xaml.cs new file mode 100644 index 0000000..52ede6b --- /dev/null +++ b/PettingZoo/UI/Tab/Undocked/UndockedTabHostWindow.xaml.cs @@ -0,0 +1,34 @@ +using System.Windows; + +namespace PettingZoo.UI.Tab.Undocked +{ + /// + /// Interaction logic for UndockedTabHostWindow.xaml + /// + public partial class UndockedTabHostWindow + { + public static UndockedTabHostWindow Create(ITabHost tabHost, ITab tab, double width, double height) + { + var viewModel = new UndockedTabHostViewModel(tabHost, tab); + var window = new UndockedTabHostWindow(viewModel) + { + Width = width, + Height = height + }; + + return window; + } + + + public UndockedTabHostWindow(UndockedTabHostViewModel viewModel) + { + DataContext = viewModel; + InitializeComponent(); + + Closed += (_, _) => + { + viewModel.WindowClosed(); + }; + } + } +} diff --git a/PettingZoo/UI/Tab/ViewTab.cs b/PettingZoo/UI/Tab/ViewTab.cs index 9ec90b9..22950dc 100644 --- a/PettingZoo/UI/Tab/ViewTab.cs +++ b/PettingZoo/UI/Tab/ViewTab.cs @@ -12,7 +12,6 @@ namespace PettingZoo.UI.Tab { public string Title => getTitle(viewModel); public ContentControl Content { get; } - public ICommand CloseTabCommand { get; } public IEnumerable ToolbarCommands => viewModel is ITabToolbarCommands tabToolbarCommands ? tabToolbarCommands.ToolbarCommands @@ -25,14 +24,13 @@ namespace PettingZoo.UI.Tab private readonly Func getTitle; - public ViewTab(ICommand closeTabCommand, TView view, TViewModel viewModel, Expression> title) + public ViewTab(TView view, TViewModel viewModel, Expression> title) { if (title.Body is not MemberExpression titleMemberExpression) throw new ArgumentException(@"Invalid expression type, expected viewModel => viewModel.TitlePropertyName", nameof(title)); var titlePropertyName = titleMemberExpression.Member.Name; - CloseTabCommand = closeTabCommand; this.viewModel = viewModel; getTitle = title.Compile(); Content = view; diff --git a/PettingZoo/UI/Tab/ViewTabFactory.cs b/PettingZoo/UI/Tab/ViewTabFactory.cs index 608d835..80d90dc 100644 --- a/PettingZoo/UI/Tab/ViewTabFactory.cs +++ b/PettingZoo/UI/Tab/ViewTabFactory.cs @@ -8,13 +8,11 @@ namespace PettingZoo.UI.Tab public class ViewTabFactory : ITabFactory { private readonly ITabHost tabHost; - private readonly ICommand closeTabCommand; - public ViewTabFactory(ITabHost tabHost, ICommand closeTabCommand) + public ViewTabFactory(ITabHost tabHost) { this.tabHost = tabHost; - this.closeTabCommand = closeTabCommand; } @@ -22,7 +20,6 @@ namespace PettingZoo.UI.Tab { var viewModel = new SubscriberViewModel(tabHost, this, connection, subscriber); return new ViewTab( - closeTabCommand, new SubscriberView(viewModel), viewModel, vm => vm.Title); @@ -33,7 +30,6 @@ namespace PettingZoo.UI.Tab { var viewModel = new PublisherViewModel(tabHost, this, connection, fromReceivedMessage); return new ViewTab( - closeTabCommand, new PublisherView(viewModel), viewModel, vm => vm.Title);