diff --git a/src/Devolutions.Terminal.App/Models/NewTabMenuResolver.cs b/src/Devolutions.Terminal.App/Models/NewTabMenuResolver.cs index b1e9726..7a7b685 100644 --- a/src/Devolutions.Terminal.App/Models/NewTabMenuResolver.cs +++ b/src/Devolutions.Terminal.App/Models/NewTabMenuResolver.cs @@ -20,10 +20,10 @@ public sealed record ResolvedNewTabMenuItem( public static class NewTabMenuResolver { - public static ProfileSettings ForMenuLaunch(ProfileSettings profile, bool shiftHeld) + public static ProfileSettings ForMenuLaunch(ProfileSettings profile, bool elevate) { ArgumentNullException.ThrowIfNull(profile); - return shiftHeld && OperatingSystem.IsWindows() + return elevate && OperatingSystem.IsWindows() ? profile.WithOverrides(new NewTerminalArgs(Elevate: true)) : profile; } diff --git a/src/Devolutions.Terminal.App/Views/MainWindow.axaml.cs b/src/Devolutions.Terminal.App/Views/MainWindow.axaml.cs index 71e4d9f..f82f59f 100644 --- a/src/Devolutions.Terminal.App/Views/MainWindow.axaml.cs +++ b/src/Devolutions.Terminal.App/Views/MainWindow.axaml.cs @@ -82,6 +82,7 @@ public partial class MainWindow : private readonly DispatcherTimer _notificationTimer; private PixelPoint? _normalPosition; private WindowSizeState _normalSize = new(); + private KeyModifiers _newTabButtonModifiers; public MainWindow() : this(0, string.Empty, null) { @@ -145,11 +146,12 @@ public MainWindow( var newTabShortcut = _settings.ActionMap .GetKeyBindingForAction("Terminal.OpenNewTab")? .ToDisplayString(); - ToolTip.SetTip( - NewTabButton, - string.IsNullOrWhiteSpace(newTabShortcut) - ? "New tab" - : $"New tab ({newTabShortcut})"); + ToolTip.SetTip(NewTabButton, NewTabButtonTooltip(newTabShortcut)); + NewTabButton.AddHandler( + PointerPressedEvent, + (_, e) => _newTabButtonModifiers = e.KeyModifiers, + RoutingStrategies.Tunnel, + handledEventsToo: true); ApplyWindowChrome(); RefreshJumpList(); _stateStore = stateStore ?? SettingsService.LoadApplicationState(); @@ -373,8 +375,28 @@ activation.Actions[0] is } && terminal == new NewTerminalArgs())); - private async void NewTab_OnClick(object? sender, RoutedEventArgs e) => - await CreateTabAsync(_settings.GetDefaultProfile()).ConfigureAwait(true); + private async void NewTab_OnClick(object? sender, RoutedEventArgs e) + { + var modifiers = _newTabButtonModifiers; + _newTabButtonModifiers = KeyModifiers.None; + await LaunchProfileAsync(_settings.GetDefaultProfile(), modifiers).ConfigureAwait(true); + } + + private static string NewTabButtonTooltip(string? newTabShortcut) + { + var lines = new List + { + string.IsNullOrWhiteSpace(newTabShortcut) ? "New tab" : $"New tab ({newTabShortcut})", + "Alt+Click to split the current window", + "Shift+Click to open a new window", + }; + if (OperatingSystem.IsWindows()) + { + lines.Add("Ctrl+Click to open as administrator"); + } + + return string.Join(Environment.NewLine, lines); + } private void Menu_OnClick(object? sender, RoutedEventArgs e) { @@ -507,29 +529,26 @@ private MenuItem CreateMenuItem(ResolvedNewTabMenuItem item) menu.Icon = CreateTabIcon(ProfileVisualDefaults.Icon(item.Profile)); menu.InputGesture = ProfileMenuGesture(item.Profile); var profile = item.Profile; - var shiftHeld = false; - if (OperatingSystem.IsWindows()) - { - ToolTip.SetTip(menu, profile.Elevate - ? "Opens as administrator" - : "Shift-click to open as administrator"); - menu.AddHandler( - PointerPressedEvent, - (_, e) => shiftHeld = e.KeyModifiers.HasFlag(KeyModifiers.Shift), - RoutingStrategies.Tunnel, - handledEventsToo: true); - menu.AddHandler( - KeyDownEvent, - (_, e) => shiftHeld = e.KeyModifiers.HasFlag(KeyModifiers.Shift), - RoutingStrategies.Tunnel, - handledEventsToo: true); - } + var pressedModifiers = KeyModifiers.None; + ToolTip.SetTip( + menu, + profile.Elevate ? "Opens as administrator" : NewTabButtonTooltip(newTabShortcut: null)); + menu.AddHandler( + PointerPressedEvent, + (_, e) => pressedModifiers = e.KeyModifiers, + RoutingStrategies.Tunnel, + handledEventsToo: true); + menu.AddHandler( + KeyDownEvent, + (_, e) => pressedModifiers = e.KeyModifiers, + RoutingStrategies.Tunnel, + handledEventsToo: true); menu.Command = new RelayCommand(() => { - var elevate = shiftHeld; - shiftHeld = false; - _ = CreateTabAsync(NewTabMenuResolver.ForMenuLaunch(profile, elevate)); + var modifiers = pressedModifiers; + pressedModifiers = KeyModifiers.None; + _ = LaunchProfileAsync(profile, modifiers); }); } else if (item.ActionId is { } actionId && @@ -545,6 +564,51 @@ private MenuItem CreateMenuItem(ResolvedNewTabMenuItem item) return menu; } + private void OpenNewWindow(INewContentArgs? content) + { + content ??= new NewTerminalArgs(); + if (_newWindowRequested is not null) + { + _newWindowRequested(new( + null, + null, + null, + null, + TerminalWindowLaunchMode.Default, + [new(ShortcutAction.NewTab, new NewTabArgs(content))])); + } + else + { + new MainWindow(ResolveProfile(content)).Show(); + } + } + + /// + /// Launches honoring the same click modifiers as Windows Terminal: + /// Alt splits the current pane, Shift opens a new window, and Ctrl opens elevated (Windows only). + /// + private async Task LaunchProfileAsync(ProfileSettings profile, KeyModifiers modifiers) + { + var elevate = OperatingSystem.IsWindows() && modifiers.HasFlag(KeyModifiers.Control); + var effectiveProfile = NewTabMenuResolver.ForMenuLaunch(profile, elevate); + + if (modifiers.HasFlag(KeyModifiers.Alt)) + { + await SplitActivePaneAsync(PaneSplitOrientation.Vertical, effectiveProfile).ConfigureAwait(true); + return; + } + + if (modifiers.HasFlag(KeyModifiers.Shift)) + { + OpenNewWindow(new NewTerminalArgs( + Profile: profile.Guid ?? profile.Name, + Elevate: elevate ? true : null)); + return; + } + + await CreateTabAsync(effectiveProfile).ConfigureAwait(true); + } + private async Task CreateTabAsync(ProfileSettings profile) { TerminalPane? pane = null; @@ -2023,22 +2087,7 @@ args with }); Register(ShortcutAction.NewWindow, ActionScope.Application, _ => true, action => { - var content = (action.Args as NewWindowArgs)?.ContentArgs ?? new NewTerminalArgs(); - if (_newWindowRequested is not null) - { - _newWindowRequested(new( - null, - null, - null, - null, - TerminalWindowLaunchMode.Default, - [new(ShortcutAction.NewTab, new NewTabArgs(content))])); - } - else - { - new MainWindow(ResolveProfile(content)).Show(); - } - + OpenNewWindow((action.Args as NewWindowArgs)?.ContentArgs); return Task.CompletedTask; }); Register(ShortcutAction.CloseWindow, ActionScope.Window, _ => true, _ => diff --git a/tests/Devolutions.Terminal.App.Tests/NewTabMenuResolverTests.cs b/tests/Devolutions.Terminal.App.Tests/NewTabMenuResolverTests.cs index 2f7b921..c229713 100644 --- a/tests/Devolutions.Terminal.App.Tests/NewTabMenuResolverTests.cs +++ b/tests/Devolutions.Terminal.App.Tests/NewTabMenuResolverTests.cs @@ -67,18 +67,18 @@ public void EmptyFoldersAreOnlyIncludedWhenAllowed() } [Fact] - public void ShiftOpeningAProfileElevatesOnlyThatLaunchOnWindows() + public void CtrlClickOpeningAProfileElevatesOnlyThatLaunchOnWindows() { var local = ProfileSettings.CreateCmd(); - var normal = NewTabMenuResolver.ForMenuLaunch(local, shiftHeld: false); - var shifted = NewTabMenuResolver.ForMenuLaunch(local, shiftHeld: true); + var normal = NewTabMenuResolver.ForMenuLaunch(local, elevate: false); + var elevated = NewTabMenuResolver.ForMenuLaunch(local, elevate: true); Assert.Same(local, normal); - Assert.Equal(OperatingSystem.IsWindows(), shifted.Elevate); + Assert.Equal(OperatingSystem.IsWindows(), elevated.Elevate); Assert.False(local.Elevate); if (OperatingSystem.IsWindows()) { - Assert.NotSame(local, shifted); + Assert.NotSame(local, elevated); } } } diff --git a/tests/Devolutions.Terminal.UI.Tests/WindowActionRegistrationTests.cs b/tests/Devolutions.Terminal.UI.Tests/WindowActionRegistrationTests.cs index f6933bf..d21c35c 100644 --- a/tests/Devolutions.Terminal.UI.Tests/WindowActionRegistrationTests.cs +++ b/tests/Devolutions.Terminal.UI.Tests/WindowActionRegistrationTests.cs @@ -12,7 +12,7 @@ namespace Devolutions.Terminal.UI.Tests; public sealed class WindowActionRegistrationTests { [AvaloniaFact] - public void NewTabDropdownUsesExistingProfileEntriesForShiftLaunch() + public void NewTabDropdownUsesExistingProfileEntriesForCtrlClickLaunch() { var window = new MainWindow(); @@ -25,9 +25,12 @@ public void NewTabDropdownUsesExistingProfileEntriesForShiftLaunch() Assert.NotEmpty(profiles); foreach (var profile in profiles) { + var tip = Assert.IsType(ToolTip.GetTip(profile)); + Assert.Contains("Alt+Click to split the current window", tip); + Assert.Contains("Shift+Click to open a new window", tip); Assert.Equal( - OperatingSystem.IsWindows() ? "Shift-click to open as administrator" : null, - ToolTip.GetTip(profile)); + OperatingSystem.IsWindows(), + tip.Contains("Ctrl+Click to open as administrator", StringComparison.Ordinal)); } }