Changeset - 40eaee10ae56
[Not reviewed]
default
! ! !
Tess Snider (Malkyne) - 3 years ago 2021-07-04 10:58:53
this@malkyne.org
First commit. New UI.
58 files changed:
.hgeol
7
App.axaml
122
README.md
147
Changeset was too big and was cut off... Show full diff anyway
0 comments (0 inline, 0 general)
.gitignore
Show inline comments
 
new file 100644
 
.idea/
 
.vscode/
 
.vs/
 

	
 
bin/
 
obj/
 

	
 
*.user
...
 
\ No newline at end of file
.hgeol
Show inline comments
 
new file 100644
 
[repository]
 
native = LF
 

 
[patterns]
 
** = native
 
**.vcproj = CRLF
 

.hgignore
Show inline comments
 
new file 100644
 
syntax: glob
 

 
.idea/
 
.vscode/
 
.vs/
 

 
bin/
 
obj/
 

 
*.user
 

 
mac/old/
 
*.userprefs
 
*.orig
 
*.swp
 
*.zip
 
*.dmg
 
.DS_Store
 
mac/bin
App.axaml
Show inline comments
 
new file 100644
 
<Application xmlns="https://github.com/avaloniaui"
 
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
 
             xmlns:local="using:DesertPaintCodex"
 
             xmlns:converters="clr-namespace:DesertPaintCodex.Converters"
 
             x:Class="DesertPaintCodex.App">
 
    <Application.DataTemplates>
 
        <local:ViewLocator/>
 
    </Application.DataTemplates>
 

	
 
    <Application.Resources>
 
        <!-- Converters -->
 
        <converters:PaintToBrushConverter   x:Key="paintToBrush" />
 
        <converters:EnumBooleanConverter    x:Key="enumBoolean" />
 
        <converters:NotEnumBooleanConverter x:Key="notEnumBoolean" />
 
        
 
        <!-- Icons and Art -->
 
        <PathGeometry x:Key="Icon_AddProfile">M28 6a6 6 0 1 1-6-6A6 6 0 0 1 28 6Zm2 18s0-10-8-10-8 10-8 10ZM12 10H8V6H4v4H0v4H4v4H8V14h4Z</PathGeometry>
 
        <PathGeometry x:Key="Icon_UpArrow">M 16 8 8 0 0 8 h 5 v 8 H 11 V 8 Z</PathGeometry>
 
        <PathGeometry x:Key="Icon_DownArrow">M 16 8 8 16 0 8 H 5 V 0 H 11 v 8 z</PathGeometry>
 
        <PathGeometry x:Key="Icon_Catalyst">M 9 8 A 1 1 0 0 1 8 9 1 1 0 0 1 7 8 1 1 0 0 1 8 7 1 1 0 0 1 9 8 Z m 5.62 8 C 12.62 16 8.88 13.22 5.83 10.17 2.15 6.5 -1.12 1.87 0.37 0.37 v 0 c 1.5 -1.49 6.13 1.79 9.8 5.46 3.67 3.67 7 8.3 5.46 9.8 A 1.38 1.38 0 0 1 14.62 16 Z M 1.08 1.08 c -0.5 0.5 1 3.91 5.46 8.38 4.46 4.47 7.88 6 8.38 5.46 0.5 -0.54 -1 -3.91 -5.46 -8.38 C 5 2.07 1.58 0.58 1.08 1.08 Z M 1.38 16 a 1.4 1.4 0 0 1 -1 -0.37 c -1.49 -1.5 1.78 -6.13 5.46 -9.8 3.68 -3.67 8.3 -6.95 9.8 -5.46 v 0 c 1.49 1.5 -1.78 6.13 -5.46 9.8 C 7.12 13.22 3.41 16 1.38 16 Z M 14.61 1 c -1 0 -4.14 1.61 -8.07 5.55 -4.48 4.47 -6 7.88 -5.46 8.38 0.54 0.5 3.91 -1 8.38 -5.46 4.47 -4.46 6 -7.88 5.46 -8.38 A 0.43 0.43 0 0 0 14.61 1 Z</PathGeometry>
 
        <DrawingImage x:Key="Image_Splash">
 
            <DrawingGroup>
 
                <GeometryDrawing Brush="#5E443F" Geometry="M 0 0 H 256 V 256 H 0 Z"/>
 
                <GeometryDrawing Brush="#9D9F37" Geometry="M192 136c-11.31-11.31 0-16 0-16s-16 0-16 16v16H160V120a32 32 0 0 0-64 0V256h64V184h32C208 184 208 152 192 136Z"/>
 
                <GeometryDrawing Brush="#E1AD56" Geometry="m 112 8 c 0 0 -48 32 -48 48 0 16 16 32 32 32 h 16 C 112 88 96 72 96 56 96 40 112 8 112 8 Z"/>
 
                <GeometryDrawing Brush="#CA7091" Geometry="M128 88s-16-16-16-32S128 8 128 8s16 32 16 48S128 88 128 88Z"/>
 
                <GeometryDrawing Brush="#80ABA4" Geometry="m 144 8 c 0 0 48 32 48 48 0 16 -16 32 -32 32 h -16 c 0 0 16 -16 16 -32 C 160 40 144 8 144 8 Z"/>
 
            </DrawingGroup>
 
        </DrawingImage>
 
    </Application.Resources>
 
    
 
    <Application.Styles>
 
        <StyleInclude Source="avares://Avalonia.Themes.Default/DefaultTheme.xaml"/>
 
        <StyleInclude Source="avares://Avalonia.Themes.Default/Accents/BaseDark.xaml"/>
 
        <StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Default.xaml"/>
 

	
 
        <Style Selector="Window">
 
            <Setter Property="Foreground" Value="#FFEDD4"/>
 
            <Setter Property="Background" Value="#312E2F"/>
 
            <Style.Resources>
 
                <SolidColorBrush x:Key="FlatBackgroundBrush" Color="#312E2F" />
 
                <SolidColorBrush x:Key="GutterBackgroundBrush" Color="#282627" />
 
            </Style.Resources>
 
        </Style>
 
        
 
        <Style Selector="TextBlock">
 
            <Setter Property="FontSize" Value="14"/>
 
        </Style>
 
        
 
        <Style Selector="TextBlock.BlockHeader">
 
            <Setter Property="FontSize" Value="16"/>
 
            <Setter Property="FontWeight" Value="Bold"/>
 
        </Style>
 

	
 
        <Style Selector=":is(Panel).Activity">
 
            <Setter Property="Margin" Value="15"/>
 
        </Style>
 
        
 
        <Style Selector="Border.ThinFrame">
 
            <Setter Property="BorderBrush" Value="#81776B"/>
 
            <Setter Property="BorderThickness" Value="1"/>
 
            <Setter Property="CornerRadius" Value="3"/>
 
            <Setter Property="Padding" Value="8"/>
 
        </Style>
 
        
 
        <Style Selector="Border.Help">
 
            <Setter Property="Background" Value="#515E2E"/>
 
            <Setter Property="BorderThickness" Value="2"/>
 
            <Setter Property="BorderBrush" Value="#FFEDD4"/>
 
            <Setter Property="Width" Value="24"/>
 
            <Setter Property="Height" Value="24"/>
 
        </Style>
 
        
 
        <Style Selector="Border.Help > TextBlock">
 
            <Setter Property="Foreground" Value="#FFEDD4"/>
 
            <Setter Property="FontWeight" Value="Black"/>
 
            <Setter Property="FontSize" Value="14"/>
 
            <Setter Property="HorizontalAlignment" Value="Center"/>
 
        </Style>
 
                
 
        <Style Selector="Path.AddProfile">
 
            <Setter Property="Data" Value="{StaticResource Icon_AddProfile}"/>
 
            <Setter Property="Fill" Value="#E1AD56"/>
 
        </Style>
 
        
 
        <Style Selector="Path.Catalyst">
 
            <Setter Property="Data" Value="{StaticResource Icon_Catalyst}"/>
 
            <Setter Property="Fill" Value="#FFEDD4"/>
 
        </Style>
 
        
 
        <Style Selector="Path.UpArrow">
 
            <Setter Property="Data" Value="{StaticResource Icon_UpArrow}"/>
 
        </Style>
 
        
 
        <Style Selector="Path.DownArrow">
 
            <Setter Property="Data" Value="{StaticResource Icon_DownArrow}"/>
 
        </Style>
 
        
 
        <Style Selector="Path.Red">
 
            <Setter Property="Fill" Value="#CA7091"/>
 
        </Style>
 
        
 
        <Style Selector="Path.Green">
 
            <Setter Property="Fill" Value="#9D9F37"/>
 
        </Style>
 
        
 
        <Style Selector="Path.Blue">
 
            <Setter Property="Fill" Value="#80ABA4"/>
 
        </Style>
 
        
 
        <Style Selector="Border.ReagentSwatch">
 
            <Setter Property="BorderBrush" Value="{DynamicResource GutterBackgroundBrush}"/>
 
            <Setter Property="BorderThickness" Value="0"/>
 
            <Setter Property="CornerRadius" Value="3"/>
 
            <Setter Property="Padding" Value="8"/>
 
            <Setter Property="Width" Value="30"/>
 
            <Setter Property="Height" Value="30"/>
 
        </Style>
 
        
 
    </Application.Styles>
 
    
 
</Application>
App.axaml.cs
Show inline comments
 
new file 100644
 
using System;
 
using Avalonia;
 
using Avalonia.Controls;
 
using Avalonia.Controls.ApplicationLifetimes;
 
using Avalonia.Markup.Xaml;
 
using DesertPaintCodex.Services;
 
using DesertPaintCodex.Views;
 

	
 
namespace DesertPaintCodex
 
{
 
    internal class App : Application
 
    {
 
        private WelcomeView? _welcomeView;
 
        private MainWindow? _mainWindow;
 
        
 
        public override void Initialize()
 
        {
 
            AvaloniaXamlLoader.Load(this);
 
        }
 

	
 
        public override void OnFrameworkInitializationCompleted()
 
        { 
 
            ShowWelcomeView();
 
            base.OnFrameworkInitializationCompleted();
 
        }
 

	
 
        public void ReturnToWelcome()
 
        {
 
            ShowWelcomeView();
 
            CloseMainWindow();
 
        }
 

	
 
        private void ShowWelcomeView()
 
        {
 
            if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop) return;
 
            
 
            desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
 
            _welcomeView         = new WelcomeView();
 
            _welcomeView.Closed += OnWelcomeViewClosed;
 
            _welcomeView.Show();
 
        }
 

	
 
        private void ShowMainWindow()
 
        {
 
            if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop) return;
 
            
 
            desktop.ShutdownMode = ShutdownMode.OnMainWindowClose;
 
            _mainWindow          = new MainWindow();
 
            _mainWindow.Closing += _mainWindow.OnMainWindowClosing;
 
            desktop.MainWindow   = _mainWindow;
 
            desktop.MainWindow.Show();
 
        }
 

	
 
        private void Shutdown()
 
        {
 
            if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop) return;
 
            desktop.Shutdown();
 
        }
 

	
 
        private void CloseMainWindow()
 
        {
 
            if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop) return;
 
            if (_mainWindow != null)
 
            {
 
                desktop.MainWindow.Closing -= _mainWindow.OnMainWindowClosing;
 
            }
 
            desktop.MainWindow.Close();
 
            _mainWindow = null;
 
            desktop.MainWindow = null;
 
        }
 

	
 
        private void OnWelcomeViewClosed(object? obj, EventArgs args)
 
        {
 
            if (_welcomeView != null) _welcomeView.Closed -= OnWelcomeViewClosed;
 
            _welcomeView = null;
 

	
 
            if (ProfileManager.CurrentProfile == null)
 
            {
 
                Shutdown();
 
            }
 
            else
 
            {
 
                ShowMainWindow();
 
            }
 
        }
 
    }
 
}
...
 
\ No newline at end of file
Assets/desert_paint_codex_icon.ico
Show inline comments
 
new file 100644
 
binary diff not shown
Converters/EnumBooleanConverter.cs
Show inline comments
 
new file 100644
 

	
 
using System;
 
using Avalonia.Data.Converters;
 

	
 
namespace DesertPaintCodex.Converters
 
{
 
    public class EnumBooleanConverter : IValueConverter
 
    {
 
        public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
 
        {
 
            if (value is not Enum en) return false;
 
            
 
            string? parameterString = parameter.ToString();
 
            if (parameterString == null) return false;
 
            
 
            object parameterValue = Enum.Parse(value.GetType(), parameterString);
 
            return parameterValue.Equals(value);
 
        }
 

	
 
        public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
 
        {
 
            if (value is not bool) return false;
 
            
 
            string? parameterString = parameter.ToString();
 
            if (parameterString == null) return false;
 

	
 
            return Enum.Parse(targetType, parameterString);
 
        }
 
    }
 
}
...
 
\ No newline at end of file
Converters/NotEnumBooleanConverter.cs
Show inline comments
 
new file 100644
 
using System;
 

	
 
namespace DesertPaintCodex.Converters
 
{
 
    public class NotEnumBooleanConverter
 
    {
 
        public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
 
        {
 
            if (value is not Enum en) return false;
 
            
 
            string? parameterString = parameter.ToString();
 
            if (parameterString == null) return false;
 
            
 
            object parameterValue = Enum.Parse(value.GetType(), parameterString);
 
            return !parameterValue.Equals(value);
 
        }
 

	
 
        public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
 
        {
 
            throw new InvalidOperationException("NotEnumBooleanoConverter can only be used OneWay.");
 
        }
 
    }
 
}
...
 
\ No newline at end of file
Converters/PaintToBrushConverter.cs
Show inline comments
 
new file 100644
 
using System;
 
using System.Collections.Generic;
 
using System.Globalization;
 
using Avalonia.Data.Converters;
 
using Avalonia.Media;
 
using DesertPaintCodex.Models;
 

	
 
namespace DesertPaintCodex.Converters
 
{
 
    /// <summary>
 
    /// Converts PaintColor to a SolidColorBrush.
 
    /// </summary>
 
    public class PaintToBrushConverter : IValueConverter
 
    {
 
        private static readonly SolidColorBrush NoColor = new();
 
        private static readonly Dictionary<PaintColor, SolidColorBrush> BrushPool = new();
 

	
 
        /// <inheritdoc/>
 
        public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
 
        {
 
            if (value is not PaintColor paintColor) return NoColor;
 
            
 
            if (BrushPool.TryGetValue(paintColor, out SolidColorBrush? brush)) return brush;
 
            
 
            brush = new SolidColorBrush(new Color(0xFF, paintColor.Red, paintColor.Green, paintColor.Blue));
 
            BrushPool.Add(paintColor, brush);
 

	
 
            return brush;
 
        }
 

	
 
        /// <inheritdoc/>
 
        public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
 
        {
 
            if (value is not SolidColorBrush brush) return new PaintColor(0, 0, 0);
 
            Color color = brush.Color;
 
            return new PaintColor(color.R, color.G, color.B);
 
        }
 
    }
 
}
...
 
\ No newline at end of file
Data/colors.txt
Show inline comments
 
new file 100644
 
#F0F8FF	AliceBlue
 
#9966CC	Amethyst
 
#FAEBD7	AntiqueWhite
 
#00FFFF	Aqua
 
#7FFFD4	Aquamarine
 
#F0FFFF	Azure
 
#FF91AF	BakerMillerPink
 
#E3CF57	Banana
 
#7C0A02	BarnRed
 
#8E388E	Beet
 
#F5F5DC	Beige
 
#FFE4C4	Bisque
 
#010101	Black
 
#FFEBCD	BlanchedAlmond
 
#FF6700	BlazeOrange
 
#0000FF	Blue
 
#8A2BE2	BlueViolet
 
#873260	Boysenberry
 
#FF007F	BrightPink
 
#A52A2A	Brown
 
#800020	BurgundyRed
 
#DEB887	BurlyWood
 
#8A360F	BurntSienna
 
#8A3324	BurntUmber
 
#5F9EA0	CadetBlue
 
#FF6103	CadmiumOrange
 
#FF9912	CadmiumYellow
 
#E07020	Carrot
 
#7FFF00	Chartreuse
 
#D2691E	Chocolate
 
#3D59AB	CobaltBlue
 
#3D9140	CobaltGreen
 
#FF7F50	Coral
 
#6495ED	CornflowerBlue
 
#FFF8DC	Cornsilk
 
#DC143C	Crimson
 
#00008B	DarkBlue
 
#008B8B	DarkCyan
 
#B8860B	DarkGoldenRod
 
#006400	DarkGreen
 
#A9A9A9	DarkGrey
 
#1A2421	DarkJungleGreen
 
#BDB76B	DarkKhaki
 
#8B008B	DarkMagenta
 
#556B2F	DarkOliveGreen
 
#FF8C00	DarkOrange
 
#9932CC	DarkOrchid
 
#8B0000	DarkRed
 
#E9967A	DarkSalmon
 
#560319	DarkScarlet
 
#8FBC8F	DarkSeaGreen
 
#3C1414	DarkSienna
 
#483D8B	DarkSlateBlue
 
#2F4F4F	DarkSlateGrey
 
#00CED1	DarkTurquoise
 
#9400D3	DarkViolet
 
#FF1493	DeepPink
 
#00BFFF	DeepSkyBlue
 
#696969	DimGrey
 
#1E90FF	DodgerBlue
 
#00009C	DukeBlue
 
#FCE6C9	EggshellWhite
 
#00C957	EmeraldGreen
 
#D19275	Feldspar
 
#B22222	FireBrick
 
#FFFAF0	FloralWhite
 
#228B22	ForestGreen
 
#FF00FF	Fuchsia
 
#DCDCDC	Gainsboro
 
#F8F8FF	GhostWhite
 
#FFD700	Gold
 
#DAA520	GoldenRod
 
#008000	Green
 
#ADFF2F	GreenYellow
 
#808080	Grey
 
#F0FFF0	HoneyDew
 
#FF69B4	HotPink
 
#002395	ImperialBlue
 
#CD5C5C	IndianRed
 
#4B0082	Indigo
 
#FFFFF0	Ivory
 
#F0E68C	Khaki
 
#E6E6FA	Lavender
 
#FFF0F5	LavenderBlush
 
#7CFC00	LawnGreen
 
#FFFACD	LemonChiffon
 
#1A1110	Licorice
 
#ADD8E6	LightBlue
 
#F08080	LightCoral
 
#E0FFFF	LightCyan
 
#FAFAD2	LightGoldenRodYellow
 
#90EE90	LightGreen
 
#D3D3D3	LightGrey
 
#FFB6C1	LightPink
 
#FFA07A	LightSalmon
 
#20B2AA	LightSeaGreen
 
#87CEFA	LightSkyBlue
 
#8470FF	LightSlateBlue
 
#778899	LightSlateGrey
 
#B0C4DE	LightSteelBlue
 
#FFFFE0	LightYellow
 
#00FF00	Lime
 
#32CD32	LimeGreen
 
#FAF0E6	Linen
 
#800000	Maroon
 
#66CDAA	MediumAquaMarine
 
#0000CD	MediumBlue
 
#BA55D3	MediumOrchid
 
#9370DB	MediumPurple
 
#3CB371	MediumSeaGreen
 
#7B68EE	MediumSlateBlue
 
#00FA9A	MediumSpringGreen
 
#48D1CC	MediumTurquoise
 
#C71585	MediumVioletRed
 
#E3A869	Melon
 
#191970	MidnightBlue
 
#F5FFFA	MintCream
 
#FFE4E1	MistyRose
 
#FFE4B5	Moccasin
 
#FFDEAD	NavajoWhite
 
#000080	Navy
 
#FDF5E6	OldLace
 
#808000	Olive
 
#6B8E23	OliveDrab
 
#FFA500	Orange
 
#FF4500	OrangeRed
 
#DA70D6	Orchid
 
#002147	OxfordBlue
 
#EEE8AA	PaleGoldenRod
 
#98FB98	PaleGreen
 
#AFEEEE	PaleTurquoise
 
#DB7093	PaleVioletRed
 
#FFEFD5	PapayaWhip
 
#FFDAB9	PeachPuff
 
#33A1C9	Peacock
 
#32127A	PersianIndigo
 
#F77FBE	PersianPink
 
#CD853F	Peru
 
#FFC0CB	Pink
 
#DDA0DD	Plum
 
#B0E0E6	PowderBlue
 
#003153	PrussianBlue
 
#800080	Purple
 
#C76114	RawSienna
 
#FF0000	Red
 
#860111	RedDevil
 
#004040	RichBlack
 
#BC8F8F	RosyBrown
 
#4169E1	RoyalBlue
 
#9B111E	RubyRed
 
#8B4513	SaddleBrown
 
#FA8072	Salmon
 
#F4A460	SandyBrown
 
#92000A	Sangria
 
#308014	SapGreen
 
#2E8B57	SeaGreen
 
#321414	SealBrown
 
#FFF5EE	SeaShell
 
#A0522D	Sienna
 
#C0C0C0	Silver
 
#87CEEB	SkyBlue
 
#6A5ACD	SlateBlue
 
#708090	SlateGrey
 
#100C08	SmokeyBlack
 
#FFFAFA	Snow
 
#00FF7F	SpringGreen
 
#4682B4	SteelBlue
 
#CC3366	SteelPink
 
#D2B48C	Tan
 
#008080	Teal
 
#D8BFD8	Thistle
 
#FF6347	Tomato
 
#40E0D0	Turquoise
 
#66023C	TyrianPurple
 
#EE82EE	Violet
 
#D02090	VioletRed
 
#F5DEB3	Wheat
 
#FFFFFF	White
 
#F5F5F5	WhiteSmoke
 
#FFFF00	Yellow
 
#9ACD32	YellowGreen
 
#0014A8	Zaffre
Data/ingredients.txt
Show inline comments
 
new file 100644
 
// Desert Paint Lab Ingredients
 
// Name | Practical Paint Name | RGB values
 
// These should be kept in the order they show up on the paint bench
 
// NOTE: EarthLight was replaced with Falcon's Bait in T9.
 

	
 
Cabbage Juice   | Cabbage     | 128, 64, 144
 
Carrot          | Carrot      | 224, 112, 32
 
Clay            | Clay        | 128, 96, 32
 
Dead Tongue     | DeadTongue  | 112, 64, 64
 
Toad Skin       | ToadSkin    | 48, 96, 48
 
Falcons Bait    | FalconsBait | 128, 240, 224
 
Red Sand        | RedSand     | 144, 16, 24
 
Lead            | Lead        | 80, 80, 96
 
Silver Powder   | Silver	  | 16, 16, 32
 
Iron            | Iron		  | 96, 48, 32
 
Copper          | Copper	  | 64, 192, 192
 
Sulfur          | Sulfur	  | catalyst
 
Potash          | Potash	  | catalyst
 
Lime            | Lime		  | catalyst
 
Saltpeter       | Saltpeter	  | catalyst
Data/template/clips.txt
Show inline comments
 
new file 100644
 

...
 
\ No newline at end of file
Data/template/dp_reactions.txt
Show inline comments
 
new file 100644
 

...
 
\ No newline at end of file
Data/template/ingredients.txt
Show inline comments
 
new file 100644
 
// Ingredients are in the form:
 
// Name | RGB values | cost | enabled (Y/N) | bulk/normal | max items per paint (1-20)
 
//
 
// It is recommended to only change the cost value
 
// It is not recommended to set many of the ingredients above 10 per paint
 

	
 
Cabbage		| 128, 64, 144   | 8      | Y | bulk    | 10
 
Carrot		| 224, 112, 32   | 8      | Y | bulk    | 10
 
Clay		| 128, 96, 32    | 10     | Y | bulk    | 20
 
DeadTongue	| 112, 64, 64    | 500    | Y | normal  | 4
 
ToadSkin	| 48, 96, 48     | 500    | Y | normal  | 4
 
FalconsBait	| 128, 240, 224  | 10000  | Y | normal  | 4
 
RedSand		| 144, 16, 24    | 4      | Y | bulk    | 20
 
Lead		| 80, 80, 96     | 50     | Y | normal  | 6
 
Silver		| 16, 16, 32     | 50     | Y | normal  | 6
 
Iron		| 96, 48, 32     | 30     | Y | normal  | 8
 
Copper		| 64, 192, 192   | 30     | Y | normal  | 8
 
Sulfur      | catalyst       | 15     | Y | normal  | 1
 
Potash      | catalyst       | 50     | Y | normal  | 1
 
Lime        | catalyst       | 20     | Y | normal  | 1
 
Saltpeter   | catalyst       | 10     | Y | normal  | 1
DesertPaintCodex.csproj
Show inline comments
 
new file 100644
 
<Project Sdk="Microsoft.NET.Sdk">
 
  <PropertyGroup>
 
    <OutputType>WinExe</OutputType>
 
    <TargetFramework>net5.0</TargetFramework>
 
    <Nullable>enable</Nullable>
 
  </PropertyGroup>
 
  <ItemGroup>
 
    <Folder Include="Models\" />
 
    <AvaloniaResource Include="Assets\**" />
 
    <None Remove="CodexTheme.xaml" />
 
  </ItemGroup>
 
  <ItemGroup>
 
    <PackageReference Include="Avalonia" Version="0.10.999-cibuild0014036-beta" />
 
    <PackageReference Include="Avalonia.Desktop" Version="0.10.999-cibuild0014036-beta" />
 
    <PackageReference Include="Avalonia.Diagnostics" Version="0.10.999-cibuild0014036-beta" />
 
    <PackageReference Include="Avalonia.ReactiveUI" Version="0.10.999-cibuild0014036-beta" />
 
    <PackageReference Include="System.Reactive" Version="5.0.0" />
 
  </ItemGroup>
 
  <ItemGroup>
 
    <None Update="Data\ingredients.txt">
 
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
 
    </None>
 
    <None Update="Data\colors.txt">
 
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
 
    </None>
 
    <None Update="Data\template\clips.txt">
 
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
 
    </None>
 
    <None Update="Data\template\dp_reactions.txt">
 
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
 
    </None>
 
    <None Update="Data\template\ingredients.txt">
 
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
 
    </None>
 
  </ItemGroup>
 
</Project>
LICENSE.md
Show inline comments
 
new file 100644
 
The MIT License (MIT)
 
=====================
 

	
 
Copyright © `2021` `Tess Snider, Jason Maltzen`
 

	
 
Permission is hereby granted, free of charge, to any person
 
obtaining a copy of this software and associated documentation
 
files (the “Software”), to deal in the Software without
 
restriction, including without limitation the rights to use,
 
copy, modify, merge, publish, distribute, sublicense, and/or sell
 
copies of the Software, and to permit persons to whom the
 
Software is furnished to do so, subject to the following
 
conditions:
 

	
 
The above copyright notice and this permission notice shall be
 
included in all copies or substantial portions of the Software.
 

	
 
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
 
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
 
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
 
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
 
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
 
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
 
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
 
OTHER DEALINGS IN THE SOFTWARE.
Models/ClipType.cs
Show inline comments
 
new file 100644
 

 
using System;
 

	
 
namespace DesertPaintCodex.Models
 
{
 
    [Flags]
 
    public enum ClipType
 
    {
 
        None      = 0,
 
        RedLow    = 0x01,
 
        GreenLow  = 0x02,
 
        BlueLow   = 0x04,
 
        RedHigh   = 0x08,
 
        GreenHigh = 0x10,
 
        BlueHigh  = 0x20
 
    }
 
}
Models/GeneratorRecipe.cs
Show inline comments
 
new file 100644
 
using System.ComponentModel;
 
using System.Runtime.CompilerServices;
 
using System.Text;
 
using JetBrains.Annotations;
 

	
 
namespace DesertPaintCodex.Models
 
{
 
    public class GeneratorRecipe : INotifyPropertyChanged
 
    {
 
        private PaintColor _color;
 
        public PaintColor Color { get => _color; set { _color = value; NotifyPropertyChanged(nameof(Color)); } }
 

	
 
        private string _recipe = "";
 
        public string Recipe { get => _recipe; set { _recipe = value; NotifyPropertyChanged(nameof(Recipe)); } }
 

	
 

	
 
        public GeneratorRecipe(PaintColor color)
 
        {
 
            _color = color;
 
        }
 

	
 
        public void DraftRecipe(PaintRecipe recipe)
 
        {
 
            StringBuilder sb = new();
 
            for (int i = 0; i < recipe.Reagents.Count; i++)
 
            {
 
                sb.Append(recipe.Reagents[i].Quantity);
 
                sb.Append(' ');
 
                sb.Append(recipe.Reagents[i].Name);
 
                if (i != recipe.Reagents.Count - 1)
 
                {
 
                    sb.Append(", ");
 
                }
 
            }
 

	
 
            Recipe = sb.ToString();
 
        }
 

	
 
        public void ClearRecipe()
 
        {
 
            Recipe = "";
 
        }
 
        
 
        
 
        public event PropertyChangedEventHandler? PropertyChanged;
 

	
 
        [NotifyPropertyChangedInvocator]
 
        private void NotifyPropertyChanged([CallerMemberName] string? propertyName = null)
 
        {
 
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
 
        }
 
    }
 
}
...
 
\ No newline at end of file
Models/InterfaceSize.cs
Show inline comments
 
new file 100644
 

 
namespace DesertPaintCodex.Models
 
{
 
    public enum InterfaceSize
 
    {
 
        Tiny = 0,
 
        Small = 1,
 
        Medium = 2,
 
        Large = 3,
 
        Huge = 4,
 
        Count
 
    }
 
}
Models/PaintColor.cs
Show inline comments
 
new file 100644
 
using System;
 

	
 
namespace DesertPaintCodex.Models
 
{
 
    public class PaintColor
 
    {
 
        private const byte  LightTextValueCutoff = 150;
 
        private const float RedIntensity   = 0.299f;
 
        private const float GreenIntensity = 0.587f;
 
        private const float BlueIntensity  = 0.114f;
 

	
 
        public byte Red { get; set; }
 
        public byte Blue { get; set; }
 
        public byte Green { get; set; }
 
        public string Name { get; private set; }
 

	
 
        public bool UseWhiteText => Red * RedIntensity + Green * GreenIntensity + Blue * BlueIntensity < LightTextValueCutoff;
 
        
 
        public PaintColor()
 
        {
 
            Name  = "Undefined";
 
            Red   = 0;
 
            Green = 0;
 
            Blue  = 0;
 
        }
 

	
 
        public PaintColor(string name, string hexRed, string hexGreen, string hexBlue)
 
        {
 
            Name = name;
 
            Red =   byte.Parse(hexRed,   System.Globalization.NumberStyles.AllowHexSpecifier);
 
            Green = byte.Parse(hexGreen, System.Globalization.NumberStyles.AllowHexSpecifier);
 
            Blue =  byte.Parse(hexBlue,  System.Globalization.NumberStyles.AllowHexSpecifier);
 
        }
 

	
 
        public PaintColor(byte red, byte green, byte blue)
 
        {
 
            Name  = "Undefined";
 
            Red   = red;
 
            Green = green;
 
            Blue  = blue;
 
        }
 

	
 
        public PaintColor(PaintColor other)
 
        {
 
            Name  = other.Name;
 
            Red   = other.Red;
 
            Green = other.Green;
 
            Blue  = other.Blue;
 
        }
 

	
 
        public int GetDistanceSquared(PaintColor otherColor)
 
        {
 
            return (int)(Math.Pow(Red - otherColor.Red, 2) +
 
                Math.Pow(Green - otherColor.Green, 2) +
 
                Math.Pow(Blue - otherColor.Blue, 2));
 
        }
 

	
 
        public void Clear()
 
        {
 
            Red   = 0;
 
            Green = 0;
 
            Blue  = 0;
 
        }
 

	
 
        public void Set(PaintColor other)
 
        {
 
            Red   = other.Red;
 
            Green = other.Green;
 
            Blue  = other.Blue;
 
            Name  = other.Name;
 
        }
 

	
 
        public string ToHexString()
 
        {
 
            return "#" + Red.ToString("X2") + Green.ToString("X2") + Blue.ToString("X2");
 
        }
 

	
 
        public override string ToString()
 
        {
 
            return "[" + Name + ", " + Red + ", " + Green + ", " + Blue + "]";
 
        }
 
    }
 
}
Models/PaintRecipe.cs
Show inline comments
 
new file 100644
 
using DesertPaintCodex.Services;
 
using System;
 
using System.Collections.Generic;
 
using System.Diagnostics;
 

	
 
namespace DesertPaintCodex.Models
 
{
 
    public class PaintRecipe
 
    {
 
        public const uint PaintRecipeMinConcentration  = 10;
 
        public const uint RibbonRecipeMinConcentration = 50;
 

	
 
        private struct RGB
 
        {
 
            public int R;
 
            public int G;
 
            public int B;
 
        };
 

	
 
        public class ReagentQuantity
 
        {
 
            public readonly string Name;
 
            public uint Quantity;
 

	
 
            public ReagentQuantity(string name, uint quantity)
 
            {
 
                Name     = name;
 
                Quantity = quantity;
 
            }
 

	
 
            public ReagentQuantity(ReagentQuantity other)
 
            {
 
                Name     = other.Name;
 
                Quantity = other.Quantity;
 
            }
 

	
 
            public override string ToString()
 
            {
 
                return $"{Name} {Quantity}";
 
            }
 
        };
 
        
 
        
 
        private readonly List<ReagentQuantity> _recipe   = new();
 
        private readonly List<string>          _reagents = new();
 

	
 
        private readonly PaintColor _reactedColor = new();
 
        private readonly PaintColor _baseColor    = new();
 

	
 
        private bool _recalculate;
 

	
 
        public PaintRecipe()
 
        {
 
        }
 

	
 
        public PaintRecipe(PaintRecipe other)
 
        {
 
            _recalculate = true;
 
            foreach (string reagentName in other._reagents)
 
            {
 
                _reagents.Add(reagentName);
 
            }
 
            foreach (ReagentQuantity copyReagent in other._recipe)
 
            {
 
                ReagentQuantity ingredient = new(copyReagent);
 
                _recipe.Add(ingredient);
 
            }
 
        }
 

	
 
        public void CopyFrom(PaintRecipe other)
 
        {
 
            _recalculate = true;
 
            _reagents.Clear();
 
            foreach (string reagentName in other._reagents)
 
            {
 
                _reagents.Add(reagentName);
 
            }
 
            _recipe.Clear();
 
            foreach (ReagentQuantity otherIngredient in other._recipe)
 
            {
 
                ReagentQuantity ingredient = new(otherIngredient);
 
                _recipe.Add(ingredient);
 
            }
 
        }
 

	
 
        public override string ToString()
 
        {
 
            string result = PaletteService.FindNearest(ReactedColor);
 
            result += " |";
 
            foreach (ReagentQuantity ri in _recipe)
 
            {
 
                result += " " + ri;
 
            }
 
            return result;
 
        }
 

	
 
        public List<ReagentQuantity> Reagents => _recipe;
 

	
 
        public PaintColor ReactedColor
 
        {
 
            get
 
            {
 
                if (!_recalculate) return _reactedColor;
 
                ComputeBaseColor();
 
                ComputeReactedColor();
 
                _recalculate = false;
 
                return _reactedColor;
 
            }
 
        }
 

	
 
        public PaintColor BaseColor
 
        {
 
            get
 
            {
 
                if (!_recalculate) return _baseColor;
 
                ComputeBaseColor();
 
                ComputeReactedColor();
 
                _recalculate = false;
 
                return _baseColor;
 
            }
 
        }
 

	
 
        public void AddReagent(string reagentName, uint quantity = 1)
 
        {
 
            if (quantity == 0)
 
            {
 
                return;
 
            }
 
            Reagent reagent = ReagentService.GetReagent(reagentName);
 

	
 
            ReagentQuantity? reagentQty = _recipe.Find(x => x.Name.Equals(reagentName));
 
            if (reagentQty != null)
 
            {
 
                if (!reagent.IsCatalyst)
 
                {
 
                    reagentQty.Quantity += quantity;
 
                }
 
            }
 
            else
 
            {
 
                ReagentQuantity newReagentQty = new(reagentName, reagent.IsCatalyst ? 1 : quantity);
 
                _recipe.Add(newReagentQty);
 
            }
 
            _reagents.Add(reagentName);
 
            _recalculate = true;
 
        }
 

	
 
        public void Clear()
 
        {
 
            _reagents.Clear();
 
            _recipe.Clear();
 
            _recalculate = true;
 
        }
 

	
 
        private static byte CalculateColor(int baseSum, uint pigmentCount, int reactSum)
 
        {
 
            // Changed to Math.Floor from Math.Round, since Round appears to be incorrect.
 
            return (byte)Math.Max(Math.Min(Math.Floor((((float)baseSum / pigmentCount) + reactSum)), 255), 0);
 
        }
 

	
 
        // Compute the color including reactions based on the player's profile
 
        private void ComputeReactedColor()
 
        {
 
            RGB baseRGB;
 
            baseRGB.R = 0;
 
            baseRGB.G = 0;
 
            baseRGB.B = 0;
 
            RGB reactionColor;
 
            reactionColor.R = 0;
 
            reactionColor.G = 0;
 
            reactionColor.B = 0;
 

	
 
            uint pigmentCount = 0;
 
            
 
            ReactionSet? reactions = ProfileManager.CurrentProfile?.Reactions;
 
            Debug.Assert(reactions != null);
 
            
 
            // track visited reagents so the reaction is only applied once
 
            HashSet<string> reagentSet   = new();
 
            List<Reagent>                  prevReagents = new();
 

	
 
            foreach (ReagentQuantity ingredient in _recipe)
 
            {
 
                string reagentName = ingredient.Name;
 
                if (string.IsNullOrEmpty(reagentName))
 
                {
 
                    continue;
 
                }
 

	
 
                Reagent reagent = ReagentService.GetReagent(reagentName);
 

	
 
                if (!reagent.IsCatalyst)
 
                {
 
                    Debug.Assert(reagent.Color != null);
 
                    baseRGB.R += (reagent.Color.Red   * (int)ingredient.Quantity); 
 
                    baseRGB.G += (reagent.Color.Green * (int)ingredient.Quantity);
 
                    baseRGB.B += (reagent.Color.Blue  * (int)ingredient.Quantity);
 
                    pigmentCount += ingredient.Quantity;
 
                }
 

	
 
                if (!reagentSet.Contains(reagentName) && reagentSet.Count <= 4)
 
                {
 
                    reagentSet.Add(reagentName);
 
                    // Run reactions.
 
                    foreach (Reagent otherReagent in prevReagents)
 
                    {
 
                        Reaction? reaction = reactions.Find(otherReagent, reagent);
 
                        if (reaction != null)
 
                        {
 
                            reactionColor.R += reaction.Red;
 
                            reactionColor.G += reaction.Green;
 
                            reactionColor.B += reaction.Blue;
 
                        }
 
                    }
 
                    prevReagents.Add(reagent);
 
                }
 
            }
 
            _reactedColor.Red = CalculateColor(baseRGB.R, pigmentCount, reactionColor.R);
 
            _reactedColor.Green = CalculateColor(baseRGB.G, pigmentCount, reactionColor.G);
 
            _reactedColor.Blue = CalculateColor(baseRGB.B, pigmentCount, reactionColor.B);
 
        }
 

	
 
        // Compute the base color without any reactions
 
        private void ComputeBaseColor()
 
        {
 
            RGB baseRGB;
 
            baseRGB.R = 0;
 
            baseRGB.G = 0;
 
            baseRGB.B = 0;
 
            uint pigmentCount = 0;
 
            foreach (ReagentQuantity ingredient in _recipe)
 
            {
 
                string reagentName = ingredient.Name;
 
                if (string.IsNullOrEmpty(reagentName))
 
                {
 
                    continue;
 
                }
 

	
 
                Reagent reagent = ReagentService.GetReagent(reagentName);
 
                
 
                if (reagent.IsCatalyst) continue;
 
                
 
                Debug.Assert(reagent.Color != null);
 
                baseRGB.R    += (reagent.Color.Red   * (int)ingredient.Quantity);
 
                baseRGB.G    += (reagent.Color.Green * (int)ingredient.Quantity);
 
                baseRGB.B    += (reagent.Color.Blue  * (int)ingredient.Quantity);
 
                pigmentCount += ingredient.Quantity;
 
            }
 
            _baseColor.Red   = CalculateColor(baseRGB.R, pigmentCount, 0);
 
            _baseColor.Green = CalculateColor(baseRGB.G, pigmentCount, 0);
 
            _baseColor.Blue  = CalculateColor(baseRGB.B, pigmentCount, 0);
 
        }
 

	
 
        // Compute the base color without any reactions
 
        public uint Cost
 
        {
 
            get
 
            {
 
                uint cost = 0;
 
                foreach (ReagentQuantity ingredient in _recipe)
 
                {
 
                    string reagentName = ingredient.Name;
 
                    if (string.IsNullOrEmpty(reagentName))
 
                    {
 
                        continue;
 
                    }
 

	
 
                    Reagent reagent = ReagentService.GetReagent(reagentName);
 
                    cost += (reagent.Cost * ingredient.Quantity);
 
                }
 
                return cost;
 
            }
 
        }
 

	
 
        public uint GetBulkCost(uint quantity)
 
        {
 
            uint cost = 0;
 
            foreach (ReagentQuantity ingredient in _recipe)
 
            {
 
                string reagentName = ingredient.Name;
 
                if (string.IsNullOrEmpty(reagentName))
 
                {
 
                    continue;
 
                }
 

	
 
                Reagent reagent = ReagentService.GetReagent(reagentName);
 
                cost += (reagent.Cost * ingredient.Quantity);
 
            }
 
            uint batchCount = (uint)Math.Ceiling((double)quantity / cost);  // number of batches require to make quantity
 
            return batchCount * cost;
 
        }
 

	
 
        public bool IsValidForConcentration(uint concentration)
 
        {
 
            uint weight = 0;
 
            foreach (ReagentQuantity ingredient in _recipe)
 
            {
 
                string reagentName = ingredient.Name;
 
                if (string.IsNullOrEmpty(reagentName))
 
                {
 
                    continue;
 
                }
 

	
 
                Reagent reagent = ReagentService.GetReagent(reagentName);
 
                if (!reagent.IsCatalyst)
 
                {
 
                    weight += ingredient.Quantity;
 
                }
 
            }
 
            return (weight >= concentration);
 
        }
 
        public bool HasMissingReactions()
 
        {
 
            ReactionSet? reactions = ProfileManager.CurrentProfile?.Reactions;
 
            Debug.Assert(reactions != null);
 

	
 
            HashSet<string> reagentSet = new();
 
            List<Reagent> prevReagents = new();
 
            
 
            foreach (ReagentQuantity ingredient in _recipe)
 
            {
 
                if (reagentSet.Count > 4) return false;
 
                
 
                string reagentName = ingredient.Name;
 
                if (string.IsNullOrEmpty(reagentName)) continue;
 
                if (reagentSet.Contains(reagentName)) continue;
 
                reagentSet.Add(reagentName);
 
                
 
                Reagent reagent = ReagentService.GetReagent(reagentName);
 

	
 
                // Find reactions.
 
                foreach (Reagent otherReagent in prevReagents)
 
                {
 
                    Reaction? reaction = reactions.Find(otherReagent, reagent);
 
                    if (reaction == null) return true;
 
                }
 
                prevReagents.Add(reagent);
 
            }
 

	
 
            return false;
 
        }
 
    }
 
}
Models/PlayerProfile.cs
Show inline comments
 
new file 100644
 
using System;
 
using System.IO;
 
using System.IO.Compression;
 
using System.Collections.Generic;
 
using System.Text.RegularExpressions;
 
using DesertPaintCodex.Util;
 
using DesertPaintCodex.Services;
 

	
 
namespace DesertPaintCodex.Models
 
{
 
    public class PlayerProfile
 
    {
 
        private const string PaintRecipeFile = "dp_recipes.txt";
 
        private const string RibbonRecipeFile = "dp_ribbons.txt";
 
        
 
        private readonly string _reactFile;
 
        private readonly string _settingsFile;
 
        private readonly string _clipFile;
 

	
 
        private static readonly Regex _recipeHeaderRegex     = new(@"^--- Recipe: (?<colorname>(\w*\s)*\w+)\s*");
 
        private static readonly Regex _recipeIngredientRegex = new(@"(?<ingredient>(\w+\s)?\w+)\s*\|\s*(?<quantity>\d+)\s*");
 
        
 
        private Settings ProfileSettings { get; } = new();
 

	
 
        public string Directory { get; }
 

	
 
        public string Name { get; private set; }
 
        
 
        public ReactionSet Reactions { get; } = new();
 

	
 
        private Dictionary<string, Dictionary<string, ClipType>> Clippers { get; } = new();
 

	
 
        public string ReagentFile { get; }
 

	
 
        public Dictionary<string, PaintRecipe> Recipes { get; } = new();
 

	
 
        public Dictionary<string, PaintRecipe> RibbonRecipes { get; } = new();
 

	
 
        public int RecipeCount
 
        {
 
            get
 
            {
 
                int count = 0;
 
                foreach (PaintRecipe recipe in Recipes.Values)
 
                {
 
                    if (recipe.IsValidForConcentration(PaintRecipe.PaintRecipeMinConcentration))
 
                    {
 
                        ++count;
 
                    }
 
                }
 
                return count;
 
            }
 
        }
 

	
 
        public int RibbonCount
 
        {
 
            get
 
            {
 
                int count = 0;
 
                foreach (PaintRecipe recipe in RibbonRecipes.Values)
 
                {
 
                    if (recipe.IsValidForConcentration(PaintRecipe.RibbonRecipeMinConcentration))
 
                    {
 
                        ++count;
 
                    }
 
                }
 
                return count;
 
            }
 
        }
 
        
 
        public PlayerProfile(string name, string directory)
 
        {
 
            Name          = name;
 
            Directory     = directory;
 
            _reactFile    = Path.Combine(directory, "dp_reactions.txt");
 
            ReagentFile   = Path.Combine(directory, "ingredients.txt");
 
            _settingsFile = Path.Combine(directory, "settings");
 
            _clipFile     = Path.Combine(directory, "clips.txt");
 
            foreach (PaintColor color in PaletteService.Colors)
 
            {
 
                Recipes.Add(color.Name, new PaintRecipe());
 
            }
 
            foreach (PaintColor color in PaletteService.Colors)
 
            {
 
                RibbonRecipes.Add(color.Name, new PaintRecipe());
 
            }
 
        }
 

	
 
        public bool Initialize()
 
        {
 
            // Copy template files into new directory.
 
            string? templatePath = FileUtils.FindApplicationResourceDirectory("template");
 

	
 
            if (templatePath == null)
 
            {
 
                return false;
 
            }
 

	
 
            // Create new directory.
 
            System.IO.Directory.CreateDirectory(Directory);
 

	
 
            DirectoryInfo di = new(templatePath);
 
            FileInfo[] templateFiles = di.GetFiles();
 

	
 
            foreach (FileInfo file in templateFiles)
 
            {
 
                string destFile = Path.Combine(Directory, file.Name);
 
                File.Copy(file.FullName, destFile, true);
 
                if (!File.Exists(destFile)) return false;
 
            }
 
            return true;
 
        }
 

	
 
        private static void WriteReaction(TextWriter writer, string reagent1, string reagent2, string r, string g, string b)
 
        {
 
            writer.Write(reagent1);
 
            writer.Write(" ");
 
            writer.Write(reagent2);
 
            writer.Write(" ");
 
            writer.Write(r);
 
            writer.Write(" ");
 
            writer.Write(g);
 
            writer.Write(" ");
 
            writer.WriteLine(b);
 
        }
 

	
 
        public static void ConvertFromPP(string ppFile, string dpFile)
 
        {
 
            using StreamReader reader = new(ppFile);
 
            using StreamWriter writer = new(dpFile);
 
            string?            line;
 
            while ((line = reader.ReadLine()) != null)
 
            {
 
                string[] tokens = line.Split('|');
 
                //if ((tokens.Length > 0) && (tokens [0] != "//"))
 
                if ((tokens.Length != 5) && (tokens[0].Trim() != "//"))
 
                {
 
                    string reagent1  = tokens[0].Trim();
 
                    string reagent2  = tokens[1].Trim();
 
                    string colorCode = tokens[2].Trim();
 
                    string change1   = tokens[3].Trim();
 
                    string change2   = tokens[4].Trim();
 
                    // Write reaction.
 
                    switch (colorCode)
 
                    {
 
                        case "W":
 
                            WriteReaction(writer, reagent1, reagent2, change1, change1, change1);
 
                            WriteReaction(writer, reagent2, reagent1, change2, change2, change2);
 
                            break;
 
                        case "R":
 
                            WriteReaction(writer, reagent1, reagent2, change1, "0", "0");
 
                            WriteReaction(writer, reagent2, reagent1, change2, "0", "0");
 
                            break;
 
                        case "G":
 
                            WriteReaction(writer, reagent1, reagent2, "0", change1, "0");
 
                            WriteReaction(writer, reagent2, reagent1, "0", change2, "0");
 
                            break;
 
                        case "B":
 
                            WriteReaction(writer, reagent1, reagent2, "0", "0", change1);
 
                            WriteReaction(writer, reagent2, reagent1, "0", "0", change2);
 
                            break;
 
                    }
 
                }
 
            }
 
        }
 

	
 
        public bool SaveToPP(string ppFile)
 
        {
 
            Reaction? reaction1, reaction2;
 
            using (StreamWriter writer = new(ppFile))
 
            {
 
                foreach (string reagentName1 in ReagentService.Names)
 
                {
 
                    // TODO: could be more efficient by only iterating over the names after reagent1
 
                    foreach (string reagentName2 in ReagentService.Names)
 
                    {
 
                        if (reagentName1.Equals(reagentName2)) continue;
 

	
 
                        Reagent reagent1 = ReagentService.GetReagent(reagentName1);
 
                        Reagent reagent2 = ReagentService.GetReagent(reagentName2);
 
                        reaction1 = Reactions.Find(reagent1, reagent2);
 
                        
 
                        if (reaction1 is not {Exported: false}) continue;
 
                        
 
                        reaction2 = Reactions.Find(reagent2, reagent1);
 
                        
 
                        if (reaction2 == null) continue;
 
                        
 
                        writer.Write(reagent1.PracticalPaintName + " | " + reagent2.PracticalPaintName + " | ");
 
                        if ((Math.Abs(reaction1.Red) > Math.Abs(reaction1.Green)) ||
 
                            (Math.Abs(reaction2.Red) > Math.Abs(reaction2.Green)))
 
                        {
 
                            writer.WriteLine("R | " + reaction1.Red + " | " + reaction2.Red);
 
                        }
 
                        else if ((Math.Abs(reaction1.Green) > Math.Abs(reaction1.Red)) ||
 
                            (Math.Abs(reaction2.Green) > Math.Abs(reaction2.Red)))
 
                        {
 
                            writer.WriteLine("G | " + reaction1.Green + " | " + reaction2.Green);
 
                        }
 
                        else if ((Math.Abs(reaction1.Blue) > Math.Abs(reaction1.Red)) ||
 
                            (Math.Abs(reaction2.Blue) > Math.Abs(reaction2.Red)))
 
                        {
 
                            writer.WriteLine("B | " + reaction1.Blue + " | " + reaction2.Blue);
 
                        }
 
                        else
 
                        {
 
                            writer.WriteLine("W | " + reaction1.Red + " | " + reaction2.Red);
 
                        }
 
                        reaction1.Exported = true;
 
                        reaction2.Exported = true;
 
                    }
 
                }
 
            }
 

	
 
            // Clear Exported flags.
 
            foreach (string reagentName1 in ReagentService.Names)
 
            {
 
                // TODO: could be more efficient by only iterating over the names after reagent1
 
                foreach (string reagentName2 in ReagentService.Names)
 
                {
 
                    if (reagentName1.Equals(reagentName2))
 
                    {
 
                        continue;
 
                    }
 
                    Reagent reagent1 = ReagentService.GetReagent(reagentName1);
 
                    Reagent reagent2 = ReagentService.GetReagent(reagentName2);
 
                    reaction1 = Reactions.Find(reagent1, reagent2);
 
                    if (reaction1 != null)
 
                    {
 
                        reaction1.Exported = false;
 
                    }
 
                    reaction2 = Reactions.Find(reagent2, reagent1);
 
                    if (reaction2 != null)
 
                    {
 
                        reaction2.Exported = false;
 
                    }
 
                }
 
            }
 
            return true;
 
        }
 

	
 
        public void ImportFromPP(string importDir)
 
        {
 
            // Convert old file.
 
            ConvertFromPP(
 
                Path.Combine(importDir, "reactions.txt"),
 
                _reactFile);
 
            try
 
            {
 
                // If there is an ingredients file, move it in.
 
                File.Copy(
 
                    Path.Combine(importDir, "ingredients.txt"),
 
                    Path.Combine(Directory, "ingredients.txt"),
 
                    true);
 
            }
 
            catch (Exception)
 
            {
 
                // If there is no ingredients file, we don't really care.	
 
            }
 
        }
 

	
 
        public void Import(string file)
 
        {
 
            ZipFile.ExtractToDirectory(file, Directory);
 
        }
 

	
 
        public void Export(string file)
 
        {
 
            ZipFile.CreateFromDirectory(Directory, file);
 
        }
 

	
 
        public bool Load()
 
        {
 
            string? line;
 
            ProfileSettings.Reset();
 
            ProfileSettings.Load(_settingsFile);
 
            Reactions.Clear();
 
            if (File.Exists(ReagentFile))
 
            {
 
                ReagentService.LoadProfileReagents(ReagentFile);
 
            }
 
            else
 
            {
 
                return false;
 
            }
 
            ReagentService.InitializeReactions(Reactions);
 
            if (!File.Exists(_reactFile))
 
            {
 
                return false;
 
            }
 
            using (StreamReader reader = new(_reactFile))
 
            {
 
                while ((line = reader.ReadLine()) != null)
 
                {
 
                    string[] tokens = line.Split(' ');
 
                    if (tokens.Length == 5)
 
                    {
 
                        Reagent reagent1 = ReagentService.GetReagent(tokens[0].Trim());
 
                        Reagent reagent2 = ReagentService.GetReagent(tokens[1].Trim());
 
                        Reaction reaction = new(
 
                            int.Parse(tokens[2].Trim()),
 
                            int.Parse(tokens[3].Trim()),
 
                            int.Parse(tokens[4].Trim())
 
                            );
 
                        Reactions.Set(reagent1, reagent2, reaction);
 
                    }
 
                }
 
            }
 

	
 
            if (!File.Exists(_clipFile)) return true;
 
            {
 
                using StreamReader reader = new StreamReader(_clipFile);
 
                while ((line = reader.ReadLine()) != null)
 
                {
 
                    string[] tokens = line.Split(' ');
 
                    
 
                    if (tokens.Length != 3) continue;
 
                    
 
                    string reagent1 = tokens[0].Trim();
 
                    if (!Clippers.ContainsKey(reagent1))
 
                    {
 
                        Clippers.Add(reagent1, new Dictionary<string, ClipType>());
 
                    }
 
                    Clippers[reagent1][tokens[1].Trim()] = (ClipType)int.Parse(tokens[2].Trim());
 
                }
 
            }
 

	
 
            return true;
 
        }
 

	
 
        public void Save()
 
        {
 
            ProfileSettings.Save(_settingsFile);
 
            Reaction? reaction;
 
            using (StreamWriter writer = new(_reactFile, false))
 
            {
 
                foreach (string reagentName1 in ReagentService.Names)
 
                {
 
                    // TODO: could be more efficient by only iterating over the names after reagent1
 
                    foreach (string reagentName2 in ReagentService.Names)
 
                    {
 
                        if (reagentName1.Equals(reagentName2))
 
                        {
 
                            continue;
 
                        }
 
                        Reagent reagent1 = ReagentService.GetReagent(reagentName1);
 
                        Reagent reagent2 = ReagentService.GetReagent(reagentName2);
 
                        reaction = Reactions.Find(reagent1, reagent2);
 
                        if (reaction != null)
 
                        {
 
                            writer.WriteLine(reagent1.PracticalPaintName + " " + reagent2.PracticalPaintName + " " +
 
                            reaction.Red + " " + reaction.Green + " " + reaction.Blue);
 
                        }
 
                    }
 
                }
 
            }
 
            using (StreamWriter writer = new StreamWriter(_clipFile, false))
 
            {
 
                foreach (var item1 in Clippers)
 
                {
 
                    foreach (var item2 in item1.Value)
 
                    {
 
                        writer.WriteLine(item1.Key + " " + item2.Key + " " + (int)item2.Value);
 
                    }
 
                }
 
            }
 
        }
 

	
 
        public ClipType PairClipStatus(string reagent1, string reagent2)
 
        {
 
            if (Clippers.TryGetValue(reagent1, out var item1))
 
            {
 
                if (item1.TryGetValue(reagent2, out var clipType))
 
                {
 
                    return clipType;
 
                }
 
            }
 
            return ClipType.None;
 
        }
 

	
 
        private void LoadRecipes(Dictionary<string, PaintRecipe> recipeDict, string filename, uint concentration)
 
        {
 
            foreach (PaintRecipe recipe in recipeDict.Values)
 
            {
 
                recipe.Clear();
 
            }
 
            string      recipeFile         = Path.Combine(Directory, filename);
 
            bool        inRecipe           = false;
 
            PaintRecipe testRecipe         = new();
 
            string?     currentRecipeColor = null;
 
            
 
            if (!File.Exists(recipeFile)) return;
 
            
 
            using StreamReader reader = new(recipeFile);
 
            
 
            string? line;
 
            while ((line = reader.ReadLine()) != null)
 
            {
 
                Match match = _recipeHeaderRegex.Match(line);
 
                if (match.Success)
 
                {
 
                    // Store previous recipe.
 
                    if ((currentRecipeColor != null) && testRecipe.IsValidForConcentration(concentration))
 
                    {
 
                        SetRecipe(currentRecipeColor, testRecipe);
 
                    }
 
                    testRecipe.Clear();
 
                    currentRecipeColor = match.Groups["colorname"].Value;
 
                    inRecipe  = true;
 
                }
 
                else if (inRecipe)
 
                {
 
                    match = _recipeIngredientRegex.Match(line);
 
                    
 
                    if (!match.Success) continue;
 
                    
 
                    string ingredient = match.Groups["ingredient"].Value;
 
                    uint   quantity   = uint.Parse(match.Groups["quantity"].Value);
 
                    
 
                    testRecipe.AddReagent(ingredient, quantity);
 
                }
 
            }
 

	
 
            if (!inRecipe || (currentRecipeColor == null)) return;
 
            
 
            // Store final recipe.
 
            if (testRecipe.IsValidForConcentration(concentration))
 
            {
 
                SetRecipe(currentRecipeColor, testRecipe);
 
            }
 
        }
 

	
 
        private void SaveRecipes(Dictionary<string, PaintRecipe> recipeDict, string filename)
 
        {
 
            string recipeFile = Path.Combine(Directory, filename);
 
            
 
            using StreamWriter writer = new(recipeFile, false);
 
            
 
            foreach (KeyValuePair<string, PaintRecipe> pair in recipeDict)
 
            {
 
                writer.WriteLine("--- Recipe: {0}", pair.Key);
 
                foreach (PaintRecipe.ReagentQuantity ingredient in pair.Value.Reagents)
 
                {
 
                    writer.WriteLine("{0,-14} | {1}", ingredient.Name, ingredient.Quantity);
 
                }
 
            }
 
        }
 

	
 
        private void DeleteRecipes(Dictionary<string, PaintRecipe> recipeDict, string filename)
 
        {
 
            string recipeFile = Path.Combine(Directory, filename);
 
            
 
            File.Delete(recipeFile);
 
            recipeDict.Clear();
 
        }
 

	
 
        public void LoadRecipes()
 
        {
 
            LoadRecipes(Recipes, PaintRecipeFile, PaintRecipe.PaintRecipeMinConcentration);
 
            LoadRecipes(RibbonRecipes, RibbonRecipeFile, PaintRecipe.RibbonRecipeMinConcentration);
 
        }
 

	
 
        public void SaveRecipes()
 
        {
 
            SaveRecipes(Recipes, PaintRecipeFile);
 
            SaveRecipes(RibbonRecipes, RibbonRecipeFile);
 
        }
 

	
 
        public void ClearRecipes()
 
        {
 
            DeleteRecipes(Recipes, PaintRecipeFile);
 
            DeleteRecipes(RibbonRecipes, RibbonRecipeFile);
 
        }
 

	
 
        public void ClearPaintRecipes()
 
        {
 
            DeleteRecipes(Recipes, PaintRecipeFile);
 
        }
 

	
 
        public void ClearRibbonRecipes()
 
        {
 
            DeleteRecipes(RibbonRecipes, RibbonRecipeFile);
 
        }
 

	
 
        public void ExportWikiRecipes(string file)
 
        {
 
            StreamWriter writer = new(file);
 
            ExportWikiFormat(writer, Recipes);
 
        }
 

	
 
        public void ExportWikiRibbons(string file)
 
        {
 
            StreamWriter writer = new StreamWriter(file);
 
            ExportWikiFormat(writer, this.RibbonRecipes);
 
        }
 

	
 
        public void ExportWikiRecipes(TextWriter writer)
 
        {
 
            ExportWikiFormat(writer, this.Recipes);
 
        }
 

	
 
        public void ExportWikiRibbons(TextWriter writer)
 
        {
 
            ExportWikiFormat(writer, this.RibbonRecipes);
 
        }
 
        
 
        public static void ExportWikiFormat(TextWriter writer, Dictionary<string, PaintRecipe> recipeDict)
 
        {
 
            using (writer)
 
            {
 
                writer.WriteLine("{| class='wikitable sortable' border=\"1\" style=\"background-color:#DEB887;\"");
 
                writer.WriteLine("! Color !! Recipe !! Missing Reactions? || Verified");
 
                foreach (PaintColor color in PaletteService.Colors)
 
                {
 
                    writer.WriteLine("|-");
 
                    string colorLine = "| ";
 
                    colorLine += "style=\"font-weight: bold; background-color: #" + color.Red.ToString("X2") + color.Green.ToString("X2") + color.Blue.ToString("X2") + ";";
 
                    
 
                    if (color.UseWhiteText)
 
                    {
 
                        // dark color gets light text
 
                        colorLine += " color: #FFFFFF;";
 
                    }
 
                    else
 
                    {
 
                        colorLine += "color: #000000;";
 
                    }
 
                    colorLine += "\" | " + color.Name + " || ";
 
                    if (recipeDict.TryGetValue(color.Name, out PaintRecipe? recipe))
 
                    {
 
                        foreach (PaintRecipe.ReagentQuantity ingredient in recipe.Reagents)
 
                        {
 
                            colorLine += " " + ingredient;
 
                        }
 
                    }
 
                    else
 
                    {
 
                        // no recipe
 
                    }
 
                    colorLine += " || ";
 

	
 
                    if (recipe == null)
 
                    {
 
                        colorLine += "?";
 
                    }
 
                    else if (recipe.HasMissingReactions())
 
                    {
 
                        colorLine += "Y";
 
                    }
 
                    else
 
                    {
 
                        colorLine += "N";
 
                    }
 

	
 
                    colorLine += " || N";
 
                    writer.WriteLine(colorLine);
 
                }
 
                writer.WriteLine("|}");
 
            }
 
        }
 

	
 
        public Reaction? FindReaction(Reagent? reagent1, Reagent? reagent2)
 
        {
 
            if ((reagent1 == null) || (reagent2 == null)) return null;
 
            return Reactions.Find(reagent1, reagent2);
 
        }
 

	
 
        public void SetReaction(Reagent reagent1, Reagent reagent2, Reaction reaction)
 
        {
 
            Reactions.Set(reagent1, reagent2, reaction);
 
        }
 

	
 
        public void ClearReaction(Reagent reagent1, Reagent reagent2)
 
        {
 
            Reactions.Remove(reagent1, reagent2);
 
        }
 

	
 
        public void SetRecipe(PaintRecipe recipe)
 
        {
 
            SetRecipe(PaletteService.FindNearest(recipe.ReactedColor), recipe);
 
        }
 

	
 
        public void SetRecipe(string colorName, PaintRecipe recipe)
 
        {
 
            if (Recipes.TryGetValue(colorName, out PaintRecipe? profileRecipe))
 
            {
 
                profileRecipe.CopyFrom(recipe);
 
            }
 
            else
 
            {
 
                Recipes.Add(colorName, new PaintRecipe(recipe));
 
            }
 
        }
 

	
 
        public void SetRibbonRecipe(PaintRecipe recipe)
 
        {
 
            SetRibbonRecipe(PaletteService.FindNearest(recipe.ReactedColor), recipe);
 
        }
 

	
 
        public void SetRibbonRecipe(string colorName, PaintRecipe recipe)
 
        {
 
            if (RibbonRecipes.TryGetValue(colorName, out PaintRecipe? profileRecipe))
 
            {
 
                profileRecipe.CopyFrom(recipe);
 
            }
 
            else
 
            {
 
                RibbonRecipes.Add(colorName, new PaintRecipe(recipe));
 
            }
 
        }
 
    }
 
}
Models/Reaction.cs
Show inline comments
 
new file 100644
 
namespace DesertPaintCodex.Models
 
{
 
    public class Reaction
 
    {
 
        public int Red { get; }
 
        public int Green { get; }
 
        public int Blue { get; }
 
        public bool Exported { get; set; }
 

	
 
        public Reaction(int r, int g, int b)
 
        {
 
            Red = r;
 
            Green = g;
 
            Blue = b;
 
        }
 

	
 
        public override string ToString()
 
        {
 
            return "[" + Red + ", " + Green + ", " + Blue + "]";
 
        }
 
    }
 
}
Models/ReactionSet.cs
Show inline comments
 
new file 100644
 
using System.Collections.Generic;
 

	
 
namespace DesertPaintCodex.Models
 
{
 
    public class ReactionSet
 
    {
 
        // ingredient -> [ingredient, reaction]
 
        private readonly Dictionary<string, Dictionary<string, Reaction?>> _reactions = new();
 

	
 
        public Reaction? Find(Reagent reagent1, Reagent reagent2)
 
        {
 
            Reaction? reaction = null;
 
            _reactions.TryGetValue(reagent1.PracticalPaintName, out Dictionary<string, Reaction?>? secondReagentDict);
 
            secondReagentDict?.TryGetValue(reagent2.PracticalPaintName, out reaction);
 
            return reaction;
 
        }
 

	
 
        public void Set(Reagent reagent1, Reagent reagent2, Reaction? reaction)
 
        {
 
            _reactions.TryGetValue(reagent1.PracticalPaintName, out Dictionary<string, Reaction?>? secondReagentDict);
 
            if (secondReagentDict == null)
 
            {
 
                secondReagentDict = new Dictionary<string, Reaction?>();
 
                _reactions.Add(reagent1.PracticalPaintName, secondReagentDict);
 
            }
 
            secondReagentDict[reagent2.PracticalPaintName] = reaction;
 
        }
 

	
 
        public void Remove(Reagent reagent1, Reagent reagent2)
 
        {
 
            _reactions.TryGetValue(reagent1.PracticalPaintName, out Dictionary<string, Reaction?>? secondReagentDict);
 
            secondReagentDict?.Remove(reagent2.PracticalPaintName);
 
        }
 

	
 
        public void Clear()
 
        {
 
            _reactions.Clear();
 
        }
 
    }
 
}
Models/ReactionTest.cs
Show inline comments
 
new file 100644
 
using System;
 
using System.ComponentModel;
 
using System.Diagnostics;
 
using System.Runtime.CompilerServices;
 
using System.Threading.Tasks;
 
using DesertPaintCodex.Services;
 
using JetBrains.Annotations;
 

	
 
namespace DesertPaintCodex.Models
 
{
 
    public class ReactionTest : INotifyPropertyChanged, IProgress<float>, IComparable<ReactionTest>
 
    {
 
        public enum TestState
 
        {
 
            Unset = -1,
 
            Untested,
 
            Scanning,
 
            LabNotFound,
 
            ClippedResult,
 
            GoodResult,
 
            Saved
 
        }
 

	
 
        public Reagent Reagent1 { get; }
 
        public Reagent Reagent2 { get; }
 

	
 
        private Reagent? _bufferReagent;
 
        public Reagent? BufferReagent
 
        {
 
            get => _bufferReagent;
 
            set
 
            {
 
                if (_bufferReagent == value) return;
 
                _bufferReagent = value;
 
                _recipe.Clear();
 
                if (_bufferReagent == null)
 
                {
 
                    _recipe.AddReagent(Reagent1.Name);
 
                    _recipe.AddReagent(Reagent2.Name);
 
                }
 
                else
 
                {
 
                    _recipe.AddReagent(_bufferReagent.Name);
 
                    _recipe.AddReagent(Reagent1.Name);
 
                    _recipe.AddReagent(Reagent2.Name);
 
                }
 
                NotifyPropertyChanged(nameof(BufferReagent));
 
                NotifyPropertyChanged(nameof(HypotheticalColor));
 
                NotifyPropertyChanged(nameof(CanScan));
 
            }
 
        }
 

	
 
        private ClipType _clipType;
 
        public ClipType Clipped {
 
            get => _clipType;
 
            set
 
            {
 
                _clipType = value;
 
                NotifyPropertyChanged(nameof(Clipped));
 
            }
 
        }
 

	
 
        public bool IsAllCatalysts { get; }
 

	
 
        private Reaction? _reaction;
 
        public Reaction? Reaction { get => _reaction; set { _reaction = value; NotifyPropertyChanged(nameof(Reaction)); } }
 

	
 
        private Reaction? _badReaction;
 
        public Reaction? BadReaction { get => _badReaction; set { _badReaction = value; NotifyPropertyChanged(nameof(BadReaction)); } }
 

	
 
        private int _scanProgress;
 
        public int ScanProgress { get => _scanProgress; set { _scanProgress = value; NotifyPropertyChanged(nameof(ScanProgress)); } }
 
        
 
        
 
        private TestState _state;
 

	
 
        public TestState State
 
        {
 
            get => _state;
 
            set
 
            {
 
                _state = value;
 
                NotifyPropertyChanged(nameof(State));
 
                NotifyPropertyChanged(nameof(Requires3Way));
 
                NotifyPropertyChanged(nameof(CanScan));
 
                NotifyPropertyChanged(nameof(IsScanning));
 
                NotifyPropertyChanged(nameof(HasResults));
 
                NotifyPropertyChanged(nameof(HasReaction));
 
                NotifyPropertyChanged(nameof(CanClear));
 
                NotifyPropertyChanged(nameof(CanSave));
 
                NotifyPropertyChanged(nameof(NoLab));
 
            }
 
        }
 

	
 
        public bool Requires3Way =>
 
            (State == TestState.ClippedResult) || IsAllCatalysts;
 

	
 
        public bool CanScan => (State == TestState.Untested) || (State == TestState.LabNotFound) ||
 
            ((State == TestState.ClippedResult) && (BufferReagent != null));
 
        
 
        public bool IsScanning => State == TestState.Scanning;
 

	
 
        public bool HasResults => State is TestState.ClippedResult or TestState.GoodResult or TestState.LabNotFound;
 

	
 
        public bool HasReaction => State is TestState.ClippedResult or TestState.GoodResult or TestState.Saved;
 
        
 
        public bool CanClear => State is TestState.ClippedResult or TestState.GoodResult or TestState.Saved;
 
        
 
        public bool CanSave => State == TestState.GoodResult;
 

	
 
        public bool NoLab => State == TestState.LabNotFound;
 
        
 

	
 
        public PaintColor? HypotheticalColor => (IsAllCatalysts && (BufferReagent == null)) ? null : _recipe.BaseColor;
 

	
 
        private PaintColor? _observedColor;
 
        public PaintColor? ObservedColor { get => _observedColor; set { _observedColor = value; NotifyPropertyChanged(nameof(ObservedColor)); } }
 

	
 
        private readonly PaintRecipe _recipe = new();
 

	
 
        public bool IsStub { get; }
 

	
 

	
 
        public ReactionTest(Reagent reagent1, Reagent reagent2, Reaction? reaction, ClipType clipType, bool isStub = false)
 
        {
 
            Reagent1 = reagent1;
 
            Reagent2 = reagent2;
 
            IsAllCatalysts = reagent1.IsCatalyst && reagent2.IsCatalyst;
 
            Clipped = clipType;
 
            Reaction = reaction;
 
            State = (reaction != null) ? TestState.Saved :
 
                (clipType == ClipType.None) ? TestState.Untested : TestState.ClippedResult;
 
            _recipe.AddReagent(reagent1.Name);
 
            _recipe.AddReagent(reagent2.Name);
 
            IsStub = isStub;
 
        }
 

	
 
        
 
        #region Actions
 
        public async Task StartScan()
 
        {
 
            Clipped = ClipType.None;
 
            ScanProgress = 0;
 
            Reaction = null;
 
            BadReaction = null;
 
            State = TestState.Scanning;
 
            bool foundLab = await ReactionScannerService.Instance.CaptureReactionAsync(this);
 
            if (foundLab)
 
            {
 
                ObservedColor = ReactionScannerService.Instance.RecordedColor;
 
                if (_observedColor != null)
 
                {
 
                    Clipped = _observedColor.Red switch
 
                        {
 
                            0   => ClipType.RedLow,
 
                            255 => ClipType.RedHigh,
 
                            _   => ClipType.None
 
                        }
 
                        | _observedColor.Green switch
 
                        {
 
                            0   => ClipType.GreenLow,
 
                            255 => ClipType.GreenHigh,
 
                            _   => ClipType.None
 
                        }
 
                        | _observedColor.Blue switch
 
                        {
 
                            0   => ClipType.BlueLow,
 
                            255 => ClipType.BlueHigh,
 
                            _   => ClipType.None
 
                        };
 

	
 
                    if (Clipped == ClipType.None)
 
                    {
 
                        State = TestState.GoodResult;
 
                        Reaction = CalculateReaction();
 

	
 
                    }
 
                    else
 
                    {
 
                        State = TestState.ClippedResult;
 
                        BadReaction = CalculateReaction();
 
                    }
 
                }
 
            }
 
            else
 
            {
 
                Debug.WriteLine("ERROR: Lab UI not found.");
 
                State = TestState.LabNotFound;
 
            }
 
        }
 

	
 
        public void CancelScan()
 
        {
 
            ReactionScannerService.Instance.CancelScan();
 
            State = TestState.Untested;
 
        }
 

	
 
        public void MarkInert()
 
        {
 
            Reaction = new Reaction(0, 0, 0);
 
            State = TestState.GoodResult;
 
        }
 

	
 
        public void ClearReaction()
 
        {
 
            PlayerProfile? profile = ProfileManager.CurrentProfile;
 
            if (profile == null) return;
 
            profile.Reactions.Remove(Reagent1, Reagent2);
 
            if (State == TestState.Saved)
 
            {
 
                profile.Save();
 
            }
 
            
 
            Reaction = null;
 
            BadReaction = null;
 
            State = TestState.Untested;
 
        }
 

	
 
        public void SaveReaction()
 
        {
 
            PlayerProfile? profile = ProfileManager.CurrentProfile;
 
            if (profile == null) return;
 
            profile.Reactions.Set(Reagent1, Reagent2, Reaction);
 
            profile.Save();
 
            State = TestState.Saved;
 
        }
 
        
 
        #endregion
 
        
 
        #region Internals
 

	
 
        private Reaction? CalculateReaction()
 
        {
 
            if (ProfileManager.CurrentProfile == null) return null;
 
            if (HypotheticalColor == null) return null;
 
            if (ObservedColor == null) return null;
 

	
 
            if (Requires3Way)
 
            {
 
                if (BufferReagent == null) return null;
 
                return ReactionScannerService.Calculate3WayReaction(ProfileManager.CurrentProfile, HypotheticalColor,
 
                    ObservedColor, BufferReagent, Reagent1, Reagent2);
 
            }
 
            return ReactionScannerService.CalculateReaction(HypotheticalColor, ObservedColor);
 
        }
 
        
 
        
 
        #endregion
 
        
 
        
 

	
 
        #region Interface Implementations
 
        
 
        public event PropertyChangedEventHandler? PropertyChanged;
 

	
 
        [NotifyPropertyChangedInvocator]
 
        private void NotifyPropertyChanged([CallerMemberName] string? propertyName = null)
 
        {
 
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
 
        }
 

	
 
        public void Report(float value)
 
        {
 
            ScanProgress = (int)Math.Round(value * 100);
 
        }
 
        
 
        #endregion
 

	
 
        public int CompareTo(ReactionTest? other)
 
        {
 
            if (other == null) return 1;
 
            
 
            if (Clipped == ClipType.None)
 
            {
 
                if (other.Clipped != ClipType.None) return -1;
 
            }
 
            else if (other.Clipped == ClipType.None) return 1;
 

	
 
            if (IsAllCatalysts)
 
            {
 
                if (!other.IsAllCatalysts) return 1;
 
            }
 
            else if (other.IsAllCatalysts) return -1;
 
            
 
            return string.CompareOrdinal(Reagent1.Name, other.Reagent1.Name) switch
 
            {
 
                < 0 => -1,
 
                > 0 => 1,
 
                _   => string.CompareOrdinal(Reagent2.Name, other.Reagent2.Name)
 
            };
 
        }
 
    }
 
}
Models/ReactionTestService.cs
Show inline comments
 
new file 100644
 
using System.Collections.Generic;
 
using System.Collections.ObjectModel;
 
using System.Diagnostics;
 
using System.Linq;
 
using DesertPaintCodex.Services;
 
using DynamicData;
 

	
 
namespace DesertPaintCodex.Models
 
{
 
    public static class ReactionTestService
 
    {
 
        private static readonly List<ReactionTest> _allTests = new();
 
        
 
        static ReactionTestService()
 
        {
 
            
 
        }
 

	
 
        public static void Initialize()
 
        {
 
            _allTests.Clear();
 
            PlayerProfile? profile = ProfileManager.CurrentProfile;
 
            
 
            Debug.Assert(profile != null);
 
            
 
            List<string> reagentNames = ReagentService.Names;
 

	
 
            foreach (Reagent reagent1 in reagentNames.Select(ReagentService.GetReagent))
 
            {
 
                foreach (Reagent reagent2 in reagentNames.Select(ReagentService.GetReagent))
 
                {
 
                    if (reagent1 == reagent2) continue;
 

	
 
                    Reaction? reaction = profile.FindReaction(reagent1, reagent2);
 
                    ClipType clipType = profile.PairClipStatus(reagent1.Name, reagent2.Name);
 
                    
 
                    ReactionTest test = new(reagent1, reagent2, reaction, clipType)
 
                    {
 
                        Clipped  = clipType,
 
                        Reaction = reaction,
 
                       
 
                    };
 

	
 
                    _allTests.Add(test);
 
                }
 
            }
 
        }
 

	
 
        public static void PopulateRemainingTests(ObservableCollection<ReactionTest> collection)
 
        {
 
            collection.Clear();
 
            collection.AddRange(_allTests.Where(test => test.State != ReactionTest.TestState.Saved).OrderBy(test => test));
 
        }
 
        
 
        public static void PopulateCompletedTests(ObservableCollection<ReactionTest> collection)
 
        {
 
            collection.Clear();
 
            collection.AddRange(_allTests.Where(test => test.State == ReactionTest.TestState.Saved).OrderBy(test => test));
 
        }
 
        
 
    }
 
}
...
 
\ No newline at end of file
Models/Reagent.cs
Show inline comments
 
new file 100644
 
using System;
 

	
 
namespace DesertPaintCodex.Models
 
{
 
    public class Reagent
 
    {
 
        private uint _cost;
 
        private uint _recipeMax = 10;
 

	
 
        public bool IsCatalyst { get; }
 
        public PaintColor? Color { get; }
 
        public string Name { get; }
 
        public string PracticalPaintName { get; }
 
        public bool Enabled { get; set; }
 

	
 
        public uint Cost
 
        {
 
            get => _cost;
 
            set => _cost = Math.Max(1, value);
 
        }
 

	
 
        public uint RecipeMax
 
        {
 
            get => _recipeMax;
 
            set
 
            {
 
                if (!IsCatalyst)
 
                {
 
                    _recipeMax = Math.Max(0, value);
 
                }
 
            }
 
        }
 

	
 
        // catalyst
 
        public Reagent(string name, string ppName)
 
        {
 
            Name = name;
 
            PracticalPaintName = ppName;
 
            Cost = 2;
 
            Enabled = true;
 
            RecipeMax = 1;
 
            IsCatalyst = true;
 
        }
 

	
 
        public Reagent(string name, string ppName, byte red, byte green, byte blue)
 
        {
 
            Color = new PaintColor(red, green, blue);
 
            Name = name;
 
            PracticalPaintName = ppName;
 
            Cost = 1;
 
            RecipeMax = 10;
 
            Enabled = true;
 
            IsCatalyst = false;
 
        }
 

	
 
        public override string ToString()
 
        {
 
            if (IsCatalyst)
 
            {
 
                return "[" + Name + ", catalyst]";
 
            }
 
            else
 
            {
 
                return "[" + Name + ", " + Color + "]";
 
            }
 
        }
 
    }
 
}
Models/RecipeItem.cs
Show inline comments
 
new file 100644
 
using System.ComponentModel;
 
using System.Runtime.CompilerServices;
 
using JetBrains.Annotations;
 

	
 
namespace DesertPaintCodex.Models
 
{
 
    public class RecipeItem : INotifyPropertyChanged
 
    {
 
        private const uint PigmentMax = 25;
 
        private Reagent _reagent;
 

	
 
        public Reagent Reagent
 
        {
 
            get => _reagent;
 
            set
 
            {
 
                _reagent = value;
 
                NotifyPropertyChanged(nameof(Reagent));
 
            }
 
        }
 

	
 
        private uint _quantity;
 

	
 
        public uint Quantity
 
        {
 
            get => _quantity;
 
            set
 
            {
 
                _quantity = value;
 
                NotifyPropertyChanged(nameof(Quantity));
 
            }
 
        }
 

	
 
        private uint _maxQty;
 

	
 
        public uint MaxQty
 
        {
 
            get => _maxQty;
 
            set
 
            {
 
                _maxQty = value;
 
                NotifyPropertyChanged(nameof(MaxQty));
 
            }
 
        }
 

	
 
        private bool _first;
 

	
 
        public bool First
 
        {
 
            get => _first;
 
            set
 
            {
 
                _first = value;
 
                NotifyPropertyChanged(nameof(First));
 
            }
 
        }
 

	
 
        private bool _last;
 

	
 
        public bool Last
 
        {
 
            get => _last;
 
            set
 
            {
 
                _last = value;
 
                NotifyPropertyChanged(nameof(Last));
 
            }
 
        }
 

	
 
        private bool _unused;
 
        public bool Unused
 
        {
 
            get => _unused;
 
            set
 
            {
 
                _unused = value;
 
                NotifyPropertyChanged(nameof(Unused));
 
            }
 
        }
 

	
 
        public RecipeItem(Reagent reagent, uint quantity)
 
        {
 
            _reagent = reagent;
 
            Quantity = quantity;
 
            MaxQty = reagent.IsCatalyst ? 1 : PigmentMax;
 
        }
 

	
 
        public override string ToString()
 
        {
 
            return $"{Reagent.Name} {Quantity}";
 
        }
 

	
 
        public event PropertyChangedEventHandler? PropertyChanged;
 

	
 
        [NotifyPropertyChangedInvocator]
 
        private void NotifyPropertyChanged([CallerMemberName] string? propertyName = null)
 
        {
 
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
 
        }
 
    }
 
}
...
 
\ No newline at end of file
Models/RecipeSearchNode.cs
Show inline comments
 
new file 100644
 
using System;
 
using System.Collections.Generic;
 
using System.IO;
 
using System.Text.RegularExpressions;
 

	
 
namespace DesertPaintCodex.Models
 
{
 
    public class RecipeSearchNode
 
    {
 
        private readonly uint[] _reagents;
 
        private readonly uint _invalidReagent;
 

	
 
        private int _nextReagentPos;
 
        public int ReagentCount => _nextReagentPos;
 

	
 
        public uint MinConcentration { get; set; } = 10;
 

	
 
        private readonly bool[] _reagentInUse;
 
        private readonly List<Reagent> _costSortedReagents;
 
        public PaintRecipe? TestRecipe { get; set; }
 

	
 
        public uint CurrentTargetQuantity { get; set; }
 
        public uint MaxConcentration { get; set; }
 
        public uint UsedQuantity { get; private set; }
 
        public uint CatalystCount { get; set; }
 
        public uint FullQuantityDepth { get; set; }
 
        public uint FullQuantity { get; set; }
 
        public uint MinReagents { get; set; }
 

	
 
        private uint _maxReagents;
 
        public uint MaxReagents
 
        {
 
            get => _maxReagents;
 
            set
 
            {
 
                _maxReagents = value;
 
                CurrentWeights = new uint[_maxReagents];
 
            }
 
        }
 

	
 
        public uint[] CurrentWeights { get; private set; }
 
        
 
        public uint LastReagent => _reagents[_nextReagentPos - 1];
 

	
 
        public RecipeSearchNode(RecipeSearchNode other)
 
        {
 
            _costSortedReagents = new List<Reagent>(other._costSortedReagents);
 
            _reagents = new uint[_costSortedReagents.Count];
 
            _invalidReagent = (uint)_costSortedReagents.Count;
 
            for (int i = 0; i < _costSortedReagents.Count; ++i)
 
            {
 
                this._reagents[i] = other._reagents[i];
 
            }
 
            _reagentInUse = new bool[_costSortedReagents.Count];
 
            for (uint i = 0; i < _costSortedReagents.Count; ++i)
 
            {
 
                _reagentInUse[i] = other._reagentInUse[i];
 
            }
 
            _nextReagentPos = other.ReagentCount;
 

	
 
            CurrentTargetQuantity = other.CurrentTargetQuantity;
 
            MaxConcentration = other.MaxConcentration;
 
            UsedQuantity = other.UsedQuantity;
 
            CatalystCount = other.CatalystCount;
 
            FullQuantityDepth = other.FullQuantityDepth;
 
            FullQuantity = other.FullQuantity;
 
            MinReagents = other.MinReagents;
 
            MaxReagents = other.MaxReagents;
 
            CurrentWeights = new uint[MaxReagents];
 
            for (int i = 0; i < MaxReagents; ++i)
 
            {
 
                CurrentWeights[i] = other.CurrentWeights[i];
 
            }
 
        }
 

	
 
        public RecipeSearchNode(List<Reagent> costSortedReagents, uint[] reagents)
 
        {
 
            _costSortedReagents = new List<Reagent>(costSortedReagents);
 
            _reagents = new uint[costSortedReagents.Count];
 
            _invalidReagent = (uint)costSortedReagents.Count;
 
            _nextReagentPos = reagents.Length;
 
            for (int i = _reagents.Length - 1; i >= _reagents.Length; --i)
 
            {
 
                reagents[i] = _invalidReagent;
 
            }
 
            for (int i = reagents.Length - 1; i >= 0; --i)
 
            {
 
                _reagents[i] = reagents[i];
 
                if (reagents[i] == _invalidReagent)
 
                {
 
                    _nextReagentPos = i;
 
                }
 
            }
 
            _reagentInUse = new bool[costSortedReagents.Count];
 
            for (uint reagentIdx = 0; reagentIdx < costSortedReagents.Count; ++reagentIdx)
 
            {
 
                _reagentInUse[reagentIdx] = false;
 
            }
 
            foreach (uint reagentIdx in this._reagents)
 
            {
 
                if (reagentIdx != _invalidReagent)
 
                {
 
                    _reagentInUse[reagentIdx] = true;
 
                }
 
            }
 

	
 
            MinReagents = (uint) _nextReagentPos;
 
            MaxReagents = (uint) _nextReagentPos;
 
            CurrentWeights = new uint[MaxReagents];
 
            UsedQuantity = 0;
 
        }
 

	
 
        // top-level search
 
        public RecipeSearchNode(List<Reagent> costSortedReagents, uint startReagent)
 
        {
 
            _costSortedReagents = new List<Reagent>(costSortedReagents);
 
            _reagents = new uint[costSortedReagents.Count];
 
            _invalidReagent = (uint)costSortedReagents.Count;
 
            _nextReagentPos = 0;
 
            for (int i = 0; i < _reagents.Length; ++i)
 
            {
 
                _reagents[i] = _invalidReagent;
 
            }
 
            _reagentInUse = new bool[costSortedReagents.Count];
 
            for (uint reagentIdx = 0; reagentIdx < costSortedReagents.Count; ++reagentIdx)
 
            {
 
                _reagentInUse[reagentIdx] = false;
 
            }
 
            _reagents[_nextReagentPos++] = NextFreeReagent(startReagent);
 
            //Console.WriteLine("Added reagent {0} at pos {1}", this.reagents[nextReagentPos-1], nextReagentPos-1);
 
            MinReagents = 1; // don't iterate up beyond the start reagent
 
            MaxReagents = 1;
 
            CurrentWeights = new uint[MaxReagents];
 
            UsedQuantity = 0;
 
        }
 

	
 
        public RecipeSearchNode(List<Reagent> costSortedReagents)
 
        {
 
            _costSortedReagents = costSortedReagents;
 
            _reagents = new uint[costSortedReagents.Count];
 
            _invalidReagent = (uint)costSortedReagents.Count;
 
            _nextReagentPos = 0;
 
            for (int i = 0; i < _reagents.Length; ++i)
 
            {
 
                _reagents[i] = _invalidReagent;
 
            }
 
            _reagentInUse = new bool[costSortedReagents.Count];
 
            for (uint reagentIdx = 0; reagentIdx < costSortedReagents.Count; ++reagentIdx)
 
            {
 
                _reagentInUse[reagentIdx] = false;
 
            }
 
            _reagents[_nextReagentPos++] = NextFreeReagent(0);
 
            MinReagents = 0;
 
            MaxReagents = 1;
 
            CurrentWeights = new uint[MaxReagents];
 
            UsedQuantity = 0;
 
        }
 

	
 
        public Reagent GetReagent(int idx)
 
        {
 
            return _costSortedReagents[(int)_reagents[idx]];
 
        }
 

	
 
        public void RemoveLastReagent()
 
        {
 
            uint reagentIdx = _reagents[_nextReagentPos - 1];
 
            ReleaseReagent(reagentIdx);
 
            if (_costSortedReagents[(int)reagentIdx].IsCatalyst)
 
            {
 
                --CatalystCount;
 
            }
 
            _reagents[_nextReagentPos - 1] = _invalidReagent;
 
            --_nextReagentPos;
 
        }
 

	
 
        public void ReplaceLastReagent(uint reagentIdx)
 
        {
 
            uint oldReagentIdx = _reagents[_nextReagentPos - 1];
 
            ReleaseReagent(oldReagentIdx);
 
            _reagents[_nextReagentPos - 1] = reagentIdx;
 
            if (_costSortedReagents[(int)oldReagentIdx].IsCatalyst)
 
            {
 
                --CatalystCount;
 
            }
 
            if (_costSortedReagents[(int)reagentIdx].IsCatalyst)
 
            {
 
                ++CatalystCount;
 
            }
 
        }
 

	
 
        public uint NextFreeReagent(uint startIdx)
 
        {
 
            uint idx = startIdx;
 
            for (; idx < _costSortedReagents.Count; ++idx)
 
            {
 
                bool inUse = _reagentInUse[idx];
 
                if ((inUse == false) && (_costSortedReagents[(int)idx].Enabled))
 
                {
 
                    //Console.WriteLine("Found free reagent idx {0}", idx);
 
                    _reagentInUse[idx] = true;
 
                    return idx;
 
                }
 
            }
 
            //Console.WriteLine("Failed to find free reagent.");
 
            return (uint)_costSortedReagents.Count;
 
        }
 

	
 
        private void ReleaseReagent(uint reagentIdx)
 
        {
 
            _reagentInUse[reagentIdx] = false;
 
        }
 

	
 
        public bool AddNextReagent()
 
        {
 
            if (ReagentCount >= MaxReagents) return false;
 

	
 
            uint nextReagent = NextFreeReagent(0);
 
            _reagents[_nextReagentPos++] = nextReagent;
 
            if (_costSortedReagents[(int)nextReagent].IsCatalyst)
 
            {
 
                ++CatalystCount;
 
            }
 
            InitForQuantity(CurrentTargetQuantity);
 

	
 
            return true;
 
        }
 

	
 
        public void InitForQuantity(uint quantity)
 
        {
 
            //System.Console.WriteLine("Init for quantity: {0}, reagent count: {1} ({2} catalysts)", quantity, ReagentCount, CatalystCount);
 
            CurrentTargetQuantity = quantity;
 
            if (CurrentTargetQuantity < (MinConcentration + CatalystCount))
 
            {
 
                // invalid quantity
 
                return;
 
            }
 
            UsedQuantity = 0;
 
            uint remainingReagents = ((uint)_nextReagentPos - CatalystCount);
 
            uint remainingWeight = CurrentTargetQuantity - CatalystCount;
 
            for (int i = 0; i < _nextReagentPos; ++i)
 
            {
 
                Reagent reagent = GetReagent(i);
 

	
 
                if (reagent.IsCatalyst)
 
                {
 
                    //Console.WriteLine("Init catalyst {0} weight 1", reagent.Name);
 
                    CurrentWeights[i] = 1;
 
                    ++UsedQuantity;
 
                }
 
                else
 
                {
 
                    uint reagentMaxWeight = reagent.RecipeMax;
 
                    if (ReagentCount <= FullQuantityDepth)
 
                    {
 
                        reagentMaxWeight = Math.Max(FullQuantity, reagentMaxWeight);
 
                    }
 
                    uint weight = Math.Min(remainingWeight - (remainingReagents-1), reagentMaxWeight);
 
                    //Console.WriteLine("Init reagent {0} weight {1}", reagent.Name, weight);
 
                    remainingWeight -= weight;
 
                    CurrentWeights[i] = weight;
 
                    UsedQuantity += weight;
 
                }
 
                --remainingReagents;
 
            }
 
        }
 

	
 
        public void SetWeight(int idx, uint quantity)
 
        {
 
            UsedQuantity -= CurrentWeights[idx];
 
            CurrentWeights[idx] = quantity;
 
            UsedQuantity += quantity;
 
        }
 

	
 
        public void SaveState(StreamWriter writer)
 
        {
 
            writer.WriteLine("---SearchNode---");
 
            writer.WriteLine("MinReagents: {0}", MinReagents);
 
            writer.WriteLine("MaxReagents: {0}", MaxReagents);
 
            writer.WriteLine("Reagents: {0}", _nextReagentPos);
 
            for (int i = 0; i < _nextReagentPos; ++i)
 
            {
 
                uint idx = _reagents[i];
 
                uint weight = CurrentWeights[i];
 
                writer.WriteLine("Reagent: {0},{1},{2}", idx, _reagentInUse[idx] ? 1 : 0, weight);
 
            }
 
            // pulled from parent: List<Reagent> costSortedReagents;
 
            // new on construct: PaintRecipe testRecipe = null;
 
            writer.WriteLine("CurrentTargetQuantity: {0}", CurrentTargetQuantity);
 
            writer.WriteLine("MinConcentration: {0}", MinConcentration);
 
            writer.WriteLine("MaxConcentration: {0}", MaxConcentration);
 
            writer.WriteLine("UsedQuantity: {0}", UsedQuantity);
 
            writer.WriteLine("CatalystCount: {0}", CatalystCount);
 
            writer.WriteLine("FullQuantity: {0}", FullQuantity);
 
            writer.WriteLine("FullQuantityDepth: {0}", FullQuantityDepth);
 
            writer.WriteLine("---EndNode---");
 
            
 
        }
 

	
 
        static readonly Regex keyValueRegex = new Regex(@"(\w+)\:\s*(.*)\s*$");
 
        static readonly Regex reagentPartsRegex = new Regex(@"(?<id>\d+),(?<inUse>\d+),(?<weight>\d+)");
 
        public bool LoadState(StreamReader reader)
 
        {
 
            string? line = reader.ReadLine();
 
            
 
            if ((line == null) || !line.Equals("---SearchNode---"))
 
            {
 
                return false;
 
            }
 

	
 
            bool success = true;
 
            while ((line = reader.ReadLine()) != null)
 
            {
 
                if (line.Equals("---EndNode---"))
 
                {
 
                    break;
 
                }
 
                Match match = keyValueRegex.Match(line);
 
                if (match.Success)
 
                {
 
                    switch (match.Groups[1].Value)
 
                    {
 
                        case "Reagents":
 
                            {
 
                                //int reagentCount = int.Parse(match.Groups[2].Value);
 
                                for (int i = 0; i < _reagents.Length; ++i)
 
                                {
 
                                    _reagents[i] = _invalidReagent;
 
                                    _reagentInUse[i] = false;
 
                                }
 
                                _nextReagentPos = 0;
 
                            }
 
                            break;
 
                        case "Reagent":
 
                            {
 
                                Match reagentInfo = reagentPartsRegex.Match(match.Groups[2].Value);
 
                                if (reagentInfo.Success)
 
                                {
 
                                    uint reagentId = uint.Parse(reagentInfo.Groups["id"].Value);
 
                                    int isInUse = int.Parse(reagentInfo.Groups["inUse"].Value);
 
                                    uint weight = uint.Parse(reagentInfo.Groups["weight"].Value);
 
                                    _reagents[_nextReagentPos] = reagentId;
 
                                    CurrentWeights[_nextReagentPos] = weight;
 
                                    if (isInUse != 0)
 
                                    {
 
                                        if (reagentId != _invalidReagent)
 
                                        {
 
                                            _reagentInUse[reagentId] = true;
 
                                        }
 
                                    }
 
                                    ++_nextReagentPos;
 
                                }
 
                                else
 
                                {
 
                                    success = false;
 
                                }
 
                            }
 
                            break;
 
                        case "CurrentTargetQuantity":
 
                            {
 
                                uint value = uint.Parse(match.Groups[2].Value);
 
                                CurrentTargetQuantity = value;
 
                            }
 
                            break;
 
                        case "MaxQuantity":
 
                            {
 
                                uint value = uint.Parse(match.Groups[2].Value);
 
                                MaxConcentration = value;
 
                            }
 
                            break;
 
                        case "MinConcentration":
 
                            {
 
                                uint value = uint.Parse(match.Groups[2].Value);
 
                                MinConcentration = value;
 
                            }
 
                            break;
 
                        case "MaxConcentration":
 
                            {
 
                                uint value = uint.Parse(match.Groups[2].Value);
 
                                MaxConcentration = value;
 
                            }
 
                            break;
 
                        case "MinReagents":
 
                            {
 
                                uint value = uint.Parse(match.Groups[2].Value);
 
                                MinReagents = value;
 
                            }
 
                            break;
 
                        case "MaxReagents":
 
                            {
 
                                uint value = uint.Parse(match.Groups[2].Value);
 
                                MaxReagents = value;
 
                            }
 
                            break;
 
                        case "UsedQuantity":
 
                            {
 
                                uint value = uint.Parse(match.Groups[2].Value);
 
                                UsedQuantity = value;
 
                            }
 
                            break;
 
                        case "CatalystCount":
 
                            {
 
                                uint value = uint.Parse(match.Groups[2].Value);
 
                                CatalystCount = value;
 
                            }
 
                            break;
 
                        case "InitialCount":
 
                            {
 
                                uint value = uint.Parse(match.Groups[2].Value);
 
                                MinReagents = value;
 
                            }
 
                            break;
 
                        case "FullQuantity":
 
                            {
 
                                uint value = uint.Parse(match.Groups[2].Value);
 
                                FullQuantity = value;
 
                            }
 
                            break;
 
                        case "FullQuantityDepth":
 
                            {
 
                                uint value = uint.Parse(match.Groups[2].Value);
 
                                FullQuantityDepth = value;
 
                            }
 
                            break;
 
                        default:
 
                            success = false;
 
                            break;
 
                    }
 
                }
 
                else
 
                {
 
                    success = false;
 
                    break;
 
                }
 
            }
 
            return success;
 
        }        
 
    }
 
}
...
 
\ No newline at end of file
Models/ScreenMetrics.cs
Show inline comments
 
new file 100644
 
using Avalonia;
 

	
 
namespace DesertPaintCodex.Models
 
{
 
    public class ScreenMetrics
 
    {
 
        public PixelRect Bounds { get; set; }
 
        public bool IsPrimary { get; set; }
 

	
 
        public string PositionToStr => $"{Bounds.X}, {Bounds.Y}";
 
        public string SizeToStr => $"{Bounds.Width}x{Bounds.Height}";
 

	
 
        public ScreenMetrics(PixelRect bounds, bool isPrimary)
 
        {
 
            Bounds = bounds;
 
            IsPrimary = isPrimary;
 
        }
 
    }
 
}
Models/Settings.cs
Show inline comments
 
new file 100644
 
using System.Collections.Generic;
 
using System.IO;
 
using System.Text.RegularExpressions;
 

	
 
namespace DesertPaintCodex.Models
 
{
 
    internal class Settings
 
    {
 
        private readonly Dictionary<string, string> _settings = new();
 

	
 
        public bool TryGet(string key, out int value)
 
        {
 
            value = 0;
 
            return _settings.TryGetValue(key.ToLower(), out string? valStr)
 
                && int.TryParse(valStr, out value);
 
        }
 
        public bool TryGet(string key, out bool value)
 
        {
 
            value = false;
 
            return _settings.TryGetValue(key.ToLower(), out string? valStr)
 
                && bool.TryParse(valStr, out value);
 

	
 
        }
 
        public void Set(string key, int value)
 
        {
 
            _settings[key.ToLower()] = value.ToString();
 
        }
 
        public void Set(string key, bool value)
 
        {
 
            _settings[key.ToLower()] = value.ToString();
 
        }
 

	
 
        public void Reset()
 
        {
 
            _settings.Clear();
 
        }
 

	
 
        public void Save(string settingsPath)
 
        {
 
            using StreamWriter writer = new(settingsPath);
 
            foreach (KeyValuePair<string, string> pair in _settings)
 
            {
 
                writer.WriteLine("{0}={1}", pair.Key, pair.Value);
 
            }
 
        }
 

	
 
        private static readonly Regex OptionEntry = new(@"(?<opt>[^#=][^=]*)=(?<optval>.*)$");
 
        
 
        public bool Load(string settingsPath)
 
        {
 
            if (!File.Exists(settingsPath)) return false;
 
            using StreamReader reader = new(settingsPath);
 
            string? line;
 
            while ((line = reader.ReadLine()) != null)
 
            {
 
                Match match = OptionEntry.Match(line);
 
                
 
                if (!match.Success) continue;
 
                
 
                string optName = match.Groups["opt"].Value.ToLower();
 
                string optVal  = match.Groups["optval"].Value.Trim();
 
                if (optName.Equals("debug"))
 
                {
 
                    // convert
 
                    optName = "enabledebugmenu";
 
                }
 
                _settings[optName.ToLower()] = optVal;
 
            }
 

	
 
            return true;
 
        }
 
    }
 
}
Program.cs
Show inline comments
 
new file 100644
 
using System;
 
using Avalonia;
 
using Avalonia.Controls.ApplicationLifetimes;
 
using Avalonia.ReactiveUI;
 

	
 
namespace DesertPaintCodex
 
{
 
    class Program
 
    {
 
        // Initialization code. Don't use any Avalonia, third-party APIs or any
 
        // SynchronizationContext-reliant code before AppMain is called: things aren't initialized
 
        // yet and stuff might break.
 
        public static void Main(string[] args) => BuildAvaloniaApp()
 
            .StartWithClassicDesktopLifetime(args);
 

	
 
        // Avalonia configuration, don't remove; also used by visual designer.
 
        public static AppBuilder BuildAvaloniaApp()
 
            => AppBuilder.Configure<App>()
 
                .UsePlatformDetect()
 
                .LogToTrace()
 
                .UseReactiveUI();
 
    }
 
}
README.md
Show inline comments
 
new file 100644
 
# Desert Paint Codex
 

	
 
(Formerly _Desert Paint Lab_)
 

	
 
This is a tool for players to record their [Pigment Laboratory](https://atitd.wiki/tale10/Pigment_Laboratory)
 
ingredient reactions in [A Tale in the Desert](http://www.atitd.com/).
 

	
 
### Latest Releases
 

	
 
You can find the latest releases at https://desert-paint-lab.malkyne.org/
 

	
 
### Features
 

	
 
* Scans your screen for the Pigment Lab reaction values, without any math, measuring, or manual number entry.
 
* Warns you when a reaction clipped outside of readable range.
 
* Supports multiple character profiles.
 
* Can export to [PracticalPaint](http://www.atitd.org/wiki/tale7/File:PracticalPaint.Zip) data format.
 
* Includes simulator for experimenting with recipes without spending ingredients.
 
* Includes a recipe generator
 

	
 
## Tale 10
 

	
 
* Desert Paint Lab is now Desert Paint Codex, and has a whole new UI, for Tale 10.
 
* Untested reactions are now kept in a list to the left, so you can immediately see what still needs testing.
 
* The application has not yet been updated for ATITD's new UI.  This will be coming soon.
 

	
 

	
 
## Tale 9
 

	
 
* New papyrus texture in interfaces
 
* Earth Light was replaced by Falcon's Bait mushrooms. Practical Paint still uses the name EarthLight internally, so the data files still use that name here as well.
 
* macOS is now a 64-bit app. Windows remains 32-bit due to incompatibilities with the UI library.
 
* macOS build does not support Catalina (is not notarized)
 

	
 
### Known issues
 

	
 
* A batcher installed on a Pigment Lab will prevent finding the right part of the screen with the resulting color.
 

	
 

	
 
## Directions
 

	
 
### Download Desert Paint Lab
 

	
 
You can always find the available downloads, on [the download page](https://desert-paint-lab.malkyne.org/).
 

	
 
The most recent version is [T9 Release 7](https://desert-paint-lab.malkyne.org/win32/DesertPaintLab_T9_V7.zip). For Mac, you can download a [dmg containing a Mac app bundle](https://desert-paint-lab.malkyne.org/macOS/DesertPaintLab_T9_V7.dmg).
 

	
 
Desert Paint Codex now uses Net Core, instead of Mono.  The package should come with everything you need to make it work.
 

	
 

	
 
### Set Up a Profile
 

	
 
The first time you run Desert Paint Lab, it will prompt you to make a profile.  You may either create a new profile, or import an existing PracticalPaint reactions.txt file.
 

	
 
### Start Testing!
 

	
 
Add two ingredients to your Pigment Lab.  Select those same ingredients in Desert Paint Lab.  Then, with the Pigment Lab dialog unobstructred, select the **Capture** button.  Once you are satisfied with the result, click the **Record** button.  The data will automatically be added to your profile.
 

	
 
### Clipped?  Huh?
 

	
 
Occasionally, you will see a warning dialog that informs you that a "Reaction clipped."  That means that one or more of the color components moved outside of the testable range.  This makes it impossible to calculate the reaction from these two ingredients.
 

	
 
You can solve this by doing a three-way test.
 

	
 
1. In your clipped reaction, let's call your **1st** ingredient **A** and your **2nd** ingredient **B**.
 
1. Select a **non-catalyst** ingredient **C** for which you have already tested **C + A** and **C + B**.  It is OK if there was a reaction in those earlier tests.  Desert Paint Lab can work out the math.
 
1. In Desert Paint Lab, select ingredients as follows:
 
    1. **C**
 
    2. **A**
 
    3. **B**
 
1. Add the ingredients in your Pigment Lab _in exactly that order_.
 
1. Capture.
 
1. Record.
 

	
 
For clipped reactions, your **C** ingredient should be one that corrects for the clipped color.  Here are some suggested **C** ingredients for clipping correction:
 

	
 

	
 
Clip Color  | Clip Direction | Correction | Suggested "C" Ingredient
 
------------|----------------|------------|---------------------------
 
Red         | Low            | +Red       | Carrot (224) or Red Sand (144)
 
Red         | High           | -Red       | Silver Powder (16) or Toad Skin (48)
 
Green       | Low            | +Green     | Falcon's Bait (240) or Copper (192)
 
Green       | High           | -Green     | Red Sand (16) or Silver Powder (16)
 
Blue        | Low            | +Blue      | Falcon's Bait (224) or Copper (192)
 
Blue        | High           | -Blue      | Red Sand (24) or Clay (32) or Carrot (32) or Silver Powder (32)
 

	
 

	
 
In many cases, it may be easiest to go back and do these three-way tests after you have finished all of your other testing.
 

	
 
### Catalysts
 

	
 
For catalyst to catalyst reaction tests, follow the three-way instructions, as above.
 

	
 
### Finishing Up
 

	
 
When you're done testing your reactions, you can either use the built-in Pigment Lab simulator (`Window > Run Simulator`) to experiment with recipes, without dipping into your precious ingredient stocks.  Alternatively, you can export your reactions in PracticalPaint format.
 

	
 
## Generating Recipes
 

	
 
### What do all these settings do?
 

	
 
* _Minimum Ingredients_ is the minimum number of ingredients the generator will use in a recipe. It's best to leave this at 1 so the simple single-ingredient recipes are included (like 10 carrots for 'carrot')
 
* _Maximum Ingredients_ limits how many ingredients will be used in a recipe. Ingredients after the 5th will still affect the resulting color, but won't use their reactions.
 
* _Silk Ribbon Recipes_ sets the generator up to create recipes for silk ribbons. Ribbons use a base concentration of 50 instead of the 10 that is used for paint.
 
* _Maximum Concentration_ is the maximum recipe concentration. Recipes with a concentration above 10 will still produce a single db of paint, but can use larger quantities of ingredients to make smaller color adjustments. Increasing this will increase the time required to search all the possible combinations.
 
* _Full Quantity Depth_ is the number of ingredients in the recipe that will use up to the "full quantity" value as the limit for those ingredients over the ingredient's maximum.
 
* _Full Quantity_ is the maximum quantity of any ingredient to use up to the Full Quantity Depth. For example, with a Full Quantity of 15 and a Full Quantity Depth of 3, a recipe could use up to 15 of each of the first 3 ingredients. After that it will be limited by the setting for that specific ingredient.
 

	
 
## Known Issues
 

	
 
### Slowness
 

	
 
If you are running on a multi-screen system, or a very high-resolution screen, you may find that Desert Paint Lab is rather slow in determining paint reactions.  That's because you have a lot of screen real-estate to scan, to look for the Pigment Lab dialog.  You can speed up the scanning process by ensuring that your Pigment Lab Dialog is as far to the upper-left of the screen as possible. Also see the advanced settings (below) for some additional options that may help.
 

	
 
### Multiple Displays
 

	
 
When Desert Paint Lab asks for the resolution for a multiple-display setup, enter the combined resolution of all displays. Generally the detected default for this will be correct. For example, two 1920x1080 displays in a side-by-side configuration would be a combined resolution of 3840x1080. In a stacked configuration, they would be 1920x2160.
 

	
 
### Retina / High-Density Screens
 

	
 
High DPI screens may be displaying the game at something other than a 1:1 game-pixel to screen-pixel ratio.  These screens didn't exist, back when Desert Paint Lab was created. The current version now prompts you for your screen resolution when it starts to work around this. If you see a crash when trying to capture a reaction, it's likely because the resolution is incorrect. For example, Snoerr's MacBook Pro has a retina screen with a resolution of 2880x1800. Mono (and therefore DesertPaintLab) detects it as having a resolution of 1440x900. He enters his resolution as 2880x1800 and sets the Game pixel width in screen pixels to 2.
 

	
 
## Mac user FAQ
 

	
 
Q: How do I find my native screen resolution?
 

	
 
A: You can find it by opening up "About this Mac" from the Apple menu and clicking on the "Displays" tab. That should show the screen size and resolution to enter.
 

	
 
## Advanced Settings
 

	
 
The settings file has several advanced options that are not available in the interface. Some of these are for debugging, while others are useful for improving the speed of finding the pigment lab interface. The settings file is found in AppData\Local\DesertPaintLab\settings on Windows, and in ~/.local/share/DesertPaintLab/settings on macOS.
 

	
 
* _scanarea.min.x_ sets the starting horizontal offset when searching for the pigment lab. This is useful in multi-screen setups to skip any leftmost displays. For example, a dual-screen setup with two side-by-side 3840x2160 displays where the game runs on the right screen would set this to 3840 to bypass scanning the left display.
 
* _scanarea.min.y_ Like scanarea.min.x, but skips the upper section of display
 
* _scanarea.max.x_ defines the rightmost edge of the area to scan
 
* _scanarea.max.y_ defines the bottom edge of the area to scan
 
* _enabledebugmenu_ enables debug tool menu to help track down problems. This also causes DPL to write debug log files during scanning and recipe generation. This can slow down scanning and recipe searching.
 
* _debugscreenshot_ automatically saves out a screenshot when searching for the pigment lab to help in debugging
 
* _logging.verbosity_ sets the verbosity of the logging between 0 and 3. The higher the value, the more detail is included in the log file.
 

	
 
## For Developers
 

	
 
This application is written in C#, and it uses the [Avalonia UI Framework](https://avaloniaui.net/). You can learn more about getting set up to use Avalonia [here](https://docs.avaloniaui.net/docs/getting-started/).
 

	
 
## Older Versions
 

	
 
The old [Desert Paint Lab repository](https://repos.malkyne.org/ATITD-Tools/Desert-Paint-Lab) is still publicly available, in case anyone wishes to reference the old code, or tinker with it.  However, it will no longer be maintained in any way by Afrah.
...
 
\ No newline at end of file
Services/PaletteService.cs
Show inline comments
 
new file 100644
 
using System.IO;
 
using System.Collections.Generic;
 
using System.Diagnostics;
 
using System.Text.RegularExpressions;
 
using DesertPaintCodex.Models;
 
using DesertPaintCodex.Util;
 

	
 
namespace DesertPaintCodex.Services
 
{
 
    internal static class PaletteService
 
    {
 
        private static readonly Regex ColorEntry = new(@"\#(?<red>\w\w)(?<green>\w\w)(?<blue>\w\w)\s*(?<name>\w+)");
 

	
 
        public static List<PaintColor> Colors { get; } = new();
 
        
 
        private static bool _initialized = false;
 
        
 
        
 
        public static void Initialize()
 
        {
 
            if (_initialized) return;
 

	
 
            string? colorsPath = FileUtils.FindApplicationResourceFile("colors.txt");
 
            Debug.Assert(colorsPath != null);
 
    
 
            Load(colorsPath);
 
        }
 

	
 
        public static void Load(string file)
 
        {
 
            using StreamReader reader = new StreamReader(file);
 
            string? line;
 
            while ((line = reader.ReadLine()) != null)
 
            {
 
                Match match = ColorEntry.Match(line);
 
                if (match.Success)
 
                {
 
                    Colors.Add(new PaintColor(match.Groups["name"].Value,
 
                        match.Groups["red"].Value,
 
                        match.Groups["green"].Value,
 
                        match.Groups["blue"].Value));
 
                }
 
            }
 
        }
 

	
 
        public static int Count => Colors.Count;
 

	
 
        public static string FindNearest(PaintColor color)
 
        {
 
            int bestDistSq = int.MaxValue;
 
            PaintColor? bestColor = null;
 
            
 
            foreach (PaintColor paintColor in Colors)
 
            {
 
                int distSq = paintColor.GetDistanceSquared(color);
 
                
 
                if (distSq >= bestDistSq) continue;
 
                
 
                bestDistSq = distSq;
 
                bestColor  = paintColor;
 
            }
 
            
 
            Debug.Assert(bestColor != null);
 
            
 
            return bestColor.Name;
 
        }
 
    }
 
}
Services/ProfileManager.cs
Show inline comments
 
new file 100644
 

 
using System;
 
using DesertPaintCodex.Models;
 
using DesertPaintCodex.Util;
 
using System.Collections.Generic;
 
using System.IO;
 

	
 
namespace DesertPaintCodex.Services
 
{
 
    internal static class ProfileManager
 
    {
 
        private static bool _areProfilesLoaded;
 
        private static readonly List<string> _profileList = new();
 
        
 
        public static PlayerProfile? CurrentProfile { get; private set; }
 
        public static bool HasProfileLoaded => CurrentProfile != null;
 

	
 
        public static List<string> GetProfileList()
 
        {
 
            // If it's already loaded, return the cached list.
 
            if (_areProfilesLoaded)
 
            {
 
                return _profileList;
 
            }
 

	
 
            // Otherwise, load the list.
 
            string appDataPath = FileUtils.AppDataPath;
 
            if (!Directory.Exists(appDataPath))
 
            {
 
                Directory.CreateDirectory(appDataPath);
 
            }
 

	
 
            DirectoryInfo di = new(appDataPath);
 
            DirectoryInfo[] dirs = di.GetDirectories();
 
            foreach (DirectoryInfo dir in dirs)
 
            {
 
                if (dir.Name != "template")
 
                {
 
                    _profileList.Add(dir.Name);
 
                }
 
            }
 

	
 
            _areProfilesLoaded = true;
 
            return _profileList;
 
        }
 

	
 
        public static PlayerProfile LoadProfile(string name)
 
        {
 
            CurrentProfile = new PlayerProfile(name, Path.Combine(FileUtils.AppDataPath, name));
 
            CurrentProfile.Load();
 
            return CurrentProfile;
 
        }
 

	
 
        public static PlayerProfile CreateNewProfile(string name)
 
        {
 
            CurrentProfile = new PlayerProfile(name, Path.Combine(FileUtils.AppDataPath, name));
 
            CurrentProfile.Initialize();
 
            // Invalidate profile list, so it will reload next time.
 
            _profileList.Clear();
 
            _areProfilesLoaded = false;
 
            return CurrentProfile;
 
        }
 

	
 
        public static int GetProfileCount()
 
        {
 
            // This is a function instead of a property, because it may be slow.
 
            List<string> profiles = GetProfileList();
 
            return profiles.Count;
 
        }
 

	
 
        public static bool HasProfiles()
 
        {
 
            // This is a function instead of a property, because it may be slow.
 
            List<string> profiles = GetProfileList();
 
            return profiles.Count > 0;
 
        }
 

	
 
        public static void UnloadProfile()
 
        {
 
            CurrentProfile = null;
 
        }
 

	
 
    }
 
}
Services/ReactionScannerService.cs
Show inline comments
 
new file 100644
 
using System;
 
using System.Diagnostics;
 
using System.IO;
 
using System.Drawing;
 
using System.Drawing.Imaging;
 
using System.Threading;
 
using System.Threading.Tasks;
 
using DesertPaintCodex.Models;
 
using DesertPaintCodex.Util;
 

	
 
namespace DesertPaintCodex.Services
 
{
 
    public class ReactionScannerService
 
    {
 
        private enum LogVerbosity
 
        {
 
            Low,
 
            Normal,
 
            High,
 
            Excessive
 
        }
 
        
 
        // Swatch is 301x20 solid color, 1px darker left/top, 1px black outer left/top, 1px light right/bottom, 1px bright right/bottom
 
        // top-right and bottom-left are bright instead of black
 

	
 
        // Color bars are 4x302 solid color, 2px border on all sides (darker left/top, lighter bottom/right
 

	
 
        private static readonly int[] SWATCH_HEIGHT = 
 
        {
 
            24, // tiny
 
            24, // small
 
            24, // medium
 
            24, // large
 
            24 // huge
 
        };
 

	
 
        private static readonly int[] SWATCH_WIDTH =
 
        {
 
            306, // tiny
 
            306, // small
 
            306, // medium
 
            320, // large
 
            350 // huge
 
        };
 

	
 
        private static readonly int[] COLOR_BAR_WIDTH =
 
        {
 
            306, // tiny
 
            306, // small -- includes left and right borders
 
            306, // medium
 
            320, // large
 
            350 // huge
 
        };
 

	
 
        private static readonly int[] RED_BAR_SPACING =
 
        {
 
            32, // tiny
 
            32, // small
 
            32, // medium
 
            32, // large
 
            32 // huge
 
        };
 

	
 
        private static readonly int[] GREEN_BAR_SPACING =
 
        {
 
            42, // tiny
 
            42, // small
 
            42, // medium
 
            42, // large
 
            42 // huge
 
        };
 

	
 
        private static readonly int[] BLUE_BAR_SPACING =
 
        {
 
            52, // tiny
 
            52, // small
 
            52, // medium
 
            52, // large
 
            52 // huge
 
        };
 
        // width to test on ends of swatch (10% on either end)
 
        private static readonly int[] SWATCH_TEST_WIDTH =
 
        {
 
            26, // tiny
 
            26, // small
 
            26, // medium
 
            28, // large
 
            31  // huge
 
        };
 

	
 
        private static ReactionScannerService? s_instance;
 
        public static ReactionScannerService Instance => s_instance ??= new ReactionScannerService();
 

	
 
        private int _swatchHeight    = SWATCH_HEIGHT[(int)Constants.DefaultInterfaceSize];
 
        private int _swatchWidth     = SWATCH_WIDTH[(int)Constants.DefaultInterfaceSize];
 
        private int _swatchTestWidth = SWATCH_TEST_WIDTH[(int)Constants.DefaultInterfaceSize];
 
        private int _colorBarWidth   = COLOR_BAR_WIDTH[(int)Constants.DefaultInterfaceSize];
 
        private int _redBarSpacing   = RED_BAR_SPACING[(int)Constants.DefaultInterfaceSize];
 
        private int _greenBarSpacing = GREEN_BAR_SPACING[(int)Constants.DefaultInterfaceSize];
 
        private int _blueBarSpacing  = BLUE_BAR_SPACING[(int)Constants.DefaultInterfaceSize];
 

	
 
        // Current Status
 
        public bool IsCaptured { get; private set; }
 

	
 
        private PaintColor _recordedColor = new PaintColor();
 
        public PaintColor RecordedColor {
 
            get => _recordedColor;
 
            private set => _recordedColor.Set(value);
 
        }
 

	
 
        private int _pixelMultiplier = 1;
 

	
 
        private InterfaceSize _interfaceSize = Constants.DefaultInterfaceSize;
 

	
 
        private bool _firstRun   = true;
 
        private int _lastSwatchX = -1;
 
        private int _lastSwatchY = -1;
 
        private int _screenX     = 0;
 
        private int _screenY     = 0;
 

	
 
        private Bitmap _targetBitmap;
 

	
 
        private LogVerbosity _logVerbosity = LogVerbosity.Normal;
 

	
 
        private CancellationTokenSource? _canceler;
 

	
 
        public StreamWriter? Log { get; set; }
 
        
 
        private void WriteLog(LogVerbosity verbosity, string format, params object[] args)
 
        {
 
            // Dumping this to Debug, for now.
 
            if (verbosity <= _logVerbosity)
 
            {
 
                Debug.WriteLine(format, args);
 
            }
 
            
 
            /*
 
            if ((Log != null) && (verbosity <= _logVerbosity))
 
            {
 
                Log.WriteLine(format, args);
 
            }
 
            */
 
        }
 
        
 
        public ReactionScannerService()
 
        {
 
            RefreshFromSettings();
 
            Debug.Assert(_targetBitmap != null);
 
        }
 

	
 
        public void RefreshFromSettings()
 
        {
 
            SettingsService.Get(SettingKey.PixelMultiplier, out _pixelMultiplier, Constants.DefaultPixelMultiplier);
 
            SettingsService.Get(SettingKey.InterfaceSize, out int interfaceSize, (int)Constants.DefaultInterfaceSize);
 
            if (Enum.IsDefined(typeof(InterfaceSize), interfaceSize))
 
            {
 
                _interfaceSize = (InterfaceSize)interfaceSize;
 
            }
 

	
 
            SettingsService.Get(SettingKey.ScreenX, out _screenX, Constants.DefaultScreenX);
 
            SettingsService.Get(SettingKey.ScreenY, out _screenY, Constants.DefaultScreenY);
 
            
 
            SettingsService.Get(SettingKey.ScreenWidth,  out int screenWidth, Constants.DefaultScreenWidth);
 
            SettingsService.Get(SettingKey.ScreenHeight, out int screenHeight, Constants.DefaultScreenHeight);
 
            
 
            _targetBitmap = new Bitmap(screenWidth, screenHeight);
 

	
 
            UpdateSwatchSizes();
 
        }
 
        
 

	
 
        private void UpdateSwatchSizes()
 
        {
 
            _swatchHeight    = SWATCH_HEIGHT[(int)_interfaceSize]     * _pixelMultiplier;
 
            _swatchWidth     = SWATCH_WIDTH[(int)_interfaceSize]      * _pixelMultiplier;
 
            _colorBarWidth   = COLOR_BAR_WIDTH[(int)_interfaceSize]   * _pixelMultiplier;
 
            _redBarSpacing   = RED_BAR_SPACING[(int)_interfaceSize]   * _pixelMultiplier;
 
            _greenBarSpacing = GREEN_BAR_SPACING[(int)_interfaceSize] * _pixelMultiplier;
 
            _blueBarSpacing  = BLUE_BAR_SPACING[(int)_interfaceSize]  * _pixelMultiplier;
 
            _swatchTestWidth = SWATCH_TEST_WIDTH[(int)_interfaceSize] * _pixelMultiplier;
 
        }
 

	
 
        public void CancelScan()
 
        {
 
            _canceler?.Cancel();
 
        }
 

	
 
        private static class ColorMatcher
 
        {
 
            public static Color Color;
 

	
 
            public static bool IsMatch(Color otherColor)
 
            {
 
                return PixelColor.IsMatch(Color, otherColor);
 
            }
 
        }
 
        
 
        private bool IsPossibleSwatchSlice(Pixels pixels, int x, int y)
 
        {
 
            // 1.) Check if the top pixel is a dark pixel.
 
            if (!PixelColor.IsDark(pixels.ColorAt(x, y))) return false;
 
            
 
            // 2.) grab the swatch color 2 down from top border
 
            ColorMatcher.Color = pixels.ColorAt(x, y + 2);
 

	
 
            // Scan the column from 2 below the top to 3 above the bottom to ensure the color matches
 
            for (int i = 2; i < (_swatchHeight-3); ++i)
 
            {
 
                if (!pixels.DoesPixelMatch(x, y + i, ColorMatcher.IsMatch)) return false;
 
            }
 

	
 
            return true;
 
        }
 

	
 
        private bool IsPossibleSwatchUpperLeft(Pixels pixels, int x, int y)
 
        {
 
            bool result = true;
 

	
 
            int swatchSolidWidth = _swatchWidth - 4;
 
            int swatchSolidHeight = _swatchHeight - 5; // 2 top and 3 bottom pixels are slightly different colors
 
            int swatchSolidLeftX = x + 2;
 
            int swatchSolidTopY = y + 2;
 
            int swatchSolidRightX = swatchSolidLeftX + swatchSolidWidth - 1;
 
            int swatchSolidBottomY = swatchSolidTopY + swatchSolidHeight - 1;
 
            
 
            Color swatchColor = pixels.ColorAt(swatchSolidLeftX, swatchSolidTopY);
 
            ColorMatcher.Color = swatchColor;
 

	
 
            // Check the other 3 corners of the swatch size for color match
 
            Color testColor = Color.Black;
 
            bool upperRightResult = pixels.DoesPixelMatch(swatchSolidRightX, swatchSolidTopY, ColorMatcher.IsMatch);
 
            if (!upperRightResult)
 
            {
 
                testColor = pixels.ColorAt(swatchSolidRightX, swatchSolidTopY);
 
                WriteLog(LogVerbosity.Excessive, "Upper-right mismatch for {8}, {9} - found {0},{1},{2} at {3}, {4} expected {5},{6},{7}", testColor.R, testColor.G, testColor.B, swatchSolidRightX, swatchSolidTopY, swatchColor.R, swatchColor.G, swatchColor.B, x, y);
 
            }
 
            bool lowerLeftResult = pixels.DoesPixelMatch(swatchSolidLeftX, swatchSolidBottomY, ColorMatcher.IsMatch);
 
            if (!lowerLeftResult)
 
            {
 
                testColor = pixels.ColorAt(swatchSolidLeftX, swatchSolidBottomY);
 
                WriteLog(LogVerbosity.Excessive, "Lower-left mismatch for {8}, {9} - found {0},{1},{2} at {3}, {4} expected {5},{6},{7}", testColor.R, testColor.G, testColor.B, swatchSolidLeftX, swatchSolidBottomY, swatchColor.R, swatchColor.G, swatchColor.B, x, y);
 
            }
 
            bool lowerRightResult = pixels.DoesPixelMatch(swatchSolidRightX, swatchSolidBottomY, ColorMatcher.IsMatch);
 
            if (!lowerRightResult)
 
            {
 
                testColor = pixels.ColorAt(swatchSolidRightX, swatchSolidBottomY);
 
                WriteLog(LogVerbosity.Excessive, "Lower-right mismatch for {8}, {9} - found {0},{1},{2} at {3}, {4} expected {5},{6},{7}", testColor.R, testColor.G, testColor.B, swatchSolidRightX, swatchSolidBottomY, swatchColor.R, swatchColor.G, swatchColor.B, x, y);
 
            }
 

	
 
            result &= upperRightResult;
 
            result &= lowerLeftResult;
 
            result &= lowerRightResult;
 
            if (!result)
 
            {
 
                // Box corners test failed
 
                WriteLog(LogVerbosity.High, "Failed to find left edge for potential swatch of color {2}, {3}, {4} at {0}, {1}", x, y, swatchColor.R, swatchColor.G, swatchColor.B);
 
                return false;
 
            }
 

	
 
            // scan down the right and left sides
 
            for (int yOff = 1; yOff < (swatchSolidHeight - 1); ++yOff)
 
            {
 
                result &= pixels.DoesPixelMatch(swatchSolidLeftX, swatchSolidTopY + yOff, ColorMatcher.IsMatch);
 
                if (!result)
 
                {
 
                    testColor = pixels.ColorAt(swatchSolidLeftX, swatchSolidTopY + yOff);
 
                    break;
 
                }
 
                result &= pixels.DoesPixelMatch(swatchSolidRightX, swatchSolidTopY + yOff, ColorMatcher.IsMatch);
 
                if (!result)
 
                {
 
                    testColor = pixels.ColorAt(swatchSolidRightX, swatchSolidTopY + yOff);
 
                    break;
 
                }
 
            }
 
            if (!result)
 
            {
 
                WriteLog(LogVerbosity.Normal, "Failed to find left/right edges for potential swatch of color {2}, {3}, {4} at {0}, {1} [failed color = {5},{6},{7}]", x, y, swatchColor.R, swatchColor.G, swatchColor.B, testColor.R, testColor.G, testColor.B);
 
                return false;
 
            }
 
            for (int xOff = 1; xOff < (swatchSolidWidth - 1); ++xOff)
 
            {
 
                result &= pixels.DoesPixelMatch(swatchSolidLeftX + xOff, swatchSolidTopY, ColorMatcher.IsMatch);
 
                if (!result)
 
                {
 
                    testColor = pixels.ColorAt(swatchSolidLeftX + xOff, swatchSolidTopY);
 
                    break;
 
                }
 
                result &= pixels.DoesPixelMatch(swatchSolidLeftX + xOff, swatchSolidBottomY, ColorMatcher.IsMatch);
 
                if (!result)
 
                {
 
                    testColor = pixels.ColorAt(swatchSolidLeftX + xOff, swatchSolidBottomY);
 
                    break;
 
                }
 
            }
 
            if (!result)
 
            {
 
                WriteLog(LogVerbosity.Normal, "Failed to match upper/lower edges for potential swatch of color {2}, {3}, {4} at {0}, {1} [failed color = {5},{6},{7}]", x, y, swatchColor.R, swatchColor.G, swatchColor.B, testColor.R, testColor.G, testColor.B);
 
                return false;
 
            }
 

	
 

	
 
            // test the left edge for dark pixels -- the bottom-most pixel is bright now
 
            int i = 0;
 
            for (i = 1; result && i < _swatchHeight - 1; ++i)
 
            {
 
                result &= pixels.DoesPixelMatch(x, y + i, PixelColor.IsDark);
 
            }
 
            if (!result)
 
            {
 
                // No dark border on the left side
 
                WriteLog(LogVerbosity.Normal, "Failed to find left border for potential swatch of color {2}, {3}, {4} at {0}, {1}", x, y, swatchColor.R, swatchColor.G, swatchColor.B);
 
                return false;
 
            }
 

	
 
            // test the dark top border and for papyrus above and below the swatch
 
            bool borderError = false;
 
            int papyErrorCount = 0;
 
            for (i = 0; result && (i < _swatchWidth - 1); ++i)
 
            {
 
                bool isBorder = pixels.DoesPixelMatch(x + i, y, PixelColor.IsDark);
 
                result &= isBorder;
 
                if (!isBorder)
 
                {
 
                    WriteLog(LogVerbosity.Normal, "Probable swatch at {0},{1} failed upper border test at {2},{3}", x, y, x + i, y);
 
                    borderError = true;
 
                }
 

	
 
                // Checking along the top of the swatch for papyrus
 
                // The row just above is shaded, so check 2 above
 
                if (y > 1)
 
                {
 
                    bool isPapyrus = pixels.DoesPixelMatch(x + i, y - 2, PixelColor.IsPapyrus);
 
                    papyErrorCount += isPapyrus ? 0 : 1;
 

	
 
                }
 
                else
 
                {
 
                    ++papyErrorCount;
 
                }
 

	
 
                // Checking along the bottom of the swatch for papyrus
 
                if (y < pixels.Height)
 
                {
 
                    bool isPapyrus = pixels.DoesPixelMatch(x + i, y + _swatchHeight, PixelColor.IsPapyrus);
 
                    papyErrorCount += isPapyrus ? 0 : 1;
 
                }
 
                else
 
                {
 
                    ++papyErrorCount;
 
                }
 
            }
 

	
 
            result &= (papyErrorCount < (_swatchWidth / 20)); // allow up to 5% error rate checking for papy texture, because this seems to be inconsistent
 
            if (!result && ((i > (_swatchWidth*0.8)) || (papyErrorCount >= (_swatchWidth/20))))
 
            {
 
                if (!borderError && (papyErrorCount < _swatchWidth))
 
                {
 
                    WriteLog(LogVerbosity.Normal, "Found a potential swatch candidate of width {0} at {1},{2} that had {3} failures matching papyrus texture", i, x, y, papyErrorCount);
 
                }
 
            }
 

	
 
            return result;
 
        }
 

	
 
        private bool TestPosition(int x, int y, Pixels pixels, ref PaintColor reactedColor)
 
        {
 
            // Check 4 corners of solid area and left/right solid bar areas
 
            bool foundSwatch = IsPossibleSwatchUpperLeft(pixels, x, y); // ((pixel_r < 0x46) && (pixel_g < 0x46) && (pixel_b < 0x46));
 
            if (foundSwatch)
 
            {
 
                WriteLog(LogVerbosity.Normal, "Found probable swatch at {0},{1} - checking border slices", x, y);
 
                int borderXOffset = 0;
 
                for (borderXOffset = 2; foundSwatch && (borderXOffset < _swatchTestWidth); ++borderXOffset)
 
                {
 
                    foundSwatch &= IsPossibleSwatchSlice(pixels, x + borderXOffset, y);
 
                    if (!foundSwatch)
 
                    {
 
                        WriteLog(LogVerbosity.Normal, "Failed slice test at {0},{1}", x + borderXOffset, y);
 
                        break;
 
                    }
 
                    foundSwatch &= IsPossibleSwatchSlice(pixels, x + _swatchWidth - borderXOffset, y);
 
                    if (!foundSwatch)
 
                    {
 
                        WriteLog(LogVerbosity.Normal, "Failed slice test at {0},{1}", x + _swatchWidth - borderXOffset, y);
 
                        break;
 
                    }
 
                }
 
            }
 

	
 
            if (!foundSwatch) return false;
 
            // WE FOUND THE SWATCH!
 
            // Now we know where the color bars are.
 
            int redPixelCount = pixels.LengthOfColorAt(x, y + _redBarSpacing, PixelColor.IsRed);
 
            reactedColor.Red = (byte)Math.Round((float)redPixelCount * 255f / (float)_colorBarWidth);
 

	
 
            int greenPixelCount = pixels.LengthOfColorAt(x, y + _greenBarSpacing, PixelColor.IsGreen);
 
            reactedColor.Green = (byte)Math.Round((float)greenPixelCount * 255f / (float)_colorBarWidth);
 

	
 
            int bluePixelCount = pixels.LengthOfColorAt(x, y + _blueBarSpacing, PixelColor.IsBlue);
 
            reactedColor.Blue = (byte)Math.Round((float)bluePixelCount * 255f / (float)_colorBarWidth);
 
            WriteLog(LogVerbosity.Low, "Found the color swatch at {0}, {1}. Color={2} Red={3}px Green={4}px Blue={5}px", x, y, reactedColor, redPixelCount, greenPixelCount, bluePixelCount);
 
            return true;
 
        }
 

	
 
        public async Task<bool> CaptureReactionAsync(IProgress<float> progress)
 
        {
 
            _canceler = new CancellationTokenSource(); // You can't re-use these, sadly.
 
            return await Task.Run(() => CaptureReaction(progress, _canceler.Token));
 
        }
 

	
 
        private bool CaptureReaction(IProgress<float> progress, CancellationToken cancellToken)
 
        {
 
            PaintColor reactedColor = new();
 
            IsCaptured = false;
 
            _recordedColor.Clear();
 

	
 
            using (var g = Graphics.FromImage(_targetBitmap))
 
            {
 
                Debug.WriteLine("Scan starting at [" + _screenX + ", " + _screenY + "]");
 
                g.CopyFromScreen(_screenX, _screenY, 0, 0, _targetBitmap.Size, CopyPixelOperation.SourceCopy);
 
            }
 
            
 
            _targetBitmap.Save(Path.Combine(ProfileManager.CurrentProfile?.Directory ?? "", "screenshot.png"), ImageFormat.Png);
 

	
 
            Pixels pixels = new(_targetBitmap, _pixelMultiplier);
 

	
 
            IsCaptured = false;
 
            if (!_firstRun)
 
            {
 
                // If this is not the first run, let's check the last location, to see if the UI is still there.
 
                if (TestPosition(_lastSwatchX, _lastSwatchY, pixels, ref reactedColor))
 
                {
 
                    IsCaptured = true;
 
                    RecordedColor = reactedColor;
 
                    return true;
 
                }
 
                
 
                _firstRun = true;
 
            }
 

	
 
            SettingsService.Get("Log.Verbosity", out var verbosityIdx, 1);
 
            _logVerbosity = (LogVerbosity)verbosityIdx;
 
            
 
            int patchTestSize = ((_swatchHeight - 5) / 2) - 1;
 

	
 
            SettingsService.Get("ScanArea.Min.X", out var startX, 0);
 
            SettingsService.Get("ScanArea.Min.Y", out var startY, 0);
 
            SettingsService.Get("ScanArea.Max.X", out var endX,   _targetBitmap.Width);
 
            SettingsService.Get("ScanArea.Max.Y", out var endY,   _targetBitmap.Height);
 
            
 
            startX = Math.Max(2, Math.Min(startX, _targetBitmap.Width - 2));
 
            startY = Math.Max(2, Math.Min(startY, _targetBitmap.Height - 2));
 
            endX = Math.Min(_targetBitmap.Width - 2, Math.Max(2, endX)) - _colorBarWidth; //  + patchTestSize;
 
            endY = Math.Min(_targetBitmap.Height - 2, Math.Max(2, endY)) - (_blueBarSpacing + 10); // + patchTestSize;
 

	
 
            Debug.WriteLine("startX: " + startX + " endX: " + endX + " startY: " + startY + " endY: " + endY);
 
            
 
            int xSpan = endX - startX;
 
            int ySpan = endY - startY;
 
            int total = xSpan * ySpan;
 

	
 
            for (int roughX = startX; roughX < endX ; roughX += patchTestSize)
 
            {
 
                int xMark = roughX - startX;
 
                
 
                for (int roughY = startY; roughY < endY; roughY += patchTestSize)
 
                {
 
                    progress.Report((float)((xMark * ySpan) + (roughY - startY)) / total);
 

	
 
                    cancellToken.ThrowIfCancellationRequested();
 

	
 
                    if (!pixels.IsSolidPatchAt(roughX, roughY, patchTestSize, patchTestSize)) continue;
 
                    Color patchColor = pixels.ColorAt(roughX, roughY);
 

	
 
                    WriteLog(LogVerbosity.Excessive, "Found a solid patch of {2},{3},{4} at {0}, {1}", roughX, roughY, patchColor.R, patchColor.G, patchColor.B);
 
                    for (int x = Math.Max(0, roughX - patchTestSize); x < roughX; ++x)
 
                    {
 
                        for (int y = Math.Max(0, roughY - patchTestSize); y < roughY; ++y)
 
                        {
 
                            WriteLog(LogVerbosity.Excessive, "Searching for potential swatch at {0},{1} after found square at {2},{3}", x, y, roughX, roughY);
 
                            
 
                            if (!TestPosition(x, y, pixels, ref reactedColor)) continue;
 
                            
 
                            RecordedColor = reactedColor;
 
                            _lastSwatchX  = x;
 
                            _lastSwatchY  = y;
 
                            _firstRun     = false;
 
                            IsCaptured    = true;
 
                            return true;
 
                        }
 
                    }
 
                    WriteLog(LogVerbosity.Excessive, "False-positive patch of color {0},{1},{2} at {3},{4}", patchColor.R, patchColor.G, patchColor.B, roughX, roughY);
 
                }
 
            }
 
            return false;
 
        }
 

	
 

	
 
        public static Reaction Calculate3WayReaction(PlayerProfile profile, PaintColor expectedColor, PaintColor reactedColor, Reagent reagent0, Reagent reagent1, Reagent reagent2)
 
        {
 
            // A 3-reagent reaction.
 
            Reaction? reaction1 = profile.FindReaction(reagent0, reagent1);
 
            Reaction? reaction2 = profile.FindReaction(reagent0, reagent2);
 

	
 
            Debug.Assert(reaction1 != null);
 
            Debug.Assert(reaction2 != null);
 

	
 
            int r = reactedColor.Red   - expectedColor.Red   - reaction1.Red   - reaction2.Red;
 
            int g = reactedColor.Green - expectedColor.Green - reaction1.Green - reaction2.Green;
 
            int b = reactedColor.Blue  - expectedColor.Blue  - reaction1.Blue  - reaction2.Blue;
 
            
 
            return new Reaction(r, g, b);
 
        }
 

	
 

	
 
        public static Reaction CalculateReaction(PaintColor expectedColor, PaintColor reactedColor)
 
        {
 
            // A 2-reagent reaction.
 
            int r = reactedColor.Red   - expectedColor.Red;
 
            int g = reactedColor.Green - expectedColor.Green;
 
            int b = reactedColor.Blue  - expectedColor.Blue;
 
            return new Reaction(r, g, b);
 
        }
 
    }
 
}
...
 
\ No newline at end of file
Services/ReagentService.cs
Show inline comments
 
new file 100644
 
using System;
 
using System.IO;
 
using System.Collections.Generic;
 
using System.Diagnostics;
 
using System.Text.RegularExpressions;
 
using DesertPaintCodex.Models;
 
using DesertPaintCodex.Util;
 

	
 
namespace DesertPaintCodex.Services
 
{
 
    internal static class ReagentService
 
    {
 
        // PP format
 
        private static readonly Regex _reagentRegex          = new(@"(?<name>\w+)\s*\|\s*(?<red>\d+),\s*(?<green>\d+),\s*(?<blue>\d+)\s*\|\s*(?<cost>\d+)\s*\|\s*(?<enabled>[YN])\s*\|\s*(?<bulk>(bulk|normal))\s*\|\s*(?<max>\d+).*");
 
        private static readonly Regex _catalystRegex         = new(@"(?<name>\w+)\s*\|\s*catalyst\s*\|\s*(?<cost>\d+)\s*\|\s*(?<enabled>[YN])\s*\|\s*(?<bulk>(bulk|normal)).*");
 
        private static readonly Regex _internalReagentRegex  = new(@"(?<name>(\w*\s)*\w+)\s*\|\s*(?<ppname>\w+)\s*\|\s*(?<red>\d+),\s*(?<green>\d+),\s*(?<blue>\d+).*");
 
        private static readonly Regex _internalCatalystRegex = new(@"(?<name>(\w+\s)*\w+)\s*\|\s*(?<ppname>\w+)\s*\|\s*catalyst.*");
 

	
 
        private static readonly Dictionary<string, Reagent> _reagents   = new();
 
        private static readonly Dictionary<string, string>  _nameLookup = new(); // pp name to our name
 

	
 
        private static string _lastReagentsFile = string.Empty;
 

	
 
        private static bool _initialized = false;
 

	
 
        public static List<string> Names { get; } = new();
 

	
 

	
 
        public static void Initialize()
 
        {
 
            if (_initialized) return;
 

	
 
            string? reagentsPath = FileUtils.FindApplicationResourceFile("ingredients.txt");
 
            Debug.Assert(reagentsPath != null);
 
    
 
            Load(reagentsPath);
 
        }
 
        
 
        // Loads reagent name/colors
 
        public static void Load(string file)
 
        {
 
            if (_lastReagentsFile == file) return;
 
            _lastReagentsFile = file;
 
            
 
            _reagents.Clear();
 
            _nameLookup.Clear();
 
            Names.Clear();
 

	
 
            using StreamReader reader = new(file);
 
            string? line;
 
            while ((line = reader.ReadLine()) != null)
 
            {
 
                Match match = _internalReagentRegex.Match(line);
 
                if (match.Success)
 
                {
 
                    string name   = match.Groups["name"].Value;
 
                    string ppname = match.Groups["ppname"].Value;
 
                    _reagents.Add(name,
 
                        new Reagent(name, ppname,
 
                            byte.Parse(match.Groups["red"].Value),
 
                            byte.Parse(match.Groups["green"].Value),
 
                            byte.Parse(match.Groups["blue"].Value)));
 
                    // nameStore.AppendValues(name);
 
                    Names.Add(name);
 
                    _nameLookup.Add(ppname, name);
 
                }
 
                else
 
                {
 
                    match = _internalCatalystRegex.Match(line);
 
                    
 
                    if (!match.Success) continue;
 
                    
 
                    string name   = match.Groups["name"].Value;
 
                    string ppname = match.Groups["ppname"].Value;
 
                    _reagents.Add(name, new Reagent(ppname, ppname));
 
                    // nameStore.AppendValues(name);
 
                    Names.Add(name);
 
                    _nameLookup.Add(ppname, name);
 
                }
 
            }
 
        }
 

	
 
        public static void LoadProfileReagents(string file)
 
        {
 
            Initialize();
 
            
 
            using StreamReader reader = new(file);
 
            string? line;
 
            while ((line = reader.ReadLine()) != null)
 
            {
 
                Match match = _reagentRegex.Match(line);
 
                if (match.Success)
 
                {
 
                    string ppname = match.Groups["name"].Value;
 
                    if (_nameLookup.TryGetValue(ppname, out string? name))
 
                    {
 
                        Reagent reagent = GetReagent(name);
 
                        if (reagent.IsCatalyst) continue;
 
                        reagent.Enabled   = match.Groups["enabled"].Value.Equals("Y");
 
                        reagent.Cost      = uint.Parse(match.Groups["cost"].Value);
 
                        reagent.RecipeMax = uint.Parse(match.Groups["max"].Value);
 
                    }
 
                    else
 
                    {
 
                        // bad name?
 
                    }
 
                }
 
                else
 
                {
 
                    match = _catalystRegex.Match(line);
 
                    
 
                    if (!match.Success) continue;
 
                    
 
                    string ppname = match.Groups["name"].Value;
 
                    if (_nameLookup.TryGetValue(ppname, out string? name))
 
                    {
 
                        Reagent reagent = GetReagent(name);
 
                        
 
                        if (reagent is not {IsCatalyst: true}) continue;
 
                        
 
                        reagent.Enabled = match.Groups["enabled"].Value.Equals("Y");
 
                        reagent.Cost    = uint.Parse(match.Groups["cost"].Value);
 
                    }
 
                    else
 
                    {
 
                        // bad name?
 
                    }
 
                }
 
            }
 
        }
 

	
 
        public static void SaveProfileReagents(string file)
 
        {
 
            Initialize();
 
            
 
            using StreamWriter writer = new(file);
 
            writer.WriteLine("// Ingredients are in the form:");
 
            writer.WriteLine("// Name | RGB values | cost | enabled (Y/N) | bulk/normal | max items per paint (1-20)");
 
            writer.WriteLine("//");
 
            writer.WriteLine("// It is recommended to only change the cost value");
 
            writer.WriteLine("// It is not recommended to set many of the ingredients above 10 per paint");
 

	
 
            List<Reagent> sortedReagents = new(_reagents.Count);
 
            sortedReagents.AddRange(_reagents.Values);
 
            sortedReagents.Sort((x, y) =>
 
                ((x.IsCatalyst && !y.IsCatalyst) ?
 
                    1 : ((y.IsCatalyst && !x.IsCatalyst) ?
 
                        -1 : string.Compare(x.PracticalPaintName, y.PracticalPaintName, StringComparison.InvariantCulture))));
 
            
 
            foreach (Reagent reagent in sortedReagents)
 
            {
 
                if (!reagent.IsCatalyst)
 
                {
 
                    writer.WriteLine("{0,-10} | {1,3}, {2,3}, {3,3} | {4,7} | {5} | {6} | {7}",
 
                        reagent.PracticalPaintName,
 
                        reagent.Color?.Red, reagent.Color?.Blue, reagent.Color?.Green,
 
                        reagent.Cost,
 
                        reagent.Enabled ? "Y" : "N",
 
                        reagent.RecipeMax >= 10 ? "  bulk" : "normal",
 
                        reagent.RecipeMax);
 
                }
 
                else
 
                {
 
                    writer.WriteLine("{0,-10} | catalyst | {1,7} | {2} | normal | 1",
 
                        reagent.PracticalPaintName,
 
                        reagent.Cost,
 
                        reagent.Enabled ? "Y" : "N");
 
                }
 
            }
 
        }
 

	
 

	
 
        public static void InitializeReactions(ReactionSet reactions)
 
        {
 
            Initialize();
 
            foreach (KeyValuePair<string, Reagent> pair1 in _reagents)
 
            {
 
                foreach (KeyValuePair<string, Reagent> pair2 in _reagents)
 
                {
 
                    if (pair1.Key != pair2.Key)
 
                    {
 
                        reactions.Set(pair1.Value, pair2.Value, null);
 
                    }
 
                }
 
            }
 
        }
 

	
 
        public static Reagent GetReagent(string reagentName)
 
        {
 
            Initialize();
 
            
 
            if (_reagents.TryGetValue(reagentName, out Reagent? returnVal)) return returnVal;
 
            // convert pp name to our internal name
 
            if (_nameLookup.TryGetValue(reagentName, out string? otherName))
 
            {
 
                _reagents.TryGetValue(otherName, out returnVal);
 
            }
 
            
 
            Debug.Assert(returnVal != null);
 

	
 
            return returnVal;
 
        }
 
    }
 
}
Services/RecipeGenerator.cs
Show inline comments
 
new file 100644
 
using System;
 
using System.Collections.Concurrent;
 
using System.Collections.Generic;
 
using System.Diagnostics;
 
using System.IO;
 
using System.Linq;
 
using System.Text.RegularExpressions;
 
using System.Threading;
 
using DesertPaintCodex.Models;
 

	
 
namespace DesertPaintCodex.Services
 
{
 
    public class NewRecipeEventArgs : EventArgs
 
    {
 
        public NewRecipeEventArgs(string color, PaintRecipe recipe)
 
        {
 
            Color  = color;
 
            Recipe = recipe;
 
        }
 

	
 
        public string Color { get; }
 

	
 
        public PaintRecipe Recipe { get; }
 
    }
 
    
 
    public class RecipeGenerator
 
    {
 
        public enum SearchType {
 
            DepthFirst,
 
            BreadthFirst
 
        };
 

	
 
        public SearchType Mode { get; set; }
 

	
 
        public uint MinConcentration { get; private set; } // minimum paint concentration
 
        public uint MaxConcentration { get; private set; } // maximum paint concentration
 
        public uint MaxReagents { get; private set; } // maximum number of reagents to use in the recipe
 
        public uint MinReagents { get; private set; } // minimum number of reagents to use in the recipe
 
        public uint FullQuantityDepth { get; private set; } // at or equal this number of reagents, ignore ingredient settings for max quantity
 
        public uint FullQuantity { get; private set; }  // The max number of a reagent to use at full quantity
 

	
 
        public uint MaxThreads { get; set; } = 15;
 
        
 
        private bool _running = false;
 

	
 
        private int _runningThreads = 0;
 
        
 
        private readonly Dictionary<string, uint> _recipeCosts = new ();
 
        private readonly Dictionary<string, PaintRecipe> _recipes = new();
 

	
 
        private uint _totalReagents;
 

	
 
        private readonly List<Reagent> _costSortedReagents = new();
 

	
 
        private readonly ConcurrentQueue<RecipeSearchNode> _searchQueue = new();
 

	
 
        private ulong _recipeCount = 0;
 

	
 
        private readonly List<Thread> _generatorThreads = new();
 
        private readonly object _workerLock = new();
 

	
 
        private bool _requestCancel = false;
 

	
 
        private StreamWriter? _log;
 

	
 
        // events
 
        public event EventHandler? Finished;
 
        public event EventHandler? Progress;
 
        public event EventHandler<NewRecipeEventArgs>? NewRecipe;
 

	
 
        public RecipeGenerator()
 
        {
 
            Mode = SearchType.BreadthFirst;
 
            foreach (PaintColor color in PaletteService.Colors)
 
            {
 
                _recipes.Add(color.Name, new PaintRecipe());
 
                _recipeCosts.Add(color.Name, uint.MaxValue);
 
            }
 
            MinReagents = 1;
 
            MaxReagents = 5;
 
            MinConcentration = 10;
 
            MaxConcentration = 20;
 
        }
 

	
 
        public Dictionary<string, PaintRecipe> Recipes => _recipes;
 

	
 
        public ulong RecipeCount => _recipeCount;
 

	
 
        public bool CanResume => (!_running && (_searchQueue.Count > 0));
 

	
 
        public string? Log
 
        {
 
            set => _log = value != null ? new StreamWriter(value) : null;
 
        }
 

	
 
        private class ReagentCostSort : IComparer<Reagent>
 
        {
 
            public int Compare(Reagent? reagent1, Reagent? reagent2)
 
            {
 
                if (reagent1 == null)
 
                {
 
                    if (reagent2 == null) return 0;
 
                    return -1;
 
                }
 
                if (reagent2 == null) return 1;
 
                
 
                return (int)reagent1.Cost - (int)reagent2.Cost;
 
            }
 
        }
 

	
 
        public void InitRecipes(Dictionary<string, PaintRecipe> initialRecipes)
 
        {
 
            if (_running)
 
            {
 
                return;
 
            }
 
            _recipeCosts.Clear();
 
            _recipes.Clear();
 
            foreach (PaintRecipe recipe in initialRecipes.Values)
 
            {
 
                //PaintRecipe recipeCopy = new PaintRecipe(recipe);
 
                AddCheapestRecipe(recipe);
 
            }
 
        }
 

	
 
        private void InitSortedReagents()
 
        {
 
            _costSortedReagents.Clear();
 
            foreach (string name in ReagentService.Names)
 
            {
 
                Reagent reagent = ReagentService.GetReagent(name);
 
                _costSortedReagents.Add(reagent);
 
            }
 
            _costSortedReagents.Sort(new ReagentCostSort());
 
        }
 

	
 
        // Generate paint recipes.
 
        // minConcentration - the minimum permitted concentration in a recipe (10 for paint, 50 for ribbons)
 
        // maxConcentration - the maximum concentration for a recipe
 
        // minReagents - the minimum number of ingredients in a recipe
 
        // maxReagents - the maximum number of ingredients allowed in a recipe
 
        // fullQuantityDepth - at this depth of ingredient or below, allow up to fullQuantity of any reagent
 
        // fullQuantity - the maximum amount of any reagent to permit up to the full quantity depth. After that, reagents are limited by the
 
        //                per-reagent value
 
        public void BeginRecipeGeneration(uint minConcentration, uint maxConcentration, uint minReagents, uint maxReagents, uint fullQuantityDepth, uint fullQuantity)
 
        {
 
            if (_running)
 
            {
 
                // Already running - don't start again
 
                return;
 
            }
 

	
 
            MinConcentration = minConcentration;
 
            MaxConcentration = maxConcentration;
 
            MinReagents = minReagents;
 
            MaxReagents = maxReagents;
 
            FullQuantity = fullQuantity;
 
            FullQuantityDepth = fullQuantityDepth;
 

	
 
            // first, sort reagents by cost.
 
            InitSortedReagents();
 

	
 
            _totalReagents = (uint)_costSortedReagents.Count;
 

	
 
            // Pre-populate recipes list with:
 
            // 1) 1-ingredient recipes @ min concentration for all enabled ingredients with a count >= min concentration
 
            // 2) any previously-generated recipes
 
            int enabledReagentCount = 0;
 
            PaintRecipe recipe = new();
 
            foreach (var reagent in _costSortedReagents.Where(reagent => reagent.Enabled))
 
            {
 
                if (!reagent.IsCatalyst && ((reagent.RecipeMax >= minConcentration) || ((FullQuantityDepth > 0) && (FullQuantity >= minConcentration))))
 
                {
 
                    recipe.Clear();
 
                    recipe.AddReagent(reagent.Name, minConcentration);
 
                    AddCheapestRecipe(recipe);
 
                }
 
                ++enabledReagentCount;
 
            }
 
            MaxReagents = (uint)Math.Min(enabledReagentCount, MaxReagents);
 
            MinReagents = Math.Min(MinReagents, MaxReagents);
 
            
 
            _searchQueue.Clear();
 

	
 
            for (uint reagentIdx = 0; reagentIdx < _costSortedReagents.Count; ++reagentIdx)
 
            {
 
                if (!_costSortedReagents[(int) reagentIdx].Enabled) continue;
 
                
 
                // queue up all combinations of MinReagents
 
                RecipeSearchNode initialNode = new(_costSortedReagents, reagentIdx)
 
                {
 
                    FullQuantity      = FullQuantity,
 
                    FullQuantityDepth = FullQuantityDepth,
 
                    MinConcentration  = minConcentration,
 
                    MaxConcentration  = maxConcentration,
 
                    MinReagents       = minReagents,
 
                    MaxReagents       = maxReagents
 
                };
 

	
 
                if (MinReagents > 1)
 
                {
 
                    while (NextReagentSetBreadthFirst(initialNode, 1, minReagents))
 
                    {
 
                        if (initialNode.ReagentCount != minReagents) continue;
 
                        
 
                        //Console.WriteLine("Initial node at size {0}/{1} with recipe: {2}", initialNode.ReagentCount, minReagents, initialNode.TestRecipe.ToString());
 
                        RecipeSearchNode searchNode = new RecipeSearchNode(initialNode);
 
                        _searchQueue.Enqueue(searchNode);
 
                    }
 
                }
 
                else
 
                {
 
                    _searchQueue.Enqueue(initialNode);
 
                }
 
            }
 

	
 
            _recipeCount = 0;
 

	
 
            _log?.WriteLine("Begin recipe generation: MaxConcentration={0} MinReagents={1} MaxReagents={2} FullQuantity={3} FullQuantityDepth={4}", MaxConcentration, MinReagents, MaxReagents, FullQuantity, FullQuantityDepth);
 

	
 
            // start worker threads to do the actual work
 
            ResumeRecipeGeneration();
 
        }
 

	
 
        public void ResumeRecipeGeneration()
 
        {
 
            if (_running)
 
            {
 
                // Already running - don't start again
 
                return;
 
            }
 
            _running = true;
 
            _requestCancel = false;
 

	
 
            _log?.WriteLine("Resuming recipe generation: pre-threads={0} reagent count={1} search queue={2}", _runningThreads, _costSortedReagents.Count, _searchQueue.Count);
 
            _runningThreads = 0; // presumably!
 

	
 
            int threadCount = Math.Min(Math.Min(_costSortedReagents.Count, _searchQueue.Count), (int)MaxThreads);
 
            if (threadCount == 0)
 
            {
 
                Finished?.Invoke(this, EventArgs.Empty);
 
            }
 
            _generatorThreads.Clear();
 
            Console.WriteLine("Starting {0} generator threads.", threadCount);
 
            for (int i = 0; i < threadCount; ++i)
 
            {
 
                Thread thr = new(Generate) {Priority = ThreadPriority.BelowNormal};
 
                _generatorThreads.Add(thr);
 
            }
 
            foreach (Thread thr in _generatorThreads)
 
            {
 
                thr.Start();
 
            }
 
        }
 

	
 
        public bool SaveState(string file)
 
        {
 
            if (_running)
 
            {
 
                // can't save state while running
 
                return false;
 
            }
 

	
 
            lock(_workerLock)
 
            {
 
                using StreamWriter writer = new(file, false);
 
                writer.WriteLine("MinReagents: {0}", MinReagents);
 
                writer.WriteLine("MaxReagents: {0}", MaxReagents);
 
                writer.WriteLine("FullQuantityDepth: {0}", FullQuantityDepth);
 
                writer.WriteLine("FullQuantity: {0}", FullQuantity);
 
                writer.WriteLine("TotalReagents: {0}", _totalReagents);
 
                writer.WriteLine("RecipeCount: {0}", _recipeCount);
 
                writer.WriteLine("SearchType: {0}", Mode.ToString());
 
                foreach (KeyValuePair<string, PaintRecipe> pair in _recipes)
 
                {
 
                    PaintRecipe recipe    = pair.Value;
 
                    string      colorName = PaletteService.FindNearest(recipe.ReactedColor);
 
                    writer.WriteLine("BeginRecipe: {0}", colorName);
 
                    foreach (PaintRecipe.ReagentQuantity reagent in recipe.Reagents)
 
                    {
 
                        writer.WriteLine("Ingredient: {0}={1}", reagent.Name, reagent.Quantity);
 
                    }
 
                    writer.WriteLine("EndRecipe: {0}", colorName);
 
                }
 
                writer.WriteLine("SearchNodes: {0}", _searchQueue.Count);
 
                foreach (RecipeSearchNode node in _searchQueue)
 
                {
 
                    node.SaveState(writer);
 
                }
 
            }
 
            return true;
 
        }
 

	
 
        private static readonly Regex _keyValueRegex = new Regex(@"(?<key>\w+)\:\s*(?<value>.+)\s*");
 
        private static readonly Regex _reagentRegex = new Regex(@"(?<ingredient>(\w+\s)*\w+)\s*=\s*(?<quantity>\d)\s*");
 
        
 
        public bool LoadState(string file)
 
        {
 
            // cannot be running, and reactions must be set
 
            if (_running)
 
            {
 
                return false;
 
            }
 

	
 
            InitSortedReagents();
 
            bool success = true;
 

	
 
            PaintRecipe? currentRecipe = null;
 
            using StreamReader reader = new(file, false);
 
            string? line;
 
            while (success && ((line = reader.ReadLine()) != null))
 
            {
 
                Match match = _keyValueRegex.Match(line);
 
                if (match.Success)
 
                {
 
                    string value = match.Groups["value"].Value;
 
                    switch(match.Groups["key"].Value)
 
                    {
 
                        case "MinReagents":
 
                            MinReagents = uint.Parse(value);
 
                            MaxReagents = Math.Max(MinReagents, MaxReagents);
 
                            break;
 
                        case "MaxReagents":
 
                            MaxReagents = uint.Parse(value);
 
                            MinReagents = Math.Min(MinReagents, MaxReagents);
 
                            break;
 
                        case "FullQuantityDepth":
 
                            FullQuantityDepth = uint.Parse(value);
 
                            break;
 
                        case "FullQuantity":
 
                            FullQuantity = uint.Parse(value);
 
                            break;
 
                        case "TotalReagents":
 
                            _totalReagents = uint.Parse(value);
 
                            break;
 
                        case "RecipeCount":
 
                            if (!ulong.TryParse(value, out _recipeCount))
 
                            {
 
                                // must have rolled to negative - try as an int and convert
 
                                long recipeCountInt = int.Parse(value);
 
                                _recipeCount = (ulong)(recipeCountInt & 0x00000000ffffffffL);
 
                            }
 
                            break;
 
                        case "BeginRecipe":
 
                            currentRecipe = new PaintRecipe();
 
                            break;
 
                        case "EndRecipe":
 
                            if (currentRecipe != null)
 
                            {
 
                                PaintColor color = currentRecipe.ReactedColor;
 
                                uint       cost  = currentRecipe.Cost;
 
                                _recipes[color.Name]     = currentRecipe; // replace
 
                                _recipeCosts[color.Name] = cost;
 
                                currentRecipe            = null;
 
                            }
 
                            break;
 
                        case "Ingredient":
 
                            if (currentRecipe != null)
 
                            {
 
                                Match ingredientMatch = _reagentRegex.Match(match.Groups["value"].Value);
 
                                if (ingredientMatch.Success)
 
                                {
 
                                    uint quantity = uint.Parse(ingredientMatch.Groups["quantity"].Value);
 
                                    currentRecipe.AddReagent(ingredientMatch.Groups["ingredient"].Value, quantity);
 
                                }
 
                                else
 
                                {
 
                                    success = false;
 
                                }
 
                            }
 
                            break;
 
                        case "SearchNodes":
 
                            int nodeCount = int.Parse(match.Groups["value"].Value);
 
                            for (int i = 0; i < nodeCount; ++i)
 
                            {
 
                                RecipeSearchNode node = new(_costSortedReagents)
 
                                {
 
                                    FullQuantity      = FullQuantity,
 
                                    FullQuantityDepth = FullQuantityDepth,
 
                                    MinReagents       = MinReagents,
 
                                    MaxReagents       = MaxReagents,
 
                                    MaxConcentration  = MaxConcentration
 
                                };
 
                                success                = success && node.LoadState(reader);
 
                                if (success)
 
                                {
 
                                    _searchQueue.Enqueue(node);
 
                                }
 
                            }
 
                            break;
 
                        case "SearchType":
 
                            Mode = (SearchType)Enum.Parse(typeof(SearchType), match.Groups["value"].Value);
 
                            break;
 
                        default:
 
                            success = false;
 
                            break;
 
                    }
 
                }
 
                else
 
                {
 
                    success = false;
 
                    break;
 
                }
 
            }
 
            return success;
 
        }
 

	
 
        private void Generate()
 
        {
 
            lock(_workerLock)
 
            {
 
                ++_runningThreads;
 
            }
 

	
 
            bool ok;
 
            do
 
            {
 
                RecipeSearchNode? node;
 
                lock (_workerLock)
 
                {
 
                    ok = _searchQueue.TryDequeue(out node);
 
                }
 
                
 
                if (!ok) continue;
 
                
 
                Debug.Assert(node != null);
 
                
 
                if (Mode == SearchType.DepthFirst)
 
                {
 
                    uint targetQuantity = (node.ReagentCount <= FullQuantityDepth)
 
                        ? ((uint)node.ReagentCount * FullQuantity) 
 
                        : node.MaxConcentration + 1;
 
                    do {
 
                        --targetQuantity;
 
                        node.InitForQuantity(targetQuantity);
 
                    } while (targetQuantity > MinConcentration && (node.CurrentTargetQuantity != node.UsedQuantity));
 
    
 
                    while ((ok = IterateDepthFirst(node)) && !_requestCancel)
 
                    {
 
                        Progress?.Invoke(this, EventArgs.Empty);
 
                    }
 
                }
 
                else
 
                {
 
                    // breadth-first search
 
                    uint targetQuantity = MinConcentration - 1;
 
                    uint quantityLimit = (node.ReagentCount <= FullQuantityDepth) 
 
                        ? (FullQuantity * (uint)node.ReagentCount) 
 
                        : node.MaxConcentration;
 
                    do {
 
                        ++targetQuantity;
 
                        node.InitForQuantity(targetQuantity);
 
                    } while ((targetQuantity < quantityLimit) && (node.CurrentTargetQuantity != node.UsedQuantity));
 
    
 
                    while ((ok = IterateBreadthFirst(node)) && !_requestCancel)
 
                    {
 
                        Progress?.Invoke(this, EventArgs.Empty);
 
                    }
 
                }
 
                if (ok)
 
                {
 
                    // stopped because cancel was requested - requeue the node in its current state for resume
 
                    _searchQueue.Enqueue(node);
 
                }
 
            } while (!_requestCancel && ok);
 

	
 
            bool done;
 
            lock(_workerLock)
 
            {
 
                --_runningThreads;
 
                //generatorThreads.Remove(Thread.CurrentThread);
 

	
 
                done = (_runningThreads == 0);
 
            }
 

	
 
            if (!done) return;
 
            
 
            _running       = false;
 
            _requestCancel = false;
 
            Finished?.Invoke(this, EventArgs.Empty);
 
        }
 

	
 
        // Add the cheapest recipe to the recipe list
 
        // returns the discarded recipe from the pair (or null if no original recipe to replace)
 
        private void AddCheapestRecipe(PaintRecipe recipe)
 
        {
 
            if (!recipe.IsValidForConcentration(MinConcentration)) return;
 
            
 
            string colorName = PaletteService.FindNearest(recipe.ReactedColor);
 
            lock (_workerLock)
 
            {
 
                if (_recipeCosts.TryGetValue(colorName, out var cost))
 
                {
 
                    if (cost <= recipe.Cost) return;
 
                        
 
                    _recipes[colorName].CopyFrom(recipe);
 
                    _recipeCosts[colorName] = recipe.Cost;
 
                    _log?.WriteLine("New recipe (cost {0}): {1}", recipe.Cost, recipe);
 
                        
 
                    if (NewRecipe == null) return;
 
                        
 
                    NewRecipeEventArgs args = new(colorName, recipe);
 
                    NewRecipe(this, args);
 
                }
 
                else
 
                {
 
                    // This would be an error!
 
                    _recipeCosts.Add(colorName, recipe.Cost);
 
                    _recipes.Add(colorName, new PaintRecipe(recipe));
 
                        
 
                    if (NewRecipe == null) return;
 
                        
 
                    NewRecipeEventArgs args = new(colorName, recipe);
 
                    NewRecipe(this, args);
 
                }
 
            }
 
        }
 

	
 
        private bool IterateDepthFirst(RecipeSearchNode node)
 
        {
 
            TestCurrentRecipe(node);
 

	
 
            // pick recipe quantities at current recipe ingredients/size
 
            if (NextRecipe(node))
 
            {
 
                lock(_workerLock)
 
                {
 
                    ++_recipeCount;
 
                }
 
                //System.Console.WriteLine("Found next recipe at size {0} qty {1}", node.Reagents.Count, node.CurrentTargetQuantity);
 
                return true;
 
            }
 

	
 
            if (NextRecipeSize(node))
 
            {
 
                //System.Console.WriteLine("Found next recipe size {0}", node.CurrentTargetQuantity);
 
                return true;
 
            }
 

	
 
            // Search for next ingredient combo - all quantity combos for previous were searched
 
            //System.Console.WriteLine("Finding next ingredient combo");
 
            do
 
            {
 
                if (node.AddNextReagent()) continue;
 
                
 
                while ((node.ReagentCount > node.MinReagents) && (node.LastReagent == (_totalReagents-1)))
 
                {
 
                    node.RemoveLastReagent();
 
                }
 
                if (node.ReagentCount == node.MinReagents)
 
                {
 
                    // done
 
                    return false;
 
                }
 
                uint nextReagent = node.NextFreeReagent(node.LastReagent);
 
                while ((node.ReagentCount > node.MinReagents) && (nextReagent >= _totalReagents))
 
                {
 
                    // No more reagents to try at this level
 
                    node.RemoveLastReagent();
 
                    if (node.ReagentCount > node.MinReagents)
 
                    {
 
                        nextReagent = node.NextFreeReagent(node.LastReagent);
 
                    }
 
                }
 
                if (node.ReagentCount == node.MinReagents)
 
                {
 
                    // done
 
                    return false;
 
                }
 
                node.ReplaceLastReagent(nextReagent);
 
            } while (node.MaxConcentration < (node.MinConcentration + node.CatalystCount));
 
            node.InitForQuantity(node.MaxConcentration);
 

	
 
            //string outStr = "{0} : {1} : ";
 
            //for (int i = 0; i < currentReagents.Count; ++i)
 
            //{
 
            //    Reagent reagent = costSortedReagents[(int)currentReagents[i]];
 
            //    if (i > 0)
 
            //    {
 
            //        outStr += ", ";
 
            //    }
 
            //    outStr += reagent.Name + " (" + reagent.Cost + ")";
 
            //}
 
            //Console.WriteLine(outStr, currentReagents.Count, recipeCount);
 
            return true;
 
        }
 

	
 
        private bool IterateBreadthFirst(RecipeSearchNode node)
 
        {
 
            // pick recipe quantities at current recipe ingredients/size
 
            TestCurrentRecipe(node);
 
            lock(_workerLock)
 
            {
 
                ++_recipeCount;
 
            }
 

	
 
            // search all quantities of current recipe
 
            if (NextRecipe(node))
 
            {
 
                //System.Console.WriteLine("Found next recipe at size {0} qty {1}", node.ReagentCount, node.CurrentTargetQuantity);
 
                return true;
 
            }
 

	
 
            // Try next quantity
 
            uint newQuantity;
 
            uint quantityLimit = ((uint)node.ReagentCount <= FullQuantityDepth) ? ((uint)node.ReagentCount * FullQuantity) : node.MaxConcentration;
 
            do {
 
                newQuantity = node.CurrentTargetQuantity + 1;
 
                //Console.WriteLine("Try quantity {0}", newQuantity);
 
                if (newQuantity > quantityLimit) continue;
 
                
 
                node.InitForQuantity(newQuantity);
 
                if (node.CurrentTargetQuantity <= node.UsedQuantity)
 
                {
 
                    //if (log != null) { lock(log) { log.WriteLine("Update quantity to {0}", node.CurrentTargetQuantity); } }
 
                    return true;
 
                }
 
            } while (newQuantity < quantityLimit);
 

	
 
            bool ok = NextReagentSetBreadthFirst(node, node.MinReagents, node.MaxReagents);
 
            return ok;
 
        }
 

	
 
        private bool NextReagentSetBreadthFirst(RecipeSearchNode node, uint minReagents, uint maxReagents)
 
        {
 
            // search all variants at this depth of recipe
 
            // increase recipe depth
 

	
 
            // next reagent in last position
 
            // if at end, pop reagent
 
            //Console.WriteLine("Finding new recipe after quantity {0}/{1} used {2}", newQuantity, node.MaxConcentration, node.UsedQuantity);
 
            node.InitForQuantity(node.MinConcentration + node.CatalystCount); // reset quantity
 
            int currentDepth = node.ReagentCount;
 
            bool recipeFound;
 
            do {
 
                //Console.WriteLine("Current depth: {0}/{1}", currentDepth, node.MaxReagents);
 
                do {
 
                    recipeFound = false;
 
                    
 
                    // back out until we find a node that can be incremented
 
                    if (currentDepth <= minReagents) continue;
 
                    
 
                    while (node.ReagentCount > minReagents)
 
                    {
 
                        if (node.LastReagent < (_totalReagents - 1))
 
                        {
 
                            var nextReagent = node.NextFreeReagent(node.LastReagent);
 
                            if (nextReagent < _totalReagents)
 
                            {
 
                                //Console.WriteLine("Replace last reagent with {0}", nextReagent);
 
                                node.ReplaceLastReagent(nextReagent);
 
                                break;
 
                            }
 
                            else
 
                            {
 
                                // shouldn't happen
 
                                //Console.WriteLine("No available reagents at depth {0}!", node.ReagentCount);
 
                                node.RemoveLastReagent();
 
                                if (node.ReagentCount == minReagents)
 
                                {
 
                                    // just popped the last reagent at the top level
 
                                    ++currentDepth;
 
                                    //if (log != null) { lock(log) { log.WriteLine("Increased depth to {0}/{1}", currentDepth, node.MaxReagents); } }
 
                                }
 
                            }
 
                        }
 
                        else
 
                        {
 
                            //Console.WriteLine("Pop last reagent");
 
                            node.RemoveLastReagent();
 
                            if (node.ReagentCount == minReagents)
 
                            {
 
                                // just popped the last reagent at the top level
 
                                ++currentDepth;
 
                                //if (log != null) { lock(log) { log.WriteLine("Increased depth to {0}/{1} [pop last reagent at top level]", currentDepth, node.MaxReagents); } }
 
                            }
 
                        }
 
                    }
 
                    // fill in the nodes up to the current depth
 
                    if (node.ReagentCount >= minReagents && currentDepth <= maxReagents)
 
                    {
 
                        recipeFound = true;
 
                        while (node.ReagentCount < currentDepth)
 
                        {
 
                            if (! node.AddNextReagent())
 
                            {
 
                                //if (log != null) { lock(log) { log.WriteLine("Failed to add reagent {0}/{1}", node.ReagentCount+1, currentDepth); } }
 
                                recipeFound = false;
 
                            }
 
                        }
 
                    }
 
                    //Console.WriteLine("Catalysts: {0} Reagents: {1} Min: {2}", node.CatalystCount, node.ReagentCount, node.MinReagents);
 
                } while ((node.CatalystCount >= node.ReagentCount) && (node.ReagentCount >= minReagents)); // make sure to skip all-catalyst combinations
 
                if (recipeFound)
 
                {
 
                    break;
 
                }
 
                else
 
                {
 
                    ++currentDepth;
 
                    //if (log != null) { lock(log) { log.WriteLine("Increased depth to {0}/{1} [no recipe]", currentDepth, node.MaxReagents); } }
 
                }
 
            } while (currentDepth <= maxReagents);
 

	
 
            if (!recipeFound) return false;
 
            
 
            node.InitForQuantity(node.MinConcentration+node.CatalystCount); // minimum quantity for this recipe
 
            
 
            node.TestRecipe ??= new PaintRecipe();
 
            node.TestRecipe.Clear();
 
            
 
            for (int i = 0; i < node.ReagentCount; ++i)
 
            {
 
                node.TestRecipe.AddReagent(node.GetReagent(i).Name, node.CurrentWeights[i]);
 
            }
 

	
 
            return recipeFound;
 
        }
 

	
 
        private void TestCurrentRecipe(RecipeSearchNode node)
 
        {
 
            node.TestRecipe ??= new PaintRecipe();
 
            node.TestRecipe.Clear();
 
            for (int i = 0; i < node.ReagentCount; ++i)
 
            {
 
                node.TestRecipe.AddReagent(node.GetReagent(i).Name, node.CurrentWeights[i]);
 
            }
 
            AddCheapestRecipe(node.TestRecipe);
 
            //if (log != null) { lock(log) { log.WriteLine("Tested recipe: {0}", node.TestRecipe); } }
 
        }
 

	
 
        private bool NextRecipe(RecipeSearchNode node)
 
        {
 
            // check for the next recipe
 
            uint remainingWeight = node.CurrentTargetQuantity - node.CatalystCount;
 
            if (remainingWeight < MinConcentration)
 
            {
 
                // not possible to make a valid recipe
 
                //Console.WriteLine("Insufficient remaining weight");
 
                return false;
 
            }
 
            //uint remainingReagents = (uint)node.Reagents.Count - node.CatalystCount;
 

	
 
            uint depth = (uint)node.ReagentCount;
 
            uint weightToConsume = 0;
 
            uint spaceBelow = 0;
 
            int reagentsBelow = 0;
 
            for (int i = (int)depth-1 ; i >= 0; --i)
 
            {
 
                uint currentWeight = node.CurrentWeights[i];
 

	
 
                if ((spaceBelow >= (weightToConsume+1)) && (currentWeight > 1))
 
                {
 
                    // reduce this node by 1, allocate remaining weight to reagents below it
 
                    node.SetWeight(i, currentWeight-1);
 
                    weightToConsume += 1;
 
                    for (int j = i+1; j < depth; ++j)
 
                    {
 
                        --reagentsBelow;
 
                        Reagent reagent = node.GetReagent(j);
 
                        uint allocated = (uint)Math.Min(reagent.IsCatalyst ? 1 : (depth <= FullQuantityDepth ? FullQuantity : reagent.RecipeMax), weightToConsume - reagentsBelow);
 
                        if (allocated > 100)
 
                        {
 
                            Console.WriteLine("ACK: allocated = {0}", allocated);
 
                        }
 
                        node.SetWeight(j, allocated);
 
                        weightToConsume -= allocated;
 
                    }
 
                    break;
 
                }
 
                else
 
                {
 
                    Reagent reagent = node.GetReagent(i);
 
                    spaceBelow += (reagent.IsCatalyst ? 1 : (depth <= FullQuantityDepth ? FullQuantity : reagent.RecipeMax));
 
                    weightToConsume += currentWeight;
 
                    ++reagentsBelow;
 
                }
 
            }
 

	
 
            //int recipeWeight = 0;
 
            //foreach (int weight in node.CurrentWeights)
 
            //{
 
            //    recipeWeight += weight;
 
            //}
 
            //if ((weightToConsume != 0) || (recipeWeight != node.CurrentTargetQuantity))
 
            //{
 
            //    Console.WriteLine("Failed recipe with leftover weight {0} ({1}/{2}):", weightToConsume, recipeWeight, node.CurrentTargetQuantity);
 
            //    for (int i = 0; i < node.Reagents.Count; ++i)
 
            //    {
 
            //        Console.WriteLine("   > {0} {1}", node.Reagent(i).Name, node.CurrentWeights[i]);
 
            //    }
 
            //}
 
            
 
            return (weightToConsume == 0);
 
        }
 

	
 
        private static bool NextRecipeSize(RecipeSearchNode node)
 
        {
 
            uint newQuantity = node.CurrentTargetQuantity - 1;
 
            if (newQuantity < (node.MinConcentration + node.CatalystCount))
 
            {
 
                return false;
 
            }
 

	
 
            node.InitForQuantity(newQuantity);
 
            return node.CurrentTargetQuantity <= node.UsedQuantity;
 
        }
 

	
 
        public void Wait()
 
        {
 
            if (_generatorThreads.Count <= 0) return;
 
            
 
            foreach (Thread thr in _generatorThreads)
 
            {
 
                thr.Join();
 
            }
 
            _generatorThreads.Clear();
 
        }
 

	
 
        public void Stop()
 
        {
 
            _requestCancel = true;
 
        }
 

	
 
        public void Reset()
 
        {
 
            foreach (PaintRecipe recipe in _recipes.Values)
 
            {
 
                recipe.Clear();
 
            }
 
            foreach (string key in _recipeCosts.Keys)
 
            {
 
                _recipeCosts[key] = uint.MaxValue;
 
            }
 
        }        
 
    }
 
}
...
 
\ No newline at end of file
Services/SettingKey.cs
Show inline comments
 
new file 100644
 
namespace DesertPaintCodex.Services
 
{
 
    public static class SettingKey
 
    {
 
        public const string InterfaceSize   = "interfacesize";
 
        public const string PixelMultiplier = "pixelmultiplier";
 
        public const string ScreenIndex     = "screenindex";
 
        public const string ScreenX         = "screenx";
 
        public const string ScreenY         = "screeny";
 
        public const string ScreenWidth     = "screenwidth";
 
        public const string ScreenHeight    = "screenheight";
 
    }
 
}
...
 
\ No newline at end of file
Services/SettingsService.cs
Show inline comments
 
new file 100644
 

 
using DesertPaintCodex.Models;
 
using DesertPaintCodex.Util;
 

	
 
namespace DesertPaintCodex.Services
 
{
 
    internal static class SettingsService
 
    {
 
        private static bool _loaded = false;
 
        
 
        private static readonly Settings _settings = new();
 

	
 
        public static void Get(string key, out int value, int defaultValue)
 
        {
 
            LoadIfNeeded();
 
            if (!_settings.TryGet(key, out value))
 
            {
 
                value = defaultValue;
 
            }
 
        }
 

	
 
        public static void Get(string key, out bool value, bool defaultValue)
 
        {
 
            LoadIfNeeded();
 
            if (!_settings.TryGet(key, out value))
 
            {
 
                value = defaultValue;
 
            }
 
        }
 

	
 
        public static void Set(string key, int value)
 
        {
 
            LoadIfNeeded();
 
            _settings.Set(key, value);
 
        }
 

	
 
        public static void Set(string key, bool value)
 
        {
 
            LoadIfNeeded();
 
            _settings.Set(key, value);
 
        }
 
        
 

	
 
        public static void Save()
 
        {
 
            LoadIfNeeded();
 
            string settingsPath = System.IO.Path.Combine(FileUtils.AppDataPath, "settings");
 
            _settings.Save(settingsPath);
 
        }
 

	
 
        public static bool Load()
 
        {
 
            _loaded = true;
 
            string settingsPath = System.IO.Path.Combine(FileUtils.AppDataPath, "settings");
 
            return _settings.Load(settingsPath);
 
        }
 

	
 
        private static void LoadIfNeeded()
 
        {
 
            if (_loaded) return;
 
            Load();
 
        }
 
    }
 
}
Util/Constants.cs
Show inline comments
 
new file 100644
 
using DesertPaintCodex.Models;
 

	
 
namespace DesertPaintCodex.Util
 
{
 
    public static class Constants
 
    {
 
        public const InterfaceSize DefaultInterfaceSize = InterfaceSize.Small;
 
        public static int DefaultPixelMultiplier = 1;
 
        public static int DefaultScreenWidth = 1920;
 
        public static int DefaultScreenHeight = 1080;
 
        public static int DefaultScreenX = 0;
 
        public static int DefaultScreenY = 0;
 

	
 
        private static ReactionTest? _stubReactionTest = null;
 

	
 
        public static ReactionTest StubReactionTest
 
        {
 
            get
 
            {
 
                if (_stubReactionTest == null)
 
                {
 
                    _stubReactionTest = new(
 
                        new Reagent("Toad Skin", "ToadSkin", 48, 96, 48), 
 
                        new Reagent("Falcons Bait", "FalconsBait", 128, 240, 224), 
 
                        null, ClipType.None, true);
 
                }
 

	
 
                return _stubReactionTest;
 
            }
 
            
 
        }
 
            
 
            
 
            
 

	
 
    }
 
}
...
 
\ No newline at end of file
Util/DialogUtil.cs
Show inline comments
 
new file 100644
 
using System.Threading.Tasks;
 
using Avalonia;
 
using Avalonia.Controls;
 
using Avalonia.Controls.ApplicationLifetimes;
 
using ReactiveUI;
 

	
 
namespace DesertPaintCodex.Util
 
{
 
    public static class DialogUtil
 
    {
 
        public static async Task ShowDialog<View, ViewModel, ReturnType>(
 
            InteractionContext<ViewModel, ReturnType> interaction) where View : Window, new()
 
        {
 
            if (Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
 
            {
 
                View       dialog = new() {DataContext = interaction.Input};
 
                ReturnType result = await dialog.ShowDialog<ReturnType>(desktop.MainWindow);
 
                interaction.SetOutput(result);
 
            }
 
        }
 
    }
 
}
...
 
\ No newline at end of file
Util/FileUtils.cs
Show inline comments
 
new file 100644
 
using System;
 

	
 
namespace DesertPaintCodex.Util
 
{
 
    internal static class FileUtils
 
    {
 
        public static string AppDataPath =>
 
            System.IO.Path.Combine(
 
                Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
 
                "DesertPaintCodex");
 

	
 
        public static string? FindApplicationResourceDirectory(string dirName)
 
        {
 
            return FindApplicationResource(dirName, System.IO.Directory.Exists);
 
        }
 

	
 
        public static string? FindApplicationResourceFile(string fileName)
 
        {
 
            return FindApplicationResource(fileName, System.IO.File.Exists);
 
        }
 

	
 
        private static string? FindApplicationResource(string path, Func<string?, bool> verify)
 
        {
 
            string resultPath;
 
            
 
            string? appPath =
 
                System.IO.Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location);
 

	
 
            if (appPath != null)
 
            {
 
                resultPath = System.IO.Path.Combine(appPath, path);
 
                if (verify(resultPath)) return resultPath;
 

	
 
                resultPath = System.IO.Path.Combine(appPath, "Data", path);
 
                if (verify(resultPath)) return resultPath;
 
            }
 

	
 
            // try "Resources/data" in case this is a Mac app bundle
 
            resultPath = System.IO.Path.Combine(
 
                Environment.GetFolderPath(Environment.SpecialFolder.Resources), "data", path);
 
            if (verify(resultPath)) return resultPath;
 
            
 
            // try "Resources" in case this is a Mac app bundle
 
            resultPath = System.IO.Path.Combine(
 
                Environment.GetFolderPath(Environment.SpecialFolder.Resources), path);
 
            if (verify(resultPath)) return resultPath;
 

	
 
            if (appPath != null)
 
            {
 
                resultPath = System.IO.Path.Combine(appPath, "Resources", "Data", path);
 
                if (verify(resultPath)) return resultPath;
 
                
 
                resultPath = System.IO.Path.Combine(appPath, "Resources", path);
 
                if (verify(resultPath)) return resultPath;
 
            }
 
            
 
            return null;
 
        }
 

	
 
        public static string FindNumberedFile(string baseName, string extension, string folder)
 
        {
 
            string filename = "";
 
            int i = 0;
 
            do
 
            {
 
                ++i;
 
                filename = System.IO.Path.Combine(folder, $"{baseName}_{i}.{extension}");
 
            }
 
            while (System.IO.File.Exists(filename));
 
            
 
            return filename;
 
        }
 
    }
 
}
Util/PixelColor.cs
Show inline comments
 
new file 100644
 
using System;
 
using System.Drawing;
 
using DesertPaintCodex.Services;
 

	
 
namespace DesertPaintCodex.Util
 
{
 
    public class PixelColor
 
    {
 
        private const int ColorTolerance = 3;
 
        
 
        public static bool IsMatch(Color colorA, Color colorB)
 
        {
 
            return ((Math.Abs(colorA.R - colorB.R) <= ColorTolerance) &&
 
                (Math.Abs(colorA.G - colorB.G) <= ColorTolerance) &&
 
                (Math.Abs(colorA.B - colorB.B) <= ColorTolerance));
 
        }
 

	
 
        public static bool IsDark(Color color)
 
        {
 
            return (color.R < 0x47) && (color.G < 0x47) && (color.B < 0x47);
 
        }
 

	
 
        public static bool IsPapyrus(Color color)
 
        {
 
            // red between 208 and 244
 
            // 240 and 255
 
            // green between 192 and 237
 
            // 223 and 250
 
            // blue between 145 and 205
 
            // 178 and 232
 
            //return ((r > 0xD0) && (g >= 0xC0) && (b >= 0x91)) &&
 
            //       ((r < 0xF4) && (g <= 0xED) && (b <= 0xCD));
 
            return ((color.R >= 0xD0) && (color.R <= 0xFF) && (color.G >= 0xC0) && (color.G <= 0xFA) && (color.B >= 0x91) && (color.B <= 0xE8));
 
        }
 

	
 
        public static bool IsRed(Color color)
 
        {
 
            return (color.R > 0x9F) && (color.G < 0x70) && (color.B < 0x70);
 
        }
 
        public static bool IsGreen(Color color)
 
        {
 
            return (color.R < 0x70) && (color.G > 0x9F) && (color.B < 0x70);
 
        }
 
        public static bool IsBlue(Color color)
 
        {
 
            return (color.R < 0x70) && (color.G < 0x70) && (color.B > 0x9F);
 
        }
 

	
 
    }
 
}
...
 
\ No newline at end of file
Util/Pixels.cs
Show inline comments
 
new file 100644
 
using System;
 
using System.Diagnostics;
 
using System.Drawing;
 

	
 
namespace DesertPaintCodex.Util
 
{
 
    public class Pixels
 
    {
 
        private readonly Bitmap _bitmap;
 
        private readonly int _pixelMultiplier;
 

	
 
        public int Height => _bitmap.Height / _pixelMultiplier;
 
        public int Width => _bitmap.Width / _pixelMultiplier;
 

	
 
        public Pixels(Bitmap bitmap, int pixellMutliplier)
 
        {
 
            _bitmap = bitmap;
 
            _pixelMultiplier = pixellMutliplier;
 
        }
 

	
 
        public Color ColorAt(int x, int y)
 
        {
 
            return _bitmap.GetPixel(x * _pixelMultiplier, y * _pixelMultiplier);
 
        }
 

	
 
        public bool DoesPixelMatch(int x, int y, Func<Color, bool> matchFunc)
 
        {
 
            int xMult = x * _pixelMultiplier;
 
            int yMult = y * _pixelMultiplier;
 

	
 
            if (xMult < 0 || xMult >= _bitmap.Width || yMult < 0 || yMult >= _bitmap.Height)
 
            {
 
                Debug.WriteLine("Problem at position [" + x + ", " + y + "]");
 
            }
 
            
 
            return matchFunc(_bitmap.GetPixel(x * _pixelMultiplier, y * _pixelMultiplier));
 
        }
 

	
 
        // Compute length of horizontal bar starting at x,y using matching function
 
        public int LengthOfColorAt(int x, int y, Func<Color, bool> matchFunc)
 
        {
 
            int count = 0;
 
            for (int xVal = x; xVal < Width; ++xVal)
 
            {
 
                if (!matchFunc(_bitmap.GetPixel(xVal * _pixelMultiplier, y * _pixelMultiplier))) break;
 
                ++count;
 
            }
 
            return count;
 
        }
 

	
 
        public bool IsSolidPatchAt(int x, int y, int patchWidth, int patchHeight)
 
        {
 
            if ((x + patchWidth >= Width) || (y + patchHeight >= Height)) return false;
 
            Color color = _bitmap.GetPixel(x * _pixelMultiplier, y * _pixelMultiplier);
 
            
 
            // Debug.WriteLine("color at {0},{1} = {2},{3},{4}", x, y, color.R, color.G, color.B);
 

	
 
            for (int pX = 0; pX < patchWidth; pX++)
 
            {
 
                for (int pY = 0; pY < patchHeight; pY++)
 
                {
 
                    if (!PixelColor.IsMatch(color, _bitmap.GetPixel((x + pX) * _pixelMultiplier, (y + pY) * _pixelMultiplier)))
 
                        return false;
 
                }
 
            }
 

	
 
            return true;
 
        }
 
    }
 
}
...
 
\ No newline at end of file
ViewLocator.cs
Show inline comments
 
new file 100644
 
using System;
 
using Avalonia.Controls;
 
using Avalonia.Controls.Templates;
 
using DesertPaintCodex.ViewModels;
 

	
 
namespace DesertPaintCodex
 
{
 
    internal class ViewLocator : IDataTemplate
 
    {
 
        public bool SupportsRecycling => false;
 

	
 
        public IControl Build(object data)
 
        {
 
            var name = data.GetType().FullName!.Replace("ViewModel", "View");
 
            var type = Type.GetType(name);
 

	
 
            if (type != null)
 
            {
 
                return (Control)Activator.CreateInstance(type)!;
 
            }
 
            else
 
            {
 
                return new TextBlock { Text = "Not Found: " + name };
 
            }
 
        }
 

	
 
        public bool Match(object data)
 
        {
 
            return data is ViewModelBase;
 
        }
 
    }
 
}
...
 
\ No newline at end of file
ViewModels/AboutViewModel.cs
Show inline comments
 
new file 100644
 
using System.Reactive;
 
using ReactiveUI;
 

	
 
namespace DesertPaintCodex.ViewModels
 
{
 
    public class AboutViewModel : ViewModelBase
 
    {
 
        private const string AboutTextLiteral =
 
            @"Desert Paint Codex (formerly Desert Paint Lab) is an Open Source project available for free under the MIT License.";
 
        
 
        private string _aboutText = AboutTextLiteral;
 
        public string AboutText { get => _aboutText; private set => this.RaiseAndSetIfChanged(ref _aboutText, value); }
 

	
 
        public AboutViewModel()
 
        {
 
            CloseDialog = ReactiveCommand.Create(() => { });
 
        }
 
        
 
        public ReactiveCommand<Unit, Unit> CloseDialog { get; }
 
    }
 
}
...
 
\ No newline at end of file
ViewModels/CreateProfileViewModel.cs
Show inline comments
 
new file 100644
 
using System.Collections.Generic;
 
using System.Reactive;
 
using System.Reactive.Linq;
 
using DesertPaintCodex.Services;
 
using ReactiveUI;
 

	
 

	
 
namespace DesertPaintCodex.ViewModels
 
{
 
    public class CreateProfileViewModel : ViewModelBase
 
    {
 
        private string _profileName = string.Empty;
 
        public string ProfileName { get => _profileName; set => this.RaiseAndSetIfChanged(ref _profileName, value); }
 

	
 
        private readonly ObservableAsPropertyHelper<bool> _duplicateWarning;
 
        public bool DuplicateWarning => _duplicateWarning.Value;
 

	
 
        public CreateProfileViewModel()
 
        {
 
            List<string> profiles = ProfileManager.GetProfileList();
 

	
 
            // Duplicate warning display logic.
 
            this.WhenAnyValue(x => x.ProfileName)
 
                .Select(profileName =>
 
                    (!string.IsNullOrWhiteSpace(profileName) &&
 
                    profiles.Contains(profileName)))
 
                    .ToProperty(this,
 
                        x => x.DuplicateWarning,
 
                        out _duplicateWarning);
 

	
 
            // TODO: Use proper validation to decorate the TextBox, as well.
 
            
 
            // OK button enabling logic.
 
            var okEnabled = this.WhenAnyValue(
 
               x => x.ProfileName,
 
               x => !string.IsNullOrWhiteSpace(x) &&
 
                    !profiles.Contains(x));
 

	
 
            // Button commands.
 
            Cancel = ReactiveCommand.Create(() => { });
 
            Ok = ReactiveCommand.Create(() => {
 
                ProfileManager.CreateNewProfile(_profileName);
 
            }, okEnabled);
 
        }
 

	
 
        public ReactiveCommand<Unit, Unit> Cancel { get; }
 
        public ReactiveCommand<Unit, Unit> Ok { get; }
 
    }
 
}
ViewModels/ExperimentLogViewModel.cs
Show inline comments
 
new file 100644
 
using System;
 
using System.Collections.Generic;
 
using System.Collections.ObjectModel;
 
using System.Diagnostics;
 
using ReactiveUI;
 
using DesertPaintCodex.Services;
 
using DesertPaintCodex.Models;
 
using DesertPaintCodex.Util;
 
using DynamicData.Binding;
 

	
 
namespace DesertPaintCodex.ViewModels
 
{
 
    public class ExperimentLogViewModel : ViewModelBase
 
    {
 
        public ObservableCollection<ReactionTest> RemainingTests { get; } = new();
 
        public ObservableCollection<ReactionTest> CompletedTests { get; } = new();
 

	
 
        private int _selectedList = 0;
 
        public int SelectedList { get => _selectedList; private set => this.RaiseAndSetIfChanged(ref _selectedList, value); }
 

	
 
        private int _selectedRemainingTest = 0;
 
        public int SelectedRemainingTest { get => _selectedRemainingTest; private set => this.RaiseAndSetIfChanged(ref _selectedRemainingTest, value); }
 
        
 
        private int _selectedCompletedTest = 0;
 
        public int SelectedCompletedTest { get => _selectedCompletedTest; private set => this.RaiseAndSetIfChanged(ref _selectedCompletedTest, value); }
 

	
 
        private ReactionTestViewModel _testView;
 
        public ReactionTestViewModel TestView { get => _testView; private set => this.RaiseAndSetIfChanged(ref _testView, value); }
 
        
 
        private readonly List<ObservableCollection<ReactionTest>> _testLists = new();
 

	
 
        public ExperimentLogViewModel()
 
        {
 
            PlayerProfile? profile = ProfileManager.CurrentProfile;
 
            Debug.Assert(profile != null);
 
            
 
            _testLists.Add(RemainingTests);
 
            _testLists.Add(CompletedTests);
 
            
 
            ReactionTestService.PopulateRemainingTests(RemainingTests);
 
            ReactionTestService.PopulateCompletedTests(CompletedTests);
 
            
 
            // If we have no remaining tests, switch to the completed list.
 
            _testView = new ReactionTestViewModel();
 
            if (RemainingTests.Count > 0)
 
            {
 
                SelectedList = 0;
 
                _testView.ReactionTest = RemainingTests[0];
 
            }
 
            else
 
            {
 
                SelectedList = 1;
 
                _testView.ReactionTest = CompletedTests[0];
 
            }
 

	
 
            this.WhenAnyPropertyChanged(nameof(SelectedList), nameof(SelectedRemainingTest), nameof(SelectedCompletedTest))
 
                .Subscribe(_ => _testView.ReactionTest = GetSelectedReactionTest());
 

	
 
            _testView.ReactionTest = GetSelectedReactionTest();
 

	
 
            TestView.SaveReaction.Subscribe(_ => OnSaveReaction());
 
            TestView.ClearReaction.Subscribe(_ => OnClearReaction());
 
        }
 

	
 
        private ReactionTest GetSelectedReactionTest()
 
        {
 
            int itemIndex = (SelectedList == 0) ? SelectedRemainingTest : SelectedCompletedTest;
 
            if (itemIndex < 0) return Constants.StubReactionTest;
 
            var list = _testLists[SelectedList];
 
            return (itemIndex >= list.Count) ? Constants.StubReactionTest : list[itemIndex];
 
        }
 

	
 
        private void OnSaveReaction()
 
        {
 
            ReactionTest test = TestView.ReactionTest;
 
            int oldPos = RemainingTests.IndexOf(test);
 
            
 
            // Move test to Completed Tests.
 
            InsertTestIntoList(test, CompletedTests);
 
            RemainingTests.RemoveAt(oldPos);
 
            
 
            // Select next Remaining Test.
 
            if (RemainingTests.Count == 0) return;
 
            SelectedRemainingTest = (oldPos == RemainingTests.Count) ? RemainingTests.Count - 1 : oldPos;
 

	
 
            // If we have just inserted our first completed test, select it.
 
            if (CompletedTests.Count == 1)
 
            {
 
                 SelectedCompletedTest = 0;
 
            }
 
        }
 

	
 
        private void OnClearReaction()
 
        {
 
            ReactionTest test = TestView.ReactionTest;
 
            if (!CompletedTests.Contains(test)) return;
 

	
 
            int oldPos = CompletedTests.IndexOf(test);
 
            
 
            // Move test to Remaining Tests.
 
            InsertTestIntoList(test, RemainingTests);
 
            CompletedTests.RemoveAt(oldPos);
 
            
 
            // Select next Completed Test.
 
            if (CompletedTests.Count == 0) return;
 
            SelectedCompletedTest = (oldPos == CompletedTests.Count) ? CompletedTests.Count - 1 : oldPos;
 

	
 
            // If we have just inserted our first remaining test, select it.
 
            if (RemainingTests.Count == 1)
 
            {
 
                SelectedRemainingTest = 0;
 
            }
 
        }
 

	
 
        private static void InsertTestIntoList(ReactionTest test, IList<ReactionTest> list)
 
        {
 
            int i;
 
            for (i = 0; i < list.Count; i++)
 
            {
 
                if (test.CompareTo(list[i]) < 0) break;
 
            }
 
            list.Insert(i, test);
 
        }
 
    }
 
}
ViewModels/MainWindowViewModel.cs
Show inline comments
 
new file 100644
 
using System.Collections.Generic;
 
using System.Diagnostics;
 
using System.IO;
 
using System.Reactive;
 
using System.Reactive.Linq;
 
using System.Threading.Tasks;
 
using Avalonia;
 
using Avalonia.Controls;
 
using Avalonia.Controls.ApplicationLifetimes;
 
using Avalonia.Input.Platform;
 
using DesertPaintCodex.Models;
 
using DesertPaintCodex.Services;
 
using ReactiveUI;
 

	
 
namespace DesertPaintCodex.ViewModels
 
{
 
    public class MainWindowViewModel : ViewModelBase
 
    {
 
        private string _statusText = string.Empty;
 
        public string StatusText { get => _statusText; private set => this.RaiseAndSetIfChanged(ref _statusText, value); }
 
        
 
        private static readonly List<string> ZipFileExtensions = new() { $"*.zip;" };
 
        private static readonly FileDialogFilter ZipDialogFilter = new() {Extensions = ZipFileExtensions};
 
        private static readonly List<FileDialogFilter> ZipDialogFilters = new() { ZipDialogFilter };
 
        private static readonly List<FileDialogFilter> NoFilters = new();
 

	
 
        public MainWindowViewModel()
 
        {
 
            if (!ProfileManager.HasProfileLoaded && ProfileManager.HasProfiles())
 
            {
 
                ProfileManager.LoadProfile(ProfileManager.GetProfileList()[0]);
 
            }
 
            Debug.Assert(ProfileManager.HasProfileLoaded);
 
            
 
            PaletteService.Initialize();
 
            ReactionTestService.Initialize();
 
            
 
            ShowAboutDialog = new Interaction<AboutViewModel, Unit>();
 
            ShowScreenSettingsDialog = new Interaction<ScreenSettingsViewModel, Unit>();
 
            
 
            StatusText = "USER PROFILE: " + ProfileManager.CurrentProfile?.Name;
 
            Exit = ReactiveCommand.Create(() => { });
 
        }
 

	
 
        public async void ManageProfiles()
 
        {
 
            if (Application.Current is not App app) return;
 
            
 
            if (await ValidateSafeExit())
 
            {
 
                ProfileManager.UnloadProfile();
 
                app.ReturnToWelcome();
 
            }
 
        }
 

	
 
        public static async void ImportProfile()
 
        {
 
            string? fileName = await GetLoadFileName("Open Zipped Profile", ZipDialogFilters);
 
            if (!string.IsNullOrEmpty(fileName))
 
            {
 
                ProfileManager.CurrentProfile?.Import(fileName);
 
            }
 
        }
 

	
 
        public static async void ExportProfile()
 
        {
 
            string? fileName = await GetSaveFileName("Save Zipped Profile", ZipDialogFilters);
 
            if (!string.IsNullOrEmpty(fileName))
 
            {
 
                ProfileManager.CurrentProfile?.Import(fileName);
 
            }
 
        }
 

	
 
        public static async void ExportForPP()
 
        {
 
            string? fileName = await GetSaveFileName("Save Practical Paint File", NoFilters);
 
            if (!string.IsNullOrEmpty(fileName))
 
            {
 
                ProfileManager.CurrentProfile?.SaveToPP(fileName);
 
            }
 
        }
 

	
 
        public static async void ExportPaintRecipes()
 
        {
 
            string? fileName = await GetSaveFileName("Export Paint Recipes", NoFilters);
 
            if (!string.IsNullOrEmpty(fileName))
 
            {
 
                ProfileManager.CurrentProfile?.ExportWikiRecipes(fileName);
 
            }
 
        }
 
        
 
        public static async void ExportRibbonRecipes()
 
        {
 
            string? fileName = await GetSaveFileName("Export Ribbon Recipes", NoFilters);
 
            if (!string.IsNullOrEmpty(fileName))
 
            {
 
                ProfileManager.CurrentProfile?.ExportWikiRibbons(fileName);
 
            }
 
        }
 

	
 
        public static async void CopyPaintRecipes()
 
        {
 
            StringWriter writer = new();
 
            ProfileManager.CurrentProfile?.ExportWikiRecipes(writer);
 
            IClipboard clipboard = Application.Current.Clipboard;
 
            await writer.FlushAsync();
 
            await clipboard.SetTextAsync(writer.ToString());
 
            writer.Close();
 
        }
 
        
 
        public static async void CopyRibbonRecipes()
 
        {
 
            StringWriter writer = new();
 
            ProfileManager.CurrentProfile?.ExportWikiRibbons(writer);
 
            IClipboard clipboard = Application.Current.Clipboard;
 
            await writer.FlushAsync();
 
            await clipboard.SetTextAsync(writer.ToString());
 
            writer.Close();
 
        }
 

	
 
        public async Task ShowScreenSettings()
 
        {
 
            await ShowScreenSettingsDialog.Handle(new ScreenSettingsViewModel());
 
        }
 

	
 
        public async Task ShowAbout()
 
        {
 
            await ShowAboutDialog.Handle(new AboutViewModel());
 
        }
 

	
 
        public async Task<bool> ValidateSafeExit()
 
        {
 
            // TODO: Determine if there's unsaved stuff we need to deal with.
 
            // return await ShowYesNoBox("Leaving so Soon?", "[A potential reason not to quit goes here]");
 
            await Task.Delay(1); // Stub to prevent warnings.
 
            return true;
 
        }
 

	
 
        private static async Task<string?> GetLoadFileName(string title, List<FileDialogFilter> filters)
 
        {
 
            if (Application.Current.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop) return null;
 
            
 
            // TODO: Figure out why the file filters aren't working.
 
            
 
            OpenFileDialog dialog = new()
 
            {
 
                Title         = title,
 
                Filters       = NoFilters, // filters,
 
                AllowMultiple = false
 
            };
 

	
 
            string[] files = await dialog.ShowAsync(desktop.MainWindow);
 
            return files.Length > 0 ? files[0] : null;
 
        }
 
        
 
        
 
        private static async Task<string?> GetSaveFileName(string title, List<FileDialogFilter> filters)
 
        {
 
            if (Application.Current.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop) return null;
 
            
 
            // TODO: Figure out why the file filters aren't working.
 
            
 
            SaveFileDialog dialog = new()
 
            {
 
                Title   = title,
 
                Filters = NoFilters, // filters
 
            };
 

	
 
            return await dialog.ShowAsync(desktop.MainWindow);
 
        }
 
        
 
        public Interaction<AboutViewModel, Unit> ShowAboutDialog { get; }
 
        public Interaction<ScreenSettingsViewModel, Unit> ShowScreenSettingsDialog { get; }
 

	
 
        public ReactiveCommand<Unit, Unit> Exit { get; }
 
    }
 
}
ViewModels/MessageBoxViewModel.cs
Show inline comments
 
new file 100644
 
using Avalonia.Media;
 
using ReactiveUI;
 

	
 
namespace DesertPaintCodex.ViewModels
 
{
 
    public class MessageBoxViewModel : ViewModelBase
 
    {
 
        private string? _title;
 
        public string? Title  { get => _title; private set => this.RaiseAndSetIfChanged(ref _title, value); }
 
        
 
        private string? _message;
 
        public string? Message  { get => _message; private set => this.RaiseAndSetIfChanged(ref _message, value); }
 

	
 
        private string? _optionAText;
 
        public string? OptionAText { get => _optionAText; private set => this.RaiseAndSetIfChanged(ref _optionAText, value); }
 

	
 
        private string? _optionBText;
 
        public string? OptionBText { get => _optionBText; private set => this.RaiseAndSetIfChanged(ref _optionBText, value); }
 
        
 
        private string? _optionCText;
 
        public string? OptionCText { get => _optionCText; private set => this.RaiseAndSetIfChanged(ref _optionCText, value); }
 
        
 
        
 
        public MessageBoxViewModel() : this("A Message For You", "This is a test message for use in developer mode", "Ok") {}
 
        
 
        public MessageBoxViewModel(string title, string message, string optionA, string? optionB = null, string? optionC = null)
 
        {
 
            Title = title;
 
            Message = message;
 
            PickOption = ReactiveCommand.Create((string value) => int.Parse(value));
 
            OptionAText = optionA;
 
            OptionBText = optionB;
 
            OptionCText = optionC;
 
        }
 
        
 
        public ReactiveCommand<string, int> PickOption { get; }
 
    }
 
}
...
 
\ No newline at end of file
ViewModels/ReactionTestViewModel.cs
Show inline comments
 
new file 100644
 
using System;
 
using System.Collections.Generic;
 
using System.Collections.ObjectModel;
 
using System.Diagnostics;
 
using System.Linq;
 
using System.Reactive;
 
using System.Reactive.Linq;
 
using System.Threading.Tasks;
 
using DesertPaintCodex.Models;
 
using DesertPaintCodex.Services;
 
using DesertPaintCodex.Util;
 
using ReactiveUI;
 
using DynamicData;
 

	
 
namespace DesertPaintCodex.ViewModels
 
{
 
    public class ReactionTestViewModel : ViewModelBase
 
    {
 
        private ReactionTest _reactionTest = Constants.StubReactionTest;
 
        public ReactionTest ReactionTest
 
        {
 
            get => _reactionTest;
 
            set => this.RaiseAndSetIfChanged(ref _reactionTest, value);
 
        }
 

	
 
        private readonly List<Reagent> _allPigmentList = new();
 
        public ObservableCollection<Reagent> BufferPigmentList { get; } = new();
 

	
 

	
 
        public ReactionTestViewModel()
 
        {
 
            List<string> reagentNames = ReagentService.Names;
 
            
 
            foreach (var reagent in reagentNames.Select(ReagentService.GetReagent).Where(x => !x.IsCatalyst))
 
            {
 
                _allPigmentList.Add(reagent);
 
            }
 

	
 
            this.WhenAnyValue(x => x.ReactionTest)
 
                .Subscribe(_ => UpdateDerivedState());
 
            
 
            ShowScreenSettingsDialog = new Interaction<ScreenSettingsViewModel, Unit>();
 
            SaveReaction = ReactiveCommand.Create(() => ReactionTest.SaveReaction());
 
            ClearReaction = ReactiveCommand.Create(() => ReactionTest.ClearReaction());
 
        }
 

	
 
        private void UpdateDerivedState()
 
        {
 
            // There are more "reactive" ways to pull this off, but I don't have time to figure out all those recipes
 
            // right now.
 
            SetupTest();
 
        }
 
            
 
        
 
        private void SetupTest()
 
        {
 
            if (ReactionTest.Requires3Way)
 
            {
 
                FilterBufferPigments();
 
            }
 
        }
 

	
 
        private void FilterBufferPigments()
 
        {
 
            // Avalonia doesn't really have proper filtering for listy controls yet.
 
            BufferPigmentList.Clear();
 

	
 
            BufferPigmentList.AddRange(_allPigmentList.Where(pigment =>
 
                    pigment != ReactionTest.Reagent1 && pigment != ReactionTest.Reagent2));
 
        }
 

	
 
        public async void Analyze()
 
        {
 
            Debug.WriteLine("Analyze");
 
            try
 
            {
 
                await ReactionTest.StartScan();
 
            }
 
            catch (OperationCanceledException)
 
            {
 
                Debug.WriteLine("Scan canceled.");
 
            }
 
        }
 

	
 
        public void MarkInert()
 
        {
 
            ReactionTest.MarkInert();
 
        }
 

	
 
        public void CancelScan()
 
        {
 
            ReactionTest.CancelScan();
 
        }
 

	
 
        public async Task OpenScreenSettings()
 
        {
 
            await ShowScreenSettingsDialog.Handle(new ScreenSettingsViewModel());
 
        }
 

	
 
        public async Task ShowClipInfo()
 
        {
 
            await ShowInfoBox("Clipped Reactions", "The Pigment Lab is only capable of displaying color channel values in the 0-255 range. However, sometimes, your reactions will push one or more channels outside of that range. When that happens, the value will be clamped either to 0 or 255. In this case, we say that the reaction was \"Clipped.\"\n\nThis is nothing to panic about, though. We solve this issue by using a third (buffer) reagent to offset the extreme value, so that it falls within measurable range, similar to how we test Catalyst+Catalyst reactions. Desert Paint Codex will automatically do the math for this, but it does move clipped reactions towards the end of your test list, because you'll want any reactions between the buffer reagent and your two test reagents already known, prior to running your buffered test.");
 
        }
 
        
 
        public Interaction<ScreenSettingsViewModel, Unit> ShowScreenSettingsDialog { get; }
 

	
 
        public ReactiveCommand<Unit, Unit> ClearReaction { get; }
 
        public ReactiveCommand<Unit, Unit> SaveReaction { get; }
 
    }
 
}
...
 
\ No newline at end of file
ViewModels/RecipeGeneratorViewModel.cs
Show inline comments
 
new file 100644
 
using System;
 
using System.Collections.Concurrent;
 
using System.Collections.Generic;
 
using System.Collections.ObjectModel;
 
using System.Diagnostics;
 
using System.Threading.Tasks;
 
using Avalonia.Threading;
 
using DesertPaintCodex.Models;
 
using DesertPaintCodex.Services;
 
using ReactiveUI;
 

	
 
namespace DesertPaintCodex.ViewModels
 
{
 
    public class RecipeGeneratorViewModel : ViewModelBase
 
    {
 
        private const long   UpdateInterval  = 2000;  // ms
 
        private const long   SaveInterval    = 30000; // ms
 
        private const string PaintStateFile  = "dp_generator_state";
 
        private const string RibbonStateFile = "dp_generator_ribbon_state";
 
        
 
        private int _selectedView = 0;
 
        public int SelectedView { get => _selectedView; private set => this.RaiseAndSetIfChanged(ref _selectedView, value); }
 
        
 
        private float _progress = 0;
 
        public float Progress { get => _progress; private set => this.RaiseAndSetIfChanged(ref _progress, value); }
 

	
 
        private int _permutationCount = 0;
 
        public int PermutationCount { get => _permutationCount; private set => this.RaiseAndSetIfChanged(ref _permutationCount, value); }
 
        
 

	
 
        private DateTime _mostRecentTime;
 
        public DateTime MostRecentTime { get => _mostRecentTime; private set => this.RaiseAndSetIfChanged(ref _mostRecentTime, value); }
 

	
 
        private GeneratorRecipe? _mostRecentRecipe;
 
        public GeneratorRecipe? MostRecentRecipe { get => _mostRecentRecipe; private set => this.RaiseAndSetIfChanged(ref _mostRecentRecipe, value); }
 

	
 

	
 
        private int _productIndex = 0;
 
        public int ProductIndex { get => _productIndex; private set => this.RaiseAndSetIfChanged(ref _productIndex, value); }
 

	
 
        private int _maxReagents;
 
        public int MaxReagents { get => _maxReagents; private set => this.RaiseAndSetIfChanged(ref _maxReagents, value); }
 
        
 
        private int _maxConcentration;
 
        public int MaxConcentration { get => _maxConcentration; private set => this.RaiseAndSetIfChanged(ref _maxConcentration, value); }
 
        
 
        private int _fullQuantity;
 
        public int FullQuantity { get => _fullQuantity; private set => this.RaiseAndSetIfChanged(ref _fullQuantity, value); }
 
        
 
        private int _fullQuantityDepth;
 
        public int FullQuantityDepth { get => _fullQuantityDepth; private set => this.RaiseAndSetIfChanged(ref _fullQuantityDepth, value); }
 

	
 
        private int _maxConcentrationMax;
 
        public int MaxConcentrationMax { get => _maxConcentrationMax; private set => this.RaiseAndSetIfChanged(ref _maxConcentrationMax, value); }
 
        
 
        private int _fullQuantitynMax;
 
        public int FullQuantityMax { get => _fullQuantitynMax; private set => this.RaiseAndSetIfChanged(ref _fullQuantitynMax, value); }
 
        
 
        
 
        #region Flags
 

	
 
        private bool _isPaused = false;
 
        public bool IsPaused  { get => _isPaused; private set => this.RaiseAndSetIfChanged(ref _isPaused, value); }
 

	
 
        private bool _isInProgress = false;
 
        public bool IsInProgress { get => _isInProgress; private set => this.RaiseAndSetIfChanged(ref _isInProgress, value); }
 

	
 
        private bool _isRunning = false;
 
        public bool IsRunning {  get => _isRunning; private set => this.RaiseAndSetIfChanged(ref _isRunning, value); }
 

	
 
        private bool _canStart = false;
 
        public bool CanStart {  get => _canStart; private set => this.RaiseAndSetIfChanged(ref _canStart, value); }
 
        
 
        private bool _canClear = false;
 
        public bool CanClear {  get => _canClear; private set => this.RaiseAndSetIfChanged(ref _canClear, value); }
 

	
 
        #endregion // Flags
 
        
 
        #region Collections
 
        
 
        public ObservableCollection<Reagent> Reagents { get; } = new();
 
        public ObservableCollection<GeneratorRecipe> AllRecipes { get; } = new();
 
        
 
        #endregion // Collections
 
        
 
        private readonly RecipeGenerator _generator;
 

	
 
        private DateTime _lastSave = DateTime.Now;
 
        private readonly PlayerProfile _profile;
 
        private uint _minConcentration;
 

	
 
        private readonly ConcurrentQueue<PaintRecipe> _pendingNewRecipes = new();
 
        private volatile int _newRecipeCount;
 
        private volatile bool _updatesAvailable;
 
        private readonly DispatcherTimer _updateTimer;
 
        
 
        private bool _ribbonMode;
 
        private bool _unsavedRecipes;
 
        private bool _saving;
 

	
 
        
 
        public RecipeGeneratorViewModel()
 
        {
 
            List<string> reagentNames = ReagentService.Names;
 
            foreach (string name in reagentNames)
 
            {
 
                Reagents.Add(ReagentService.GetReagent(name));
 
            }
 
            
 
            SettingsService.Get("Generator.RibbonMode", out bool ribbonMode, false);
 
            ProductIndex = ribbonMode ? 1 : 0;
 
            _ribbonMode = ribbonMode;
 

	
 
            PlayerProfile? profile = ProfileManager.CurrentProfile;
 
            Debug.Assert(profile != null);
 
            _profile = profile;
 
            
 
            _profile.LoadRecipes();
 

	
 
            _generator = new RecipeGenerator();
 

	
 
            _generator.Progress  += OnProgress;
 
            _generator.NewRecipe += OnNewRecipe;
 
            _generator.Finished  += OnGeneratorStopped;
 

	
 
            SettingsService.Get("Generator.Logging", out bool logGenerator, false);
 
            if (logGenerator)
 
            {
 
                string logDir = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
 
                _generator.Log = System.IO.Path.Combine(logDir, "dpl_generator.txt");
 
            }
 
            
 
            if (ribbonMode)
 
            {
 
                InitStateForRibbons();
 
            }
 
            else
 
            {
 
                InitStateForPaint();
 
            }
 

	
 
            _updateTimer = new DispatcherTimer {Interval = TimeSpan.FromMilliseconds(UpdateInterval)};
 
            _updateTimer.Tick += Update;
 

	
 
            this.WhenAnyValue(x => x.ProductIndex).Subscribe(_ => ChangeMode());
 
        }
 

	
 
        public void Start()
 
        {
 
            IsInProgress = true;
 
            IsRunning = true;
 
            ReagentService.SaveProfileReagents(_profile.ReagentFile);
 
            SaveSettings(_ribbonMode ? "Ribbon" : "Paint");
 
            _updateTimer.Start();
 
            SelectedView = 1;
 
            
 
            _generator.BeginRecipeGeneration(
 
                _minConcentration, 
 
                (uint)MaxConcentration, 
 
                1, 
 
                (uint)MaxReagents, 
 
                (uint)FullQuantityDepth, 
 
                (uint)FullQuantity);
 
        }
 

	
 
        
 
        public void End()
 
        {
 
            IsPaused     = false;
 
            IsRunning    = false;
 
            IsInProgress = false;
 
            _updateTimer.Stop();
 
            _profile.SaveRecipes();
 
            
 
            CanClear = true;
 
        }
 

	
 
        public void Pause()
 
        {
 
            IsPaused  = true;
 
            IsRunning = false;
 
            _updateTimer.Stop();
 
            _generator.Stop();
 
        }
 

	
 
        public void Resume()
 
        {
 
            IsPaused  = false;
 
            IsRunning = true;
 
            _updateTimer.Start();
 
            SelectedView = 1;
 
            
 
            _generator.ResumeRecipeGeneration();
 

	
 
        }
 

	
 
        public async Task Clear()
 
        {
 
            bool result = await ShowYesNoBox("Clear Recipes", "Are you sure you want to clear your recipes?");
 
            if (result)
 
            {
 
                CanClear = false;
 
                if (_ribbonMode)
 
                {
 
                    _profile.ClearRibbonRecipes();
 
                }
 
                else
 
                {
 
                    _profile.ClearPaintRecipes();
 
                }
 

	
 
                foreach (GeneratorRecipe recipe in AllRecipes)
 
                {
 
                    recipe.ClearRecipe();
 
                }
 
                
 
                _generator.Reset();
 
            }
 
        }
 

	
 
        private void InitStateForPaint()
 
        {
 
            MaxConcentrationMax = 20;
 
            FullQuantityMax = 20;
 
            InitState("Paint", PaintRecipe.PaintRecipeMinConcentration, _profile.Recipes, PaintStateFile);
 

	
 
        }
 

	
 
        private void InitStateForRibbons()
 
        {
 
            MaxConcentrationMax = 100;
 
            FullQuantityMax = 100;
 
            InitState("Ribbon", PaintRecipe.RibbonRecipeMinConcentration, _profile.RibbonRecipes, RibbonStateFile);
 
        }
 

	
 
        private void InitState(string mode, uint minConcentration, Dictionary<string, PaintRecipe> recipes, string stateFileName)
 
        {
 
            _minConcentration = minConcentration;
 
            _generator.InitRecipes(recipes);
 
            
 
            string stateFile = System.IO.Path.Combine(_profile.Directory, stateFileName);
 
            if (System.IO.File.Exists(stateFile))
 
            {
 
                _generator.LoadState(stateFile);
 
                
 
                if (_generator.CanResume)
 
                {
 
                    IsInProgress = true;
 
                    IsPaused = true;
 

	
 
                    MaxReagents       = (int) _generator.MaxReagents;
 
                    MaxConcentration  = (int) _generator.MaxConcentration;
 
                    FullQuantity      = (int) _generator.FullQuantity;
 
                    FullQuantityDepth = (int) _generator.FullQuantityDepth;
 
                    
 
                    SaveSettings(mode);
 
                }
 
                else
 
                {
 
                    IsInProgress = false;
 
                    IsPaused = false;
 
                    CanClear = true;
 
                }
 
            }
 
            else if (mode == "Paint")
 
            {
 
                LoadPaintSettings();
 
            }
 
            else
 
            {
 
                LoadRibbonSettings();
 
            }
 

	
 
            AllRecipes.Clear();
 

	
 
            foreach (PaintColor color in PaletteService.Colors)
 
            {
 
                GeneratorRecipe genRecipe = new(color);
 
                if (recipes.TryGetValue(color.Name, out PaintRecipe? recipe)) //  && recipe.IsValidForConcentration(minConcentration)) // This check is redundant, since they're checked when loading.
 
                {
 
                    genRecipe.DraftRecipe(recipe);
 
                }
 
                AllRecipes.Add(genRecipe);
 
            }
 
        }
 

	
 
        #region Event Handlers
 
        private void OnProgress(object? sender, EventArgs args)
 
        {
 
            _newRecipeCount = (int)_generator.RecipeCount;
 
            _updatesAvailable = true;
 
        }
 

	
 
        private void OnNewRecipe(object? sender, NewRecipeEventArgs args)
 
        {
 
            PaintRecipe recipe = new(args.Recipe);
 
            _pendingNewRecipes.Enqueue(recipe);
 
            _updatesAvailable = true;
 
        }
 
        
 
        
 
        private void OnGeneratorStopped(object? sender, EventArgs args)
 
        {
 
            SaveState();
 
            
 
            if (_saving)
 
            {
 
                _generator.ResumeRecipeGeneration();
 
                _lastSave = DateTime.Now;
 
                _saving = false;
 
                return;
 
            }
 

	
 
            if (IsPaused) return;
 

	
 
            End();
 
        }
 

	
 
        private void Update(object? sender, EventArgs e)
 
        {
 
            if (!_updatesAvailable) return;
 
            if (_saving) return;
 

	
 
            _updatesAvailable = false;
 

	
 
            DateTime now = DateTime.Now;
 
            
 
            // Update test count.
 
            PermutationCount = _newRecipeCount;
 
            
 
            // Pull in new recipes.
 
            if (!_pendingNewRecipes.IsEmpty)
 
            {
 
                GeneratorRecipe? lastRecipe = null;
 
                while (_pendingNewRecipes.TryDequeue(out PaintRecipe? newRecipe))
 
                {
 
                    string recipeColor = PaletteService.FindNearest(newRecipe.ReactedColor);
 
                    foreach (GeneratorRecipe recipe in AllRecipes)
 
                    {
 
                        if (recipe.Color.Name != recipeColor) continue;
 
                        recipe.DraftRecipe(newRecipe);
 
                        lastRecipe = recipe;
 
                        if (_ribbonMode)
 
                        {
 
                            _profile.SetRibbonRecipe(recipeColor, newRecipe);
 
                        }
 
                        else
 
                        {
 
                            _profile.SetRecipe(recipeColor, newRecipe);
 
                        }
 
                        break;
 
                    }
 
                }
 

	
 
                // If at least one recipe was processed, let's check to see if it's time to save, and
 
                // highlight that recipe.
 
                if (lastRecipe != null)
 
                {
 
                    _unsavedRecipes = true;
 
                    MostRecentRecipe = lastRecipe;
 
                    MostRecentTime = now;
 
                }
 
            }
 
            
 
            // Save if it is time.
 
            if (!((now - _lastSave).TotalMilliseconds > SaveInterval)) return;
 
            
 
            if (_unsavedRecipes)
 
            {
 
                _profile.SaveRecipes();
 
                _unsavedRecipes = false;
 
            }
 

	
 
            _saving = true;
 
            _generator.Stop();
 
        }
 
        
 
        #endregion
 

	
 

	
 
        private void SavePaintSettings()
 
        {
 
            SaveSettings("Paint");
 
        }
 

	
 
        private void LoadPaintSettings()
 
        {
 
            LoadSettings("Paint", 5, 20, 20, 4);
 
        }
 

	
 
        private void SaveRibbonSettings()
 
        {
 
            SaveSettings("Ribbon");
 
        }
 

	
 
        private void LoadRibbonSettings()
 
        {
 
            LoadSettings("Ribbon", 5, 75, 75, 4);
 
        }
 

	
 

	
 
        private void SaveSettings(string mode)
 
        {
 
            SettingsService.Set($"Generator.{mode}.MaxReagents",       MaxReagents);
 
            SettingsService.Set($"Generator.{mode}.MaxConcentration",  MaxConcentration);
 
            SettingsService.Set($"Generator.{mode}.FullQuantity",      FullQuantity);
 
            SettingsService.Set($"Generator.{mode}.FulLQuantityDepth", FullQuantityDepth);
 
        }
 

	
 
        private void LoadSettings(string mode, int defaultMaxReagents, int defaultMaxConcentration,
 
            int defaultFullQuantity, int defaultFullQuantityDepth)
 
        {
 
            SettingsService.Get($"Generator.{mode}.MaxReagents",       out int  maxReagents,       defaultMaxReagents);
 
            SettingsService.Get($"Generator.{mode}.MaxConcentration",  out int  maxConcentration,  defaultMaxConcentration);
 
            SettingsService.Get($"Generator.{mode}.FullQuantity",      out int  fullQuantity,      defaultFullQuantity);
 
            SettingsService.Get($"Generator.{mode}.FullQuantityDepth", out int  fullQuantityDepth, defaultFullQuantityDepth);
 
            
 
            MaxReagents       = maxReagents;
 
            MaxConcentration  = maxConcentration;
 
            FullQuantity      = fullQuantity;
 
            FullQuantityDepth = fullQuantityDepth;
 
        }
 

	
 
        private void SaveState()
 
        {
 
            _generator.SaveState(System.IO.Path.Combine(_profile.Directory,
 
                _ribbonMode ? RibbonStateFile : PaintStateFile));
 
        }
 

	
 
        private void ChangeMode()
 
        {
 
            if (ProductIndex == 0)
 
            {
 
                if (!_ribbonMode) return;
 
                _ribbonMode = false;
 
                InitStateForPaint();
 
            }
 
            else
 
            {
 
                if (_ribbonMode) return;
 
                _ribbonMode = true;
 
                InitStateForRibbons();
 
            }
 
        }
 
    }
 
}
ViewModels/ScreenSettingsViewModel.cs
Show inline comments
 
new file 100644
 

 
using Avalonia.Controls;
 
using Avalonia.Platform;
 
using DesertPaintCodex.Models;
 
using DesertPaintCodex.Services;
 
using ReactiveUI;
 
using System.Collections.Generic;
 
using System.Collections.ObjectModel;
 
using System.Diagnostics;
 
using System.Reactive;
 
using Avalonia;
 
using Avalonia.Controls.ApplicationLifetimes;
 
using DesertPaintCodex.Util;
 

	
 
namespace DesertPaintCodex.ViewModels
 
{
 
    public class ScreenSettingsViewModel : ViewModelBase
 
    {
 
        public ObservableCollection<string> InterfaceSizes { get; } = new();
 
        public ObservableCollection<ScreenMetrics> Screens { get; } = new();
 

	
 
        private int _selectedInterfaceSize;
 
        public int SelectedInterfaceSize { get => _selectedInterfaceSize; private set => this.RaiseAndSetIfChanged(ref _selectedInterfaceSize, value); }
 

	
 
        private int _pixeMultiplier;
 
        public int PixelMultiplier { get => _pixeMultiplier; private set => this.RaiseAndSetIfChanged(ref _pixeMultiplier, value); }
 

	
 
        private int _screenIndex;
 
        public int ScreenIndex { get => _screenIndex; private set => this.RaiseAndSetIfChanged(ref _screenIndex, value); }
 
        
 
        public ScreenSettingsViewModel()
 
        {
 
            if (Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
 
            {
 
                IScreenImpl screen = desktop.MainWindow.PlatformImpl.Screen;
 
                for (int i = 0; i < (int)InterfaceSize.Count; i++)
 
                {
 
                    InterfaceSizes.Add(((InterfaceSize)i).ToString());
 
                }
 

	
 
                IReadOnlyList<Screen> screens = screen.AllScreens;
 

	
 
                for (int i = 0; i < screens.Count; i++)
 
                {
 
                    Screens.Add(new ScreenMetrics(screens[i].Bounds, screens[i].Primary));
 
                }
 
            
 
                SettingsService.Get(SettingKey.InterfaceSize,   out _selectedInterfaceSize, (int)Constants.DefaultInterfaceSize);
 
                SettingsService.Get(SettingKey.PixelMultiplier, out _pixeMultiplier, Constants.DefaultPixelMultiplier);
 
                SettingsService.Get(SettingKey.ScreenIndex,     out _screenIndex, 0);
 

	
 
                _screenIndex = System.Math.Min(_screenIndex, screens.Count - 1); // In case the user has fewer screens than last time.
 

	
 
                Save = ReactiveCommand.Create(() => {
 
                    SettingsService.Set(SettingKey.InterfaceSize,   _selectedInterfaceSize);
 
                    SettingsService.Set(SettingKey.PixelMultiplier, _pixeMultiplier);
 
                    SettingsService.Set(SettingKey.ScreenIndex,     _screenIndex);
 
                    SettingsService.Set(SettingKey.ScreenX,         screens[_screenIndex].Bounds.X);
 
                    SettingsService.Set(SettingKey.ScreenY,         screens[_screenIndex].Bounds.Y);
 
                    SettingsService.Set(SettingKey.ScreenWidth,     screens[_screenIndex].Bounds.Width);
 
                    SettingsService.Set(SettingKey.ScreenHeight,    screens[_screenIndex].Bounds.Height);
 

	
 
                    SettingsService.Save();
 
                
 
                    ReactionScannerService.Instance.RefreshFromSettings();
 
                });
 
            }
 
            else
 
            {
 
                // Placeholder stuff.
 
                
 
                for (int i = 0; i < (int)InterfaceSize.Count; i++)
 
                {
 
                    InterfaceSizes.Add(((InterfaceSize)i).ToString());
 
                }
 
            
 
                Screens.Add(new ScreenMetrics(new Avalonia.PixelRect(0, 0, 1920, 1080), true));
 
                Screens.Add(new ScreenMetrics(new Avalonia.PixelRect(1920, 0, 1920, 1080), false));
 
            
 
                SelectedInterfaceSize = 0;
 
                PixelMultiplier       = 1;
 
                ScreenIndex           = 0;
 
                
 
                Save = ReactiveCommand.Create(() => { });
 
            }
 
        }
 

	
 
        public ReactiveCommand<Unit, Unit> Save { get; }
 
    }
 
}
ViewModels/SelectProfileViewModel.cs
Show inline comments
 
new file 100644
 
using System.Reactive;
 
using ReactiveUI;
 
using System.Collections.Generic;
 
using System.Diagnostics;
 
using DesertPaintCodex.Services;
 

	
 
namespace DesertPaintCodex.ViewModels
 
{
 
    public class SelectProfileViewModel : ViewModelBase
 
    {
 
        public List<string> Profiles { get; private set; }
 

	
 
        private string? _selectedItem;
 
        public string? SelectedItem { get => _selectedItem; set => this.RaiseAndSetIfChanged(ref _selectedItem, value); }
 
        
 
        public SelectProfileViewModel()
 
        {
 
            Profiles = ProfileManager.GetProfileList();
 

	
 
            var okEnabled = this.WhenAnyValue(
 
                x => x.SelectedItem,
 
                x => !string.IsNullOrWhiteSpace(x));
 
            
 
            NewProfile = ReactiveCommand.Create(() => { });
 

	
 
            Ok = ReactiveCommand.Create(() =>
 
            {
 
                Debug.Assert(_selectedItem != null);
 
                ProfileManager.LoadProfile(_selectedItem);
 
            }, okEnabled);
 

	
 
            Cancel = ReactiveCommand.Create(() => { });
 

	
 
            if (Profiles.Count > 0)
 
            {
 
                SelectedItem = Profiles[0];
 
            }
 
        }
 
        
 
        public ReactiveCommand<Unit, Unit> NewProfile { get; }
 

	
 
        public ReactiveCommand<Unit, Unit> Ok { get; }
 

	
 
        public ReactiveCommand<Unit, Unit> Cancel { get; }
 
    }
 
}
ViewModels/SimulatorViewModel.cs
Show inline comments
 
new file 100644
 
using System;
 
using System.Collections.Generic;
 
using System.Collections.ObjectModel;
 
using System.Collections.Specialized;
 
using Avalonia;
 
using Avalonia.Input.Platform;
 
using DesertPaintCodex.Models;
 
using DesertPaintCodex.Services;
 
using DynamicData;
 
using DynamicData.Binding;
 
using ReactiveUI;
 

	
 
namespace DesertPaintCodex.ViewModels
 
{
 
    public class SimulatorViewModel : ViewModelBase
 
    {
 
        private PaintColor? _paintColor;
 
        public PaintColor? PaintColor
 
        {
 
            get => _paintColor;
 
            set => this.RaiseAndSetIfChanged(ref _paintColor, value);
 
        }
 

	
 
        private bool _hasMissingReactions;
 
        public bool HasMissingReactions
 
        {
 
            get => _hasMissingReactions;
 
            set => this.RaiseAndSetIfChanged(ref _hasMissingReactions, value);
 
        }
 

	
 
        private bool _isGoodRecipe;
 

	
 
        public bool IsGoodRecipe
 
        {
 
            get => _isGoodRecipe;
 
            set => this.RaiseAndSetIfChanged(ref _isGoodRecipe, value);
 
        }
 
        
 
        public ObservableCollection<Reagent> Reagents { get; } = new();
 
        public ObservableCollection<Reagent> ActiveReagents { get; } = new();
 
        public ObservableCollection<RecipeItem> RecipeItems { get; } = new();
 

	
 
        
 
        private readonly PaintRecipe _currentRecipe = new();
 

	
 
        public SimulatorViewModel()
 
        {
 
            List<string> reagentNames = ReagentService.Names;
 
            for (int i = 0; i < reagentNames.Count; i++)
 
            {
 
                Reagents.Add(ReagentService.GetReagent(reagentNames[i]));
 
            }
 

	
 
            ActiveReagents.CollectionChanged += OnActiveReagentsChanged;
 

	
 
            RecipeItems
 
                .ToObservableChangeSet()
 
                .AutoRefresh(item => item.Quantity)
 
                .Subscribe(_ => Refresh());
 
        }
 

	
 
        public async void CopyToClipboard()
 
        {
 
            IClipboard clipboard = Application.Current.Clipboard;
 
            await clipboard.SetTextAsync(_currentRecipe.ToString());
 
        }
 

	
 
        public void MoveItemUp(RecipeItem item)
 
        {
 
            int pos = RecipeItems.IndexOf(item);
 
            if (pos <= 0) return;
 

	
 
            RecipeItems.RemoveAt(pos);
 
            RecipeItems.Insert(pos - 1, item);
 

	
 
            Refresh();
 
        }
 

	
 
        public void MoveItemDown(RecipeItem item)
 
        {
 
            int pos = RecipeItems.IndexOf(item);
 
            if ((pos < 0) || (pos >= RecipeItems.Count - 1)) return;
 

	
 
            RecipeItems.RemoveAt(pos);
 
            RecipeItems.Insert(pos + 1, item);
 

	
 
            Refresh();
 
        }
 
        
 
        private void UpdateRecipe()
 
        {
 
            _currentRecipe.Clear();
 
            foreach (RecipeItem entry in RecipeItems)
 
            {
 
                if (!entry.Unused)
 
                {
 
                    _currentRecipe.AddReagent(entry.Reagent.Name, entry.Quantity);
 
                }
 
            }
 

	
 
            PaintColor = null; // TODO: Find a better way to kick the paint swatch when reassigning color from the same ref.
 
            PaintColor          = _currentRecipe.ReactedColor;
 
            HasMissingReactions = _currentRecipe.HasMissingReactions();
 
            IsGoodRecipe        = !HasMissingReactions && _currentRecipe.IsValidForConcentration(10);
 
        }
 

	
 
        private void OnActiveReagentsChanged(object? sender, NotifyCollectionChangedEventArgs e)
 
        {
 
            HashSet<Reagent> unmatchedReagents = new(ActiveReagents);
 
            
 
            for (int i = RecipeItems.Count - 1; i >= 0; i--)
 
            {
 
                bool found = false;
 
                foreach (Reagent reagent in ActiveReagents)
 
                {
 
                    if (reagent == RecipeItems[i].Reagent)
 
                    {
 
                        unmatchedReagents.Remove(reagent);
 
                        found = true;
 
                        break;
 
                    }
 
                }
 

	
 
                if (!found) RecipeItems.RemoveAt(i);
 
            }
 

	
 
            foreach (Reagent reagent in unmatchedReagents)
 
            {
 
                RecipeItems.Add(new RecipeItem(reagent, 1));
 
            }
 

	
 
            Refresh();
 
        }
 

	
 
        private void Refresh()
 
        {
 
            UpdateFlags();
 
            UpdateRecipe();
 
        }
 

	
 
        private void UpdateFlags()
 
        {
 
            int activeIngredients = 0;
 
            for (int i = 0; i < RecipeItems.Count; i++)
 
            {
 
                RecipeItem item = RecipeItems[i];
 
                item.First = i == 0;
 
                item.Last = i == RecipeItems.Count - 1;
 
                if (item.Quantity > 0)
 
                {
 
                    activeIngredients++;
 
                }
 
                item.Unused = ((activeIngredients > 5) && item.Reagent.IsCatalyst) || (item.Quantity == 0);
 
            }
 
        }
 
    }
 
}
ViewModels/ValidatableViewModelBase.cs
Show inline comments
 
new file 100644
 
using System;
 
using System.Collections;
 
using System.Collections.Generic;
 
using System.ComponentModel;
 

	
 
namespace DesertPaintCodex.ViewModels
 
{
 
    public class ValidatableViewModelBase : ViewModelBase, INotifyDataErrorInfo
 
    {
 
        private static readonly string [] NoErrors = new string[0];
 
        private Dictionary <string, List<string>> _errorsByPropertyName = new () ;
 
    
 
        public IEnumerable GetErrors(string? propertyName)
 
        {
 
            if (propertyName != null && _errorsByPropertyName. TryGetValue(propertyName, out var errorList))  
 
            {
 
                return errorList;
 
            }
 
            return NoErrors;
 
        }
 

	
 
        protected virtual void SetError(string propertyName, string error)
 
        {
 
            if (_errorsByPropertyName.TryGetValue(propertyName, out var errorList))
 
            {
 
                if (!errorList.Contains(error))
 
                {
 
                    errorList.Add(error);
 
                }
 
            }
 
            else
 
            {
 
                _errorsByPropertyName.Add(propertyName, new List<string> {error});
 
            }
 

	
 
            ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(propertyName));
 
        }
 

	
 
        protected virtual void RemoveErrors(string propertyName)
 
        {
 
            if (_errorsByPropertyName.ContainsKey(propertyName))
 
            {
 
                _errorsByPropertyName.Remove(propertyName);
 
                ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(propertyName));
 
            }
 
        }
 

	
 
        public bool HasErrors => _errorsByPropertyName.Count > 0 ;   
 

	
 
        public event EventHandler<DataErrorsChangedEventArgs>? ErrorsChanged;
 
    }
 
}
...
 
\ No newline at end of file
ViewModels/ViewModelBase.cs
Show inline comments
 
new file 100644
 
using System;
 
using System.Diagnostics;
 
using System.Reactive.Linq;
 
using System.Runtime.InteropServices;
 
using System.Threading.Tasks;
 
using Avalonia.Platform;
 
using DesertPaintCodex.Views;
 
using ReactiveUI;
 

	
 
namespace DesertPaintCodex.ViewModels
 
{
 
    public class ViewModelBase : ReactiveObject
 
    {
 
        public ViewModelBase()
 
        {
 
            ShowMessageBoxDialog = new Interaction<MessageBoxViewModel, int>();
 
        }
 

	
 
        public void OpenBrowser(string url)
 
        {
 
            if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
 
            {
 
                // If no associated application/json MimeType is found xdg-open opens retun error
 
                // but it tries to open it anyway using the console editor (nano, vim, other..)
 
                ShellExec($"xdg-open {url}", waitForExit: false);
 
            }
 
            else
 
            {
 
                using (Process.Start(new ProcessStartInfo
 
                {
 
                    FileName        = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? url : "open",
 
                    Arguments       = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? $"-e {url}" : "",
 
                    CreateNoWindow  = true,
 
                    UseShellExecute = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
 
                })){}
 
            }
 
        }
 

	
 
        private static void ShellExec(string cmd, bool waitForExit = true)
 
        {
 
            var escapedArgs = cmd.Replace("\"", "\\\"");
 

	
 
            using var process = Process.Start(
 
                new ProcessStartInfo
 
                {
 
                    FileName               = "/bin/sh",
 
                    Arguments              = $"-c \"{escapedArgs}\"",
 
                    RedirectStandardOutput = true,
 
                    UseShellExecute        = false,
 
                    CreateNoWindow         = true,
 
                    WindowStyle            = ProcessWindowStyle.Hidden
 
                }
 
            );
 
            if (waitForExit)
 
            {
 
                process?.WaitForExit();
 
            }
 
        }
 
        
 
        public async Task<int> ShowMessageBox(string title, string message, string? optionA = null, string? optionB = null, string? optionC = null)
 
        {
 
            if (string.IsNullOrEmpty(optionA))
 
            {
 
                optionA = "Ok";
 
            }
 

	
 
            return await ShowMessageBoxDialog.Handle(new MessageBoxViewModel(title, message, optionA, optionB, optionC));
 
        }
 

	
 
        public async Task<bool> ShowYesNoBox(string title, string message)
 
        {
 
            int rawVal = await ShowMessageBox(title, message, "No", "Yes");
 
            return rawVal == 1;
 
        }
 
        
 
        public async Task<bool> ShowOkCancelBox(string title, string message)
 
        {
 
            int rawVal = await ShowMessageBox(title, message, "Cancel", "Ok");
 
            return rawVal == 1;
 
        }
 

	
 
        public async Task ShowInfoBox(string title, string message)
 
        {
 
            await ShowMessageBox(title, message, "Close");
 
        }
 
        
 
        public Interaction<MessageBoxViewModel, int> ShowMessageBoxDialog { get; }
 
    }
 
}

Changeset was too big and was cut off... Show full diff anyway

0 comments (0 inline, 0 general)