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;
+ }
+ }
+}