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 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-11-13 19:25:15 -05:00
1 parent fc5c2c9469
commit ef7ebd2f44
4 files changed
+343 -7

No files matched your search

+28 -5
View File
@@ -16,15 +16,16 @@ namespace OpenNest.Avalonia
/// </summary>
internal static class DesktopSettings
{
public static SharedSettingsSource Shared { get; } = SharedSettingsSource.FromStorageFile();
/// <summary>The storage mode and nest server from storage.json; Tools > Options can change the server.</summary>
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));
/// <summary>The storage mode and nest server, read once from storage.json as <see cref="Shared"/> does.</summary>
private static readonly NestStorageSettings Storage = NestStorageSettings.Load(NestStorageSettings.DefaultPath);
private static RemoteNestRepository repository;
private static string repositoryUrl;
/// <summary>
/// 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);
/// <summary>The nest server URL saved in storage.json (blank when none).</summary>
public static string ServerUrl => storage.ServerUrl;
/// <summary>
/// Saves <paramref name="serverUrl"/> as the nest server in storage.json, keeping the
/// storage mode, as the WinForms Options form does; the next request uses it.
/// </summary>
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;
}
/// <summary>The post processors loaded from Posts/ beside the app, by load order.</summary>
public static IReadOnlyList<IPostProcessor> PostProcessors { get; private set; } = [];
+3
View File
@@ -42,6 +42,9 @@
<MenuItem Header="Next N_est" InputGesture="Ctrl+Tab" Click="OnNextNest" />
<MenuItem Header="Previous Ne_st" InputGesture="Ctrl+Shift+Tab" Click="OnPreviousNest" />
</MenuItem>
<MenuItem Header="_Tools">
<MenuItem x:Name="OptionsMenuItem" Header="_Options..." Click="OnOptions" />
</MenuItem>
</Menu>
<Border DockPanel.Dock="Top" Padding="4" BorderThickness="0,0,0,1" BorderBrush="#D0D0D0">
+22 -2
View File
@@ -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;
+290
View File
@@ -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
{
/// <summary>
/// Tools &gt; 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.
/// </summary>
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();
}
/// <summary>
/// Shows the dialog over <paramref name="owner"/>; true when the options were saved.
/// <paramref name="settingsPath"/> is the shared user-settings.json.
/// </summary>
public static async Task<bool> ShowAsync(Window owner, string settingsPath) =>
await new OptionsDialog(settingsPath).ShowDialog<bool?>(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<string>(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;
}
}
}