using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Data;
using OpenNest.Engine;
using OpenNest.Engine.CuttingPlanning;
using OpenNest.Engine.Fill;
using OpenNest.Engine.Strategies;
namespace OpenNest.Desktop;
///
/// The values Tools > Options saves with its Save button, under the WinForms user-setting
/// names both desktop apps share in user-settings.json. A missing or unreadable value is its
/// WinForms default. The update feed URL and the nest server URL have their own buttons
/// (, ), so Save leaves them alone.
///
public sealed record DesktopOptions
{
public const string CreateNewNestOnOpenName = "CreateNewNestOnOpen";
public const string AutoSizePlateFactorName = "AutoSizePlateFactor";
public const string CuttingPlanEffortName = "CuttingPlanEffort";
public const string ClonePlacementOrderName = "ClonePlacementOrder";
public const string DisabledStrategiesName = "DisabledStrategies";
public const string UpdateIncludeDevBuildsName = "UpdateIncludeDevBuilds";
/// The auto-size factor's range and step, as the WinForms box allows.
public const double AutoSizePlateFactorMinimum = 0;
public const double AutoSizePlateFactorMaximum = 100;
public const double AutoSizePlateFactorIncrement = 0.125;
public const int AutoSizePlateFactorDecimals = 4;
/// Cut planning effort's step; its range is Plan Cutting's budget multiplier range.
public const double CuttingPlanEffortIncrement = 0.1;
public static double CuttingPlanEffortMinimum => CuttingPlanBatch.MinimumBudgetMultiplier;
public static double CuttingPlanEffortMaximum => CuttingPlanBatch.MaximumBudgetMultiplier;
/// The WinForms setting defaults.
public static DesktopOptions Defaults { get; } = new();
/// Create a new nest when the app starts.
public bool CreateNewNestOnOpen { get; init; } = true;
/// The amount Resize Plate rounds the plate size up to.
public double AutoSizePlateFactor { get; init; } = 0.5;
/// Plan Cutting's search budget multiplier (1 is normal).
public double CuttingPlanEffort { get; init; } = CuttingPlanBatch.DefaultBudgetMultiplier;
/// The colour scheme's name, as stores it.
public string ColorScheme { get; init; } = ColorSchemeRegistry.DefaultSchemeName;
/// Which way Shift+click and Ctrl+click clones push the copy.
public SettleOrder ClonePlacementOrder { get; init; } = SettleOrder.ClosestToCorner;
/// Fill strategies left out of the pipeline, stored comma-separated by name.
public IReadOnlyCollection DisabledStrategies { get; init; } = Array.Empty();
/// Check the rolling development-build release as well as stable releases.
public bool UpdateIncludeDevBuilds { get; init; }
/// The saved values; one missing or unreadable is its WinForms default.
public static DesktopOptions Read(IReadOnlyDictionary settings)
{
ArgumentNullException.ThrowIfNull(settings);
return new DesktopOptions
{
CreateNewNestOnOpen = UserSettingText.ReadBool(settings, CreateNewNestOnOpenName, Defaults.CreateNewNestOnOpen),
AutoSizePlateFactor = UserSettingText.ReadDouble(settings, AutoSizePlateFactorName, Defaults.AutoSizePlateFactor),
CuttingPlanEffort = UserSettingText.ReadDouble(settings, CuttingPlanEffortName, Defaults.CuttingPlanEffort),
ColorScheme = ColorSchemeRegistry.SavedSchemeName(settings),
ClonePlacementOrder = ClonePlacement.Parse(
UserSettingText.ReadString(settings, ClonePlacementOrderName, null)),
DisabledStrategies = FillStrategyOptions.ParseDisabled(
UserSettingText.ReadString(settings, DisabledStrategiesName, "")),
UpdateIncludeDevBuilds = UserSettingText.ReadBool(settings, UpdateIncludeDevBuildsName, Defaults.UpdateIncludeDevBuilds),
};
}
///
/// Stores the values Save writes, in the WinForms text form; other settings are kept, and
/// the cut planning effort is stored as entered (Plan Cutting clamps it when it reads it).
///
public void WriteTo(IDictionary settings)
{
ArgumentNullException.ThrowIfNull(settings);
settings[CreateNewNestOnOpenName] = UserSettingText.Format(CreateNewNestOnOpen);
settings[UpdateIncludeDevBuildsName] = UserSettingText.Format(UpdateIncludeDevBuilds);
settings[AutoSizePlateFactorName] = UserSettingText.Format(AutoSizePlateFactor);
settings[CuttingPlanEffortName] = UserSettingText.Format(CuttingPlanEffort);
settings[ColorSchemeRegistry.SettingName] = string.IsNullOrEmpty(ColorScheme)
? ColorSchemeRegistry.DefaultSchemeName
: ColorScheme;
settings[ClonePlacementOrderName] = ClonePlacementOrder.ToString();
settings[DisabledStrategiesName] = FillStrategyOptions.FormatDisabled(DisabledStrategies);
}
/// The colour schemes to choose from: the built-ins, then those in Schemes.
public static IReadOnlyList ColorSchemeNames =>
ColorSchemeRegistry.AllSchemes.Select(s => s.Name).ToList();
///
/// The index of the saved scheme in (exact name), or the first
/// scheme when it is no longer there, as the WinForms combo selects it.
///
public int ColorSchemeIndex(IReadOnlyList names)
{
ArgumentNullException.ThrowIfNull(names);
for (var i = 0; i < names.Count; i++)
{
if (names[i] == ColorScheme)
return i;
}
return 0;
}
/// The auto-size factor as the Options box shows it: within its range.
public double AutoSizePlateFactorForEditing =>
double.IsFinite(AutoSizePlateFactor)
? System.Math.Clamp(AutoSizePlateFactor, AutoSizePlateFactorMinimum, AutoSizePlateFactorMaximum)
: Defaults.AutoSizePlateFactor;
/// The cut planning effort as the Options box shows it: the value Plan Cutting uses.
public double CuttingPlanEffortForEditing => CuttingPlanBatch.ClampBudgetMultiplier(CuttingPlanEffort);
///
/// Applies what takes effect without a restart: the fill strategy enablement and the colour
/// scheme (whose lets each front end recolour and
/// repaint). Run after Save and, from the saved settings, at startup.
///
public void Apply()
{
FillStrategyOptions.Apply(DisabledStrategies);
ColorSchemeRegistry.Apply(ColorSchemeRegistry.Get(ColorScheme));
}
}
/// The Options choices for Shift+click and Ctrl+click clone placement, in combo order.
public static class ClonePlacement
{
public static IReadOnlyList<(SettleOrder Order, string Text)> Choices { get; } = new[]
{
(SettleOrder.ClosestToCorner, "Closest to origin"),
(SettleOrder.HorizontalFirst, "Push horizontally first"),
(SettleOrder.VerticalFirst, "Push vertically first"),
};
/// The stored enum name's order; an unknown name is the closest-to-origin search.
public static SettleOrder Parse(string stored) =>
Enum.TryParse(stored, out var order) && Enum.IsDefined(order)
? order
: SettleOrder.ClosestToCorner;
/// The combo index of , the first for one not listed.
public static int IndexOf(SettleOrder order)
{
for (var i = 0; i < Choices.Count; i++)
{
if (Choices[i].Order == order)
return i;
}
return 0;
}
/// The order at a combo index; no selection is the first choice.
public static SettleOrder At(int index) => Choices[System.Math.Clamp(index, 0, Choices.Count - 1)].Order;
}
/// A fill strategy as the Options grid lists it.
public sealed record FillStrategyRow(string Name, NestPhase Phase, int Order);
///
/// The Options fill strategy grid and the DisabledStrategies setting: names separated by commas,
/// matched without regard to case, applied to .
///
public static class FillStrategyOptions
{
/// Every built-in strategy, in pipeline order.
public static IReadOnlyList Rows =>
FillStrategyRegistry.AllStrategies.Select(s => new FillStrategyRow(s.Name, s.Phase, s.Order)).ToList();
/// The names in a stored value; blank entries are skipped.
public static HashSet ParseDisabled(string value)
{
if (string.IsNullOrWhiteSpace(value))
return new HashSet(StringComparer.OrdinalIgnoreCase);
return new HashSet(
value.Split(',').Select(s => s.Trim()).Where(s => s.Length > 0),
StringComparer.OrdinalIgnoreCase);
}
/// The stored form of .
public static string FormatDisabled(IEnumerable names) =>
names == null ? "" : string.Join(",", names);
/// Re-enables every strategy, then disables .
public static void Apply(IEnumerable disabled)
{
FillStrategyRegistry.Enable(FillStrategyRegistry.AllStrategies.Select(s => s.Name).ToArray());
var names = disabled?.ToArray() ?? Array.Empty();
if (names.Length > 0)
FillStrategyRegistry.Disable(names);
}
/// Applies the DisabledStrategies value stored in .
public static void ApplySaved(IReadOnlyDictionary settings) =>
Apply(ParseDisabled(UserSettingText.ReadString(settings, DesktopOptions.DisabledStrategiesName, "")));
}
public enum OptionNoticeKind
{
Information,
Warning,
Error,
}
/// What to tell the user after an Options button, and how.
public sealed record OptionNotice(string Title, string Text, OptionNoticeKind Kind);
///
/// Tools > Options > Set / Change Server: the nest server URL in storage.json, keeping the
/// storage mode (File / Database is chosen under File > Storage Mode).
///
public static class NestServerUrlOption
{
public const string Title = "Server URL";
///
/// Validates and passes the normalized URL to ,
/// which writes storage.json; returns what to tell the user and the URL saved (null when not).
///
public static (OptionNotice Notice, string SavedUrl) Save(string text, Action save)
{
ArgumentNullException.ThrowIfNull(save);
if (!NestStorageSettings.TryNormalizeServerUrl(text, out var url))
return (new OptionNotice(Title, "Enter a valid http or https server URL.", OptionNoticeKind.Warning), null);
try
{
save(url);
return (new OptionNotice(Title, "Server URL saved. Storage mode has not changed.",
OptionNoticeKind.Information), url);
}
catch (Exception ex)
{
return (new OptionNotice(Title, $"Could not save the server URL: {ex.Message}", OptionNoticeKind.Error), null);
}
}
/// Writes into the storage.json at , keeping its mode.
public static NestStorageSettings SaveToFile(string path, string serverUrl)
{
var next = new NestStorageSettings
{
Mode = NestStorageSettings.Load(path).Mode,
ServerUrl = serverUrl,
};
next.Save(path);
return NestStorageSettings.Load(path);
}
}
///
/// Tools > Options > Save Feed URL: the UpdateFeedUrl user setting. Blank means the default
/// source: the nest server's mirror in Database mode (GitHub Releases if it has none), otherwise
/// GitHub Releases. A change takes effect after a restart.
///
public static class UpdateFeedOption
{
public const string SettingName = "UpdateFeedUrl";
public const string Title = "Update Feed URL";
/// The saved feed URL; blank (the default) when none.
public static string Read(IReadOnlyDictionary settings) =>
UserSettingText.ReadString(settings, SettingName, "");
///
/// Validates (blank clears it) and passes the normalized URL to
/// ; returns what to tell the user and the URL saved (null when not).
///
public static (OptionNotice Notice, string SavedUrl) Save(string text, Action save)
{
ArgumentNullException.ThrowIfNull(save);
text = text?.Trim() ?? "";
string url;
string saved;
if (text.Length == 0)
{
url = "";
saved = "Update feed URL cleared. After a restart, updates come from "
+ "the nest server's mirror in Database mode (GitHub Releases if it has none), "
+ "otherwise from GitHub Releases.";
}
else if (NestStorageSettings.TryNormalizeServerUrl(text, out url))
{
saved = "Update feed URL saved. Restart OpenNest for the new feed to take effect.";
}
else
{
return (new OptionNotice(Title, "Enter a valid http or https feed URL, or clear the field to "
+ "use the default source: the nest server's mirror in Database mode, "
+ "otherwise GitHub Releases.", OptionNoticeKind.Warning), null);
}
try
{
save(url);
return (new OptionNotice(Title, saved, OptionNoticeKind.Information), url);
}
catch (Exception ex)
{
return (new OptionNotice(Title, $"Could not save the update feed URL: {ex.Message}", OptionNoticeKind.Error), null);
}
}
}