From ef7ebd2f44d45902f89037e0c5f7bd0bfedc0a69 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 13 Nov 2026 19:25:15 -0500 Subject: [PATCH] feat(avalonia): add Tools > Options on the shared Options model Auto-size factor, colour scheme, cut planning effort, clone push placement, new nest on startup and the fill strategy grid save to the user settings both apps share and apply at once (open nests take the new scheme's colours); the saved fill strategies apply at startup. Set / Change Server writes storage.json keeping the storage mode, and the next server request uses it; Save Feed URL and Include development builds save for the updater. MaterialLedger stays in the WinForms app: its API key is DPAPI-protected. Co-Authored-By: Claude Opus 5.5 --- OpenNest.Avalonia/DesktopSettings.cs | 33 ++- OpenNest.Avalonia/MainWindow.axaml | 3 + OpenNest.Avalonia/MainWindow.axaml.cs | 24 ++- OpenNest.Avalonia/OptionsDialog.cs | 290 ++++++++++++++++++++++++++ 4 files changed, 343 insertions(+), 7 deletions(-) create mode 100644 OpenNest.Avalonia/OptionsDialog.cs diff --git a/OpenNest.Avalonia/DesktopSettings.cs b/OpenNest.Avalonia/DesktopSettings.cs index 24cebd24..f8aa2b99 100644 --- a/OpenNest.Avalonia/DesktopSettings.cs +++ b/OpenNest.Avalonia/DesktopSettings.cs @@ -16,15 +16,16 @@ namespace OpenNest.Avalonia /// internal static class DesktopSettings { - public static SharedSettingsSource Shared { get; } = SharedSettingsSource.FromStorageFile(); + /// The storage mode and nest server from storage.json; Tools > Options can change the server. + private static NestStorageSettings storage = NestStorageSettings.Load(NestStorageSettings.DefaultPath); + + public static SharedSettingsSource Shared { get; } = new(() => storage); public static MachineSettingsSource Machines { get; } = new(Shared, new MachineUserSettingsFile(UserSettingsFile.DefaultPath)); - /// The storage mode and nest server, read once from storage.json as does. - private static readonly NestStorageSettings Storage = NestStorageSettings.Load(NestStorageSettings.DefaultPath); - private static RemoteNestRepository repository; + private static string repositoryUrl; /// /// File > New nests, numbered by the nest server in Database mode and otherwise by the @@ -33,7 +34,29 @@ namespace OpenNest.Avalonia public static NewNestSource NewNests { get; } = new( Machines, new NewNestUserSettingsFile(UserSettingsFile.DefaultPath), - () => Storage.IsDatabaseMode ? repository ??= new RemoteNestRepository(Storage.ServerUrl) : null); + () => storage.IsDatabaseMode ? Repository(storage.ServerUrl) : null); + + /// The nest server URL saved in storage.json (blank when none). + public static string ServerUrl => storage.ServerUrl; + + /// + /// Saves as the nest server in storage.json, keeping the + /// storage mode, as the WinForms Options form does; the next request uses it. + /// + public static void SaveServerUrl(string serverUrl) => + storage = NestServerUrlOption.SaveToFile(NestStorageSettings.DefaultPath, serverUrl); + + // One client per server, recreated when the URL changes. + private static RemoteNestRepository Repository(string url) + { + if (repository == null || repositoryUrl != url) + { + repository?.Dispose(); + repository = new RemoteNestRepository(url); + repositoryUrl = url; + } + return repository; + } /// The post processors loaded from Posts/ beside the app, by load order. public static IReadOnlyList PostProcessors { get; private set; } = []; diff --git a/OpenNest.Avalonia/MainWindow.axaml b/OpenNest.Avalonia/MainWindow.axaml index ff16e5db..a681ae0a 100644 --- a/OpenNest.Avalonia/MainWindow.axaml +++ b/OpenNest.Avalonia/MainWindow.axaml @@ -42,6 +42,9 @@ + + + diff --git a/OpenNest.Avalonia/MainWindow.axaml.cs b/OpenNest.Avalonia/MainWindow.axaml.cs index b72fea1b..41a95408 100644 --- a/OpenNest.Avalonia/MainWindow.axaml.cs +++ b/OpenNest.Avalonia/MainWindow.axaml.cs @@ -57,8 +57,13 @@ namespace OpenNest.Avalonia if (DesktopSettings.LoadPosts(Path.Combine(AppContext.BaseDirectory, "Posts")) is { } postWarning) notice = notice == null ? postWarning : $"{notice} {postWarning}"; MachinesLoaded = LoadMachinesAsync(); - // The colour scheme chosen in the WinForms app's options, from the shared user settings. - ColorSchemeRegistry.ApplySaved(Data.UserSettingsFile.Read(Data.UserSettingsFile.DefaultPath)); + // The colour scheme and fill strategies chosen in Tools > Options (either app's), from + // the shared user settings; applying a scheme later recolours the open nests. + var userSettings = Data.UserSettingsFile.Read(Data.UserSettingsFile.DefaultPath); + ColorSchemeRegistry.ApplySaved(userSettings); + FillStrategyOptions.ApplySaved(userSettings); + ColorSchemeRegistry.Applied += OnColorSchemeApplied; + Closed += (_, _) => ColorSchemeRegistry.Applied -= OnColorSchemeApplied; tabs.Changed += (_, _) => ShowActiveTab(); KeyBindings.Add(new KeyBinding { @@ -735,6 +740,21 @@ namespace OpenNest.Avalonia private void StepPlate(int step) => ActiveView?.StepPlate(step); + // Tools > Options. Settings for features the app does not have yet (Database mode, the + // update feed) are still saved, for the WinForms app and later milestones. + private async void OnOptions(object sender, RoutedEventArgs e) => + await OptionsDialog.ShowAsync(this, Data.UserSettingsFile.DefaultPath); + + // As the WinForms nest windows: every open nest takes the new scheme's drawing colours. + private void OnColorSchemeApplied(object sender, ColorScheme scheme) + { + foreach (var (tab, view) in views) + { + ColorSchemeRegistry.RecolorDrawings(tab.Document.Nest); + view.PlateView.InvalidateVisual(); + } + } + private void UpdateStatus() { var view = ActiveView; diff --git a/OpenNest.Avalonia/OptionsDialog.cs b/OpenNest.Avalonia/OptionsDialog.cs new file mode 100644 index 00000000..d82959fb --- /dev/null +++ b/OpenNest.Avalonia/OptionsDialog.cs @@ -0,0 +1,290 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; +using OpenNest.Data; +using OpenNest.Desktop; + +namespace OpenNest.Avalonia +{ + /// + /// Tools > Options, as the WinForms Options form: Save writes the general options and the + /// fill strategies to the user settings both apps share and applies them; the nest server URL + /// (storage.json) and the update feed URL have their own buttons that save at once. The + /// MaterialLedger connection is not here: its API key is kept with Windows data protection, + /// so it is set in the WinForms app. + /// + internal sealed class OptionsDialog : Window + { + private static readonly IBrush Muted = Brush.Parse("#6B7280"); + + private readonly string settingsPath; + private readonly CheckBox createNewNest = new() { Content = "Create a new nest on startup" }; + private readonly NumericUpDown autoSizeFactor = new() + { + Minimum = (decimal)DesktopOptions.AutoSizePlateFactorMinimum, + Maximum = (decimal)DesktopOptions.AutoSizePlateFactorMaximum, + Increment = (decimal)DesktopOptions.AutoSizePlateFactorIncrement, + FormatString = "0.####", + Width = 150, + HorizontalAlignment = HorizontalAlignment.Left, + }; + private readonly ComboBox colorScheme = new() { Width = 200 }; + private readonly NumericUpDown planningEffort = new() + { + Minimum = (decimal)DesktopOptions.CuttingPlanEffortMinimum, + Maximum = (decimal)DesktopOptions.CuttingPlanEffortMaximum, + Increment = (decimal)DesktopOptions.CuttingPlanEffortIncrement, + FormatString = "0.0", + Width = 150, + HorizontalAlignment = HorizontalAlignment.Left, + }; + private readonly ComboBox clonePlacement = new() { Width = 200 }; + private readonly TextBox serverUrl = new() { Watermark = "http://server:port" }; + private readonly TextBox updateFeedUrl = new(); + private readonly CheckBox includeDevBuilds = new() + { + Content = "Include development builds (latest master; takes effect after restart)", + }; + private readonly List<(CheckBox Enabled, string Name)> strategies = new(); + + private OptionsDialog(string settingsPath) + { + this.settingsPath = settingsPath; + Title = "Options"; + Width = 720; + SizeToContent = SizeToContent.Height; + CanResize = false; + WindowStartupLocation = WindowStartupLocation.CenterOwner; + + var general = new Grid + { + ColumnDefinitions = new ColumnDefinitions("Auto,*"), + RowDefinitions = new RowDefinitions("Auto,Auto,Auto,Auto,Auto"), + ColumnSpacing = 12, + RowSpacing = 8, + }; + AddRow(general, 0, "Auto-size plate factor:", autoSizeFactor, + "The amount to round the plate size up."); + AddRow(general, 1, "Color scheme:", colorScheme, null); + AddRow(general, 2, "Cut planning effort:", planningEffort, + "How long Plan Cutting searches for a route; 1 is normal."); + AddRow(general, 3, "Clone push placement:", clonePlacement, + "Which way Shift+click and Ctrl+click copies are pushed."); + Grid.SetRow(createNewNest, 4); + Grid.SetColumnSpan(createNewNest, 2); + general.Children.Add(createNewNest); + + var setServer = new Button { Content = "Set / Change Server" }; + setServer.Click += async (_, _) => await SetServerAsync(); + var server = Group( + "Nest server URL (File / Database mode is selected under File > Storage Mode)", + UrlRow(serverUrl, setServer)); + + var saveFeed = new Button { Content = "Save Feed URL" }; + saveFeed.Click += async (_, _) => await SaveFeedAsync(); + var updates = Group( + "Auto-update feed URL (blank: nest server mirror in Database mode, else GitHub Releases)", + UrlRow(updateFeedUrl, saveFeed), includeDevBuilds); + + var ledger = new TextBlock + { + Text = "MaterialLedger is set in the Windows app: its API key is saved with Windows data protection.", + Foreground = Muted, + TextWrapping = TextWrapping.Wrap, + }; + + var save = new Button { Content = "Save", IsDefault = true, MinWidth = 80 }; + save.Click += async (_, _) => await SaveAsync(); + var cancel = new Button { Content = "Cancel", IsCancel = true, MinWidth = 80 }; + cancel.Click += (_, _) => Close(false); + var buttons = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Right, + Spacing = 8, + Children = { save, cancel }, + }; + + var panel = new StackPanel + { + Margin = new Thickness(18, 18, 18, 8), + Spacing = 14, + Children = + { + general, + server, + ledger, + updates, + Group("Fill Strategies", StrategyGrid()), + }, + }; + // Save and Cancel stay in view when a small screen makes the options scroll. + buttons.Margin = new Thickness(18, 8, 18, 18); + DockPanel.SetDock(buttons, Dock.Bottom); + Content = new DockPanel + { + Children = { buttons, new ScrollViewer { Content = panel } }, + }; + Load(); + } + + /// + /// Shows the dialog over ; true when the options were saved. + /// is the shared user-settings.json. + /// + public static async Task ShowAsync(Window owner, string settingsPath) => + await new OptionsDialog(settingsPath).ShowDialog(owner) == true; + + private void Load() + { + var settings = UserSettingsFile.Read(settingsPath); + var options = DesktopOptions.Read(settings); + serverUrl.Text = DesktopSettings.ServerUrl; + updateFeedUrl.Text = UpdateFeedOption.Read(settings); + includeDevBuilds.IsChecked = options.UpdateIncludeDevBuilds; + createNewNest.IsChecked = options.CreateNewNestOnOpen; + autoSizeFactor.Value = (decimal)options.AutoSizePlateFactorForEditing; + planningEffort.Value = (decimal)options.CuttingPlanEffortForEditing; + + var schemes = DesktopOptions.ColorSchemeNames; + colorScheme.ItemsSource = schemes; + colorScheme.SelectedIndex = options.ColorSchemeIndex(schemes); + + clonePlacement.ItemsSource = ClonePlacement.Choices.Select(c => c.Text).ToList(); + clonePlacement.SelectedIndex = ClonePlacement.IndexOf(options.ClonePlacementOrder); + + var disabled = new HashSet(options.DisabledStrategies, StringComparer.OrdinalIgnoreCase); + foreach (var (enabled, name) in strategies) + enabled.IsChecked = !disabled.Contains(name); + } + + private DesktopOptions FromControls() => new() + { + CreateNewNestOnOpen = createNewNest.IsChecked == true, + UpdateIncludeDevBuilds = includeDevBuilds.IsChecked == true, + // A cleared box keeps the value it had, as a WinForms numeric box does. + AutoSizePlateFactor = (double)(autoSizeFactor.Value ?? (decimal)DesktopOptions.Defaults.AutoSizePlateFactor), + CuttingPlanEffort = (double)(planningEffort.Value ?? (decimal)DesktopOptions.Defaults.CuttingPlanEffort), + ColorScheme = colorScheme.SelectedItem as string ?? ColorSchemeRegistry.DefaultSchemeName, + ClonePlacementOrder = ClonePlacement.At(clonePlacement.SelectedIndex), + DisabledStrategies = strategies.Where(s => s.Enabled.IsChecked != true).Select(s => s.Name).ToList(), + }; + + private async Task SaveAsync() + { + var options = FromControls(); + if (!UserSettingsFile.Update(settingsPath, options.WriteTo)) + { + await ConfirmDialog.ShowAsync(this, "Options", + $"Could not save the options to {settingsPath}. Nothing was changed."); + return; + } + options.Apply(); + Close(true); + } + + private async Task SetServerAsync() + { + var (notice, url) = NestServerUrlOption.Save(serverUrl.Text, DesktopSettings.SaveServerUrl); + if (url != null) + serverUrl.Text = DesktopSettings.ServerUrl; + await ConfirmDialog.ShowAsync(this, notice.Title, notice.Text); + } + + private async Task SaveFeedAsync() + { + var (notice, url) = UpdateFeedOption.Save(updateFeedUrl.Text, next => + { + if (!UserSettingsFile.Update(settingsPath, values => values[UpdateFeedOption.SettingName] = next)) + throw new System.IO.IOException($"{settingsPath} could not be written."); + }); + if (url != null) + updateFeedUrl.Text = url; + await ConfirmDialog.ShowAsync(this, notice.Title, notice.Text); + } + + private Grid StrategyGrid() + { + var rows = FillStrategyOptions.Rows; + var grid = new Grid + { + ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto,Auto"), + ColumnSpacing = 16, + RowSpacing = 4, + }; + grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto)); + var header = 1; + foreach (var (text, column) in new[] { ("Strategy", 1), ("Phase", 2), ("Order", 3) }) + { + var cell = new TextBlock { Text = text, FontWeight = FontWeight.SemiBold }; + Grid.SetColumn(cell, column); + grid.Children.Add(cell); + } + for (var i = 0; i < rows.Count; i++) + { + var row = rows[i]; + grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto)); + var enabled = new CheckBox { IsChecked = true, MinWidth = 0, MinHeight = 0 }; + strategies.Add((enabled, row.Name)); + var cells = new Control[] + { + enabled, + Cell(row.Name), + Cell(row.Phase.ToString()), + Cell(row.Order.ToString()), + }; + for (var column = 0; column < cells.Length; column++) + { + Grid.SetRow(cells[column], i + header); + Grid.SetColumn(cells[column], column); + grid.Children.Add(cells[column]); + } + } + return grid; + } + + private static TextBlock Cell(string text) => + new() { Text = text, VerticalAlignment = VerticalAlignment.Center }; + + private static void AddRow(Grid grid, int row, string label, Control editor, string tip) + { + var text = new TextBlock { Text = label, VerticalAlignment = VerticalAlignment.Center }; + Grid.SetRow(text, row); + Grid.SetRow(editor, row); + Grid.SetColumn(editor, 1); + if (tip != null) + ToolTip.SetTip(editor, tip); + grid.Children.Add(text); + grid.Children.Add(editor); + } + + private static Grid UrlRow(TextBox box, Button button) + { + var row = new Grid { ColumnDefinitions = new ColumnDefinitions("*,Auto"), ColumnSpacing = 8 }; + Grid.SetColumn(button, 1); + button.MinWidth = 150; + row.Children.Add(box); + row.Children.Add(button); + return row; + } + + private static StackPanel Group(string header, params Control[] children) + { + var group = new StackPanel { Spacing = 8 }; + group.Children.Add(new TextBlock + { + Text = header, + FontWeight = FontWeight.SemiBold, + TextWrapping = TextWrapping.Wrap, + }); + foreach (var child in children) + group.Children.Add(child); + return group; + } + } +}