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