From 7cea55217d581ad0bfd43b8ea67426b2de9b30f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Sat, 5 Sep 2026 12:46:12 -0400 Subject: [PATCH 1/2] Host settings as a tab and match the Windows Terminal settings UI Windows Terminal shows its settings as a tab inside the main window, and our settings editor was a separate window with a noticeably different look. Move the editor into a tab and align its visuals with the real thing. Extract the editor into a SettingsView UserControl so it can be hosted anywhere; SettingsWindow now wraps that same view. TerminalTab gains CustomContent (and the IsSettingsTab predicate) so a tab can host non-terminal content, and OpenSettingsTab creates or focuses the single settings tab. The settings tab carries a placeholder pane, so guard the terminal-only paths that would otherwise act on it: ActiveControl returns null there (disabling the control-scope actions), and paste, duplicate, tear-off, restore-last-closed, movePane, layout capture and new-tab sizing all skip it. Without these, tearing off or duplicating the settings tab spawned a bogus PowerShell session, and Ctrl+V was swallowed before reaching the settings text boxes. Visuals are measured against a maximized Windows Terminal capture: page/nav #282828 with #333333 borderless cards, 320px nav, 62px rows with 6px gaps, cards spanning the same x range, a hamburger, profiles listed in the nav, and a footer with Open JSON file on the left plus an accent Save and Discard changes on the right. Enum-ish settings become combo boxes and every toggle row shows its On/Off state through the new SettingsToggle control. AddProfile now commits pending JSON editor buffers first instead of silently discarding unsaved Actions, keybindings and environment edits. The headless UI test application loads FluentTheme so real control templates apply. --- .../Views/MainWindow.axaml.cs | 114 ++- .../Controls/SettingsRow.cs | 11 +- .../Controls/SettingsToggle.cs | 73 ++ .../PageViewModels.cs | 12 + .../SettingsEditorViewModel.cs | 91 +- .../SettingsTheme.axaml | 106 ++- .../SettingsView.axaml | 895 ++++++++++++++++++ .../SettingsView.axaml.cs | 19 + .../SettingsViewFactory.cs | 16 +- .../SettingsWindow.axaml | 662 +------------ .../SettingsWindow.axaml.cs | 1 + .../SettingsEditorViewModelTests.cs | 66 +- .../Devolutions.Terminal.UI.Tests.csproj | 1 + .../TestApplication.cs | 3 + .../WindowActionRegistrationTests.cs | 129 +++ 15 files changed, 1476 insertions(+), 723 deletions(-) create mode 100644 src/Devolutions.Terminal.Settings.Editor/Controls/SettingsToggle.cs create mode 100644 src/Devolutions.Terminal.Settings.Editor/SettingsView.axaml create mode 100644 src/Devolutions.Terminal.Settings.Editor/SettingsView.axaml.cs diff --git a/src/Devolutions.Terminal.App/Views/MainWindow.axaml.cs b/src/Devolutions.Terminal.App/Views/MainWindow.axaml.cs index 006820d..bd737e2 100644 --- a/src/Devolutions.Terminal.App/Views/MainWindow.axaml.cs +++ b/src/Devolutions.Terminal.App/Views/MainWindow.axaml.cs @@ -608,7 +608,7 @@ private async Task SplitActivePaneAsync( { var tab = _activeTab; var activePane = tab?.Panes.ActiveContent; - if (tab is null || activePane is null || tab.IsClosing) + if (tab is null || tab.IsSettingsTab || activePane is null || tab.IsClosing) { return; } @@ -656,6 +656,12 @@ private void ActivateTab(TerminalTab tab) SynchronizeTitle(tab); RebuildTabs(); RebuildTerminalHost(); + if (tab.IsSettingsTab) + { + tab.CustomContent?.Focus(); + return; + } + tab.Panes.ActiveContent?.Control.Focus(); } @@ -711,7 +717,7 @@ private async Task CloseTabAsync(TerminalTab tab, bool remember = true) tab.IsClosing = true; var wasActive = ReferenceEquals(_activeTab, tab); DetachPaneControls(tab); - _tabCollection.Close(tab, CaptureTab, remember); + _tabCollection.Close(tab, CaptureTab, remember && !tab.IsSettingsTab); if (wasActive) { _activeTab = null; @@ -755,6 +761,19 @@ private void RebuildTerminalHost() TerminalHost.Children.Clear(); var tab = _activeTab; + if (tab?.CustomContent is { } custom) + { + if (custom.Parent is Panel parent) + { + parent.Children.Remove(custom); + } + + custom.HorizontalAlignment = HorizontalAlignment.Stretch; + custom.VerticalAlignment = VerticalAlignment.Stretch; + TerminalHost.Children.Add(custom); + return; + } + if (tab?.Panes.Root is null) { return; @@ -876,7 +895,17 @@ private void RebuildTabs() ClipToBounds = true, }; var prefix = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 6 }; - if (!string.IsNullOrWhiteSpace(presentation.Icon)) + if (tab.IsSettingsTab) + { + prefix.Children.Add(new TextBlock + { + Text = "\uE713", + FontFamily = new FontFamily("Segoe Fluent Icons, Segoe MDL2 Assets"), + FontSize = 14, + VerticalAlignment = VerticalAlignment.Center, + }); + } + else if (!string.IsNullOrWhiteSpace(presentation.Icon)) { prefix.Children.Add(CreateTabIcon(presentation.Icon)); } @@ -1103,7 +1132,14 @@ private ContextMenu CreateTabContextMenu(TerminalTab tab) => new MenuItem { Header = "Duplicate", - Command = new RelayCommand(() => _ = RestoreTabAsync(CaptureTab(tab), regenerateIdentities: true)), + IsEnabled = !tab.IsSettingsTab, + Command = new RelayCommand(() => + { + if (!tab.IsSettingsTab) + { + _ = RestoreTabAsync(CaptureTab(tab), regenerateIdentities: true); + } + }), }, new MenuItem { @@ -1174,7 +1210,8 @@ private void EndTabDrag(TerminalTab tab, Control control, PointerReleasedEventAr return; } - if (position.Y < -24 || position.Y > TabStrip.Bounds.Height + 24) + if (!tab.IsSettingsTab && + (position.Y < -24 || position.Y > TabStrip.Bounds.Height + 24)) { var local = e.GetPosition(this); var screen = new PixelPoint(Position.X + (int)local.X, Position.Y + (int)local.Y); @@ -1242,7 +1279,9 @@ private Button CreateCloseButton(TerminalTab tab) return close; } - private TermControl? ActiveControl => _activeTab?.Panes.ActiveContent?.Control; + private TermControl? ActiveControl => _activeTab is { IsSettingsTab: false } terminalTab + ? terminalTab.Panes.ActiveContent?.Control + : null; private void ConfigureActionDispatcher() { @@ -1357,7 +1396,8 @@ private void ConfigureActionDispatcher() Register(ShortcutAction.NewTab, ActionScope.Tab, _ => true, async action => await CreateTabAsync(ResolveProfile((action.Args as NewTabArgs)?.ContentArgs)).ConfigureAwait(true)); - Register(ShortcutAction.DuplicateTab, ActionScope.Tab, _ => _activeTab?.Panes.ActiveContent is not null, + Register(ShortcutAction.DuplicateTab, ActionScope.Tab, + _ => _activeTab is { IsSettingsTab: false } && _activeTab.Panes.ActiveContent is not null, async _ => await RestoreTabAsync(CaptureTab(_activeTab!), regenerateIdentities: true).ConfigureAwait(true)); Register(ShortcutAction.CloseTab, ActionScope.Tab, action => ResolveTab((action.Args as CloseTabArgs)?.Index) is not null, async action => await CloseTabAsync(ResolveTab((action.Args as CloseTabArgs)?.Index)!).ConfigureAwait(true)); @@ -1575,7 +1615,7 @@ action.Args is SwapPaneArgs args && }); Register(ShortcutAction.RestartConnection, ActionScope.Pane, _ => ActiveControl is not null, async _ => await ActiveControl!.RestartAsync().ConfigureAwait(true)); - Register(ShortcutAction.TogglePaneReadOnly, ActionScope.Pane, _ => _activeTab?.Panes.ActiveContent is not null, _ => + Register(ShortcutAction.TogglePaneReadOnly, ActionScope.Pane, _ => ActiveControl is not null, _ => { var state = _activeTab!.Panes.ActiveContent!.Presentation; state.IsReadOnly = !state.IsReadOnly; @@ -2112,8 +2152,9 @@ private void MoveFocus(MoveFocusArgs args) private bool CanMovePane(ActionAndArgs action) => action.Args is MovePaneArgs args && string.IsNullOrEmpty(args.Window) && - _activeTab?.Panes.ActiveContent is not null && - ResolveTab(args.TabIndex) is { } target && + _activeTab is { IsSettingsTab: false } && + _activeTab.Panes.ActiveContent is not null && + ResolveTab(args.TabIndex) is { IsSettingsTab: false } target && !ReferenceEquals(target, _activeTab); private void MovePane(MovePaneArgs args) @@ -2544,6 +2585,7 @@ private async Task PasteCoordinatedAsync() var clipboard = TopLevel.GetTopLevel(this)?.Clipboard; var text = clipboard is null ? null : await clipboard.TryGetTextAsync().ConfigureAwait(true); if (tab is null || + tab.IsSettingsTab || activePane is null || !_tabs.Contains(tab) || string.IsNullOrEmpty(text)) @@ -2646,7 +2688,8 @@ private bool TryRenameWindow(string name) } private bool CanPaste() => - _activeTab?.Panes.ActiveContent is { } activePane && + _activeTab is { IsSettingsTab: false } && + _activeTab.Panes.ActiveContent is { } activePane && _activeTab.BroadcastInput.ResolveTargets(activePane, _activeTab.Panes.Leaves()).Count > 0; private void TitleBar_OnPointerPressed(object? sender, PointerPressedEventArgs e) @@ -2908,16 +2951,45 @@ private void CaptureNormalWindowBounds() }; } + private void OpenSettingsTab() + { + var existing = _tabs.FirstOrDefault(static tab => tab.IsSettingsTab); + if (existing is not null) + { + ActivateTab(existing); + return; + } + + var view = SettingsViewFactory.CreateView( + () => SettingsService.LoadWithDynamicProfiles(_dynamicProfileManager), + SaveSettingsAndRefresh, + SettingsService.CreateDefault); + var profile = new ProfileSettings + { + Name = "Settings", + TabTitle = "Settings", + }; + var pane = new TerminalPane( + _nextPaneId++, + CreateSessionDescriptor(profile), + profile, + new TermControl()); + var tab = new TerminalTab(pane) + { + CustomContent = view, + Title = "Settings", + }; + _tabCollection.Add(tab); + ActivateTab(tab); + } + private void OpenSettings(SettingsTarget target = SettingsTarget.SettingsUI) { switch (target) { case SettingsTarget.SettingsUI: case SettingsTarget.AllFiles: - SettingsViewFactory.CreateWindow( - () => SettingsService.LoadWithDynamicProfiles(_dynamicProfileManager), - SaveSettingsAndRefresh, - SettingsService.CreateDefault).Show(this); + OpenSettingsTab(); break; case SettingsTarget.SettingsFile: SaveSettingsAndRefresh(SettingsService.LoadWithDynamicProfiles(_dynamicProfileManager)); @@ -3046,7 +3118,9 @@ private async Task OpenWorkspaceAsync(string name) private (int Columns, int Rows) InitialTerminalSize() { - var profile = _activeTab?.Panes.ActiveContent?.Profile ?? _settings.GetDefaultProfile(); + var profile = _activeTab is { IsSettingsTab: false } terminalTab + ? terminalTab.Panes.ActiveContent?.Profile ?? _settings.GetDefaultProfile() + : _settings.GetDefaultProfile(); var cell = ActiveControl?.CellSize ?? TermControl.MeasureCell(profile, DisplayScale()); var columns = Math.Max( 20, @@ -3063,8 +3137,8 @@ private static int ScrollbarWidth(ProfileSettings profile) => public TerminalWindowLayoutDescriptor CaptureLayout() => new() { - ActiveTabId = _activeTab?.Id, - Tabs = _tabs.Select(CaptureTab).ToList(), + ActiveTabId = _activeTab is { IsSettingsTab: false } ? _activeTab.Id : null, + Tabs = _tabs.Where(static tab => !tab.IsSettingsTab).Select(CaptureTab).ToList(), }; private TabLayoutDescriptor CaptureTab(TerminalTab tab) => @@ -4233,6 +4307,10 @@ public TerminalTab(TerminalPane initialPane) Color = initialPane.Presentation.Color; } + public Control? CustomContent { get; set; } + + public bool IsSettingsTab => CustomContent is not null; + public TerminalTab(Guid id, PaneTree panes) { Id = id == Guid.Empty ? Guid.NewGuid() : id; diff --git a/src/Devolutions.Terminal.Settings.Editor/Controls/SettingsRow.cs b/src/Devolutions.Terminal.Settings.Editor/Controls/SettingsRow.cs index dc76cd2..21665bb 100644 --- a/src/Devolutions.Terminal.Settings.Editor/Controls/SettingsRow.cs +++ b/src/Devolutions.Terminal.Settings.Editor/Controls/SettingsRow.cs @@ -31,9 +31,8 @@ public SettingsRow() }; _value = new ContentControl { - Width = 248, - MinWidth = 248, - MaxWidth = 248, + MinWidth = 120, + MaxWidth = 240, Margin = new Thickness(24, 0, 0, 0), HorizontalAlignment = HorizontalAlignment.Right, VerticalAlignment = VerticalAlignment.Center, @@ -47,8 +46,8 @@ public SettingsRow() var layout = new Grid { ColumnDefinitions = new ColumnDefinitions("*,Auto"), - MinHeight = 44, - Margin = new Thickness(16, 6), + MinHeight = 40, + Margin = new Thickness(16, 11), }; layout.Children.Add(labels); Grid.SetColumn(_value, 1); @@ -56,7 +55,7 @@ public SettingsRow() var border = new Border { CornerRadius = new CornerRadius(4), - BorderThickness = new Thickness(1), + BorderThickness = new Thickness(0), Child = layout, }; border.Classes.Add("settings-row"); diff --git a/src/Devolutions.Terminal.Settings.Editor/Controls/SettingsToggle.cs b/src/Devolutions.Terminal.Settings.Editor/Controls/SettingsToggle.cs new file mode 100644 index 0000000..a896976 --- /dev/null +++ b/src/Devolutions.Terminal.Settings.Editor/Controls/SettingsToggle.cs @@ -0,0 +1,73 @@ +using Avalonia; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Layout; + +namespace Devolutions.Terminal.Settings.Editor.Controls; + +/// +/// A Windows Terminal style toggle: the current state is written to the left of the switch. +/// +public sealed class SettingsToggle : UserControl +{ + private readonly TextBlock _state; + private readonly ToggleSwitch _toggle; + + public static readonly StyledProperty IsCheckedProperty = + AvaloniaProperty.Register( + nameof(IsChecked), + defaultBindingMode: BindingMode.TwoWay); + + public SettingsToggle() + { + _state = new TextBlock + { + VerticalAlignment = VerticalAlignment.Center, + Opacity = 0.72, + }; + _toggle = new ToggleSwitch + { + OnContent = string.Empty, + OffContent = string.Empty, + }; + _toggle.IsCheckedChanged += (_, _) => IsChecked = _toggle.IsChecked ?? false; + var layout = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 10, + HorizontalAlignment = HorizontalAlignment.Right, + Children = { _state, _toggle }, + }; + base.Content = layout; + UpdateState(); + } + + public bool IsChecked + { + get => GetValue(IsCheckedProperty); + set => SetValue(IsCheckedProperty, value); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == IsCheckedProperty) + { + UpdateState(); + } + else if (change.Property == AutomationProperties.NameProperty) + { + AutomationProperties.SetName(_toggle, AutomationProperties.GetName(this) ?? string.Empty); + } + } + + private void UpdateState() + { + _state.Text = IsChecked ? "On" : "Off"; + if (_toggle.IsChecked != IsChecked) + { + _toggle.IsChecked = IsChecked; + } + } +} diff --git a/src/Devolutions.Terminal.Settings.Editor/PageViewModels.cs b/src/Devolutions.Terminal.Settings.Editor/PageViewModels.cs index 5c4f886..e957b60 100644 --- a/src/Devolutions.Terminal.Settings.Editor/PageViewModels.cs +++ b/src/Devolutions.Terminal.Settings.Editor/PageViewModels.cs @@ -54,7 +54,10 @@ public bool CenterOnLaunch public string CenterOnLaunchState => CenterOnLaunch ? "On" : "Off"; public LaunchMode LaunchMode { get => settings.LaunchMode; set => Change(settings.LaunchMode, value, v => settings.LaunchMode = v); } public string FirstWindowPreference { get => settings.FirstWindowPreference; set => Change(settings.FirstWindowPreference, value, v => settings.FirstWindowPreference = v); } + public IReadOnlyList FirstWindowPreferenceChoices { get; } = + ["defaultProfile", "persistedLayoutAndContent", "persistedWindowLayout"]; public string WindowingBehavior { get => settings.WindowingBehavior; set => Change(settings.WindowingBehavior, value, v => settings.WindowingBehavior = v); } + public IReadOnlyList WindowingBehaviorChoices { get; } = ["useNew", "useExisting"]; public string StartupActions { get => settings.StartupActions; set => Change(settings.StartupActions, value, v => settings.StartupActions = v); } public bool AlwaysOnTop { @@ -102,6 +105,7 @@ public sealed class InteractionSettingsViewModel(AppSettings settings, Action ch public bool DetectUrls { get => settings.DetectUrls; set => Change(settings.DetectUrls, value, v => settings.DetectUrls = v); } public bool WarnAboutLargePaste { get => settings.WarnAboutLargePaste; set => Change(settings.WarnAboutLargePaste, value, v => settings.WarnAboutLargePaste = v); } public string WarnAboutMultiLinePaste { get => settings.WarnAboutMultiLinePaste; set => Change(settings.WarnAboutMultiLinePaste, value, v => settings.WarnAboutMultiLinePaste = v); } + public IReadOnlyList WarnAboutMultiLinePasteChoices { get; } = ["automatic", "always", "never"]; public string WordDelimiters { get => settings.WordDelimiters; set => Change(settings.WordDelimiters, value, v => settings.WordDelimiters = v); } private void Change(T oldValue, T newValue, Action update) @@ -139,6 +143,7 @@ public ThemeItemViewModel? SelectedTheme public bool UseAcrylicInTabRow { get => settings.UseAcrylicInTabRow; set => Change(settings.UseAcrylicInTabRow, value, v => settings.UseAcrylicInTabRow = v); } public bool DisableAnimations { get => settings.DisableAnimations; set => Change(settings.DisableAnimations, value, v => settings.DisableAnimations = v); } public string NewTabPosition { get => settings.NewTabPosition; set => Change(settings.NewTabPosition, value, v => settings.NewTabPosition = v); } + public IReadOnlyList NewTabPositionChoices { get; } = ["afterLastTab", "atEnd"]; private void Change(T oldValue, T newValue, Action update) { @@ -397,6 +402,9 @@ public ProfileTerminalSettingsViewModel(IReadOnlyList prof } public IReadOnlyList CloseOnExitModes { get; } = Enum.GetValues(); + public IReadOnlyList CursorShapeChoices { get; } = + ["bar", "doubleUnderscore", "emptyBox", "filledBox", "underscore", "vintage"]; + public IReadOnlyList ScrollbarStateChoices { get; } = ["visible", "hidden"]; public IReadOnlyList Profiles { get; } public ProfileItemViewModel? SelectedProfile { get => _selectedProfile; set => SetProperty(ref _selectedProfile, value); } } @@ -789,6 +797,7 @@ public string TerminalEngine v => settings.TerminalEngine = v); } public string GraphicsApi { get => settings.GraphicsApi; set => Change(settings.GraphicsApi, value, v => settings.GraphicsApi = v); } + public IReadOnlyList GraphicsApiChoices { get; } = ["automatic", "direct3d11", "opengl", "software"]; public bool DisablePartialInvalidation { get => settings.DisablePartialInvalidation; set => Change(settings.DisablePartialInvalidation, value, v => settings.DisablePartialInvalidation = v); } public bool SoftwareRendering { get => settings.SoftwareRendering; set => Change(settings.SoftwareRendering, value, v => settings.SoftwareRendering = v); } public bool UseBackgroundImageForWindow { get => settings.UseBackgroundImageForWindow; set => Change(settings.UseBackgroundImageForWindow, value, v => settings.UseBackgroundImageForWindow = v); } @@ -807,8 +816,11 @@ public sealed class CompatibilitySettingsViewModel(AppSettings settings, Action : SettingsPageViewModel("Compatibility", "Configure text measurement, character width, and compatibility switches.") { public string TextMeasurement { get => settings.TextMeasurement; set => Change(settings.TextMeasurement, value, v => settings.TextMeasurement = v); } + public IReadOnlyList TextMeasurementChoices { get; } = ["graphemes", "cells"]; public string AmbiguousWidth { get => settings.AmbiguousWidth; set => Change(settings.AmbiguousWidth, value, v => settings.AmbiguousWidth = v); } + public IReadOnlyList AmbiguousWidthChoices { get; } = ["narrow", "wide"]; public string DefaultInputScope { get => settings.DefaultInputScope; set => Change(settings.DefaultInputScope, value, v => settings.DefaultInputScope = v); } + public IReadOnlyList DefaultInputScopeChoices { get; } = ["default", "keyboard", "touch"]; public bool AllowHeadless { get => settings.AllowHeadless; set => Change(settings.AllowHeadless, value, v => settings.AllowHeadless = v); } public bool EnableUnfocusedAcrylic { get => settings.EnableUnfocusedAcrylic; set => Change(settings.EnableUnfocusedAcrylic, value, v => settings.EnableUnfocusedAcrylic = v); } public bool InputServiceWarning { get => settings.InputServiceWarning; set => Change(settings.InputServiceWarning, value, v => settings.InputServiceWarning = v); } diff --git a/src/Devolutions.Terminal.Settings.Editor/SettingsEditorViewModel.cs b/src/Devolutions.Terminal.Settings.Editor/SettingsEditorViewModel.cs index 8eea287..ea1c026 100644 --- a/src/Devolutions.Terminal.Settings.Editor/SettingsEditorViewModel.cs +++ b/src/Devolutions.Terminal.Settings.Editor/SettingsEditorViewModel.cs @@ -17,6 +17,7 @@ public sealed class SettingsNavigationItem public bool HasGroupHeader => !string.IsNullOrEmpty(GroupHeader); public required string Keywords { get; init; } public required object ViewModel { get; init; } + public ProfileItemViewModel? Profile { get; init; } public override string ToString() => Title; } @@ -62,10 +63,11 @@ public SettingsEditorViewModel( _getRevision = getRevision ?? (() => null); _settings = _load(); _loadedRevision = _getRevision(); - ApplyCommand = new(Apply, () => IsDirty); - RevertCommand = new(Revert, () => IsDirty); + ApplyCommand = new(Apply); + RevertCommand = new(Revert); ResetCommand = new(ResetToDefaults); OpenJsonCommand = new(OpenJsonFile); + AddProfileCommand = new(AddProfile); BuildPages(SettingsPage.Startup); } @@ -106,6 +108,12 @@ public SettingsNavigationItem? SelectedNavigationItem { if (SetProperty(ref _selectedNavigationItem, value)) { + if (value?.Profile is { } profile && + value.ViewModel is ProfilesSettingsViewModel profilesPage) + { + profilesPage.SelectedProfile = profile; + } + OnPropertyChanged(nameof(CurrentPage)); } } @@ -131,6 +139,7 @@ private set public RelayCommand RevertCommand { get; } public RelayCommand ResetCommand { get; } public RelayCommand OpenJsonCommand { get; } + public RelayCommand AddProfileCommand { get; } public void SelectPage(SettingsPage page) { @@ -190,6 +199,37 @@ public void ResetToDefaults() StatusMessage = "Factory defaults loaded. Apply to save them."; } + public void AddProfile() + { + if (!TryCommitEditors(out var error)) + { + StatusMessage = error!; + return; + } + + var profile = new ProfileSettings + { + Guid = $"{{{Guid.NewGuid()}}}", + Name = "New profile", + Commandline = OperatingSystem.IsWindows() + ? @"%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe" + : "/bin/bash", + Origin = SettingsOrigin.User, + }; + _settings.Profiles.Add(profile); + MarkDirty(); + BuildPages(SettingsPage.Profiles); + var created = _navigationItems.FirstOrDefault(item => + item.Profile is not null && + string.Equals(item.Profile.Guid, profile.Guid, StringComparison.OrdinalIgnoreCase)); + if (created is not null) + { + SelectedNavigationItem = created; + } + + StatusMessage = "New profile added. Apply to save it."; + } + private void OpenJsonFile() { try @@ -239,8 +279,9 @@ private void BuildPages(SettingsPage selectedPage) .ToArray(); _actions = new(_settings, MarkDirty); _newTabMenu = new(_settings, MarkDirty); - _navigationItems = - [ + var profilesPage = new ProfilesSettingsViewModel(_profiles); + var items = new List + { Item(SettingsPage.Startup, "Startup", "launch default profile window position startup actions", new StartupSettingsViewModel(_settings, MarkDirty)), Item(SettingsPage.Interaction, "Interaction", "copy paste selection mouse urls focus", new InteractionSettingsViewModel(_settings, MarkDirty)), Item(SettingsPage.Appearance, "Appearance", "global themes tabs acrylic mica visual", new AppearanceSettingsViewModel(_settings, MarkDirty)), @@ -248,13 +289,29 @@ private void BuildPages(SettingsPage selectedPage) Item(SettingsPage.Rendering, "Rendering", "graphics api software invalidation", new RenderingSettingsViewModel(_settings, MarkDirty)), Item(SettingsPage.Compatibility, "Compatibility", "text measurement width input headless acrylic", new CompatibilitySettingsViewModel(_settings, MarkDirty)), Item(SettingsPage.Actions, "Actions", "keybindings key chord command json", _actions), - Item(SettingsPage.NewTabMenu, "New tab menu", "menu folder separator profile action json", _newTabMenu), + Item(SettingsPage.NewTabMenu, "New Tab Menu", "menu folder separator profile action json", _newTabMenu), Item(SettingsPage.Extensions, "Extensions", "sources fragments experimental language notification", new ExtensionsSettingsViewModel(_settings, MarkDirty)), - Item(SettingsPage.Profiles, "Defaults", "profile commandline directory icon tab title hidden", new ProfilesSettingsViewModel(_profiles), "Profiles"), - Item(SettingsPage.ProfileAppearance, "Profile appearance", "profile font colors opacity background image", new ProfileAppearanceSettingsViewModel(_profiles)), - Item(SettingsPage.ProfileTerminal, "Profile terminal", "profile scrollback cursor close antialiasing", new ProfileTerminalSettingsViewModel(_profiles)), - Item(SettingsPage.ProfileAdvanced, "Profile advanced", "profile vt environment kitty osc compatibility", new ProfileAdvancedSettingsViewModel(_profiles)), - ]; + Item(SettingsPage.Profiles, "Defaults", "profile commandline directory icon tab title hidden", profilesPage, "Profiles"), + }; + var windows = OperatingSystem.IsWindows(); + foreach (var profile in _profiles) + { + items.Add(new SettingsNavigationItem + { + Page = SettingsPage.Profiles, + IconFontFamily = windows ? "Segoe Fluent Icons" : "Cascadia Mono", + Icon = NavigationIcon(profile.Icon, windows), + Title = profile.Name, + Keywords = $"profile {profile.Name} {profile.Commandline}", + ViewModel = profilesPage, + Profile = profile, + }); + } + + items.Add(Item(SettingsPage.ProfileAppearance, "Profile appearance", "profile font colors opacity background image", new ProfileAppearanceSettingsViewModel(_profiles))); + items.Add(Item(SettingsPage.ProfileTerminal, "Profile terminal", "profile scrollback cursor close antialiasing", new ProfileTerminalSettingsViewModel(_profiles))); + items.Add(Item(SettingsPage.ProfileAdvanced, "Profile advanced", "profile vt environment kitty osc compatibility", new ProfileAdvancedSettingsViewModel(_profiles))); + _navigationItems = items; Diagnostics = _settings.Diagnostics .Select(diagnostic => new SettingsDiagnosticViewModel( diagnostic.Severity.ToString(), @@ -269,6 +326,20 @@ private void BuildPages(SettingsPage selectedPage) OnPropertyChanged(nameof(SettingsPath)); } + private static string NavigationIcon(string? icon, bool windows) + { + if (string.IsNullOrWhiteSpace(icon) || + icon.Contains("://", StringComparison.Ordinal) || + icon.Contains('\\') || + icon.Contains('/') || + icon.Length > 4) + { + return windows ? "\uE756" : ">"; + } + + return icon; + } + private static SettingsNavigationItem Item( SettingsPage page, string title, diff --git a/src/Devolutions.Terminal.Settings.Editor/SettingsTheme.axaml b/src/Devolutions.Terminal.Settings.Editor/SettingsTheme.axaml index d3e7f52..c8cbbdd 100644 --- a/src/Devolutions.Terminal.Settings.Editor/SettingsTheme.axaml +++ b/src/Devolutions.Terminal.Settings.Editor/SettingsTheme.axaml @@ -6,33 +6,41 @@ - + - + - + + - + - + - - + + + - - + + - + + + + + + @@ -149,20 +174,48 @@ + + + + + + + + diff --git a/src/Devolutions.Terminal.Settings.Editor/SettingsView.axaml b/src/Devolutions.Terminal.Settings.Editor/SettingsView.axaml new file mode 100644 index 0000000..b1e80fb --- /dev/null +++ b/src/Devolutions.Terminal.Settings.Editor/SettingsView.axaml @@ -0,0 +1,895 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Devolutions.Terminal.Settings.Editor/SettingsView.axaml.cs b/src/Devolutions.Terminal.Settings.Editor/SettingsView.axaml.cs new file mode 100644 index 0000000..4a4d878 --- /dev/null +++ b/src/Devolutions.Terminal.Settings.Editor/SettingsView.axaml.cs @@ -0,0 +1,19 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace Devolutions.Terminal.Settings.Editor; + +public partial class SettingsView : UserControl +{ + public SettingsView() + : this(new SettingsEditorViewModel()) + { + } + + public SettingsView(SettingsEditorViewModel viewModel) + { + ArgumentNullException.ThrowIfNull(viewModel); + AvaloniaXamlLoader.Load(this); + DataContext = viewModel; + } +} diff --git a/src/Devolutions.Terminal.Settings.Editor/SettingsViewFactory.cs b/src/Devolutions.Terminal.Settings.Editor/SettingsViewFactory.cs index 1965afd..c303f3b 100644 --- a/src/Devolutions.Terminal.Settings.Editor/SettingsViewFactory.cs +++ b/src/Devolutions.Terminal.Settings.Editor/SettingsViewFactory.cs @@ -11,5 +11,19 @@ public static SettingsWindow CreateWindow( Action save, Func createDefault, Func? getRevision = null) => - new(new SettingsEditorViewModel(load, save, createDefault, getRevision)); + new(CreateViewModel(load, save, createDefault, getRevision)); + + public static SettingsView CreateView( + Func load, + Action save, + Func createDefault, + Func? getRevision = null) => + new(CreateViewModel(load, save, createDefault, getRevision)); + + private static SettingsEditorViewModel CreateViewModel( + Func load, + Action save, + Func createDefault, + Func? getRevision) => + new(load, save, createDefault, getRevision); } diff --git a/src/Devolutions.Terminal.Settings.Editor/SettingsWindow.axaml b/src/Devolutions.Terminal.Settings.Editor/SettingsWindow.axaml index b942348..9e429d0 100644 --- a/src/Devolutions.Terminal.Settings.Editor/SettingsWindow.axaml +++ b/src/Devolutions.Terminal.Settings.Editor/SettingsWindow.axaml @@ -1,670 +1,10 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -