Flow.Launcher/Plugins/Flow.Launcher.Plugin.Calculator/Main.cs
2025-09-21 20:23:00 +08:00

432 lines
16 KiB
C#

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text.RegularExpressions;
using System.Windows.Controls;
using Mages.Core;
using Flow.Launcher.Plugin.Calculator.Views;
using Flow.Launcher.Plugin.Calculator.ViewModels;
namespace Flow.Launcher.Plugin.Calculator
{
public class Main : IPlugin, IPluginI18n, ISettingProvider
{
private static readonly Regex ThousandGroupRegex = MainRegexHelper.GetThousandGroupRegex();
private static readonly Regex NumberRegex = MainRegexHelper.GetNumberRegex();
private static readonly Regex PowRegex = MainRegexHelper.GetPowRegex();
private static readonly Regex LogRegex = MainRegexHelper.GetLogRegex();
private static readonly Regex LnRegex = MainRegexHelper.GetLnRegex();
private static readonly Regex FunctionRegex = MainRegexHelper.GetFunctionRegex();
private static Engine MagesEngine;
private const string Comma = ",";
private const string Dot = ".";
private const string IcoPath = "Images/calculator.png";
private static readonly List<Result> EmptyResults = [];
internal static PluginInitContext Context { get; set; } = null!;
private Settings _settings;
private SettingsViewModel _viewModel;
public void Init(PluginInitContext context)
{
Context = context;
_settings = context.API.LoadSettingJsonStorage<Settings>();
_viewModel = new SettingsViewModel(_settings);
MagesEngine = new Engine(new Configuration
{
Scope = new Dictionary<string, object>
{
{ "e", Math.E }, // e is not contained in the default mages engine
}
});
}
public List<Result> Query(Query query)
{
if (string.IsNullOrWhiteSpace(query.Search))
{
return EmptyResults;
}
try
{
var search = query.Search;
bool isFunctionPresent = FunctionRegex.IsMatch(search);
// Mages is case sensitive, so we need to convert all function names to lower case.
search = FunctionRegex.Replace(search, m => m.Value.ToLowerInvariant());
var decimalSep = GetDecimalSeparator();
var groupSep = GetGroupSeparator(decimalSep);
var expression = NumberRegex.Replace(search, m => NormalizeNumber(m.Value, isFunctionPresent, decimalSep, groupSep));
// WORKAROUND START: The 'pow' function in Mages v3.0.0 is broken.
// https://github.com/FlorianRappl/Mages/issues/132
// We bypass it by rewriting any pow(x,y) expression to the equivalent (x^y) expression
// before the engine sees it. This loop handles nested calls.
{
string previous;
do
{
previous = expression;
expression = PowRegex.Replace(previous, PowMatchEvaluator);
} while (previous != expression);
}
// WORKAROUND END
// WORKAROUND START: The 'log' & 'ln' function in Mages v3.0.0 are broken.
// https://github.com/FlorianRappl/Mages/issues/137
// We bypass it by rewriting any log & ln expression to the equivalent (log10 & log) expression
// before the engine sees it. This loop handles nested calls.
{
string previous;
do
{
previous = expression;
expression = LogRegex.Replace(previous, LogMatchEvaluator);
} while (previous != expression);
}
{
string previous;
do
{
previous = expression;
expression = LnRegex.Replace(previous, LnMatchEvaluator);
} while (previous != expression);
}
// WORKAROUND END
var result = MagesEngine.Interpret(expression);
if (result == null || string.IsNullOrEmpty(result.ToString()))
{
if (!_settings.ShowErrorMessage) return EmptyResults;
return
[
new Result
{
Title = Localize.flowlauncher_plugin_calculator_expression_not_complete(),
IcoPath = IcoPath
}
];
}
if (result.ToString() == "NaN")
{
result = Localize.flowlauncher_plugin_calculator_not_a_number();
}
if (result is Function)
{
result = Localize.flowlauncher_plugin_calculator_expression_not_complete();
}
if (!string.IsNullOrEmpty(result.ToString()))
{
decimal roundedResult = Math.Round(Convert.ToDecimal(result), _settings.MaxDecimalPlaces, MidpointRounding.AwayFromZero);
string newResult = FormatResult(roundedResult);
return
[
new Result
{
Title = newResult,
IcoPath = IcoPath,
Score = 300,
// Check context nullability for unit testing
SubTitle = Context == null ? string.Empty : Localize.flowlauncher_plugin_calculator_copy_number_to_clipboard(),
CopyText = newResult,
Action = c =>
{
try
{
Context.API.CopyToClipboard(newResult);
return true;
}
catch (ExternalException)
{
Context.API.ShowMsgBox(Localize.flowlauncher_plugin_calculator_failed_to_copy());
return false;
}
}
}
];
}
}
catch (Exception)
{
// Mages engine can throw various exceptions, for simplicity we catch them all and show a generic message.
if (!_settings.ShowErrorMessage) return EmptyResults;
return
[
new Result
{
Title = Localize.flowlauncher_plugin_calculator_expression_not_complete(),
IcoPath = IcoPath
}
];
}
return EmptyResults;
}
private static string PowMatchEvaluator(Match m)
{
// m.Groups[1].Value will be `(...)` with parens
var contentWithParen = m.Groups[1].Value;
// remove outer parens. `(min(2,3), 4)` becomes `min(2,3), 4`
var argsContent = contentWithParen[1..^1];
var bracketCount = 0;
var splitIndex = -1;
// Find the top-level comma that separates the two arguments of pow.
for (var i = 0; i < argsContent.Length; i++)
{
switch (argsContent[i])
{
case '(':
case '[':
bracketCount++;
break;
case ')':
case ']':
bracketCount--;
break;
case ',' when bracketCount == 0:
splitIndex = i;
break;
}
if (splitIndex != -1)
break;
}
if (splitIndex == -1)
{
// This indicates malformed arguments for pow, e.g., pow(5) or pow().
// Return original string to let Mages handle the error.
return m.Value;
}
var arg1 = argsContent[..splitIndex].Trim();
var arg2 = argsContent[(splitIndex + 1)..].Trim();
// Check for empty arguments which can happen with stray commas, e.g., pow(,5)
if (string.IsNullOrEmpty(arg1) || string.IsNullOrEmpty(arg2))
{
return m.Value;
}
return $"({arg1}^{arg2})";
}
private static string LogMatchEvaluator(Match m)
{
// m.Groups[1].Value will be `(...)` with parens
var contentWithParen = m.Groups[1].Value;
var argsContent = contentWithParen[1..^1];
// log is unary — if malformed, return original to let Mages handle it
var arg = argsContent.Trim();
if (string.IsNullOrEmpty(arg)) return m.Value;
// log(x) -> log10(x) (natural log)
return $"(log10({arg}))";
}
private static string LnMatchEvaluator(Match m)
{
// m.Groups[1].Value will be `(...)` with parens
var contentWithParen = m.Groups[1].Value;
var argsContent = contentWithParen[1..^1];
// ln is unary — if malformed, return original to let Mages handle it
var arg = argsContent.Trim();
if (string.IsNullOrEmpty(arg)) return m.Value;
// ln(x) -> log(x) (natural log)
return $"(log({arg}))";
}
private static string NormalizeNumber(string numberStr, bool isFunctionPresent, string decimalSep, string groupSep)
{
if (isFunctionPresent)
{
// STRICT MODE: When functions are present, ',' is ALWAYS an argument separator.
if (numberStr.Contains(','))
{
return numberStr;
}
string processedStr = numberStr;
// Handle group separator, with special care for ambiguous dot.
if (!string.IsNullOrEmpty(groupSep))
{
if (groupSep == ".")
{
var parts = processedStr.Split('.');
if (parts.Length > 1)
{
var culture = CultureInfo.CurrentCulture;
if (IsValidGrouping(parts, culture.NumberFormat.NumberGroupSizes))
{
processedStr = processedStr.Replace(groupSep, "");
}
// If not grouped, it's likely a decimal number, so we don't strip dots.
}
}
else
{
processedStr = processedStr.Replace(groupSep, "");
}
}
// Handle decimal separator.
if (decimalSep != ".")
{
processedStr = processedStr.Replace(decimalSep, ".");
}
return processedStr;
}
else
{
// LENIENT MODE: No functions are present, so we can be flexible.
string processedStr = numberStr;
if (!string.IsNullOrEmpty(groupSep))
{
processedStr = processedStr.Replace(groupSep, "");
}
if (decimalSep != ".")
{
processedStr = processedStr.Replace(decimalSep, ".");
}
return processedStr;
}
}
private static bool IsValidGrouping(string[] parts, int[] groupSizes)
{
if (parts.Length <= 1) return true;
if (groupSizes is null || groupSizes.Length == 0 || groupSizes[0] == 0)
return false; // has groups, but culture defines none.
var firstPart = parts[0];
if (firstPart.StartsWith('-')) firstPart = firstPart[1..];
if (firstPart.Length == 0) return false; // e.g. ",123"
if (firstPart.Length > groupSizes[0]) return false;
var lastGroupSize = groupSizes.Last();
var canRepeatLastGroup = lastGroupSize != 0;
int groupIndex = 0;
for (int i = parts.Length - 1; i > 0; i--)
{
int expectedSize;
if (groupIndex < groupSizes.Length)
{
expectedSize = groupSizes[groupIndex];
}
else if(canRepeatLastGroup)
{
expectedSize = lastGroupSize;
}
else
{
return false;
}
if (parts[i].Length != expectedSize) return false;
groupIndex++;
}
return true;
}
private string FormatResult(decimal roundedResult)
{
string decimalSeparator = GetDecimalSeparator();
string groupSeparator = GetGroupSeparator(decimalSeparator);
string resultStr = roundedResult.ToString(CultureInfo.InvariantCulture);
string[] parts = resultStr.Split('.');
string integerPart = parts[0];
string fractionalPart = parts.Length > 1 ? parts[1] : string.Empty;
if (integerPart.Length > 3)
{
integerPart = ThousandGroupRegex.Replace(integerPart, groupSeparator);
}
if (!string.IsNullOrEmpty(fractionalPart))
{
return integerPart + decimalSeparator + fractionalPart;
}
return integerPart;
}
private string GetGroupSeparator(string decimalSeparator)
{
var culture = CultureInfo.CurrentCulture;
var systemGroupSeparator = culture.NumberFormat.NumberGroupSeparator;
if (_settings.DecimalSeparator == DecimalSeparator.UseSystemLocale)
{
return systemGroupSeparator;
}
// When a custom decimal separator is used,
// use the system's group separator unless it conflicts with the custom decimal separator.
if (decimalSeparator == systemGroupSeparator)
{
// Conflict: use the opposite of the decimal separator as a fallback.
return decimalSeparator == Dot ? Comma : Dot;
}
return systemGroupSeparator;
}
private string GetDecimalSeparator()
{
string systemDecimalSeparator = CultureInfo.CurrentCulture.NumberFormat.NumberDecimalSeparator;
return _settings.DecimalSeparator switch
{
DecimalSeparator.UseSystemLocale => systemDecimalSeparator,
DecimalSeparator.Dot => Dot,
DecimalSeparator.Comma => Comma,
_ => systemDecimalSeparator,
};
}
public string GetTranslatedPluginTitle()
{
return Localize.flowlauncher_plugin_calculator_plugin_name();
}
public string GetTranslatedPluginDescription()
{
return Localize.flowlauncher_plugin_calculator_plugin_description();
}
public Control CreateSettingPanel()
{
return new CalculatorSettings(_settings);
}
public void OnCultureInfoChanged(CultureInfo newCulture)
{
DecimalSeparatorLocalized.UpdateLabels(_viewModel.AllDecimalSeparator);
}
}
}