diff --git a/PleasantUI.sln b/PleasantUI.sln index 714bf104..c05dd4ef 100644 --- a/PleasantUI.sln +++ b/PleasantUI.sln @@ -12,24 +12,31 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PleasantUI.DataGrid", "src\ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PleasantUI.Example", "samples\PleasantUI.Example\PleasantUI.Example.csproj", "{59DC0F4B-421E-407D-9435-F6ED14765634}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "designer", "designer", "{EBAC1577-CFD7-477C-848E-86BFFC345D90}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PleasantUI.ToolKit", "src\PleasantUI.ToolKit\PleasantUI.ToolKit.csproj", "{722B39BB-1CBF-4762-A5C5-439F4EB3A781}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PleasantUI.Designer", "designer\PleasantUI.Designer\PleasantUI.Designer.csproj", "{88C264B9-51D4-41CF-AEC0-24F5075FA6B8}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_build", "_build", "{D6E493DE-5180-4BA0-B122-D155A5934D08}" + ProjectSection(SolutionItems) = preProject + build\Package.props = build\Package.props + build\PleasantUI.targets = build\PleasantUI.targets + build\PleasantUI.Localization.Analyzer.targets = build\PleasantUI.Localization.Analyzer.targets + build\PleasantUI.Localization.Generator.targets = build\PleasantUI.Localization.Generator.targets + EndProjectSection EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "generators", "generators", "{941F7A66-EFCB-4141-97CA-A0B779ED1BB4}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "generators", "generators", "{77203EAD-CB0D-4D37-B8E7-2F2BA0A1B1EF}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PleasantUI.MaterialIcons", "src\PleasantUI.MaterialIcons\PleasantUI.MaterialIcons.csproj", "{2ABE0576-837B-4D1B-970D-00B52BF0E078}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PleasantUI.Localization.Generator", "generators\PleasantUI.Localization.Generator\PleasantUI.Localization.Generator.csproj", "{967BBBAA-7F6F-4DA6-A8E5-9F0A70DFF684}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PleasantUI.MaterialIcons.SourceGenerator", "generators\PleasantUI.MaterialIcons.SourceGenerator\PleasantUI.MaterialIcons.SourceGenerator.csproj", "{9FDFD065-7664-4E0C-B928-D7FC8B074EDD}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "analyzers", "analyzers", "{20ECD7A9-C3D1-41AA-A8BB-640E3B28BBC0}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PleasantUI.ToolKit", "src\PleasantUI.ToolKit\PleasantUI.ToolKit.csproj", "{722B39BB-1CBF-4762-A5C5-439F4EB3A781}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PleasantUI.Localization.Analyzer", "analyzers\PleasantUI.Localization.Analyzer\PleasantUI.Localization.Analyzer.csproj", "{A519CDCF-2774-4FD9-AE5F-0F6B62F7C346}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "build", "build", "{D6E493DE-5180-4BA0-B122-D155A5934D08}" - ProjectSection(SolutionItems) = preProject - build\Package.props = build\Package.props - EndProjectSection +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_tests", "_tests", "{80E17402-8124-43B7-A0BB-E939CDD20E21}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "analyzers", "analyzers", "{6B8337BF-8256-47CF-836E-C5A3C745D931}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PleasantUI.Localization.Analyzer.Tests", "tests\analyzers\PleasantUI.Localization.Analyzer.Tests\PleasantUI.Localization.Analyzer.Tests.csproj", "{D3E71BE9-D438-48ED-A99D-EF30AEFD66A5}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "common", "common", "{4BCCA866-A31E-49B1-AB79-B9C94651D295}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PleasantUI.Tests", "tests\PleasantUI.Tests\PleasantUI.Tests.csproj", "{A6230EC5-60E1-41E8-BE8E-CA9A82D7F835}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -57,29 +64,35 @@ Global {59DC0F4B-421E-407D-9435-F6ED14765634}.Debug|Any CPU.Build.0 = Debug|Any CPU {59DC0F4B-421E-407D-9435-F6ED14765634}.Release|Any CPU.ActiveCfg = Release|Any CPU {59DC0F4B-421E-407D-9435-F6ED14765634}.Release|Any CPU.Build.0 = Release|Any CPU - {88C264B9-51D4-41CF-AEC0-24F5075FA6B8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {88C264B9-51D4-41CF-AEC0-24F5075FA6B8}.Debug|Any CPU.Build.0 = Debug|Any CPU - {88C264B9-51D4-41CF-AEC0-24F5075FA6B8}.Release|Any CPU.ActiveCfg = Release|Any CPU - {88C264B9-51D4-41CF-AEC0-24F5075FA6B8}.Release|Any CPU.Build.0 = Release|Any CPU - {2ABE0576-837B-4D1B-970D-00B52BF0E078}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {2ABE0576-837B-4D1B-970D-00B52BF0E078}.Debug|Any CPU.Build.0 = Debug|Any CPU - {2ABE0576-837B-4D1B-970D-00B52BF0E078}.Release|Any CPU.ActiveCfg = Release|Any CPU - {2ABE0576-837B-4D1B-970D-00B52BF0E078}.Release|Any CPU.Build.0 = Release|Any CPU - {9FDFD065-7664-4E0C-B928-D7FC8B074EDD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {9FDFD065-7664-4E0C-B928-D7FC8B074EDD}.Debug|Any CPU.Build.0 = Debug|Any CPU - {9FDFD065-7664-4E0C-B928-D7FC8B074EDD}.Release|Any CPU.ActiveCfg = Release|Any CPU - {9FDFD065-7664-4E0C-B928-D7FC8B074EDD}.Release|Any CPU.Build.0 = Release|Any CPU {722B39BB-1CBF-4762-A5C5-439F4EB3A781}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {722B39BB-1CBF-4762-A5C5-439F4EB3A781}.Debug|Any CPU.Build.0 = Debug|Any CPU {722B39BB-1CBF-4762-A5C5-439F4EB3A781}.Release|Any CPU.ActiveCfg = Release|Any CPU {722B39BB-1CBF-4762-A5C5-439F4EB3A781}.Release|Any CPU.Build.0 = Release|Any CPU + {967BBBAA-7F6F-4DA6-A8E5-9F0A70DFF684}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {967BBBAA-7F6F-4DA6-A8E5-9F0A70DFF684}.Debug|Any CPU.Build.0 = Debug|Any CPU + {967BBBAA-7F6F-4DA6-A8E5-9F0A70DFF684}.Release|Any CPU.ActiveCfg = Release|Any CPU + {967BBBAA-7F6F-4DA6-A8E5-9F0A70DFF684}.Release|Any CPU.Build.0 = Release|Any CPU + {A519CDCF-2774-4FD9-AE5F-0F6B62F7C346}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A519CDCF-2774-4FD9-AE5F-0F6B62F7C346}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A519CDCF-2774-4FD9-AE5F-0F6B62F7C346}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A519CDCF-2774-4FD9-AE5F-0F6B62F7C346}.Release|Any CPU.Build.0 = Release|Any CPU + {D3E71BE9-D438-48ED-A99D-EF30AEFD66A5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D3E71BE9-D438-48ED-A99D-EF30AEFD66A5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D3E71BE9-D438-48ED-A99D-EF30AEFD66A5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D3E71BE9-D438-48ED-A99D-EF30AEFD66A5}.Release|Any CPU.Build.0 = Release|Any CPU + {A6230EC5-60E1-41E8-BE8E-CA9A82D7F835}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A6230EC5-60E1-41E8-BE8E-CA9A82D7F835}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A6230EC5-60E1-41E8-BE8E-CA9A82D7F835}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A6230EC5-60E1-41E8-BE8E-CA9A82D7F835}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {71BFCFA6-B53E-4F28-819F-51CB26516B5A} = {BEE78D8A-ED3D-4D34-9B29-43D5BB953D40} {91F6CDD0-6925-4289-90EC-8BDD245F7BD0} = {BEE78D8A-ED3D-4D34-9B29-43D5BB953D40} {59DC0F4B-421E-407D-9435-F6ED14765634} = {BEE78D8A-ED3D-4D34-9B29-43D5BB953D40} - {88C264B9-51D4-41CF-AEC0-24F5075FA6B8} = {EBAC1577-CFD7-477C-848E-86BFFC345D90} - {9FDFD065-7664-4E0C-B928-D7FC8B074EDD} = {941F7A66-EFCB-4141-97CA-A0B779ED1BB4} - {2ABE0576-837B-4D1B-970D-00B52BF0E078} = {4BCCA866-A31E-49B1-AB79-B9C94651D295} + {967BBBAA-7F6F-4DA6-A8E5-9F0A70DFF684} = {77203EAD-CB0D-4D37-B8E7-2F2BA0A1B1EF} + {A519CDCF-2774-4FD9-AE5F-0F6B62F7C346} = {20ECD7A9-C3D1-41AA-A8BB-640E3B28BBC0} + {6B8337BF-8256-47CF-836E-C5A3C745D931} = {80E17402-8124-43B7-A0BB-E939CDD20E21} + {D3E71BE9-D438-48ED-A99D-EF30AEFD66A5} = {6B8337BF-8256-47CF-836E-C5A3C745D931} + {A6230EC5-60E1-41E8-BE8E-CA9A82D7F835} = {80E17402-8124-43B7-A0BB-E939CDD20E21} EndGlobalSection EndGlobal diff --git a/README.md b/README.md index 7cb5f909..3b3a198c 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,16 @@ > **Repositories:** [Original (Onebeld)](https://github.com/Onebeld/PleasantUI) · [Fork (ghudulf)](https://github.com/ghudulf/PleasantUI) -PleasantUI is a cross-platform UI theme and control library for [Avalonia](https://github.com/AvaloniaUI/Avalonia), inspired by Microsoft Fluent Design and the WinUI/UWP visual language. It completely re-styles every standard Avalonia control and adds a suite of custom controls, a multi-theme engine with custom theme support, a reactive localization system, and a custom window chrome — all AOT-compatible with no `rd.xml` required. +PleasantUI is a cross-platform UI theme and control library for [Avalonia](https://github.com/AvaloniaUI/Avalonia), inspired by Microsoft Fluent Design, Google Material Design and the WinUI/UWP visual language. It completely re-styles every standard Avalonia control and adds a suite of custom controls, a multi-theme engine with custom theme support, a reactive localization system, and a custom window chrome — all AOT-compatible with no `rd.xml` required. The project has been in active development since 2021, originally as part of the [Regul](https://github.com/Onebeld/Regul) and [Regul Save Cleaner](https://github.com/Onebeld/RegulSaveCleaner) projects. +## 🏛️ Philosophy + +The philosophy is to provide the user with a _**pleasant**_ experience in using applications built on this library. This library isn't intended to be compared to other design principles or other UI libraries. If the interface looks good, then this library is doing its job. + +This library may also contain elements from other design principles or UI libraries if it makes the interface _**pleasant**_ enough (but with due attribution, of course). + ## ✨ Features ### Complete Fluent-style control theming @@ -47,7 +53,6 @@ Controls built from scratch that go beyond what Avalonia ships: | `InformationBlock` | Compact pill-shaped label combining an icon and a value | ![Imgur](https://i.imgur.com/SX0BZ2k.png) | | `MarkedTextBox` / `MarkedNumericUpDown` | Input controls with inline label/unit markers | None | | `RippleEffect` | Material-style ripple click feedback | ![Imgur](https://i.imgur.com/2WtIPvj.gif) | -| `SmoothScrollViewer` | ScrollViewer with inertia gesture support | None | | `PleasantMiniWindow` | Lightweight floating window | None | | `Timeline` | Displays a list of events in chronological order along a vertical axis. Supports four layout modes, custom icons, and five severity types. | ![Imgur](https://i.imgur.com/DGP3nDR.png) | | `InstallWizard` | A multi-step installation wizard with a sidebar step list, progress bar, and Back / Next / Cancel navigation. | ![Imgur](https://i.imgur.com/bbgKH3L.png) | @@ -311,9 +316,17 @@ string title = Localizer.TrDefault("DialogTitle", "Confirm"); ## ❤️ Credits -- [Avalonia](https://github.com/AvaloniaUI/Avalonia) -- Some controls inspired by PieroCastillo's [Aura.UI](https://github.com/PieroCastillo/Aura.UI) -- [ProgressRing](https://github.com/ymg2006/FluentAvalonia.ProgressRing) by ymg2006 +### Main +- [Avalonia](https://github.com/AvaloniaUI/Avalonia) under license from MIT - Built with [JetBrains Rider](https://www.jetbrains.com/rider/) +### Controls and Code +- [Aura.UI](https://github.com/PieroCastillo/Aura.UI) (PieroCastillo) - NavigationView and AuraTabView under license from MIT +- [ProgressRing](https://github.com/ymg2006/FluentAvalonia.ProgressRing) (ymg2006) - under license from MIT +- [RichTextKit](https://github.com/toptensoftware/RichTextKit) (toptensoftware) - implementation of BinarySearch under license from Apache 2.0 +- [weather](https://github.com/rocksdanister/weather) (rocksdanister) - implementation of BackdropBlurBorder under license from MIT +- [Xaml.Behaviors.Interactions.Animated](https://github.com/Egolds/Xaml.Behaviors.Interactions.Animated) (Egolds) - implementation of VerticalScrollViewerAnimatedBehavior under license from MIT +- [BilibiliClient](https://github.com/afunc233/BilibiliClient) (afunc233) - implementation of VirtualizingWrapPanel +- [Material.Avalonia](https://github.com/AvaloniaCommunity/Material.Avalonia) (CommunityAvalonia) - implementation of Ripple Effect under license from MIT + diff --git a/analyzers/PleasantUI.Localization.Analyzer/AnalyzerReleases.Shipped.md b/analyzers/PleasantUI.Localization.Analyzer/AnalyzerReleases.Shipped.md new file mode 100644 index 00000000..8dd1b9ad --- /dev/null +++ b/analyzers/PleasantUI.Localization.Analyzer/AnalyzerReleases.Shipped.md @@ -0,0 +1,8 @@ +## Release 1.0 + +### New Rules + +| Rule ID | Category | Severity | Notes | +|---------|----------|----------|--------------------------------------------------| +| AB0001 | Naming | Warning | Type names should not contain the company name. | +| AB0002 | Usage | Warning | The speed must be lower than the Speed of Light. | \ No newline at end of file diff --git a/analyzers/PleasantUI.Localization.Analyzer/AnalyzerReleases.Unshipped.md b/analyzers/PleasantUI.Localization.Analyzer/AnalyzerReleases.Unshipped.md new file mode 100644 index 00000000..8400ebe5 --- /dev/null +++ b/analyzers/PleasantUI.Localization.Analyzer/AnalyzerReleases.Unshipped.md @@ -0,0 +1,6 @@ +### New Rules + +| Rule ID | Category | Severity | Notes | +|---------|----------|----------|-------| +PUILOC101 | Localization | Warning | ResxUsageAnalyzer +PUILOC102 | Localization | Warning | MissingResxKeyAnalyzer \ No newline at end of file diff --git a/analyzers/PleasantUI.Localization.Analyzer/LocExclusions.txt b/analyzers/PleasantUI.Localization.Analyzer/LocExclusions.txt new file mode 100644 index 00000000..8ff604e3 --- /dev/null +++ b/analyzers/PleasantUI.Localization.Analyzer/LocExclusions.txt @@ -0,0 +1,61 @@ +AttachOverWindows +Back +Cancel +ChangeMenu +Close +CloseMenu +Collapse +CollapseMenu +ColorWheel +Confirm +CopyMenu +CutMenu +DeleteMenu +Expand +ExpandMenu +Menu +Next +NoItems +Off +Ok +On +PasteMenu +PathPicker/OpenFile +Restore +RestoreMenu +SearchableComboBox/SearchPlaceholder +Theme/Light +Theme/Cave +Theme/Cherry +Theme/Custom +Theme/Dark +Theme/Ice +Theme/Lunar +Theme/Sunny +Theme/System +Theme/Mint +Theme/Spruce +Theme/Strawberry +ToolKit/AddColor +ToolKit/AddYourNewColor +ToolKit/Colors +ToolKit/CustomThemes +ToolKit/NoCustomThemes +ToolKit/Redo +ToolKit/SavedColors +ToolKit/ThemeEditor +ToolKit/ThemeEditor/ColorCopiedToClipboard +ToolKit/ThemeEditor/CopyTheme +ToolKit/ThemeEditor/DragAndDropThemeHere +ToolKit/ThemeEditor/ExportTheme +ToolKit/ThemeEditor/GetColorsBasedOn +ToolKit/ThemeEditor/ImportTheme +ToolKit/ThemeEditor/NumberOfColorsInstalled +ToolKit/ThemeEditor/PasteTheme +ToolKit/ThemeEditor/ThemeCopiedToClipboard +ToolKit/ThemeEditor/ThemeExported +ToolKit/ThemeEditor/ThemeImported +ToolKit/ThemeEditor/ThemeImportError +ToolKit/ThemeEditor/ThemeName +ToolKit/Themes +ToolKit/Undo \ No newline at end of file diff --git a/analyzers/PleasantUI.Localization.Analyzer/MissingResxKeyAnalyzer.cs b/analyzers/PleasantUI.Localization.Analyzer/MissingResxKeyAnalyzer.cs new file mode 100644 index 00000000..1c78a646 --- /dev/null +++ b/analyzers/PleasantUI.Localization.Analyzer/MissingResxKeyAnalyzer.cs @@ -0,0 +1,374 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Xml.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Text; + +namespace PleasantUI.Localization.Analyzer; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class MissingResxKeyAnalyzer : DiagnosticAnalyzer +{ + private static readonly DiagnosticDescriptor Rule = new( + "PUILOC102", + "Missing localization key", + "Localization key '{0}' is used in code but is missing from the .resx files", + "Localization", + DiagnosticSeverity.Warning, + true + ); + + private static readonly Regex CultureSuffixRegex = new(@"\.[a-z]{2}(-[A-Z]{2,4})?(-[A-Z][a-z]{3})?$", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + private enum TargetMethodType + { + Tr, + TrDefault, + TryGetString, + BindingCreate + } + + public override ImmutableArray SupportedDiagnostics => [Rule]; + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + context.RegisterCompilationStartAction(compilationContext => + { + AnalyzerConfigOptions globalOptions = + compilationContext.Options.AnalyzerConfigOptionsProvider.GlobalOptions; + if (globalOptions.TryGetValue("build_property.PleasantUIDisableResxAnalyzer", out string? msbuildValue) && + string.Equals(msbuildValue.Trim(), "true", StringComparison.OrdinalIgnoreCase)) + return; + + HashSet existingKeys = []; + + List resxFiles = compilationContext.Options.AdditionalFiles + .Where(f => Path.GetExtension(f.Path).Equals(".resx", StringComparison.OrdinalIgnoreCase)) + .Where(f => !CultureSuffixRegex.IsMatch(Path.GetFileNameWithoutExtension(f.Path))) + .ToList(); + + foreach (AdditionalText? file in resxFiles) + { + SourceText? sourceText = file.GetText(compilationContext.CancellationToken); + if (sourceText == null) continue; + try + { + XDocument doc = XDocument.Parse(sourceText.ToString()); + IEnumerable? dataElements = doc.Root?.Elements("data"); + + if (dataElements == null) + continue; + + foreach (XElement? element in dataElements) + { + string? name = element.Attribute("name")?.Value; + if (!string.IsNullOrEmpty(name)) existingKeys.Add(name?.Trim()); + } + } + catch + { + // ignored + } + } + + compilationContext.RegisterSemanticModelAction(semanticContext => + { + AnalyzeLocalizerCalls( + semanticContext.SemanticModel, + existingKeys, + semanticContext.ReportDiagnostic, + semanticContext.CancellationToken); + }); + + compilationContext.RegisterSyntaxNodeAction(syntaxContext => + { + AnalyzePropertyDeclaration( + (PropertyDeclarationSyntax)syntaxContext.Node, + syntaxContext.SemanticModel, + existingKeys, + syntaxContext.ReportDiagnostic, + syntaxContext.CancellationToken); + }, SyntaxKind.PropertyDeclaration); + + compilationContext.RegisterAdditionalFileAction(fileContext => + { + if (Path.GetExtension(fileContext.AdditionalFile.Path) + .Equals(".axaml", StringComparison.OrdinalIgnoreCase)) + { + AnalyzeAxamlFile( + fileContext.AdditionalFile, + existingKeys, + fileContext.ReportDiagnostic, + fileContext.CancellationToken); + } + }); + }); + } + + private static void AnalyzeLocalizerCalls( + SemanticModel model, + HashSet existingKeys, + Action reportDiagnostic, + System.Threading.CancellationToken cancellationToken) + { + SyntaxNode root = model.SyntaxTree.GetRoot(cancellationToken); + + foreach (InvocationExpressionSyntax invocation in root.DescendantNodes().OfType()) + { + if (invocation.ArgumentList.Arguments.Count == 0) continue; + + ISymbol? symbol = model.GetSymbolInfo(invocation, cancellationToken).Symbol + ?? model.GetSymbolInfo(invocation.Expression, cancellationToken).Symbol; + + if (symbol is not IMethodSymbol methodSymbol || + !IsTargetLocalizationMethod(methodSymbol, out TargetMethodType methodType)) + continue; + + SeparatedSyntaxList args = invocation.ArgumentList.Arguments; + ExpressionSyntax keyExpr = args[0].Expression; + + string? rawKey = TryGetStringArgument(model, keyExpr); + if (string.IsNullOrEmpty(rawKey)) continue; + + string? context = ExtractContextFromArguments(args, methodType, model); + string combinedKey = !string.IsNullOrEmpty(context) ? $"{context}/{rawKey}" : rawKey!; + + if (!existingKeys.Contains(combinedKey)) + reportDiagnostic(Diagnostic.Create(Rule, keyExpr.GetLocation(), combinedKey)); + } + } + + private static void AnalyzePropertyDeclaration( + PropertyDeclarationSyntax propDecl, + SemanticModel model, + HashSet existingKeys, + Action reportDiagnostic, + System.Threading.CancellationToken cancellationToken) + { + ExpressionSyntax? initializer = propDecl.Initializer?.Value ?? propDecl.ExpressionBody?.Expression; + if (initializer == null) return; + + string? key = TryGetStringArgument(model, initializer); + if (string.IsNullOrEmpty(key)) return; + + if (model.GetDeclaredSymbol(propDecl, cancellationToken) is not { } propertySymbol) + return; + + bool isLocalizationProperty = false; + + if (propertySymbol.Name.Contains("TitleKey")) + isLocalizationProperty = true; + else if (propertySymbol.IsOverride) + { + IPropertySymbol? current = propertySymbol.OverriddenProperty; + while (current != null) + { + if (current.Name.Contains("TitleKey")) + { + isLocalizationProperty = true; + break; + } + + current = current.OverriddenProperty; + } + } + + if (isLocalizationProperty && !existingKeys.Contains(key ?? string.Empty)) + reportDiagnostic(Diagnostic.Create(Rule, initializer.GetLocation(), key)); + } + + private static bool IsTargetLocalizationMethod(IMethodSymbol method, out TargetMethodType type) + { + type = default; + string typeName = method.ContainingType?.Name ?? string.Empty; + switch (typeName) + { + case "LocalizeBinding" when method.Name == "Create": + type = TargetMethodType.BindingCreate; + return true; + case "Localizer": + switch (method.Name) + { + case "Tr": + type = TargetMethodType.Tr; + return true; + case "TrDefault": + type = TargetMethodType.TrDefault; + return true; + case "TryGetString": + type = TargetMethodType.TryGetString; + return true; + } + + break; + } + + return false; + } + + private static string? ExtractContextFromArguments(SeparatedSyntaxList args, + TargetMethodType methodType, SemanticModel model) + { + if (methodType == TargetMethodType.BindingCreate && args.Count > 1) + return TryGetStringArgument(model, args[1].Expression); + + ArgumentSyntax? contextArg = args.FirstOrDefault(a => a.NameColon?.Name.Identifier.ValueText == "context"); + + if (contextArg != null) + return TryGetStringArgument(model, contextArg.Expression); + + return methodType switch + { + TargetMethodType.Tr when args.Count > 1 && args[1].NameColon == null => TryGetStringArgument(model, + args[1].Expression), + TargetMethodType.TrDefault when args.Count > 2 && args[2].NameColon == null => TryGetStringArgument(model, + args[2].Expression), + _ => null + }; + } + + private static void AnalyzeAxamlFile(AdditionalText axamlFile, HashSet existingKeys, + Action reportDiagnostic, System.Threading.CancellationToken cancellationToken) + { + SourceText? sourceText = axamlFile.GetText(cancellationToken); + if (sourceText == null) return; + + try + { + string textContent = sourceText.ToString(); + XDocument doc = XDocument.Parse(textContent, LoadOptions.SetLineInfo); + if (doc.Root == null) return; + + foreach (XElement element in doc.Descendants()) + { + if (cancellationToken.IsCancellationRequested) return; + + foreach (XAttribute attribute in element.Attributes()) + { + string value = attribute.Value.Trim(); + if (!value.StartsWith("{", StringComparison.Ordinal) || !value.Contains("Localize ")) + continue; + + (string Key, string? Context)? parsed = ExtractKeyFromExtension(value); + + if (parsed == null) + continue; + + string combinedKey = !string.IsNullOrEmpty(parsed.Value.Context) + ? $"{parsed.Value.Context}/{parsed.Value.Key}" + : parsed.Value.Key; + + if (existingKeys.Contains(combinedKey)) + continue; + + System.Xml.IXmlLineInfo lineInfo = attribute; + + if (!lineInfo.HasLineInfo()) + continue; + + int lineNumber = lineInfo.LineNumber - 1; + + TextLine textLine = sourceText.Lines[lineNumber]; + string lineText = textLine.ToString(); + + int indexInLine = lineText.IndexOf(attribute.Value, StringComparison.Ordinal); + if (indexInLine == -1) indexInLine = lineInfo.LinePosition - 1; + + int startPosition = textLine.Start + indexInLine; + int length = attribute.Value.Length; + + TextSpan span = new(startPosition, length); + LinePositionSpan lineSpan = new( + new LinePosition(lineNumber, indexInLine), + new LinePosition(lineNumber, indexInLine + length) + ); + + Location loc = Location.Create(axamlFile.Path, span, lineSpan); + reportDiagnostic(Diagnostic.Create(Rule, loc, combinedKey)); + } + } + } + catch + { + // ignored + } + } + + private static (string Key, string? Context)? ExtractKeyFromExtension(string extensionValue) + { + string content = extensionValue.Trim('{', '}').Trim(); + int firstSpace = content.IndexOf(' '); + + if (firstSpace == -1) + return null; + + string argumentsPart = content.Substring(firstSpace + 1).Trim(); + List arguments = SplitXamlArguments(argumentsPart); + + if (arguments.Count == 0) + return null; + + string rawKey = arguments[0].Trim().Trim('\'', '"'); + + if (rawKey.StartsWith("{", StringComparison.Ordinal) || rawKey.Contains("LocKey.")) + return null; + + string? context = null; + for (int i = 1; i < arguments.Count; i++) + { + string arg = arguments[i].Trim(); + if (arg.StartsWith("Context", StringComparison.OrdinalIgnoreCase) && arg.Contains("=")) + context = arg.Substring(arg.IndexOf('=') + 1).Trim('\'', '"', ' '); + } + + return (rawKey, context); + } + + private static string? TryGetStringArgument(SemanticModel model, ExpressionSyntax expression) + { + if (expression is LiteralExpressionSyntax literal) + return literal.Token.ValueText; + + return model.GetConstantValue(expression).Value?.ToString(); + } + + private static List SplitXamlArguments(string p) + { + List r = []; + int braceCount = 0; + int startIdx = 0; + + for (int i = 0; i < p.Length; i++) + { + switch (p[i]) + { + case '{': + braceCount++; + break; + case '}': + braceCount--; + break; + case ',' when braceCount == 0: + r.Add(p.Substring(startIdx, i - startIdx)); + startIdx = i + 1; + break; + } + } + + if (startIdx < p.Length) + r.Add(p.Substring(startIdx)); + + return r; + } +} \ No newline at end of file diff --git a/analyzers/PleasantUI.Localization.Analyzer/PleasantUI.Localization.Analyzer.csproj b/analyzers/PleasantUI.Localization.Analyzer/PleasantUI.Localization.Analyzer.csproj new file mode 100644 index 00000000..55b154d8 --- /dev/null +++ b/analyzers/PleasantUI.Localization.Analyzer/PleasantUI.Localization.Analyzer.csproj @@ -0,0 +1,41 @@ + + + + netstandard2.0 + false + enable + latest + + true + true + + PleasantUI.Localization.Analyzer + PleasantUI.Localization.Analyzer + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + ResXFileCodeGenerator + Resources.Designer.cs + + + + + + True + True + Resources.resx + + + + diff --git a/analyzers/PleasantUI.Localization.Analyzer/Properties/launchSettings.json b/analyzers/PleasantUI.Localization.Analyzer/Properties/launchSettings.json new file mode 100644 index 00000000..0deb2b28 --- /dev/null +++ b/analyzers/PleasantUI.Localization.Analyzer/Properties/launchSettings.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "DebugRoslynAnalyzers": { + "commandName": "DebugRoslynComponent", + "targetProject": "../PleasantUI.Localization.Analyzer.Sample/PleasantUI.Localization.Analyzer.Sample.csproj" + } + } +} \ No newline at end of file diff --git a/analyzers/PleasantUI.Localization.Analyzer/Readme.md b/analyzers/PleasantUI.Localization.Analyzer/Readme.md new file mode 100644 index 00000000..a76b1902 --- /dev/null +++ b/analyzers/PleasantUI.Localization.Analyzer/Readme.md @@ -0,0 +1,29 @@ +# Roslyn Analyzers Sample + +A set of three sample projects that includes Roslyn analyzers with code fix providers. Enjoy this template to learn from and modify analyzers for your own needs. + +## Content +### PleasantUI.Localization.Analyzer +A .NET Standard project with implementations of sample analyzers and code fix providers. +**You must build this project to see the results (warnings) in the IDE.** + +- [SampleSemanticAnalyzer.cs](SampleSemanticAnalyzer.cs): An analyzer that reports invalid values used for the `speed` parameter of the `SetSpeed` function. +- [SampleSyntaxAnalyzer.cs](SampleSyntaxAnalyzer.cs): An analyzer that reports the company name used in class definitions. +- [SampleCodeFixProvider.cs](SampleCodeFixProvider.cs): A code fix that renames classes with company name in their definition. The fix is linked to [SampleSyntaxAnalyzer.cs](SampleSyntaxAnalyzer.cs). + +### PleasantUI.Localization.Analyzer.Sample +A project that references the sample analyzers. Note the parameters of `ProjectReference` in [PleasantUI.Localization.Analyzer.Sample.csproj](../PleasantUI.Localization.Analyzer.Sample/PleasantUI.Localization.Analyzer.Sample.csproj), they make sure that the project is referenced as a set of analyzers. + +### PleasantUI.Localization.Analyzer.Tests +Unit tests for the sample analyzers and code fix provider. The easiest way to develop language-related features is to start with unit tests. + +## How To? +### How to debug? +- Use the [launchSettings.json](Properties/launchSettings.json) profile. +- Debug tests. + +### How can I determine which syntax nodes I should expect? +Consider using the Roslyn Visualizer tool window, which allows you to observe the syntax tree. + +### Learn more about wiring analyzers +The complete set of information is available at [roslyn github repo wiki](https://github.com/dotnet/roslyn/blob/main/docs/wiki/README.md). \ No newline at end of file diff --git a/analyzers/PleasantUI.Localization.Analyzer/Resources.Designer.cs b/analyzers/PleasantUI.Localization.Analyzer/Resources.Designer.cs new file mode 100644 index 00000000..a850efa5 --- /dev/null +++ b/analyzers/PleasantUI.Localization.Analyzer/Resources.Designer.cs @@ -0,0 +1,89 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace PleasantUI.Localization.Analyzer +{ + using System; + + + [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [System.Diagnostics.DebuggerNonUserCodeAttribute()] + [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Resources + { + private static System.Resources.ResourceManager resourceMan; + + private static System.Globalization.CultureInfo resourceCulture; + + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", + "CA1811:AvoidUncalledPrivateCode")] + internal Resources() + { + } + + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] + internal static System.Resources.ResourceManager ResourceManager + { + get + { + if (object.Equals(null, resourceMan)) + { + System.Resources.ResourceManager temp = + new System.Resources.ResourceManager("PleasantUI.Localization.Analyzer.Resources", + typeof(Resources).Assembly); + resourceMan = temp; + } + + return resourceMan; + } + } + + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] + internal static System.Globalization.CultureInfo Culture + { + get { return resourceCulture; } + set { resourceCulture = value; } + } + + internal static string AB0001Description + { + get { return ResourceManager.GetString("AB0001Description", resourceCulture); } + } + + internal static string AB0001MessageFormat + { + get { return ResourceManager.GetString("AB0001MessageFormat", resourceCulture); } + } + + internal static string AB0001Title + { + get { return ResourceManager.GetString("AB0001Title", resourceCulture); } + } + + internal static string AB0001CodeFixTitle + { + get { return ResourceManager.GetString("AB0001CodeFixTitle", resourceCulture); } + } + + internal static string AB0002Description + { + get { return ResourceManager.GetString("AB0002Description", resourceCulture); } + } + + internal static string AB0002MessageFormat + { + get { return ResourceManager.GetString("AB0002MessageFormat", resourceCulture); } + } + + internal static string AB0002Title + { + get { return ResourceManager.GetString("AB0002Title", resourceCulture); } + } + } +} \ No newline at end of file diff --git a/analyzers/PleasantUI.Localization.Analyzer/Resources.resx b/analyzers/PleasantUI.Localization.Analyzer/Resources.resx new file mode 100644 index 00000000..889364d9 --- /dev/null +++ b/analyzers/PleasantUI.Localization.Analyzer/Resources.resx @@ -0,0 +1,42 @@ + + + + + + + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Type names should not contain the company name.An optional longer localizable description of the diagnostic. + + + Type name '{0}' contains the company nameThe format-able message the diagnostic displays. + + + Type name contains the company nameThe title of the diagnostic. + + + Replace '{0}' with '{1}'The title of the code fix. + + + The speed must be lower than the Speed of Light.An optional longer localizable description of the diagnostic. + + + The specified speed '{0}' must be lower than the Speed of LightThe format-able message the diagnostic displays. + + + The impossible speedThe title of the diagnostic. + + \ No newline at end of file diff --git a/analyzers/PleasantUI.Localization.Analyzer/ResxUsageAnalyzer.cs b/analyzers/PleasantUI.Localization.Analyzer/ResxUsageAnalyzer.cs new file mode 100644 index 00000000..a6c9f4e5 --- /dev/null +++ b/analyzers/PleasantUI.Localization.Analyzer/ResxUsageAnalyzer.cs @@ -0,0 +1,773 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text.RegularExpressions; +using System.Threading; +using System.Xml; +using System.Xml.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Text; +using TypeInfo = Microsoft.CodeAnalysis.TypeInfo; + +namespace PleasantUI.Localization.Analyzer; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class ResxUsageAnalyzer : DiagnosticAnalyzer +{ + private static readonly DiagnosticDescriptor Rule = new( + "PUILOC101", + "Unused localization key", + "Localization key '{0}' from file '{1}' is not used in the project", + "Localization", + DiagnosticSeverity.Warning, + true, + customTags: ["CompilationEnd"] + ); + + private static readonly Regex CultureSuffixRegex = new(@"\.[a-z]{2}(-[A-Z]{2,4})?(-[A-Z][a-z]{3})?$", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + private static readonly ConcurrentHashSet LibraryExclusions = LoadEmbeddedExclusions(); + + private enum TargetMethodType + { + Tr, + TrDefault, + TryGetString, + BindingCreate, + Indexer + } + + public override ImmutableArray SupportedDiagnostics => [Rule]; + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + context.RegisterCompilationStartAction(compilationContext => + { + AnalyzerConfigOptions globalOptions = compilationContext.Options.AnalyzerConfigOptionsProvider.GlobalOptions; + + if (globalOptions.TryGetValue("build_property.PleasantUIDisableResxAnalyzer", out string? msbuildValue) && + string.Equals(msbuildValue.Trim(), "true", StringComparison.OrdinalIgnoreCase)) + return; + + Compilation compilation = compilationContext.Compilation; + + List resxFiles = compilationContext.Options.AdditionalFiles + .Where(f => Path.GetExtension(f.Path).Equals(".resx", StringComparison.OrdinalIgnoreCase)) + .Where(f => !IsLocalizationCultureFile(f.Path)) + .ToList(); + + List axamlFiles = compilationContext.Options.AdditionalFiles + .Where(f => Path.GetExtension(f.Path).Equals(".axaml", StringComparison.OrdinalIgnoreCase)) + .ToList(); + + if (!resxFiles.Any()) return; + + ConcurrentDictionary + trackedKeys = new(); + ConcurrentHashSet normalizedTrackedKeys = new(); + ConcurrentHashSet usedNormalizedKeys = new(); + + foreach (AdditionalText? file in resxFiles) + { + SourceText? sourceText = file.GetText(compilationContext.CancellationToken); + if (sourceText == null) continue; + + try + { + XDocument doc = XDocument.Parse(sourceText.ToString(), LoadOptions.SetLineInfo); + IEnumerable? dataElements = doc.Root?.Elements("data"); + if (dataElements == null) continue; + + foreach (XElement element in dataElements) + { + string? keyName = element.Attribute("name")?.Value; + if (string.IsNullOrEmpty(keyName)) continue; + + if (ShouldIgnoreKey(element)) continue; + + IXmlLineInfo lineInfo = element; + int line = lineInfo.HasLineInfo() ? lineInfo.LineNumber : 1; + + if (trackedKeys.TryAdd(keyName ?? string.Empty, (Path.GetFileName(file.Path), file, line))) + normalizedTrackedKeys.Add(NormalizeKey(keyName)); + } + } + catch + { + // ignored + } + } + + if (!trackedKeys.Any()) return; + + foreach (AdditionalText axamlText in axamlFiles) + { + AnalyzeAxamlFile(axamlText, normalizedTrackedKeys, usedNormalizedKeys, + compilationContext.CancellationToken); + } + + foreach (string exclusion in LibraryExclusions.ToEnumerable()) + { + if (normalizedTrackedKeys.Contains(exclusion)) + usedNormalizedKeys.Add(exclusion); + } + + compilationContext.RegisterSemanticModelAction(semanticContext => + { + AnalyzeLocalizerCalls( + semanticContext.SemanticModel, + compilation, + normalizedTrackedKeys, + usedNormalizedKeys, + semanticContext.CancellationToken); + }); + + compilationContext.RegisterCompilationEndAction(endContext => + { + foreach (var keyPair in trackedKeys) + { + string originalKey = keyPair.Key; + string normalizedKey = NormalizeKey(originalKey); + + if (usedNormalizedKeys.Contains(normalizedKey)) + continue; + + (string? fileName, AdditionalText? additionalText, int lineNumber) = keyPair.Value; + SourceText? sourceText = additionalText.GetText(endContext.CancellationToken); + + Location location; + if (sourceText != null) + { + TextSpan? textSpan = FindKeySpan(sourceText, originalKey, lineNumber); + location = textSpan.HasValue + ? Location.Create(additionalText.Path, textSpan.Value, + sourceText.Lines.GetLinePositionSpan(textSpan.Value)) + : Location.Create(additionalText.Path, new TextSpan(0, 0), + new LinePositionSpan(new LinePosition(Math.Max(0, lineNumber - 1), 0), + new LinePosition(Math.Max(0, lineNumber - 1), 0))); + } + else + { + location = Location.Create(additionalText.Path, new TextSpan(0, 0), + new LinePositionSpan(new LinePosition(Math.Max(0, lineNumber - 1), 0), + new LinePosition(Math.Max(0, lineNumber - 1), 0))); + } + + Diagnostic diagnostic = Diagnostic.Create(Rule, location, originalKey, fileName); + endContext.ReportDiagnostic(diagnostic); + } + }); + }); + } + + private static void AnalyzeLocalizerCalls( + SemanticModel model, + Compilation compilation, + ConcurrentHashSet normalizedTrackedKeys, + ConcurrentHashSet usedNormalizedKeys, + CancellationToken cancellationToken) + { + SyntaxNode root = model.SyntaxTree.GetRoot(cancellationToken); + + foreach (InvocationExpressionSyntax invocation in root.DescendantNodes().OfType()) + { + if (invocation.ArgumentList.Arguments.Count == 0) continue; + + ISymbol? symbol = model.GetSymbolInfo(invocation, cancellationToken).Symbol + ?? model.GetSymbolInfo(invocation.Expression, cancellationToken).Symbol; + + if (symbol is not IMethodSymbol methodSymbol || !IsLocalizationMethod(methodSymbol, out TargetMethodType methodType)) + continue; + + SeparatedSyntaxList args = invocation.ArgumentList.Arguments; + ProcessKeyExpression(args[0].Expression, args, methodType, model, compilation, normalizedTrackedKeys, + usedNormalizedKeys, cancellationToken); + } + + foreach (ElementAccessExpressionSyntax elementAccess in root.DescendantNodes() + .OfType()) + { + if (elementAccess.ArgumentList.Arguments.Count == 0) continue; + + ISymbol? symbol = model.GetSymbolInfo(elementAccess, cancellationToken).Symbol; + + if (symbol is not IPropertySymbol { IsIndexer: true } propertySymbol || propertySymbol.ContainingType?.Name != "Localizer") + continue; + + SeparatedSyntaxList args = elementAccess.ArgumentList.Arguments; + ProcessKeyExpression(args[0].Expression, args, TargetMethodType.Indexer, model, compilation, + normalizedTrackedKeys, usedNormalizedKeys, cancellationToken); + } + + foreach (var creation in root.DescendantNodes().OfType()) + { + if (creation.ArgumentList == null || creation.ArgumentList.Arguments.Count == 0) continue; + + SymbolInfo symbolInfo = model.GetSymbolInfo(creation, cancellationToken); + INamedTypeSymbol? targetType = null; + + if (symbolInfo.Symbol is IMethodSymbol ctorSymbol) + targetType = ctorSymbol.ContainingType; + else if (creation is ImplicitObjectCreationExpressionSyntax) + targetType = model.GetTypeInfo(creation, cancellationToken).Type as INamedTypeSymbol; + + if (targetType == null) continue; + + for (int i = 0; i < creation.ArgumentList.Arguments.Count; i++) + { + if (!TryTraceConstructorParameterToProperty(targetType, i)) + continue; + + ExpressionSyntax argExpr = creation.ArgumentList.Arguments[i].Expression; + string? incKey = IsLocKeyMember(argExpr, model, cancellationToken, out string? enumName) + ? enumName + : TryGetStringArgument(model, argExpr); + + if (!string.IsNullOrEmpty(incKey)) + MarkAsUsed(incKey, normalizedTrackedKeys, usedNormalizedKeys); + } + } + } + + private static void ProcessKeyExpression( + ExpressionSyntax keyExpression, + SeparatedSyntaxList originalArgs, + TargetMethodType methodType, + SemanticModel model, + Compilation compilation, + ConcurrentHashSet normalizedTrackedKeys, + ConcurrentHashSet usedNormalizedKeys, + CancellationToken cancellationToken) + { + if (IsLocKeyMember(keyExpression, model, cancellationToken, out string? directKey)) + { + MarkAsUsed(directKey, normalizedTrackedKeys, usedNormalizedKeys); + return; + } + + string? key = TryGetStringArgument(model, keyExpression); + + if (string.IsNullOrEmpty(key) && keyExpression is IdentifierNameSyntax or MemberAccessExpressionSyntax) + { + ISymbol? symbol = model.GetSymbolInfo(keyExpression, cancellationToken).Symbol; + if (symbol is IPropertySymbol || symbol is IFieldSymbol) + { + INamedTypeSymbol? containingType = symbol.ContainingType; + if (containingType != null) + { + if (TryTracePropertyToConstructorParameter(symbol, containingType, out int paramIndex)) + ProcessConstructorArguments(containingType, paramIndex, originalArgs, methodType, model, + normalizedTrackedKeys, usedNormalizedKeys, cancellationToken); + + ProcessInheritedAndLocalProperties(symbol, containingType, originalArgs, methodType, model, + compilation, normalizedTrackedKeys, usedNormalizedKeys, cancellationToken); + + return; + } + } + } + + if (string.IsNullOrEmpty(key) && keyExpression is IdentifierNameSyntax identifier) + { + ISymbol? localSymbol = model.GetSymbolInfo(identifier, cancellationToken).Symbol; + if (localSymbol is IParameterSymbol { ContainingSymbol: IMethodSymbol containingMethod } parameterSymbol) + { + int paramIndex = parameterSymbol.Ordinal; + + if (containingMethod.MethodKind == MethodKind.Constructor) + { + ProcessConstructorArguments(containingMethod.ContainingType, paramIndex, originalArgs, methodType, + model, normalizedTrackedKeys, usedNormalizedKeys, cancellationToken); + return; + } + + ProcessMethodWrapperArguments(containingMethod, paramIndex, originalArgs, methodType, model, + compilation, normalizedTrackedKeys, usedNormalizedKeys, cancellationToken); + + return; + } + } + + if (string.IsNullOrEmpty(key)) + return; + + string? context = ExtractContext(originalArgs, methodType, model); + MarkAsUsed(context != null ? $"{context}/{key}" : key, normalizedTrackedKeys, usedNormalizedKeys); + } + + private static void ProcessMethodWrapperArguments( + IMethodSymbol wrapperMethod, + int parameterIndex, + SeparatedSyntaxList originalArgs, + TargetMethodType methodType, + SemanticModel model, + Compilation compilation, + ConcurrentHashSet normalizedTrackedKeys, + ConcurrentHashSet usedNormalizedKeys, + CancellationToken cancellationToken) + { + SyntaxNode root = model.SyntaxTree.GetRoot(cancellationToken); + + foreach (InvocationExpressionSyntax? invocation in root.DescendantNodes().OfType()) + { + if (invocation.ArgumentList.Arguments.Count <= parameterIndex) continue; + + ISymbol? symbol = model.GetSymbolInfo(invocation, cancellationToken).Symbol; + + if (!SymbolEqualityComparer.Default.Equals(symbol, wrapperMethod)) + continue; + + ExpressionSyntax incomingArgExpr = invocation.ArgumentList.Arguments[parameterIndex].Expression; + + ProcessKeyExpression(incomingArgExpr, originalArgs, methodType, model, compilation, + normalizedTrackedKeys, usedNormalizedKeys, cancellationToken); + } + } + + private static void ProcessConstructorArguments( + INamedTypeSymbol targetClassType, + int parameterIndex, + SeparatedSyntaxList originalArgs, + TargetMethodType methodType, + SemanticModel model, + ConcurrentHashSet normalizedTrackedKeys, + ConcurrentHashSet usedNormalizedKeys, + CancellationToken cancellationToken) + { + string targetTypeName = targetClassType.Name; + SyntaxNode root = model.SyntaxTree.GetRoot(cancellationToken); + + foreach (BaseObjectCreationExpressionSyntax? creation in root.DescendantNodes().OfType()) + { + if (creation.ArgumentList == null || creation.ArgumentList.Arguments.Count <= parameterIndex) continue; + + bool isTargetCtor = false; + SymbolInfo symbolInfo = model.GetSymbolInfo(creation, cancellationToken); + + if (symbolInfo.Symbol is IMethodSymbol ctorSymbol) + isTargetCtor = SymbolEqualityComparer.Default.Equals(ctorSymbol.ContainingType, targetClassType); + else if (creation is ImplicitObjectCreationExpressionSyntax) + { + TypeInfo typeInfo = model.GetTypeInfo(creation, cancellationToken); + isTargetCtor = typeInfo.Type?.Name == targetTypeName; + } + + if (!isTargetCtor) + continue; + + ExpressionSyntax argExpr = creation.ArgumentList.Arguments[parameterIndex].Expression; + string? incKey = IsLocKeyMember(argExpr, model, cancellationToken, out string? enumName) + ? enumName + : TryGetStringArgument(model, argExpr); + + if (string.IsNullOrEmpty(incKey)) + continue; + + string? context = ExtractContext(originalArgs, methodType, model); + MarkAsUsed(context != null ? $"{context}/{incKey}" : incKey, normalizedTrackedKeys, + usedNormalizedKeys); + } + } + + private static void ProcessInheritedAndLocalProperties( + ISymbol baseMember, + INamedTypeSymbol baseType, + SeparatedSyntaxList originalArgs, + TargetMethodType methodType, + SemanticModel baseModel, + Compilation compilation, + ConcurrentHashSet normalizedTrackedKeys, + ConcurrentHashSet usedNormalizedKeys, + CancellationToken cancellationToken) + { + IEnumerable allTypes = GetAllTypes(compilation.GlobalNamespace) + .Where(t => SymbolEqualityComparer.Default.Equals(t, baseType) || IsSubclassOf(t, baseType)); + + foreach (INamedTypeSymbol? typeSymbol in allTypes) + { + ISymbol? member = typeSymbol.GetMembers().FirstOrDefault(m => m.Name == baseMember.Name); + if (member == null) continue; + + foreach (SyntaxReference? reference in member.DeclaringSyntaxReferences) + { + SyntaxNode syntax = reference.GetSyntax(cancellationToken); + string? val = null; + + switch (syntax) + { + case PropertyDeclarationSyntax { Initializer: not null } prop: + val = TryGetStringFromExpression(prop.Initializer.Value, baseModel, + cancellationToken); + break; + case PropertyDeclarationSyntax prop: + { + if (prop.ExpressionBody != null) + val = TryGetStringFromExpression(prop.ExpressionBody.Expression, baseModel, + cancellationToken); + break; + } + case VariableDeclaratorSyntax { Initializer: not null } field: + val = TryGetStringFromExpression(field.Initializer.Value, baseModel, + cancellationToken); + break; + } + + if (string.IsNullOrEmpty(val)) + continue; + + string? context = ExtractContext(originalArgs, methodType, baseModel); + MarkAsUsed(context != null ? $"{context}/{val}" : val, normalizedTrackedKeys, usedNormalizedKeys); + } + } + } + + private static bool TryTraceConstructorParameterToProperty( + INamedTypeSymbol containingType, + int parameterIndex) + { + foreach (IMethodSymbol? ctor in containingType.Constructors) + { + if (ctor.Parameters.Length <= parameterIndex) continue; + IParameterSymbol targetParam = ctor.Parameters[parameterIndex]; + + foreach (SyntaxNode syntax in ctor.DeclaringSyntaxReferences.Select(reference => reference.GetSyntax())) + { + if (syntax is not ConstructorDeclarationSyntax ctorDecl || ctorDecl.Body == null) + continue; + + foreach (AssignmentExpressionSyntax? assignment in ctorDecl.Body.DescendantNodes().OfType()) + { + if (assignment.Right is not IdentifierNameSyntax rightId || rightId.Identifier.ValueText != targetParam.Name) + continue; + + string memberName = assignment.Left switch + { + IdentifierNameSyntax leftId => leftId.Identifier.ValueText, + MemberAccessExpressionSyntax { Expression: ThisExpressionSyntax } ma => ma.Name + .Identifier.ValueText, + _ => string.Empty + }; + + if (string.IsNullOrEmpty(memberName)) + continue; + + containingType.GetMembers().FirstOrDefault(m => m.Name == memberName); + return true; + } + } + } + + return false; + } + + private static bool TryTracePropertyToConstructorParameter( + ISymbol propertyOrFieldSymbol, + INamedTypeSymbol containingType, + out int parameterIndex) + { + parameterIndex = -1; + + foreach (IMethodSymbol? ctor in containingType.Constructors) + { + foreach (SyntaxNode syntax in ctor.DeclaringSyntaxReferences.Select(reference => reference.GetSyntax())) + { + if (syntax is not ConstructorDeclarationSyntax ctorDecl || ctorDecl.Body == null) + continue; + + foreach (AssignmentExpressionSyntax? assignment in ctorDecl.Body.DescendantNodes().OfType()) + { + if ((assignment.Left is not IdentifierNameSyntax leftId || + leftId.Identifier.ValueText != propertyOrFieldSymbol.Name) && + (assignment.Left is not MemberAccessExpressionSyntax { Expression: ThisExpressionSyntax } ma || + ma.Name.Identifier.ValueText != propertyOrFieldSymbol.Name)) + continue; + + if (assignment.Right is not IdentifierNameSyntax rightId) continue; + + IParameterSymbol? param = ctor.Parameters.FirstOrDefault(p => p.Name == rightId.Identifier.ValueText); + + if (param == null) continue; + + parameterIndex = param.Ordinal; + return true; + } + } + } + + return false; + } + + private static void MarkAsUsed(string? rawKey, ConcurrentHashSet normalizedTrackedKeys, ConcurrentHashSet usedNormalizedKeys) + { + if (string.IsNullOrEmpty(rawKey)) return; + string normalized = NormalizeKey(rawKey); + + if (normalizedTrackedKeys.Contains(normalized)) + usedNormalizedKeys.Add(normalized); + } + + private static string? ExtractContext(SeparatedSyntaxList args, TargetMethodType methodType, + SemanticModel model) + { + return methodType switch + { + TargetMethodType.BindingCreate => args.Count > 1 ? TryGetStringArgument(model, args[1].Expression) : null, + TargetMethodType.Tr => args.Count > 1 ? TryGetStringArgument(model, args[1].Expression) : null, + TargetMethodType.TrDefault => args.Count > 2 ? TryGetStringArgument(model, args[2].Expression) : null, + _ => null + }; + } + + private static bool IsLocalizationMethod(IMethodSymbol method, out TargetMethodType methodType) + { + methodType = default; + INamedTypeSymbol? containingType = method.ContainingType; + if (containingType == null) return false; + + switch (containingType.Name) + { + case "LocalizeBinding" when method.Name == "Create": + methodType = TargetMethodType.BindingCreate; + return true; + case "Localizer" when method.Name == "Tr": + methodType = TargetMethodType.Tr; + return true; + case "Localizer" when method.Name == "TrDefault": + methodType = TargetMethodType.TrDefault; + return true; + case "Localizer" when method.Name == "TryGetString": + methodType = TargetMethodType.TryGetString; + return true; + default: + return false; + } + } + + private static bool IsLocKeyMember(ExpressionSyntax expr, SemanticModel model, CancellationToken token, + out string? keyName) + { + keyName = null; + + ISymbol? symbol = expr switch + { + MemberAccessExpressionSyntax memberAccess => model.GetSymbolInfo(memberAccess, token).Symbol, + IdentifierNameSyntax identifier => model.GetSymbolInfo(identifier, token).Symbol, + _ => null + }; + + if (symbol == null || (symbol is not IFieldSymbol && symbol is not IPropertySymbol)) + return false; + + if (symbol.ContainingType?.Name != "LocKey") + return false; + + keyName = symbol.Name; + return true; + + } + + private static string? TryGetStringArgument(SemanticModel model, ExpressionSyntax expression) + { + if (expression is LiteralExpressionSyntax literal) return literal.Token.ValueText; + Optional constant = model.GetConstantValue(expression); + return constant.HasValue ? constant.Value?.ToString() : null; + } + + private static string? TryGetStringFromExpression(ExpressionSyntax expr, SemanticModel model, CancellationToken token) + { + if (expr is LiteralExpressionSyntax literal) return literal.Token.ValueText; + + if (IsLocKeyMember(expr, model, token, out string? name)) return name; + + Optional constant = model.GetConstantValue(expr, token); + return constant.HasValue ? constant.Value?.ToString() : null; + } + + private static TextSpan? FindKeySpan(SourceText sourceText, string keyName, int approximateLine) + { + int startLine = Math.Max(0, approximateLine - 3); + int endLine = Math.Min(sourceText.Lines.Count - 1, approximateLine + 3); + string searchPattern = $"name=\"{keyName}\""; + + for (int i = startLine; i <= endLine; i++) + { + TextLine line = sourceText.Lines[i]; + string lineText = line.ToString(); + int index = lineText.IndexOf(searchPattern, StringComparison.OrdinalIgnoreCase); + if (index >= 0) + return new TextSpan(line.Start + index + "name=\"".Length, keyName.Length); + } + + return null; + } + + private static string NormalizeKey(string? key) => + string.IsNullOrEmpty(key) ? string.Empty : key?.Replace("__", "/").Trim() ?? string.Empty; + + private static bool IsSubclassOf(INamedTypeSymbol type, INamedTypeSymbol baseType) => type.BaseType != null && + (SymbolEqualityComparer.Default.Equals(type.BaseType, baseType) || IsSubclassOf(type.BaseType, baseType)); + + private static IEnumerable GetAllTypes(INamespaceSymbol ns) => + ns.GetTypeMembers().Concat(ns.GetNamespaceMembers().SelectMany(GetAllTypes)); + + private static bool IsLocalizationCultureFile(string filePath) => + CultureSuffixRegex.IsMatch(Path.GetFileNameWithoutExtension(filePath)); + + private static bool ShouldIgnoreKey(XElement dataElement) => dataElement.Element("comment")?.Value.Trim() + .Contains("PLUI-Unused", StringComparison.OrdinalIgnoreCase) == true; + + private static ConcurrentHashSet LoadEmbeddedExclusions() + { + ConcurrentHashSet exclusions = new(); + try + { + Assembly assembly = typeof(ResxUsageAnalyzer).Assembly; + string? resourceName = assembly.GetManifestResourceNames() + .FirstOrDefault(n => n.EndsWith("LocExclusions.txt", StringComparison.OrdinalIgnoreCase)); + + if (string.IsNullOrEmpty(resourceName)) + return exclusions; + + using Stream? stream = assembly.GetManifestResourceStream(resourceName); + + if (stream == null) + return exclusions; + + using StreamReader reader = new(stream); + while (reader.ReadLine() is { } line) + if (!string.IsNullOrWhiteSpace(line)) exclusions.Add(line.Trim()); + } + catch + { + // ignored + } + + return exclusions; + } + + private static void AnalyzeAxamlFile(AdditionalText axamlFile, ConcurrentHashSet normalizedTrackedKeys, + ConcurrentHashSet usedNormalizedKeys, CancellationToken cancellationToken) + { + SourceText? sourceText = axamlFile.GetText(cancellationToken); + if (sourceText == null) return; + + try + { + XDocument doc = XDocument.Parse(sourceText.ToString()); + + if (doc.Root == null) + return; + + foreach (XElement element in doc.DescendantNodes().OfType()) + { + if (cancellationToken.IsCancellationRequested) + return; + + foreach (XAttribute attribute in element.Attributes()) + { + string value = attribute.Value.Trim(); + + if (value.StartsWith("{", StringComparison.Ordinal) && value.Contains("Localize ")) + ParseXamlMarkupExtension(value, normalizedTrackedKeys, usedNormalizedKeys); + } + } + } + catch + { + // ignored + } + } + + private static void ParseXamlMarkupExtension( + string extensionValue, + ConcurrentHashSet normalizedTrackedKeys, + ConcurrentHashSet usedNormalizedKeys) + { + string content = extensionValue.Trim('{', '}').Trim(); + + int firstSpace = content.IndexOf(' '); + if (firstSpace == -1) return; + + string argumentsPart = content.Substring(firstSpace + 1).Trim(); + + List arguments = SplitXamlArguments(argumentsPart); + if (arguments.Count == 0) return; + + string? rawKey = null; + string? context = null; + + string firstArg = arguments[0].Trim(); + + if (firstArg.StartsWith("{", StringComparison.Ordinal) && firstArg.Contains("LocKey.")) + { + int lastDot = firstArg.LastIndexOf('.'); + if (lastDot != -1) + rawKey = firstArg.Substring(lastDot + 1).Trim('}', ' ', '\t', '\r', '\n'); + } + else + rawKey = firstArg.Trim('\'', '"'); + + for (int i = 1; i < arguments.Count; i++) + { + string arg = arguments[i].Trim(); + + if (!arg.StartsWith("Context", StringComparison.OrdinalIgnoreCase)) + continue; + + int eqIndex = arg.IndexOf('='); + if (eqIndex != -1) + context = arg.Substring(eqIndex + 1).Trim('\'', '"', ' '); + } + + if (string.IsNullOrWhiteSpace(rawKey)) return; + + string? combinedKey = !string.IsNullOrEmpty(context) ? $"{context}/{rawKey}" : rawKey; + string normalized = NormalizeKey(combinedKey); + + if (normalizedTrackedKeys.Contains(normalized)) + usedNormalizedKeys.Add(normalized); + } + + private static List SplitXamlArguments(string argumentsPart) + { + List results = []; + int braceCount = 0; + int startIdx = 0; + + for (int i = 0; i < argumentsPart.Length; i++) + { + char c = argumentsPart[i]; + if (c == '{') braceCount++; + else if (c == '}') braceCount--; + else if (c == ',' && braceCount == 0) + { + results.Add(argumentsPart.Substring(startIdx, i - startIdx)); + startIdx = i + 1; + } + } + + if (startIdx < argumentsPart.Length) + results.Add(argumentsPart.Substring(startIdx)); + + return results; + } + + private class ConcurrentHashSet + { + private readonly ConcurrentDictionary _dictionary = new(); + public void Add(T item) => _dictionary.TryAdd(item, 0); + public bool Contains(T item) => _dictionary.ContainsKey(item); + public IEnumerable ToEnumerable() => _dictionary.Keys; + } +} \ No newline at end of file diff --git a/build/Package.props b/build/Package.props index 8b02683c..afb07122 100644 --- a/build/Package.props +++ b/build/Package.props @@ -1,17 +1,17 @@  - net9.0 + net10.0 enable enable default - 5.2.2 + 5.3.0 Dmitry Zhutkov (Onebeld), Andrey Savich (pieckenst) Dmitry Zhutkov (Onebeld) theme, design, xaml, library, ui, gui, control, csharp, styled-components, interface, dotnet, nuget, style, avalonia, controls, user-interface, styles, avaloniaui, pleasant, graphical-user-interface PleasantUI is a cross-platform UI theme and control library for Avalonia, inspired by Microsoft Fluent Design and the WinUI/UWP visual language. It provides complete re-styled themes for all standard Avalonia controls — buttons, checkboxes, sliders, text boxes, data grids, calendars, combo boxes, and more — along with a suite of custom Pleasant controls including NavigationView, PleasantTabView, ProgressRing, OptionsDisplayItem, PleasantSnackbar, InformationBlock, ContentDialog, and a full theme editor with custom theme support. Supports light, dark, and multiple accent color themes out of the box, with a reactive localization system and desktop/mobile adaptive layouts. Onebeld - 5.2.2.0 - 5.2.2.0 + 5.3.0.0 + 5.3.0.0 $(PackageVersion) true https://github.com/Onebeld/PleasantUI @@ -27,10 +27,10 @@ https://github.com/Onebeld/PleasantUI - - + + - + diff --git a/build/PleasantUI.Localization.Analyzer.targets b/build/PleasantUI.Localization.Analyzer.targets new file mode 100644 index 00000000..b45696a3 --- /dev/null +++ b/build/PleasantUI.Localization.Analyzer.targets @@ -0,0 +1,8 @@ + + + false + + + + + \ No newline at end of file diff --git a/build/PleasantUI.Localization.Generator.targets b/build/PleasantUI.Localization.Generator.targets new file mode 100644 index 00000000..9628f130 --- /dev/null +++ b/build/PleasantUI.Localization.Generator.targets @@ -0,0 +1,8 @@ + + + false + + + + + \ No newline at end of file diff --git a/build/PleasantUI.targets b/build/PleasantUI.targets new file mode 100644 index 00000000..80ef73f2 --- /dev/null +++ b/build/PleasantUI.targets @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/designer/PleasantUI.Designer/App.axaml b/designer/PleasantUI.Designer/App.axaml deleted file mode 100644 index 84902959..00000000 --- a/designer/PleasantUI.Designer/App.axaml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - \ No newline at end of file diff --git a/designer/PleasantUI.Designer/App.axaml.cs b/designer/PleasantUI.Designer/App.axaml.cs deleted file mode 100644 index 12a545e4..00000000 --- a/designer/PleasantUI.Designer/App.axaml.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Avalonia; -using Avalonia.Controls.ApplicationLifetimes; -using Avalonia.Markup.Xaml; -using PleasantUI.Controls; - -namespace PleasantUI.Designer; - -public partial class App : Application -{ - public override void Initialize() => AvaloniaXamlLoader.Load(this); - - public override void OnFrameworkInitializationCompleted() - { - if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) - desktop.MainWindow = new PleasantWindow(); - - base.OnFrameworkInitializationCompleted(); - } -} \ No newline at end of file diff --git a/designer/PleasantUI.Designer/PleasantUI.Designer.csproj b/designer/PleasantUI.Designer/PleasantUI.Designer.csproj deleted file mode 100644 index fba65afc..00000000 --- a/designer/PleasantUI.Designer/PleasantUI.Designer.csproj +++ /dev/null @@ -1,28 +0,0 @@ - - - WinExe - net9.0 - enable - true - app.manifest - true - Full - - - - embedded - true - - - - - - - - - - - - - - diff --git a/designer/PleasantUI.Designer/Program.cs b/designer/PleasantUI.Designer/Program.cs deleted file mode 100644 index a4df59c9..00000000 --- a/designer/PleasantUI.Designer/Program.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Avalonia; -using System; - -namespace PleasantUI.Designer; - -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. - [STAThread] - 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() - .UsePlatformDetect() - .LogToTrace(); -} \ No newline at end of file diff --git a/designer/PleasantUI.Designer/app.manifest b/designer/PleasantUI.Designer/app.manifest deleted file mode 100644 index 4a26d06e..00000000 --- a/designer/PleasantUI.Designer/app.manifest +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - diff --git a/generators/PleasantUI.Localization.Generator/AnalyzerReleases.Shipped.md b/generators/PleasantUI.Localization.Generator/AnalyzerReleases.Shipped.md new file mode 100644 index 00000000..60b59dd9 --- /dev/null +++ b/generators/PleasantUI.Localization.Generator/AnalyzerReleases.Shipped.md @@ -0,0 +1,3 @@ +; Shipped analyzer releases +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + diff --git a/generators/PleasantUI.Localization.Generator/AnalyzerReleases.Unshipped.md b/generators/PleasantUI.Localization.Generator/AnalyzerReleases.Unshipped.md new file mode 100644 index 00000000..60e10160 --- /dev/null +++ b/generators/PleasantUI.Localization.Generator/AnalyzerReleases.Unshipped.md @@ -0,0 +1,10 @@ +; Unshipped analyzer release +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + +### New Rules + +| Rule ID | Category | Severity | Notes | +|-----------|--------------|----------|---------------------------| +| PUILOC001 | Localization | Info | ResxLocalizationGenerator | +| PUILOC002 | Localization | Warning | ResxLocalizationGenerator | +| PUILOC003 | Localization | Error | ResxLocalizationGenerator | \ No newline at end of file diff --git a/generators/PleasantUI.MaterialIcons.SourceGenerator/PleasantUI.MaterialIcons.SourceGenerator.csproj b/generators/PleasantUI.Localization.Generator/PleasantUI.Localization.Generator.csproj similarity index 66% rename from generators/PleasantUI.MaterialIcons.SourceGenerator/PleasantUI.MaterialIcons.SourceGenerator.csproj rename to generators/PleasantUI.Localization.Generator/PleasantUI.Localization.Generator.csproj index 3568ce1c..a6568c68 100644 --- a/generators/PleasantUI.MaterialIcons.SourceGenerator/PleasantUI.MaterialIcons.SourceGenerator.csproj +++ b/generators/PleasantUI.Localization.Generator/PleasantUI.Localization.Generator.csproj @@ -1,4 +1,5 @@ + netstandard2.0 false @@ -8,18 +9,20 @@ true true - PleasantUI.MaterialIcons.SourceGenerator - PleasantUI.MaterialIcons.SourceGenerator + PleasantUI.Localization.Generator + PleasantUI.Localization.Generator - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + - + + + diff --git a/generators/PleasantUI.MaterialIcons.SourceGenerator/Properties/launchSettings.json b/generators/PleasantUI.Localization.Generator/Properties/launchSettings.json similarity index 64% rename from generators/PleasantUI.MaterialIcons.SourceGenerator/Properties/launchSettings.json rename to generators/PleasantUI.Localization.Generator/Properties/launchSettings.json index ef93413a..a1c8b40d 100644 --- a/generators/PleasantUI.MaterialIcons.SourceGenerator/Properties/launchSettings.json +++ b/generators/PleasantUI.Localization.Generator/Properties/launchSettings.json @@ -1,9 +1,9 @@ -{ +{ "$schema": "https://json.schemastore.org/launchsettings.json", "profiles": { "DebugRoslynSourceGenerator": { "commandName": "DebugRoslynComponent", - "targetProject": "../../src/PleasantUI.MaterialIcons/PleasantUI.MaterialIcons.csproj" + "targetProject": "../../samples/PleasantUI.Example/PleasantUI.Example.csproj" } } } \ No newline at end of file diff --git a/generators/PleasantUI.Localization.Generator/ResxLocalizationGenerator.cs b/generators/PleasantUI.Localization.Generator/ResxLocalizationGenerator.cs new file mode 100644 index 00000000..a6800a09 --- /dev/null +++ b/generators/PleasantUI.Localization.Generator/ResxLocalizationGenerator.cs @@ -0,0 +1,196 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Xml.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace PleasantUI.Localization.Generator; + +[Generator] +public class ResxLocalizationGenerator : IIncrementalGenerator +{ + private static readonly DiagnosticDescriptor FoundKeysDescriptor = new( + "PUILOC001", + "Localization keys found", + "Source Generator found {0} unique localization keys from {1} .resx files", + "Localization", + DiagnosticSeverity.Info, + true + ); + + private static readonly DiagnosticDescriptor NoResxFilesDescriptor = new( + "PUILOC002", + "No .resx files found", + "No .resx files were found in the project. Source Generator did not generate LocKey enum.", + "Localization", + DiagnosticSeverity.Warning, + true + ); + + private static readonly DiagnosticDescriptor ParsingErrorDescriptor = new( + "PUILOC003", + "Resx Parsing Error", + "Failed to parse .resx file '{0}': {1}", + "Localization", + DiagnosticSeverity.Error, + true + ); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + IncrementalValueProvider customSettingProvider = context.AnalyzerConfigOptionsProvider + .Select((options, _) => + options.GlobalOptions.TryGetValue("build_property.PleasantUIDisableResxKeyGenerator", out string? value) ? value : null); + + IncrementalValuesProvider resxProvider = context.AdditionalTextsProvider + .Where(static f => f.Path.EndsWith(".resx", StringComparison.OrdinalIgnoreCase)); + + var resxWithContent = resxProvider.Select((file, _) => + { + try + { + string content = file.GetText()?.ToString() ?? ""; + List keys = ExtractKeys(content, file.Path); + + return (FilePath: file.Path, Keys: keys, Success: true, Error: null); + } + catch (Exception e) + { + return (FilePath: file.Path, Keys: new List(), Success: false, Error: e.Message); + } + }); + + var valueProvider = resxWithContent.Collect().Combine(customSettingProvider); + + context.RegisterSourceOutput(valueProvider, (spc, source) => + { + var files = source.Left; + string? settingValue = source.Right; + + if (string.Equals(settingValue, "true", StringComparison.OrdinalIgnoreCase)) + return; + + if (files.IsEmpty) + { + spc.ReportDiagnostic(Diagnostic.Create( + NoResxFilesDescriptor, + Location.None)); + return; + } + + HashSet allKeys = []; + int processedFiles = 0; + + foreach (var file in files) + { + if (!file.Success) + { + spc.ReportDiagnostic(Diagnostic.Create(ParsingErrorDescriptor, + Location.None, + file.FilePath, + file.Error ?? "Unknown error")); + + continue; + } + + if (file.Keys.Count <= 0) + continue; + + processedFiles++; + foreach (string key in file.Keys) + allKeys.Add(key); + } + + if (allKeys.Count == 0) + { + spc.ReportDiagnostic(Diagnostic.Create( + NoResxFilesDescriptor, + Location.None)); + + return; + } + + spc.ReportDiagnostic(Diagnostic.Create( + FoundKeysDescriptor, + Location.None, + allKeys.Count, + processedFiles)); + + List orderedKeys = allKeys.OrderBy(k => k).ToList(); + string sourceText = GenerateLockKeyEnum(orderedKeys, processedFiles); + + spc.AddSource("LocKey.g.cs", SourceText.From(sourceText, Encoding.UTF8)); + }); + } + + + private static string GenerateLockKeyEnum(List keys, int fileCount) + { + StringBuilder sb = new(); + + sb.AppendLine("// "); + sb.AppendLine("// Generated by PleasantUI.Localization.Generator"); + sb.AppendLine($"// {DateTime.Now:yyyy-MM-dd HH:mm:ss} from {fileCount} .resx file(s)"); + sb.AppendLine("// Do not modify this file manually."); + + sb.AppendLine("namespace PleasantUI.Core.Localization;"); + sb.AppendLine(""); + sb.AppendLine("/// "); + sb.AppendLine("/// Strongly-typed localization keys generated from .resx files."); + sb.AppendLine("/// "); + sb.AppendLine("public enum LocKey"); + sb.AppendLine("{"); + + foreach (string key in keys) + { + string safeName = SanitizeIdentifier(key); + sb.AppendLine($" {safeName}, // Original: {key}"); + } + + sb.AppendLine("}"); + + return sb.ToString(); + } + + private static List ExtractKeys(string content, string filePath) + { + List keys = []; + + try + { + XDocument doc = XDocument.Parse(content); + + foreach (XElement element in doc.Descendants("data")) + { + string? name = element.Attribute("name")?.Value; + if (!string.IsNullOrWhiteSpace(name)) + keys.Add(name!); + } + } + catch (Exception e) + { + throw new InvalidOperationException($"XML parsing failed for {filePath}", e); + } + + return keys; + } + + private static string SanitizeIdentifier(string name) + { + string sanitized = name + .Replace("/", "__") + .Replace("\\", "__") + .Replace(".", "_") + .Replace("-", "_"); + + sanitized = Regex.Replace(sanitized, "[^a-zA-Z0-9_]", "_"); + + if (char.IsDigit(sanitized[0])) + sanitized = "_" + sanitized; + + return sanitized; + } +} \ No newline at end of file diff --git a/generators/PleasantUI.MaterialIcons.SourceGenerator/MaterialIconsSourceGenerator.cs b/generators/PleasantUI.MaterialIcons.SourceGenerator/MaterialIconsSourceGenerator.cs deleted file mode 100644 index 156abb2e..00000000 --- a/generators/PleasantUI.MaterialIcons.SourceGenerator/MaterialIconsSourceGenerator.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System; -using System.IO; -using System.Net.Http; -using System.Text; -using Microsoft.CodeAnalysis; -using System.Text.Json.Nodes; -using System.Threading.Tasks; - -namespace PleasantUI.MaterialIcons.SourceGenerator; - -[Generator] -public class MaterialIconsSourceGenerator : IIncrementalGenerator -{ - const string MaterialIconsFetchApi = "https://dev.materialdesignicons.com/api/package/38EF63D0-4744-11E4-B3CF-842B2B6CFE1B"; - - private JsonNode? _json; - - public void Initialize(IncrementalGeneratorInitializationContext context) - { - using HttpClient httpClient = new(); - - Task task = Task.Run(() => httpClient.GetStreamAsync(MaterialIconsFetchApi)); - task.Wait(); - - Stream? dataStream = task.Result; - JsonNode? json = JsonNode.Parse(dataStream); - - _json = json ?? throw new Exception("Unable to load JSON"); - - if (_json is null) - throw new NullReferenceException("JSON is null"); - - JsonArray icons = _json.Root["icons"]!.AsArray(); - - const string generated = """ - // - using Avalonia.Media; - - namespace PleasantUI; - - public static partial class MaterialIcons - { - """; - - StringBuilder stringBuilder = new(generated); - - foreach (JsonNode? icon in icons) - { - string name = icon!["name"]!.GetValue().Underscore().Pascalize(); - string data = icon["data"]!.GetValue(); - - stringBuilder.Append($"\tpublic static Geometry {name} = Geometry.Parse(\"{data}\");\n"); - } - - stringBuilder.Append("}\n"); - - context.RegisterPostInitializationOutput(ctx => ctx.AddSource("MaterialIcons.g.cs", stringBuilder.ToString())); - } -} \ No newline at end of file diff --git a/generators/PleasantUI.MaterialIcons.SourceGenerator/StringExtensions.cs b/generators/PleasantUI.MaterialIcons.SourceGenerator/StringExtensions.cs deleted file mode 100644 index d61bf56d..00000000 --- a/generators/PleasantUI.MaterialIcons.SourceGenerator/StringExtensions.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Text.RegularExpressions; - -namespace PleasantUI.MaterialIcons.SourceGenerator; - -public static class StringExtensions -{ - public static string Underscore(this string input) { - return Regex.Replace( - Regex.Replace( - Regex.Replace(input, @"([\p{Lu}]+)([\p{Lu}][\p{Ll}])", "$1_$2"), @"([\p{Ll}\d])([\p{Lu}])", "$1_$2"), @"[-\s]", "_").ToLower(); - } - - public static string Pascalize(this string input) - { - return Regex.Replace(input, "(?:^|_| +)(.)", match => match.Groups[1].Value.ToUpper()); - } -} \ No newline at end of file diff --git a/samples/PleasantUI.Example.Desktop/App.axaml b/samples/PleasantUI.Example.Desktop/App.axaml index 185878a6..0b6bcfa7 100644 --- a/samples/PleasantUI.Example.Desktop/App.axaml +++ b/samples/PleasantUI.Example.Desktop/App.axaml @@ -1,5 +1,8 @@ @@ -14,11 +17,26 @@ + + + + + + + + + + diff --git a/samples/PleasantUI.Example.Desktop/App.axaml.cs b/samples/PleasantUI.Example.Desktop/App.axaml.cs index 41a5070e..2a81383f 100644 --- a/samples/PleasantUI.Example.Desktop/App.axaml.cs +++ b/samples/PleasantUI.Example.Desktop/App.axaml.cs @@ -2,17 +2,14 @@ using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; -using Avalonia.Markup.Xaml.Styling; using Avalonia.Media; using PleasantUI.Controls; -using PleasantUI.Controls.Chrome; using PleasantUI.Core; namespace PleasantUI.Example.Desktop; -public partial class App : PleasantUiExampleApp +public class App : PleasantUiExampleApp { - private StyleInclude? _vguiExampleStyles; private PleasantTrayPopup? _trayPopup; public override void Initialize() @@ -39,21 +36,9 @@ public override void OnFrameworkInitializationCompleted() PleasantTheme = Styles[0] as PleasantTheme ?? throw new NullReferenceException("PleasantTheme is null"); InitializeFromSettings(); - - UpdateVguiExampleStyles(); - if (PleasantSettings.Current is not null) - PleasantSettings.Current.PropertyChanged += (_, e) => - { - if (e.PropertyName == nameof(PleasantSettings.Current.Theme)) - UpdateVguiExampleStyles(); - }; Main = new MainWindow { DataContext = ViewModel }; - // Apply compact titlebar now that Main exists (UpdateVguiExampleStyles ran before Main was created) - if (PleasantSettings.Current?.Theme == "VGUI" && Main is PleasantWindow winInit) - winInit.TitleBarType = PleasantTitleBar.Type.Compact; - TopLevel = TopLevel.GetTopLevel(Main as PleasantWindow) ?? throw new NullReferenceException("TopLevel is null"); @@ -69,7 +54,7 @@ public override void OnFrameworkInitializationCompleted() private void WireTrayIcon() { - var icons = TrayIcon.GetIcons(this); + TrayIcons? icons = TrayIcon.GetIcons(this); if (icons is null || icons.Count == 0) return; icons[0].Clicked += OnTrayIconClicked; @@ -89,15 +74,15 @@ private void OnTrayIconClicked(object? sender, EventArgs e) private PleasantTrayPopup BuildTrayPopup() { - var mainWindow = Main as Window; + Window? mainWindow = Main as Window; // Footer: show/hide main window + exit - var showHideBtn = new Button + Button showHideBtn = new() { Content = mainWindow?.IsVisible == true ? "Hide main window" : "Show main window", HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Stretch, HorizontalContentAlignment = Avalonia.Layout.HorizontalAlignment.Left, - Padding = new Avalonia.Thickness(8, 6), + Padding = new Thickness(8, 6), }; showHideBtn.Click += (_, _) => { @@ -107,12 +92,12 @@ private PleasantTrayPopup BuildTrayPopup() else { mainWindow.Show(); mainWindow.Activate(); } }; - var exitBtn = new Button + Button exitBtn = new() { Content = "Exit", HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Stretch, HorizontalContentAlignment = Avalonia.Layout.HorizontalAlignment.Left, - Padding = new Avalonia.Thickness(8, 6), + Padding = new Thickness(8, 6), Foreground = new SolidColorBrush(Color.Parse("#FF6B6B")), }; exitBtn.Click += (_, _) => @@ -122,19 +107,19 @@ private PleasantTrayPopup BuildTrayPopup() d.Shutdown(); }; - var footer = new StackPanel { Spacing = 2, Children = { showHideBtn, exitBtn } }; + StackPanel footer = new() { Spacing = 2, Children = { showHideBtn, exitBtn } }; // Content: version info - var content = new StackPanel + StackPanel content = new() { - Margin = new Avalonia.Thickness(14, 8), + Margin = new Thickness(14, 8), Spacing = 4, Children = { new TextBlock { Text = "PleasantUI — cross-platform UI library for Avalonia.", - TextWrapping = Avalonia.Media.TextWrapping.Wrap, + TextWrapping = TextWrapping.Wrap, FontSize = 12, Foreground = new SolidColorBrush(Color.Parse("#AAFFFFFF")), }, @@ -147,9 +132,9 @@ private PleasantTrayPopup BuildTrayPopup() } }; - ((IResourceHost)this).TryFindResource("PleasantUILogo", out var appIconResource); + this.TryFindResource("PleasantUILogo", out object? appIconResource); - var popup = new PleasantTrayPopup + PleasantTrayPopup popup = new() { Width = 280, AppTitle = "PleasantUI Example", @@ -163,37 +148,4 @@ private PleasantTrayPopup BuildTrayPopup() return popup; } - - // ── VGUI styles ─────────────────────────────────────────────────────────── - - private void UpdateVguiExampleStyles() - { - bool isVgui = PleasantSettings.Current?.Theme == "VGUI"; - if (isVgui) - { - if (_vguiExampleStyles is null) - { - _vguiExampleStyles = new StyleInclude(new Uri("avares://PleasantUI.Example/")) - { - Source = new Uri("avares://PleasantUI.Example/Styling/VGUIExampleStyles.axaml") - }; - Styles.Add(_vguiExampleStyles); - } - } - else - { - if (_vguiExampleStyles is not null) - { - Styles.Remove(_vguiExampleStyles); - _vguiExampleStyles = null; - } - } - - if (Main is PleasantWindow win) - { - win.TitleBarType = isVgui - ? PleasantTitleBar.Type.Compact - : PleasantTitleBar.Type.ClassicExtended; - } - } } \ No newline at end of file diff --git a/samples/PleasantUI.Example.Desktop/ExampleSplashScreen.cs b/samples/PleasantUI.Example.Desktop/ExampleSplashScreen.cs index 1b60bf00..160878c7 100644 --- a/samples/PleasantUI.Example.Desktop/ExampleSplashScreen.cs +++ b/samples/PleasantUI.Example.Desktop/ExampleSplashScreen.cs @@ -17,7 +17,7 @@ public class ExampleSplashScreen : IPleasantSplashScreen public IBrush? Background => null; // inherits window background public int MinimumShowTime => 2000; // show for at least 2 seconds - public object? SplashScreenContent => BuildContent(); + public object SplashScreenContent => BuildContent(); public async Task RunTasks(CancellationToken cancellationToken) { @@ -30,7 +30,7 @@ public async Task RunTasks(CancellationToken cancellationToken) private static object BuildContent() { // Logo image from app resources - var logo = new Image + Image logo = new() { Width = 96, Height = 96, @@ -44,7 +44,7 @@ private static object BuildContent() logo.Source = image; } - var appName = new TextBlock + TextBlock appName = new() { Text = "PleasantUI", FontSize = 28, @@ -53,7 +53,7 @@ private static object BuildContent() Margin = new Thickness(0, 16, 0, 0) }; - var subtitle = new TextBlock + TextBlock subtitle = new() { Text = "Example Application", FontSize = 14, @@ -62,7 +62,7 @@ private static object BuildContent() Opacity = 0.6 }; - var progress = new ProgressBar + ProgressBar progress = new() { IsIndeterminate = true, Width = 160, @@ -71,7 +71,7 @@ private static object BuildContent() HorizontalAlignment = HorizontalAlignment.Center }; - var panel = new StackPanel + StackPanel panel = new() { HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, diff --git a/samples/PleasantUI.Example.Desktop/PleasantUI.Example.Desktop.csproj b/samples/PleasantUI.Example.Desktop/PleasantUI.Example.Desktop.csproj index 8db2d6c6..c2c9f59e 100644 --- a/samples/PleasantUI.Example.Desktop/PleasantUI.Example.Desktop.csproj +++ b/samples/PleasantUI.Example.Desktop/PleasantUI.Example.Desktop.csproj @@ -1,7 +1,7 @@  Exe - net9.0 + net10.0 enable enable true @@ -19,13 +19,15 @@ - - - - - + + + + + + + - + diff --git a/samples/PleasantUI.Example/AppSettings.cs b/samples/PleasantUI.Example/AppSettings.cs new file mode 100644 index 00000000..a8361b97 --- /dev/null +++ b/samples/PleasantUI.Example/AppSettings.cs @@ -0,0 +1,22 @@ +using System.Runtime.Serialization; +using PleasantUI.Core; + +namespace PleasantUI.Example; + +public class AppSettings : ViewModelBase +{ + /// + /// Gets the singleton instance of the AppSettings class. + /// + public static AppSettings? Current { get; set; } + + /// + /// Gets or sets the current language code (e.g., "en", "ru"). + /// + [DataMember] + public string Language + { + get; + set => SetProperty(ref field, value); + } = "en"; +} \ No newline at end of file diff --git a/samples/PleasantUI.Example/Assets/Images.axaml b/samples/PleasantUI.Example/Assets/Images.axaml index 21763fa9..1a3de787 100644 --- a/samples/PleasantUI.Example/Assets/Images.axaml +++ b/samples/PleasantUI.Example/Assets/Images.axaml @@ -3,7 +3,15 @@ - + + + + + + + + @@ -15,20 +23,46 @@ - + - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + F1 M603.4,67.6z M0,0z M148.2,54.3L183.7,54.3 183.7,66.5 133.1,66.5 133.1,1 182.5,1 182.5,13.2 148.1,13.2 148.1,27.4 178.5,27.4 178.5,39.1 148.2,39.1 148.2,54.2z M318.2,33.4A31.3,31.3,0,0,0,310.8,30.2C308.1,29.3 305.4,28.6 302.7,28 300,27.4 297.5,26.8 295.3,26.1 293,25.5 291.3,24.7 289.8,23.7 288.5,22.7 287.8,21.3 287.8,19.6 287.8,18.2 288.2,16.9 289,15.8 289.8,14.8 291.2,13.8 293,13.2 294.9,12.5 297.3,12.2 300.2,12.2 303.1,12.2 306.2,12.6 309.2,13.4 312.2,14.2 315.2,15.5 318.3,17.3L323,5.8C320,3.8 316.4,2.4 312.4,1.5 308.4,0.6 304.4,0 300.4,0 294.1,0 289,1 285,2.8 280.9,4.6 277.8,7 275.8,10.1 273.8,13.2 272.8,16.6 272.8,20.4 272.8,23.8 273.5,26.7 274.8,28.9 276.3,31.2 278.1,32.9 280.3,34.4 282.6,35.7 285,36.8 287.8,37.7 290.5,38.6 293.2,39.3 295.8,39.9 298.6,40.5 301.1,41.1 303.3,41.8 305.5,42.5 307.3,43.3 308.7,44.3A5,5,0,0,1,310.7,48.4C310.7,49.8 310.3,51 309.5,52.1 308.5,53.1 307.2,54 305.4,54.6A36.7,36.7,0,0,1,286.5,53.6C282.7,52.3,279.4,50.6,276.5,48.6L271.5,60C274.4,62.3,278.2,64,283,65.5A50,50,0,0,0,313.5,64.8C317.5,62.9 320.6,60.5 322.7,57.4 324.7,54.4 325.7,51 325.7,47.4 325.7,43.9 325.1,41.1 323.7,38.8 322.3,36.6 320.5,34.8 318.2,33.4z M236,1.1L265.2,66.5 249.3,66.5 243.6,52.5 213.3,52.5 207.4,66.5 191.9,66.5 221,1 235.9,1z M238.8,41L228.4,16 218,41 238.8,41z M87.2,1L72,1 72,66.4 120,66.4 120,54 87.2,54 87.2,1z M53.2,12C55.5,15.6 56.7,19.8 56.7,24.7 56.7,29.6 55.5,33.7 53.2,37.3A22.1,22.1,0,0,1,43.4,45.5A36.8,36.8,0,0,1,28.3,48.4L15,48.4 15,66.4 0,66.4 0,1 28.3,1C34.1,1 39.2,2 43.4,3.9 47.6,5.8 50.9,8.5 53.2,12z M41.3,24.8C41.3,21.2 40.2,18.4 37.9,16.4 35.6,14.4 32.1,13.4 27.4,13.4L15.1,13.4 15.1,36.1 27.4,36.1C32.1,36.1 35.6,35.1 37.9,33.1 40.2,31.1 41.4,28.4 41.4,24.8z M489.7,13.4L510.7,13.4 510.7,66.4 525.7,66.4 525.7,13.4 546.7,13.4 546.7,1.1 489.7,1.1 489.7,13.4z M581.2,18.9C581.2,21.9 580.6,24.1 579.3,25.5 578.1,26.8 576.3,27.5 574.1,27.5 571.9,27.5 570.1,26.8 568.8,25.5 567.5,24.1 566.9,21.9 566.9,18.9L566.9,1 559.5,1 559.5,19C559.5,23.7 560.8,27.4 563.3,29.9 565.9,32.4 569.5,33.7 574,33.7 578.5,33.7 582.2,32.4 584.7,29.9 587.3,27.4 588.6,23.7 588.6,19L588.6,0.999999999999993 581.2,0.999999999999993 581.2,19z M596,1L596,33.2 603.4,33.2 603.4,1 596,1z M462,40.8L429.6,1 417,1 417,66.4 432,66.4 432,26.8 464.5,66.5 477,66.5 477,1 462,1 462,40.7z M376.4,1L405.6,66.4 389.7,66.4 383.9,52.4 353.7,52.4 347.8,66.4 332.3,66.4 361.4,1 376.3,1z M379.2,40.9L368.8,15.9 358.4,40.9 379.2,40.9z F1 M434.6,39.2z M0,0z M122,5.6A19.8,19.8,0,0,0,117.6,27.4A20,20,0,0,0,136.6,39.2A19.6,19.6,0,0,0,155.6,11.8C150.4,-1.2,131.6,-3.7,121.9,5.6z M136.6,32C127.3,32.1 121.6,23 125,14.6 129,4.8 144,4.7 148,14.6 151.1,23 146,32.1 136.6,32z M197.3,0L205.5,0 205.5,39.2 198.8,39.2 178.8,14.6 178.8,39.2 170.6,39.2 170.6,0 177.3,0 197.3,24.6 197.3,0z M297,25A9.7,9.7,0,0,0,291.4,18.7C294.1,16.8 295.7,13.7 295.5,10.5 295.3,2.8 288.7,0 281,0L262.5,0 262.5,39.2 282.1,39.2C291.8,39.4,299.3,34.8,297.1,25.1z M270.8,6.9L280.2,6.9C283.8,6.9 287.2,7.7 287.2,11.5 287.2,15.3 283.8,16 280.2,16.1L270.8,16.1 270.8,6.8z M281.6,32.5L270.8,32.5 270.8,22.8 281.6,22.8C284.1,22.8 286,23.2 287.3,24 288.5,24.8 289.1,26 289.1,27.7 289.1,31.8 285.5,32.4 281.6,32.5z M319.4,32.1L340.8,32.1 340.8,39.3 311,39.3 311,0 340,0 340,7.2 319.4,7.2 319.4,15.8 337.7,15.8 337.7,22.8 319.4,22.8 319.4,32z M362.7,32.1L382.5,32.1 382.5,39.3 354.4,39.3 354.4,0 362.7,0 362.7,32z M431.9,9.4C428.2,3,421,0,413.5,0L396,0 396,39.2 413.3,39.2C417.3,39.2 421.1,38.4 424.3,36.8 434.3,32.1 437.3,18.5 431.8,9.3z M424.7,26.2C422.2,30.2,417.9,32.1,413,32.1L404.4,32.1 404.4,7.2 413,7.2C415.7,7.2 418.1,7.7 420,8.8 422,9.8 423.6,11.2 424.7,13 426.9,17.1 426.9,22 424.7,26.1z M227.5,32L249,32 249,39.2 219.2,39.2 219.2,0 248.2,0 248.2,7.2 227.5,7.2 227.5,15.8 245.8,15.8 245.8,22.8 227.5,22.8 227.5,32z M14.4,6.3C7.8,4.3 4.4,0 4.4,0 4.4,0 3.9,5.5 8.2,9.4L0,15C11.2,18.7 11.2,18.3 16.7,23.3 15.1,23.7 13.4,23.6 11.8,23 13.3,25.5 17.2,27.6 22.2,27.3 45.9,43.2 78.9,43.3 102.5,27.7A74.8,74.8,0,0,0,14.4,6.3z M21.8,20.1A64,64,0,0,1,41.8,13.7A17.9,17.9,0,0,0,42.2,30.5A61.6,61.6,0,0,1,21.8,20z M65.3,33.5A71,71,0,0,1,51,32.6C44.9,28.6 43.3,18.8 48.1,13.1 55.9,12.6 63.8,13.5 71.3,15.6 74.3,22.2 71.8,30.1 65.3,33.6z M74,32.6C77.3,28.3 78.5,22.8 77.2,17.6 83,19.8 88.2,22.6 93.1,26.5 87.1,29.5 80.7,31.6 74,32.6z M65.4,21.2C65.6,29.9,52.6,30.2,52.4,21.5A6.5,6.5,0,0,1,61.4,15.5C58.9,18.1,62,22.7,65.4,21.2z + M12,2A10,10 0 0,0 2,12C2,16.42 4.87,20.17 8.84,21.5C9.34,21.58 9.5,21.27 9.5,21C9.5,20.77 9.5,20.14 9.5,19.31C6.73,19.91 6.14,17.97 6.14,17.97C5.68,16.81 5.03,16.5 5.03,16.5C4.12,15.88 5.1,15.9 5.1,15.9C6.1,15.97 6.63,16.93 6.63,16.93C7.5,18.45 8.97,18 9.54,17.76C9.63,17.11 9.89,16.67 10.17,16.42C7.95,16.17 5.62,15.31 5.62,11.5C5.62,10.39 6,9.5 6.65,8.79C6.55,8.54 6.2,7.5 6.75,6.15C6.75,6.15 7.59,5.88 9.5,7.17C10.29,6.95 11.15,6.84 12,6.84C12.85,6.84 13.71,6.95 14.5,7.17C16.41,5.88 17.25,6.15 17.25,6.15C17.8,7.5 17.45,8.54 17.35,8.79C18,9.5 18.38,10.39 18.38,11.5C18.38,15.32 16.04,16.16 13.81,16.41C14.17,16.72 14.5,17.33 14.5,18.26C14.5,19.6 14.5,20.68 14.5,21C14.5,21.27 14.66,21.59 15.17,21.5C19.14,20.16 22,16.42 22,12A10,10 0 0,0 12,2Z + M5.01,112.9L37.61,0h50.1l-10.1,35c-.1.2-.2.4-.3.6l-26.6,92.4h24.8c-10.4,25.9-18.5,46.2-24.3,60.9-45.8-.5-58.6-33.3-47.4-72.1M51.41,189l60.4-86.9h-25.6l22.3-55.7c38.2,4,56.2,34.1,45.6,70.5-11.3,39.1-57.1,72.1-101.7,72.1h-1Z + M164.47,51.65c-.03-21.95-17.13-39.95-37.19-46.44C102.36-2.85,69.51-1.68,45.72,9.54,16.89,23.15,7.83,52.95,7.49,82.68c-.28,24.44,2.16,88.8,38.47,89.26,26.98.34,30.99-34.42,43.47-51.16,8.88-11.91,20.31-15.27,34.39-18.76,24.19-5.99,40.68-25.08,40.64-50.37Z + M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z + M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12C19.5,11.67 19.47,11.34 19.43,11L21.54,9.37C21.73,9.22 21.78,8.95 21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.96 19.05,5.05L16.56,6.05C16.04,5.66 15.5,5.32 14.87,5.07L14.5,2.42C14.46,2.18 14.25,2 14,2H10C9.75,2 9.54,2.18 9.5,2.42L9.13,5.07C8.5,5.32 7.96,5.66 7.44,6.05L4.95,5.05C4.73,4.96 4.46,5.05 4.34,5.27L2.34,8.73C2.21,8.95 2.27,9.22 2.46,9.37L4.57,11C4.53,11.34 4.5,11.67 4.5,12C4.5,12.33 4.53,12.65 4.57,12.97L2.46,14.63C2.27,14.78 2.21,15.05 2.34,15.27L4.34,18.73C4.46,18.95 4.73,19.03 4.95,18.95L7.44,17.94C7.96,18.34 8.5,18.68 9.13,18.93L9.5,21.58C9.54,21.82 9.75,22 10,22H14C14.25,22 14.46,21.82 14.5,21.58L14.87,18.93C15.5,18.67 16.04,18.34 16.56,17.94L19.05,18.95C19.27,19.03 19.54,18.95 19.66,18.73L21.66,15.27C21.78,15.05 21.73,14.78 21.54,14.63L19.43,12.97Z + \ No newline at end of file diff --git a/samples/PleasantUI.Example/DataTemplates/ControlPageCardItemTemplate.axaml b/samples/PleasantUI.Example/DataTemplates/ControlPageCardItemTemplate.axaml index e0c5f88f..d1103e90 100644 --- a/samples/PleasantUI.Example/DataTemplates/ControlPageCardItemTemplate.axaml +++ b/samples/PleasantUI.Example/DataTemplates/ControlPageCardItemTemplate.axaml @@ -3,8 +3,8 @@ xmlns:models="clr-namespace:PleasantUI.Example.Models"> - + diff --git a/samples/PleasantUI.Example/Views/Pages/ControlPages/ButtonPageView.axaml b/samples/PleasantUI.Example/Views/Pages/ControlPages/ButtonPageView.axaml index 36436fa5..0aa3a987 100644 --- a/samples/PleasantUI.Example/Views/Pages/ControlPages/ButtonPageView.axaml +++ b/samples/PleasantUI.Example/Views/Pages/ControlPages/ButtonPageView.axaml @@ -8,14 +8,14 @@ -