diff --git a/AGENTS.md b/AGENTS.md index 8284c7fe6a..cbf364b206 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ ## General Project Information -- Language: **C# targeting .NET 8** (desktop application). +- Language: **C# targeting .NET 8** (desktop application). - This is a level editor suite for a family of 3D game engines used in the classic Tomb Raider series. - Level formats are grid-based, room-based, and portal-based. - A room is a spatial container for level geometry and game entities. @@ -10,21 +10,43 @@ ## General Guidelines +### Files and Namespaces + - Files must use Windows line endings. Only standard ASCII symbols are allowed; do not use Unicode symbols. -- `using` directives are grouped and sorted as follows: `DarkUI` namespaces first, then `System` namespaces, followed by third-party and local namespaces. -- Namespace declarations and type definitions should place the opening brace on a new line. -- Prefer grouping all feature-related functionality within a self-contained module or modules. Avoid creating large code blocks over 10–15 lines in existing modules; instead, offload code to helper functions. -- Avoid duplicating and copypasting code. Implement helper methods instead, whenever similar code is used within a given module, class or feature scope. +- Every document should end with a trailing newline. +- `using` directives and namespace declarations should always be sorted alphabetically. +- Remove unused `using` statements. +- Prefer importing namespaces over fully qualifying framework types when there is no ambiguity. Remove redundant qualifiers such as `System.StringComparison` and use `StringComparison` directly when no namespace conflict exists. +- Prefer file-scoped namespaces where a file contains a single namespace and no language constraint prevents it. If a block-scoped namespace is still required, place the opening brace on a new line and sort multiple namespace declarations alphabetically. + +### Nullability + +- Each refactor should enable nullable reference types for the touched code. Add `#nullable enabled` at the top of the file only when the project does not already enable nullables, and update the touched code to use nullable annotations and checks correctly. +- Always use `is null` / `is not null` rather than `== null` / `!= null`. +- Prefer nullability attributes and helpers such as `[NotNullWhen]`, `[MemberNotNull]`, `[MaybeNullWhen]` and related annotations when they improve flow analysis and keep the API clear. +- Avoid the null-forgiving operator (`!`) where possible. Prefer flow analysis, null checks, annotations and helper methods instead, and use `!` only when it is truly necessary to express a proven invariant the compiler cannot infer. + +### Architecture and Composition + +- Keep feature-related functionality within self-contained modules. Avoid large code blocks over 10-15 lines in existing modules; move that logic into helpers or dedicated types. +- Always look for opportunities to de-duplicate code and fix duplication where suitable. Prefer shared helpers or extracted modules when similar code appears within a module, class or feature scope. +- Prefer modern .NET and C# conventions when project-specific guidance does not require something else. +- Design new code with service-based composition in mind. Favor dependency injection seams, and use the temporary `TombLib.WPF` service locator only in code paths that already depend on it. +- Keep vertical slice architecture in mind when choosing where new features, helpers and dependencies should live. ## Formatting -- **Indentation** is four spaces; tabs are not used. +### Indentation + +- Indentation uses four spaces; tabs are not used. -- **Braces**: - - Always use braces for multi-statement blocks. - - Do not use braces for single-statement blocks, unless they are within multiple `else if` conditions where surrounding statements are multi-line. - - - Opening curly brace `{` for structures, classes and methods should be on the next line, not on the same line: +### Braces + +- Always use braces for multi-statement blocks. +- Single-line conditions should not use braces when the entire `if` / `else if` / `else` chain stays single-line. +- Multi-line conditions or multi-line bodies must always use braces. +- If any branch in an `if` / `else if` / `else` chain uses braces, all sibling branches should use braces as well. +- Opening curly brace `{` for structures, classes and methods should be on the next line, not on the same line: ```csharp public class Foo @@ -39,27 +61,29 @@ } ``` - - Anonymous delegates and lambdas should keep the brace on the same line: - `delegate () { ... }` or `() => { ... }`. - -- **Line breaks and spacing**: - - A blank line separates logically distinct groups of members (fields, constructors, public methods, private helpers, etc.). - - Spaces around binary operators (`=`, `+`, `==`, etc.) and after commas. - - A single space follows keyword `if`/`for`/`while` before the opening parenthesis. - - Expressions may be broken into multiple lines and aligned with the previous line's indentation level to improve readability. - - However, chained LINQ method calls, lambdas or function/method arguments should not be broken into multiple lines, unless they reach more than 150 symbols in length. - - - Do not collapse early exits or single-statement conditions into a single line: - - Bad example: - ```csharp - if (condition) return; - ``` - Do this instead: - ```csharp - if (condition) - return; - ``` +- Anonymous delegates and lambdas should keep the brace on the same line: `delegate () { ... }` or `() => { ... }`. + +### Line Breaks and Spacing + +- A blank line separates logically distinct groups of members (fields, constructors, public methods, private helpers, etc.). +- Within method bodies, use a blank line between logically distinct statements and before a control-flow block that starts a new step. +- Avoid whitespace-only lines or dead indentation; blank lines should be truly blank. +- Spaces around binary operators (`=`, `+`, `==`, etc.) and after commas. +- A single space follows keyword `if` / `for` / `while` before the opening parenthesis. +- Expressions may be broken into multiple lines and aligned with the previous line's indentation level to improve readability. +- Chained LINQ method calls, lambdas or function arguments should stay on one line unless they exceed roughly 150 characters. +- Do not collapse early exits or single-statement conditions into one line. + + Bad example: + ```csharp + if (condition) return; + ``` + + Do this instead: + ```csharp + if (condition) + return; + ``` ## Naming @@ -75,40 +99,54 @@ - Fields are generally declared as `public` or `private readonly` depending on usage; expose state via properties where appropriate. - `var` type should be preferred where possible, when the right-hand type is evident from the initializer. -- Explicit typing should be only used when it is required by logic or compiler, or when type name is shorter than 6 symbols (e.g. `int`, `bool`, `float`). +- Explicit typing should only be used when it is required by logic or compiler, or when the type name is shorter than 6 symbols (e.g. `int`, `bool`, `float`). - For floating-point numbers, always use `f` postfix and decimal, even if value is not fractional (e.g. `2.0f`). +- Consider `record` or `record struct` when value semantics, immutability, or concise data-carrier behavior make them a better fit than a class or struct. +- Prefer expression-bodied members for methods or properties whose implementation is a single readable line. +- Prefer collection expressions (`[]`, `[item]`, `[..items]`) over `Array.Empty()`, explicit array or list construction, or simple `.ToArray()` / `.ToList()` materialization when the target type supports them and the result stays clear. ## Control Flow and Syntax - Avoid excessive condition nesting and use early exits / breaks where possible. - LINQ and lambda expressions are used for collections (`FirstOrDefault`, `Where`, `Any`, etc.). +- Use pattern matching where it keeps the code clearer or removes redundant casts, temporary variables or branching. +- Under nullable-aware code, avoid throwing `ArgumentNullException` for non-nullable parameters when the guard adds no meaningful value. +- When an exception type exposes helper APIs such as `ArgumentNullException.ThrowIfNull`, prefer those helpers over manual `if` blocks when the behavior stays clear. - Exception and error handling is done with `try`/`catch`, and caught exceptions are logged with [NLog](https://nlog-project.org/) where appropriate. -- Warnings must also be logged by NLog, if cause for the incorrect behaviour is user action. +- Warnings caused by user action should also be logged through NLog. ## Comments - When comments appear they are single-line `//`. Block comments (`/* ... */`) are rare. - Comments are sparse. Code relies on meaningful names rather than inline documentation. -- Do not use `` if surrounding code and/or module isn't already using it. Only add `` for non-private methods with high complexity. -- If module or function implements complex functionality, a brief description (2-3 lines) may be added in front of it, separated by a blank line from the function body. +- Add XML documentation to classes where it clarifies intent, and to public methods and public properties by default. Use XML documentation for private members only when the behavior is complex enough that names alone are not sufficient. +- If a module or function implements complex functionality, use brief section comments to split long methods into smaller, digestible steps. - All descriptive comments should end with a full stop (`.`). ## Code Grouping -- Large methods should group related actions together, separated by blank lines. +- Large methods should group related actions together, separated by blank lines and short section comments when they cannot be broken apart further. - Constants and static helpers that are used several times should appear at the top of a class. - Constants that are used only within a scope of a method, should be declared within this method. - One-liner lambdas may be grouped together, if they share similar meaning or functionality. +- Prefer one top-level type per file when practical. Keep multiple classes, enums, records or interfaces in the same file only when they are strictly coupled. +- When a class grows too large in size or scope, split it into smaller partial classes organized by responsibility. Use partial classes only when the responsibilities still belong to the same type; otherwise extract a dedicated helper, service or type instead. +- Avoid one-line wrapper methods unless they remove duplication, enforce a policy, or provide meaning beyond a direct redirect. +- Do not keep generic helper methods inside the same feature class. First check whether a suitable shared helper already exists elsewhere in the codebase; otherwise extract the helper into the most suitable shared library project or dedicated module. +- If a helper method is broad in scope, such as a general WPF helper like `FindAncestor()`, first verify whether an equivalent already exists. If not, place it in the narrowest suitable shared library among `TombLib`, `TombLib.Scripting`, `TombLib.WPF` and `DarkUI.WPF` rather than adding it to a feature-local helper class. ## User Interface Implementation - For WinForms-based workflows, maintain the existing Visual Studio module pair for each control or unit: `.cs` and `.Designer.cs`. - For existing WinForms-based `DarkUI` controls and containers, prefer to use existing WinForms-based `DarkUI` controls. -- For new controls and containers with complex logic, or where WinForms may not perform fast enough, prefer `DarkUI.WPF` framework. Use `GeometryIOSettingsWindow` as a reference. +- For new WPF views and view models, use `GeometryIOSettingsWindow` as the reference for structure, localization and service usage patterns. +- When writing WPF UI, prioritize localization and the existing localization infrastructure from `TombLib.WPF`. +- Creating new generic WPF controls should be delegated to `DarkUI.WPF`. +- For new controls and containers with complex logic, or where WinForms may not perform fast enough, prefer `DarkUI.WPF`. - Use `CommunityToolkit` functionality where possible. ## Performance - For 3D rendering controls, prefer more performant approaches and locally cache frequently used data within the function scope whenever possible. - Avoid scenarios where bulk data updates may cause event floods, as the project relies heavily on event subscriptions across multiple controls and sub-controls. -- Use `Parallel` for bulk operations to maximize performance. Avoid using it in thread-unsafe contexts or when operating on serial data sets. \ No newline at end of file +- Use `Parallel` for bulk operations to maximize performance. Avoid using it in thread-unsafe contexts or when operating on serial data sets. diff --git a/DarkUI/DarkUI.WPF/Converters/HtmlToUIColorConverter.cs b/DarkUI/DarkUI.WPF/Converters/HtmlToUIColorConverter.cs new file mode 100644 index 0000000000..34bfd05355 --- /dev/null +++ b/DarkUI/DarkUI.WPF/Converters/HtmlToUIColorConverter.cs @@ -0,0 +1,22 @@ +using System; +using System.Drawing; +using System.Globalization; +using System.Windows.Data; + +namespace DarkUI.WPF.Converters; + +public class HtmlToUIColorConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + var colString = (string)value; + var col = ColorTranslator.FromHtml(colString); + return System.Windows.Media.Color.FromArgb(col.A, col.R, col.G, col.B); + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + var col = (System.Windows.Media.Color)value; + return ColorTranslator.ToHtml(Color.FromArgb(col.A, col.R, col.G, col.B)); + } +} diff --git a/DarkUI/DarkUI.WPF/Converters/VectorToBrushConverter.cs b/DarkUI/DarkUI.WPF/Converters/VectorToBrushConverter.cs new file mode 100644 index 0000000000..3e7cf8aae1 --- /dev/null +++ b/DarkUI/DarkUI.WPF/Converters/VectorToBrushConverter.cs @@ -0,0 +1,34 @@ +using System; +using System.Globalization; +using System.Numerics; +using System.Windows.Data; +using System.Windows.Media; + +namespace DarkUI.WPF.Converters; + +public class VectorToBrushConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is not Vector4 vec) + return Binding.DoNothing; + + byte r = (byte)(Math.Clamp(vec.X, 0.0f, 1.0f) * 255.0f); + byte g = (byte)(Math.Clamp(vec.Y, 0.0f, 1.0f) * 255.0f); + byte b = (byte)(Math.Clamp(vec.Z, 0.0f, 1.0f) * 255.0f); + byte a = (byte)(Math.Clamp(vec.W, 0.0f, 1.0f) * 255.0f); + + var brush = new SolidColorBrush(Color.FromArgb(a, r, g, b)); + brush.Freeze(); + return brush; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is not SolidColorBrush brush) + return Binding.DoNothing; + + Color col = brush.Color; + return new Vector4(col.R / 255.0f, col.G / 255.0f, col.B / 255.0f, col.A / 255.0f); + } +} diff --git a/DarkUI/DarkUI.WPF/Converters/VectorToUIColorConverter.cs b/DarkUI/DarkUI.WPF/Converters/VectorToUIColorConverter.cs new file mode 100644 index 0000000000..4359d20679 --- /dev/null +++ b/DarkUI/DarkUI.WPF/Converters/VectorToUIColorConverter.cs @@ -0,0 +1,26 @@ +using System; +using System.Globalization; +using System.Numerics; +using System.Windows.Data; +using System.Windows.Media; + +namespace DarkUI.WPF.Converters; + +public class VectorToUIColorConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + var vec = (Vector4)value; + byte r = (byte)(Math.Clamp(vec.X, 0.0f, 1.0f) * 255.0f); + byte g = (byte)(Math.Clamp(vec.Y, 0.0f, 1.0f) * 255.0f); + byte b = (byte)(Math.Clamp(vec.Z, 0.0f, 1.0f) * 255.0f); + byte a = (byte)(Math.Clamp(vec.W, 0.0f, 1.0f) * 255.0f); + return Color.FromArgb(a, r, g, b); + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + var col = (Color)value; + return new Vector4(col.R / 255.0f, col.G / 255.0f, col.B / 255.0f, col.A / 255.0f); + } +} diff --git a/DarkUI/DarkUI.WPF/CustomControls/AutoCompleteBox.cs b/DarkUI/DarkUI.WPF/CustomControls/AutoCompleteBox.cs index 47656a59b6..eb3d2f2bfb 100644 --- a/DarkUI/DarkUI.WPF/CustomControls/AutoCompleteBox.cs +++ b/DarkUI/DarkUI.WPF/CustomControls/AutoCompleteBox.cs @@ -17,6 +17,7 @@ public class AutoCompleteBox : Control public static readonly DependencyProperty IsSuggestionVisibleProperty; public static readonly DependencyProperty ItemsSourceProperty; public static readonly DependencyProperty SelectItemProperty; + public static readonly DependencyProperty CharacterCasingProperty; public string Text { @@ -42,6 +43,12 @@ public ICommand SelectItem set => SetValue(SelectItemProperty, value); } + public CharacterCasing CharacterCasing + { + get => (CharacterCasing)GetValue(CharacterCasingProperty); + set => SetValue(CharacterCasingProperty, value); + } + public TextBox? InputTextBox { get; set; } public Popup? Popup { get; set; } public ListBox? SuggestionBox { get; set; } @@ -71,6 +78,12 @@ static AutoCompleteBox() typeof(ICommand), typeof(AutoCompleteBox), new PropertyMetadata(null)); + + CharacterCasingProperty = DependencyProperty.Register( + nameof(CharacterCasing), + typeof(CharacterCasing), + typeof(AutoCompleteBox), + new PropertyMetadata(CharacterCasing.Normal)); } public override void OnApplyTemplate() @@ -122,7 +135,12 @@ public override void OnApplyTemplate() protected override void OnPropertyChanged(DependencyPropertyChangedEventArgs e) { if (e.Property == TextProperty && InputTextBox is not null) - InputTextBox.Text = (string)e.NewValue; + { + string newValue = (string)e.NewValue; + + if (!string.Equals(InputTextBox.Text, newValue, StringComparison.Ordinal)) + InputTextBox.Text = newValue; + } base.OnPropertyChanged(e); } diff --git a/DarkUI/DarkUI.WPF/CustomControls/NumericUpDown.cs b/DarkUI/DarkUI.WPF/CustomControls/NumericUpDown.cs index 37e14a2245..f8c35eaa3d 100644 --- a/DarkUI/DarkUI.WPF/CustomControls/NumericUpDown.cs +++ b/DarkUI/DarkUI.WPF/CustomControls/NumericUpDown.cs @@ -1,25 +1,131 @@ using System; using System.Globalization; using System.Windows; +using System.Windows.Automation; +using System.Windows.Automation.Peers; +using System.Windows.Automation.Provider; using System.Windows.Controls; +using System.Windows.Controls.Primitives; using System.Windows.Input; namespace DarkUI.WPF.CustomControls; [TemplatePart(Name = "PART_TextBox", Type = typeof(TextBox))] -[TemplatePart(Name = "PART_IncreaseButton", Type = typeof(Button))] -[TemplatePart(Name = "PART_DecreaseButton", Type = typeof(Button))] +[TemplatePart(Name = "PART_IncreaseButton", Type = typeof(ButtonBase))] +[TemplatePart(Name = "PART_DecreaseButton", Type = typeof(ButtonBase))] public class NumericUpDown : Control { + private const NumberStyles NumericTextStyle = NumberStyles.Number; + private const string DecreaseButtonPartName = "PART_DecreaseButton"; + private const string IncreaseButtonPartName = "PART_IncreaseButton"; + private const string TextBoxPartName = "PART_TextBox"; + public static readonly DependencyProperty ValueProperty; public static readonly DependencyProperty MinimumProperty; public static readonly DependencyProperty MaximumProperty; public static readonly DependencyProperty IncrementProperty; + public static readonly DependencyProperty LargeIncrementProperty; + public static readonly DependencyProperty DecimalPlacesProperty; public static readonly DependencyProperty FormatStringProperty; public static readonly DependencyProperty TextAlignmentProperty; + public static readonly DependencyProperty LoopValuesProperty; + public static readonly DependencyProperty ChangeValueOnMouseWheelProperty; + public static readonly DependencyProperty RequireFocusForMouseWheelProperty; + public static readonly DependencyProperty RepeatDelayProperty; + public static readonly DependencyProperty RepeatIntervalProperty; public event EventHandler? ValueChanged; + static NumericUpDown() + { + ValueProperty = DependencyProperty.Register( + nameof(Value), + typeof(double), + typeof(NumericUpDown), + new FrameworkPropertyMetadata(0.0, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnValueChanged, CoerceValue), + IsValidNumber); + + MinimumProperty = DependencyProperty.Register( + nameof(Minimum), + typeof(double), + typeof(NumericUpDown), + new FrameworkPropertyMetadata(double.MinValue, OnMinimumChanged), + IsValidNumber); + + MaximumProperty = DependencyProperty.Register( + nameof(Maximum), + typeof(double), + typeof(NumericUpDown), + new FrameworkPropertyMetadata(double.MaxValue, OnMaximumChanged, CoerceMaximum), + IsValidNumber); + + IncrementProperty = DependencyProperty.Register( + nameof(Increment), + typeof(double), + typeof(NumericUpDown), + new FrameworkPropertyMetadata(1.0), + IsValidPositiveNumber); + + LargeIncrementProperty = DependencyProperty.Register( + nameof(LargeIncrement), + typeof(double), + typeof(NumericUpDown), + new FrameworkPropertyMetadata(0.0), + IsValidLargeIncrement); + + DecimalPlacesProperty = DependencyProperty.Register( + nameof(DecimalPlaces), + typeof(int), + typeof(NumericUpDown), + new FrameworkPropertyMetadata(-1, OnDecimalPlacesChanged), + IsValidDecimalPlaces); + + FormatStringProperty = DependencyProperty.Register( + nameof(FormatString), + typeof(string), + typeof(NumericUpDown), + new FrameworkPropertyMetadata("F0"), + value => value is string); + + TextAlignmentProperty = DependencyProperty.Register( + nameof(TextAlignment), + typeof(TextAlignment), + typeof(NumericUpDown), + new FrameworkPropertyMetadata(TextAlignment.Right)); + + LoopValuesProperty = DependencyProperty.Register( + nameof(LoopValues), + typeof(bool), + typeof(NumericUpDown), + new FrameworkPropertyMetadata(false)); + + ChangeValueOnMouseWheelProperty = DependencyProperty.Register( + nameof(ChangeValueOnMouseWheel), + typeof(bool), + typeof(NumericUpDown), + new FrameworkPropertyMetadata(true)); + + RequireFocusForMouseWheelProperty = DependencyProperty.Register( + nameof(RequireFocusForMouseWheel), + typeof(bool), + typeof(NumericUpDown), + new FrameworkPropertyMetadata(false)); + + RepeatDelayProperty = DependencyProperty.Register( + nameof(RepeatDelay), + typeof(int), + typeof(NumericUpDown), + new FrameworkPropertyMetadata(400), + IsValidNonNegativeInteger); + + RepeatIntervalProperty = DependencyProperty.Register( + nameof(RepeatInterval), + typeof(int), + typeof(NumericUpDown), + new FrameworkPropertyMetadata(60), + IsValidPositiveInteger); + } + public double Value { get => (double)GetValue(ValueProperty); @@ -44,6 +150,18 @@ public double Increment set => SetValue(IncrementProperty, value); } + public double LargeIncrement + { + get => (double)GetValue(LargeIncrementProperty); + set => SetValue(LargeIncrementProperty, value); + } + + public int DecimalPlaces + { + get => (int)GetValue(DecimalPlacesProperty); + set => SetValue(DecimalPlacesProperty, value); + } + public string FormatString { get => (string)GetValue(FormatStringProperty); @@ -56,97 +174,140 @@ public TextAlignment TextAlignment set => SetValue(TextAlignmentProperty, value); } - public TextBox? TextBox { get; set; } - public Button? IncreaseButton { get; set; } - public Button? DecreaseButton { get; set; } - - static NumericUpDown() + public bool LoopValues { - ValueProperty = DependencyProperty.Register( - nameof(Value), - typeof(double), - typeof(NumericUpDown), - new FrameworkPropertyMetadata(0.0, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnValueChanged)); - - MinimumProperty = DependencyProperty.Register( - nameof(Minimum), - typeof(double), - typeof(NumericUpDown), - new FrameworkPropertyMetadata(double.MinValue)); + get => (bool)GetValue(LoopValuesProperty); + set => SetValue(LoopValuesProperty, value); + } - MaximumProperty = DependencyProperty.Register( - nameof(Maximum), - typeof(double), - typeof(NumericUpDown), - new FrameworkPropertyMetadata(double.MaxValue)); + public bool ChangeValueOnMouseWheel + { + get => (bool)GetValue(ChangeValueOnMouseWheelProperty); + set => SetValue(ChangeValueOnMouseWheelProperty, value); + } - IncrementProperty = DependencyProperty.Register( - nameof(Increment), - typeof(double), - typeof(NumericUpDown), - new FrameworkPropertyMetadata(1.0)); + public bool RequireFocusForMouseWheel + { + get => (bool)GetValue(RequireFocusForMouseWheelProperty); + set => SetValue(RequireFocusForMouseWheelProperty, value); + } - FormatStringProperty = DependencyProperty.Register( - nameof(FormatString), - typeof(string), - typeof(NumericUpDown), - new FrameworkPropertyMetadata("F0")); + public int RepeatDelay + { + get => (int)GetValue(RepeatDelayProperty); + set => SetValue(RepeatDelayProperty, value); + } - TextAlignmentProperty = DependencyProperty.Register( - nameof(TextAlignment), - typeof(TextAlignment), - typeof(NumericUpDown), - new FrameworkPropertyMetadata(TextAlignment.Right)); + public int RepeatInterval + { + get => (int)GetValue(RepeatIntervalProperty); + set => SetValue(RepeatIntervalProperty, value); } + public TextBox? TextBox { get; private set; } + public ButtonBase? IncreaseButton { get; private set; } + public ButtonBase? DecreaseButton { get; private set; } + public override void OnApplyTemplate() { + ClearTemplatePartHandlers(); + base.OnApplyTemplate(); - TextBox = GetTemplateChild("PART_TextBox") as TextBox; - IncreaseButton = GetTemplateChild("PART_IncreaseButton") as Button; - DecreaseButton = GetTemplateChild("PART_DecreaseButton") as Button; + TextBox = GetTemplateChild(TextBoxPartName) as TextBox; + IncreaseButton = GetTemplateChild(IncreaseButtonPartName) as ButtonBase; + DecreaseButton = GetTemplateChild(DecreaseButtonPartName) as ButtonBase; if (TextBox is not null) { - TextBox.Text = Value.ToString(FormatString); - TextBox.TextAlignment = TextAlignment; + ApplyTextBoxProperties(); + TextBox.PreviewTextInput += TextBox_PreviewTextInput; TextBox.KeyDown += TextBox_KeyDown; TextBox.LostFocus += TextBox_LostFocus; + + DataObject.AddPastingHandler(TextBox, TextBox_Pasting); } if (IncreaseButton is not null) - IncreaseButton.Click += (sender, e) => Value = Math.Min(Value + Increment, Maximum); + IncreaseButton.Click += IncreaseButton_Click; if (DecreaseButton is not null) - DecreaseButton.Click += (sender, e) => Value = Math.Max(Value - Increment, Minimum); + DecreaseButton.Click += DecreaseButton_Click; + + UpdateButtonStates(); } + protected override AutomationPeer OnCreateAutomationPeer() + => new NumericUpDownAutomationPeer(this); + protected override void OnPropertyChanged(DependencyPropertyChangedEventArgs e) { base.OnPropertyChanged(e); - if (TextBox is not null) - { - if (e.Property == ValueProperty) - TextBox.Text = Value.ToString(FormatString); - else if (e.Property == TextAlignmentProperty) - TextBox.TextAlignment = TextAlignment; - } + if (e.Property == ValueProperty || e.Property == FormatStringProperty || e.Property == DecimalPlacesProperty) + UpdateTextBoxText(); + else if (e.Property == TextAlignmentProperty) + UpdateTextAlignment(); + + if (e.Property == ValueProperty || e.Property == MinimumProperty || e.Property == MaximumProperty || e.Property == LoopValuesProperty || e.Property == IsEnabledProperty) + UpdateButtonStates(); } protected override void OnPreviewKeyDown(KeyEventArgs e) { - if (e.Key == Key.Up) - { - Value = Math.Min(Value + Increment, Maximum); - e.Handled = true; - } - else if (e.Key == Key.Down) + base.OnPreviewKeyDown(e); + + if (e.Handled) + return; + + switch (e.Key) { - Value = Math.Max(Value - Increment, Minimum); - e.Handled = true; + case Key.Enter: + CommitText(); + e.Handled = true; + break; + + case Key.Escape: + UpdateTextBoxText(); + e.Handled = true; + break; + + case Key.Up: + CommitText(); + ChangeValue(GetKeyboardIncrement()); + e.Handled = true; + break; + + case Key.Down: + CommitText(); + ChangeValue(-GetKeyboardIncrement()); + e.Handled = true; + break; + + case Key.PageUp: + CommitText(); + ChangeValue(GetLargeIncrement()); + e.Handled = true; + break; + + case Key.PageDown: + CommitText(); + ChangeValue(-GetLargeIncrement()); + e.Handled = true; + break; + + case Key.Home: + CommitText(); + Value = Minimum; + e.Handled = true; + break; + + case Key.End: + CommitText(); + Value = Maximum; + e.Handled = true; + break; } } @@ -154,41 +315,323 @@ protected override void OnPreviewMouseWheel(MouseWheelEventArgs e) { base.OnPreviewMouseWheel(e); + if (e.Handled || !ChangeValueOnMouseWheel || (RequireFocusForMouseWheel && !IsKeyboardFocusWithin)) + return; + + CommitText(); + if (e.Delta > 0) - Value = Math.Min(Value + Increment, Maximum); + ChangeValue(GetKeyboardIncrement()); else if (e.Delta < 0) - Value = Math.Max(Value - Increment, Minimum); + ChangeValue(-GetKeyboardIncrement()); + else + return; e.Handled = true; } + private void ClearTemplatePartHandlers() + { + if (TextBox is not null) + { + TextBox.PreviewTextInput -= TextBox_PreviewTextInput; + TextBox.KeyDown -= TextBox_KeyDown; + TextBox.LostFocus -= TextBox_LostFocus; + + DataObject.RemovePastingHandler(TextBox, TextBox_Pasting); + } + + if (IncreaseButton is not null) + IncreaseButton.Click -= IncreaseButton_Click; + + if (DecreaseButton is not null) + DecreaseButton.Click -= DecreaseButton_Click; + } + + private void ApplyTextBoxProperties() + { + UpdateTextAlignment(); + UpdateTextBoxText(); + } + + private void IncreaseButton_Click(object sender, RoutedEventArgs e) + { + CommitText(); + ChangeValue(GetKeyboardIncrement()); + } + + private void DecreaseButton_Click(object sender, RoutedEventArgs e) + { + CommitText(); + ChangeValue(-GetKeyboardIncrement()); + } + private void TextBox_KeyDown(object sender, KeyEventArgs e) { - if (e.Key == Key.Enter) - ApplyTextBoxValue(); + if (e.Key != Key.Enter) + return; + + CommitText(); + e.Handled = true; } private void TextBox_PreviewTextInput(object sender, TextCompositionEventArgs e) { - // Prevent character input, but allow the current culture's decimal separator - if (!double.TryParse(e.Text, out _) && e.Text != CultureInfo.CurrentCulture.NumberFormat.NumberDecimalSeparator) + if (sender is not TextBox textBox || !IsTextAllowed(GetProposedText(textBox, e.Text), allowIntermediate: true)) e.Handled = true; } + private void TextBox_Pasting(object sender, DataObjectPastingEventArgs e) + { + if (sender is not TextBox textBox || !e.DataObject.GetDataPresent(DataFormats.Text)) + { + e.CancelCommand(); + return; + } + + if (e.DataObject.GetData(DataFormats.Text) is not string pastedText + || !IsTextAllowed(GetProposedText(textBox, pastedText), allowIntermediate: false)) + { + e.CancelCommand(); + } + + } + private void TextBox_LostFocus(object sender, RoutedEventArgs e) - => ApplyTextBoxValue(); + => CommitText(); - private void ApplyTextBoxValue() + private bool CommitText() { - if (double.TryParse(TextBox!.Text, out double value)) - Value = Math.Min(Math.Max(value, Minimum), Maximum); - else - TextBox.Text = Value.ToString(FormatString); + if (TextBox is null) + return false; + + if (TryParseText(TextBox.Text, out var value)) + { + Value = NormalizeValue(value); + UpdateTextBoxText(); + return true; + } + + UpdateTextBoxText(); + return false; + } + + private void ChangeValue(double delta) + => Value = GetNextValue(delta); + + private double GetNextValue(double delta) + { + var nextValue = RoundValue(Value + delta); + + if (!LoopValues || Maximum <= Minimum) + return ClampValue(nextValue); + + if (nextValue > Maximum) + return Minimum; + + if (nextValue < Minimum) + return Maximum; + + return nextValue; + } + + private double GetKeyboardIncrement() + => Keyboard.Modifiers.HasFlag(ModifierKeys.Shift) ? GetLargeIncrement() : Increment; + + private double GetLargeIncrement() + => LargeIncrement > 0.0 ? LargeIncrement : Increment * 10.0; + + private double NormalizeValue(double value) + => ClampValue(RoundValue(value)); + + private double ClampValue(double value) + => Math.Min(Math.Max(value, Minimum), Maximum); + + private double RoundValue(double value) + { + if (DecimalPlaces < 0) + return value; + + return Math.Round(value, DecimalPlaces, MidpointRounding.AwayFromZero); + } + + private void UpdateTextBoxText() + { + if (TextBox is not null) + TextBox.Text = FormatValue(Value); + } + + private void UpdateTextAlignment() + { + if (TextBox is not null) + TextBox.TextAlignment = TextAlignment; + } + + private void UpdateButtonStates() + { + var canIncrease = IsEnabled && (LoopValues || Value < Maximum); + var canDecrease = IsEnabled && (LoopValues || Value > Minimum); + + if (IncreaseButton is not null) + IncreaseButton.IsEnabled = canIncrease; + + if (DecreaseButton is not null) + DecreaseButton.IsEnabled = canDecrease; + } + + private string FormatValue(double value) + { + try + { + return value.ToString(FormatString, CultureInfo.CurrentCulture); + } + catch (FormatException) + { + return value.ToString(CultureInfo.CurrentCulture); + } + } + + private bool IsTextAllowed(string text, bool allowIntermediate) + { + if (TryParseText(text, out _)) + return true; + + return allowIntermediate && IsIntermediateText(text); + } + + private static bool TryParseText(string text, out double value) + { + if (!double.TryParse(text, NumericTextStyle, CultureInfo.CurrentCulture, out value)) + return false; + + return IsValidNumber(value); + } + + private bool IsIntermediateText(string text) + { + var trimmedText = text.Trim(); + var numberFormat = CultureInfo.CurrentCulture.NumberFormat; + var negativeDecimalPrefix = numberFormat.NegativeSign + numberFormat.NumberDecimalSeparator; + var positiveDecimalPrefix = numberFormat.PositiveSign + numberFormat.NumberDecimalSeparator; + + return trimmedText.Length == 0 || + trimmedText == numberFormat.NumberDecimalSeparator || + trimmedText == numberFormat.PositiveSign || + trimmedText == positiveDecimalPrefix || + (Minimum < 0.0 && (trimmedText == numberFormat.NegativeSign || trimmedText == negativeDecimalPrefix)); + } + + private static string GetProposedText(TextBox textBox, string input) + { + var text = textBox.Text; + return text.Remove(textBox.SelectionStart, textBox.SelectionLength).Insert(textBox.SelectionStart, input); + } + + private static object CoerceValue(DependencyObject dependency, object baseValue) + { + var control = (NumericUpDown)dependency; + return control.NormalizeValue((double)baseValue); + } + + private static object CoerceMaximum(DependencyObject dependency, object baseValue) + { + var control = (NumericUpDown)dependency; + return Math.Max((double)baseValue, control.Minimum); + } + + private static void OnMinimumChanged(DependencyObject dependency, DependencyPropertyChangedEventArgs e) + { + var control = (NumericUpDown)dependency; + control.CoerceValue(MaximumProperty); + control.CoerceValue(ValueProperty); + } + + private static void OnMaximumChanged(DependencyObject dependency, DependencyPropertyChangedEventArgs e) + { + var control = (NumericUpDown)dependency; + control.CoerceValue(ValueProperty); + } + + private static void OnDecimalPlacesChanged(DependencyObject dependency, DependencyPropertyChangedEventArgs e) + { + var control = (NumericUpDown)dependency; + control.CoerceValue(ValueProperty); } private static void OnValueChanged(DependencyObject dependency, DependencyPropertyChangedEventArgs e) { var control = (NumericUpDown)dependency; control.ValueChanged?.Invoke(control, EventArgs.Empty); + + if (UIElementAutomationPeer.FromElement(control) is NumericUpDownAutomationPeer peer) + peer.RaiseValueChanged((double)e.OldValue, (double)e.NewValue); + } + + private static bool IsValidNumber(object value) + => value is double number && IsValidNumber(number); + + private static bool IsValidNumber(double value) + => !double.IsNaN(value) && !double.IsInfinity(value); + + private static bool IsValidPositiveNumber(object value) + => value is double number && IsValidNumber(number) && number > 0.0; + + private static bool IsValidLargeIncrement(object value) + => value is double number && IsValidNumber(number) && number >= 0.0; + + private static bool IsValidDecimalPlaces(object value) + => value is int decimalPlaces && decimalPlaces >= -1 && decimalPlaces <= 15; + + private static bool IsValidNonNegativeInteger(object value) + => value is int number && number >= 0; + + private static bool IsValidPositiveInteger(object value) + => value is int number && number > 0; + + private sealed class NumericUpDownAutomationPeer : FrameworkElementAutomationPeer, IRangeValueProvider + { + public NumericUpDownAutomationPeer(NumericUpDown owner) + : base(owner) + { } + + private NumericUpDown OwnerControl => (NumericUpDown)Owner; + + public bool IsReadOnly => !OwnerControl.IsEnabled; + + public double LargeChange => OwnerControl.GetLargeIncrement(); + + public double Maximum => OwnerControl.Maximum; + + public double Minimum => OwnerControl.Minimum; + + public double SmallChange => OwnerControl.Increment; + + public double Value => OwnerControl.Value; + + public override object? GetPattern(PatternInterface patternInterface) + { + if (patternInterface == PatternInterface.RangeValue) + return this; + + return base.GetPattern(patternInterface); + } + + public void RaiseValueChanged(double oldValue, double newValue) + => RaisePropertyChangedEvent(RangeValuePatternIdentifiers.ValueProperty, oldValue, newValue); + + public void SetValue(double value) + { + if (!OwnerControl.IsEnabled) + throw new ElementNotEnabledException(); + + if (value < OwnerControl.Minimum || value > OwnerControl.Maximum) + throw new ArgumentOutOfRangeException(nameof(value)); + + OwnerControl.Value = value; + } + + protected override AutomationControlType GetAutomationControlTypeCore() => AutomationControlType.Spinner; + + protected override string GetClassNameCore() => nameof(NumericUpDown); } } diff --git a/DarkUI/DarkUI.WPF/DarkUI.WPF.csproj b/DarkUI/DarkUI.WPF/DarkUI.WPF.csproj index 054b6f2730..14535c8bf4 100644 --- a/DarkUI/DarkUI.WPF/DarkUI.WPF.csproj +++ b/DarkUI/DarkUI.WPF/DarkUI.WPF.csproj @@ -34,6 +34,10 @@ + + + + diff --git a/DarkUI/DarkUI.WPF/Dictionaries/BlackColors.xaml b/DarkUI/DarkUI.WPF/Dictionaries/BlackColors.xaml index 9fd8a7550e..d7f076dbc6 100644 --- a/DarkUI/DarkUI.WPF/Dictionaries/BlackColors.xaml +++ b/DarkUI/DarkUI.WPF/Dictionaries/BlackColors.xaml @@ -32,6 +32,7 @@ 0.5 0.25 0.5 + 1.0 0.3 3 diff --git a/DarkUI/DarkUI.WPF/Dictionaries/DarkColors.xaml b/DarkUI/DarkUI.WPF/Dictionaries/DarkColors.xaml index 542f15f518..3c85524c27 100644 --- a/DarkUI/DarkUI.WPF/Dictionaries/DarkColors.xaml +++ b/DarkUI/DarkUI.WPF/Dictionaries/DarkColors.xaml @@ -1,4 +1,5 @@  @@ -34,6 +35,7 @@ 0.5 0.25 0.5 + 1.0 0.3 3 diff --git a/DarkUI/DarkUI.WPF/Dictionaries/DarkColors.xaml.cs b/DarkUI/DarkUI.WPF/Dictionaries/DarkColors.xaml.cs new file mode 100644 index 0000000000..2f804e145e --- /dev/null +++ b/DarkUI/DarkUI.WPF/Dictionaries/DarkColors.xaml.cs @@ -0,0 +1,47 @@ +using DarkUI.Config; + +using System.Windows; + +namespace DarkUI.WPF.Dictionaries; + +public partial class DarkColors : ResourceDictionary +{ + public DarkColors() + { + InitializeComponent(); + + if (!Colors.HasBrightnessChanged) + return; + + ApplyDarkUiColors(); + } + + private void ApplyDarkUiColors() + { + // Keep the XAML palette as the fallback and overwrite it only when DarkUI brightness has changed. + SetColor("Color_Text", Colors.LightText); + SetColor("Color_Background", Colors.GreyBackground); + SetColor("Color_Background_Alternative", Colors.HeaderBackground); + SetColor("Color_Background_Control", Colors.LightBackground); + SetColor("Color_Background_Defaulted", Colors.DarkBlueBackground); + SetColor("Color_Background_Disabled", Colors.DarkGreySelection); + SetColor("Color_Background_High", Colors.LighterBackground); + SetColor("Color_Background_Low", Colors.MediumBackground); + + SetColor("Color_Border", Colors.GreySelection); + SetColor("Color_Border_High", Colors.LightestBackground); + SetColor("Color_Border_Low", Colors.MediumBackground); + + SetColor("Color_Highlight", Colors.BlueHighlight); + SetColor("Color_Selection", Colors.BlueSelection); + SetColor("Color_Selection_LostFocus", Colors.GreySelection); + SetColor("Color_WindowBorder", Colors.BlueBackground); + + this["Opacity_Icon"] = (double)Colors.Brightness; + } + + private void SetColor(string key, System.Drawing.Color color) + { + this[key] = System.Windows.Media.Color.FromArgb(color.A, color.R, color.G, color.B); + } +} diff --git a/DarkUI/DarkUI.WPF/Dictionaries/LightColors.xaml b/DarkUI/DarkUI.WPF/Dictionaries/LightColors.xaml index b6e6eaa2f6..455ae2fa91 100644 --- a/DarkUI/DarkUI.WPF/Dictionaries/LightColors.xaml +++ b/DarkUI/DarkUI.WPF/Dictionaries/LightColors.xaml @@ -32,6 +32,7 @@ 0.25 0.2 0.5 + 1.0 0.3 3 diff --git a/DarkUI/DarkUI.WPF/Styles/AutoCompleteBox.xaml b/DarkUI/DarkUI.WPF/Styles/AutoCompleteBox.xaml index b71cacb310..fae4118cd5 100644 --- a/DarkUI/DarkUI.WPF/Styles/AutoCompleteBox.xaml +++ b/DarkUI/DarkUI.WPF/Styles/AutoCompleteBox.xaml @@ -13,7 +13,7 @@ - + - + diff --git a/DarkUI/DarkUI.WPF/Styles/ComboBox.xaml b/DarkUI/DarkUI.WPF/Styles/ComboBox.xaml index 6f8b7168ea..8d665fcca9 100644 --- a/DarkUI/DarkUI.WPF/Styles/ComboBox.xaml +++ b/DarkUI/DarkUI.WPF/Styles/ComboBox.xaml @@ -1,15 +1,12 @@  - - +