diff --git a/sources/editor/Stride.Core.Assets.Editor/View/Behaviors/GuidInputMaskBehavior.cs b/sources/editor/Stride.Core.Assets.Editor/View/Behaviors/GuidInputMaskBehavior.cs new file mode 100644 index 0000000000..d0a9c97ff9 --- /dev/null +++ b/sources/editor/Stride.Core.Assets.Editor/View/Behaviors/GuidInputMaskBehavior.cs @@ -0,0 +1,151 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. +using System.Windows.Controls; +using Microsoft.Xaml.Behaviors; + +namespace Stride.Core.Assets.Editor.View.Behaviors +{ + /// + /// Live input mask for Guid text: re-groups hex input as 8-4-4-4-12 while typing, inserting + /// the dashes automatically (including right after the 8th/12th/16th/20th hex digit, so the + /// caret is already past the dash for the next group). Deletions are left alone — removing a + /// dash with backspace doesn't fight the user by re-appending it. Input that isn't plain + /// hex/dashes (braces, garbage) is left untouched; commit-time validation deals with it. + /// + public class GuidInputMaskBehavior : Behavior + { + private const int HexDigits = 32; + private const int FullLength = 36; // 32 hex digits + 4 dashes + private static readonly int[] GroupSizes = [8, 4, 4, 4, 12]; + + private bool updating; + private int previousLength; + + protected override void OnAttached() + { + base.OnAttached(); + // A formatted Guid is exactly 36 chars: once full, further typing is rejected natively + // (replacing a selection still works). Programmatic sets are unaffected. + AssociatedObject.MaxLength = FullLength; + previousLength = AssociatedObject.Text?.Length ?? 0; + AssociatedObject.TextChanged += OnTextChanged; + } + + protected override void OnDetaching() + { + AssociatedObject.TextChanged -= OnTextChanged; + base.OnDetaching(); + } + + private void OnTextChanged(object sender, TextChangedEventArgs e) + { + if (updating) + return; + + var box = AssociatedObject; + var text = box.Text ?? string.Empty; + var grew = text.Length > previousLength; + previousLength = text.Length; + + // Also tolerate the brace/paren Guid forms on paste ("{...}", "(...)"): the wrapper + // characters are dropped so the content still fits the mask. + var raw = text.Replace("-", "").Trim('{', '}', '(', ')', ' '); + var valid = raw.Length <= HexDigits && IsHex(raw); + SetInvalidHint(!valid); + if (!valid) + return; + + var formatted = Format(raw, appendTrailingDash: grew); + if (formatted == text) + return; + + var rawBeforeCaret = CountHexBefore(text, box.CaretIndex); + + updating = true; + try + { + // SetCurrentValue keeps the Text binding alive (a plain Text= would clear it). + box.SetCurrentValue(TextBox.TextProperty, formatted); + box.CaretIndex = CaretAfter(formatted, rawBeforeCaret, grew); + previousLength = formatted.Length; + } + finally + { + updating = false; + } + } + + /// + /// Live feedback while typing: non-hex content turns the text red immediately, without + /// blocking or rewriting the input — commit-time validation still does the hard reject. + /// + private void SetInvalidHint(bool invalid) + { + if (invalid) + AssociatedObject.SetCurrentValue(System.Windows.Controls.Control.ForegroundProperty, System.Windows.Media.Brushes.IndianRed); + else + AssociatedObject.InvalidateProperty(System.Windows.Controls.Control.ForegroundProperty); + } + + private static bool IsHex(string s) + { + foreach (var c in s) + { + if (!char.IsAsciiHexDigit(c)) + return false; + } + return true; + } + + private static string Format(string raw, bool appendTrailingDash) + { + var result = new System.Text.StringBuilder(raw.Length + 4); + int taken = 0; + foreach (var size in GroupSizes) + { + if (taken >= raw.Length) + break; + + var count = System.Math.Min(size, raw.Length - taken); + result.Append(raw, taken, count); + taken += count; + + var groupFull = count == size && taken < HexDigits; + // Dash between groups when more digits follow; trailing dash only on growth, + // so backspacing over a dash doesn't immediately re-append it. + if (groupFull && (taken < raw.Length || appendTrailingDash)) + result.Append('-'); + } + return result.ToString(); + } + + private static int CountHexBefore(string text, int caret) + { + int count = 0; + for (int i = 0; i < caret && i < text.Length; i++) + { + if (text[i] != '-') + count++; + } + return count; + } + + private static int CaretAfter(string formatted, int rawBefore, bool grew) + { + int i = 0, seen = 0; + while (i < formatted.Length && seen < rawBefore) + { + if (formatted[i] != '-') + seen++; + i++; + } + // After typing, hop over the dash we just inserted so the next digit starts the new group. + if (grew) + { + while (i < formatted.Length && formatted[i] == '-') + i++; + } + return i; + } + } +} diff --git a/sources/editor/Stride.Core.Assets.Editor/View/DefaultPropertyTemplateProviders.xaml b/sources/editor/Stride.Core.Assets.Editor/View/DefaultPropertyTemplateProviders.xaml index ac4752ef2a..492e181024 100644 --- a/sources/editor/Stride.Core.Assets.Editor/View/DefaultPropertyTemplateProviders.xaml +++ b/sources/editor/Stride.Core.Assets.Editor/View/DefaultPropertyTemplateProviders.xaml @@ -1009,6 +1009,35 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/sources/editor/Stride.Core.Assets.Editor/View/ValueConverters/GuidTemplateRemainder.cs b/sources/editor/Stride.Core.Assets.Editor/View/ValueConverters/GuidTemplateRemainder.cs new file mode 100644 index 0000000000..1ec40daa37 --- /dev/null +++ b/sources/editor/Stride.Core.Assets.Editor/View/ValueConverters/GuidTemplateRemainder.cs @@ -0,0 +1,25 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. +using System; +using System.Globalization; +using Stride.Core.Presentation.ValueConverters; + +namespace Stride.Core.Assets.Editor.View.ValueConverters +{ + /// + /// Ghost-text helper for the Guid editor: given the text typed so far, returns the rest of the + /// canonical Guid template ("00000000-0000-0000-0000-000000000000"), so the editor can show + /// inline how many characters (and which dashes) are still missing. Empty once the input + /// reaches the full template length. + /// + public class GuidTemplateRemainder : OneWayValueConverter + { + private const string Template = "00000000-0000-0000-0000-000000000000"; + + public override object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + var text = value as string ?? string.Empty; + return text.Length < Template.Length ? Template[text.Length..] : string.Empty; + } + } +} diff --git a/sources/editor/Stride.Core.Assets.Editor/View/ValueConverters/GuidToString.cs b/sources/editor/Stride.Core.Assets.Editor/View/ValueConverters/GuidToString.cs new file mode 100644 index 0000000000..11a9ba5510 --- /dev/null +++ b/sources/editor/Stride.Core.Assets.Editor/View/ValueConverters/GuidToString.cs @@ -0,0 +1,25 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. +using System; +using System.Globalization; +using Stride.Core.Presentation.Quantum.ViewModels; +using Stride.Core.Presentation.ValueConverters; + +namespace Stride.Core.Assets.Editor.View.ValueConverters +{ + public class GuidToString : ValueConverterBase + { + public override object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + return value != NodeViewModel.DifferentValues ? value?.ToString() ?? string.Empty : null; + } + + public override object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + // Invalid text is passed through untouched: the strongly-typed node setter then throws + // InvalidCastException, which TextBoxBase turns into TextToSourceValueConversionFailed — + // the validation behavior shows its error adorner and the text reverts to the source value. + return Guid.TryParse(value as string, out var guid) ? guid : value; + } + } +}