diff --git a/src/Avalonia/HandyControlDemo_Avalonia/Data/DemoInfo.json b/src/Avalonia/HandyControlDemo_Avalonia/Data/DemoInfo.json
index 91de8154..5c507e64 100644
--- a/src/Avalonia/HandyControlDemo_Avalonia/Data/DemoInfo.json
+++ b/src/Avalonia/HandyControlDemo_Avalonia/Data/DemoInfo.json
@@ -46,6 +46,7 @@
[ "ButtonGroup", "ButtonGroupDemo", "Brush.ButtonGroup", "", "Button" ],
[ "ProgressButton", "ProgressButtonDemo", "Brush.ProgressBarStatusBar8", "", "Button" ],
[ "Card", "CardDemo", "Brush.ContactCard", "", "DataDisplay" ],
+ [ "PinBox", "PinBoxDemo", "Brush.Ellipsis", "", "DataEntry" ],
[ "ElementGroup", "ElementGroupDemo", "Brush.ButtonGroup", "", "Layout" ],
[ "CirclePanel", "CirclePanelDemo", "Brush.ColorWheel", "", "Layout" ],
[ "Grid", "GridDemo", "Brush.GridPane", "", "Layout" ],
diff --git a/src/Avalonia/HandyControlDemo_Avalonia/UserControl/Controls/PinBoxDemo.axaml b/src/Avalonia/HandyControlDemo_Avalonia/UserControl/Controls/PinBoxDemo.axaml
new file mode 100644
index 00000000..e84216ba
--- /dev/null
+++ b/src/Avalonia/HandyControlDemo_Avalonia/UserControl/Controls/PinBoxDemo.axaml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Avalonia/HandyControlDemo_Avalonia/UserControl/Controls/PinBoxDemo.axaml.cs b/src/Avalonia/HandyControlDemo_Avalonia/UserControl/Controls/PinBoxDemo.axaml.cs
new file mode 100644
index 00000000..b65b579d
--- /dev/null
+++ b/src/Avalonia/HandyControlDemo_Avalonia/UserControl/Controls/PinBoxDemo.axaml.cs
@@ -0,0 +1,29 @@
+using Avalonia.Interactivity;
+using HandyControl.Controls;
+using System;
+using System.Reflection;
+
+namespace HandyControlDemo.UserControl;
+
+public partial class PinBoxDemo : Avalonia.Controls.UserControl
+{
+ public PinBoxDemo()
+ {
+ InitializeComponent();
+ }
+
+ private void PinBox_OnCompleted(object? sender, RoutedEventArgs e)
+ {
+ if (e.Source is PinBox pinBox)
+ {
+ TryShowGrowl(pinBox.Password);
+ }
+ }
+
+ private static void TryShowGrowl(string message)
+ {
+ var growlType = Type.GetType("HandyControl.Controls.Growl, HandyControl");
+ var infoMethod = growlType?.GetMethod("Info", BindingFlags.Public | BindingFlags.Static, [typeof(string), typeof(string)]);
+ infoMethod?.Invoke(null, [message, string.Empty]);
+ }
+}
diff --git a/src/Avalonia/HandyControl_Avalonia/Controls/Input/PinBox.cs b/src/Avalonia/HandyControl_Avalonia/Controls/Input/PinBox.cs
new file mode 100644
index 00000000..5b43f51d
--- /dev/null
+++ b/src/Avalonia/HandyControl_Avalonia/Controls/Input/PinBox.cs
@@ -0,0 +1,469 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Metadata;
+using Avalonia.Controls.Primitives;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Threading;
+
+namespace HandyControl.Controls;
+
+[TemplatePart(Name = ElementPanel, Type = typeof(Panel))]
+public class PinBox : TemplatedControl
+{
+ private const string ElementPanel = "PART_Panel";
+ private const int MinLength = 4;
+
+ public static readonly StyledProperty PasswordCharProperty =
+ AvaloniaProperty.Register(nameof(PasswordChar), '●');
+
+ public static readonly StyledProperty LengthProperty =
+ AvaloniaProperty.Register(nameof(Length), MinLength);
+
+ public static readonly StyledProperty ItemMarginProperty =
+ AvaloniaProperty.Register(nameof(ItemMargin), new Thickness(4, 0));
+
+ public static readonly StyledProperty ItemWidthProperty =
+ AvaloniaProperty.Register(nameof(ItemWidth), 32d);
+
+ public static readonly StyledProperty ItemHeightProperty =
+ AvaloniaProperty.Register(nameof(ItemHeight), 32d);
+
+ public static readonly StyledProperty SelectionBrushProperty =
+ AvaloniaProperty.Register(nameof(SelectionBrush));
+
+ public static readonly StyledProperty CaretBrushProperty =
+ AvaloniaProperty.Register(nameof(CaretBrush));
+
+ public static readonly StyledProperty IsSafeEnabledProperty =
+ AvaloniaProperty.Register(nameof(IsSafeEnabled), true);
+
+ public static readonly StyledProperty UnsafePasswordProperty =
+ AvaloniaProperty.Register(nameof(UnsafePassword), defaultBindingMode: Avalonia.Data.BindingMode.TwoWay);
+
+ public static readonly StyledProperty PasswordProperty =
+ AvaloniaProperty.Register(nameof(Password));
+
+ public static readonly RoutedEvent CompletedEvent =
+ RoutedEvent.Register(nameof(Completed), RoutingStrategies.Bubble);
+
+ private Panel? _panel;
+ private readonly List _textBoxes = [];
+ private bool _isInternalAction;
+ private int _inputIndex;
+ private string _cachedPassword = string.Empty;
+
+ static PinBox()
+ {
+ FocusableProperty.OverrideDefaultValue(false);
+
+ LengthProperty.Changed.AddClassHandler((pinBox, e) =>
+ {
+ var newLength = e.GetNewValue();
+ if (newLength < MinLength)
+ {
+ pinBox.SetCurrentValue(LengthProperty, MinLength);
+ return;
+ }
+
+ pinBox.UpdateItems();
+ });
+
+ ItemMarginProperty.Changed.AddClassHandler((pinBox, _) => pinBox.ApplyItemVisualProperties());
+ ItemWidthProperty.Changed.AddClassHandler((pinBox, _) => pinBox.ApplyItemVisualProperties());
+ ItemHeightProperty.Changed.AddClassHandler((pinBox, _) => pinBox.ApplyItemVisualProperties());
+ PasswordCharProperty.Changed.AddClassHandler((pinBox, _) => pinBox.ApplyItemVisualProperties());
+ ForegroundProperty.Changed.AddClassHandler((pinBox, _) => pinBox.ApplyItemVisualProperties());
+ SelectionBrushProperty.Changed.AddClassHandler((pinBox, _) => pinBox.ApplyItemVisualProperties());
+ CaretBrushProperty.Changed.AddClassHandler((pinBox, _) => pinBox.ApplyItemVisualProperties());
+
+ PasswordProperty.Changed.AddClassHandler((pinBox, e) =>
+ {
+ if (pinBox._isInternalAction)
+ {
+ return;
+ }
+
+ pinBox.ApplyPasswordToItems(e.GetNewValue() ?? string.Empty);
+ pinBox.UpdateUnsafePassword();
+ });
+
+ UnsafePasswordProperty.Changed.AddClassHandler((pinBox, e) =>
+ {
+ if (pinBox._isInternalAction || pinBox.IsSafeEnabled)
+ {
+ return;
+ }
+
+ pinBox.SetCurrentValue(PasswordProperty, e.GetNewValue() ?? string.Empty);
+ });
+
+ IsSafeEnabledProperty.Changed.AddClassHandler((pinBox, e) => pinBox.OnIsSafeEnabledChanged(e.GetNewValue()));
+ }
+
+ public event EventHandler Completed
+ {
+ add => AddHandler(CompletedEvent, value);
+ remove => RemoveHandler(CompletedEvent, value);
+ }
+
+ public char PasswordChar
+ {
+ get => GetValue(PasswordCharProperty);
+ set => SetValue(PasswordCharProperty, value);
+ }
+
+ public int Length
+ {
+ get => GetValue(LengthProperty);
+ set => SetValue(LengthProperty, value < MinLength ? MinLength : value);
+ }
+
+ public Thickness ItemMargin
+ {
+ get => GetValue(ItemMarginProperty);
+ set => SetValue(ItemMarginProperty, value);
+ }
+
+ public double ItemWidth
+ {
+ get => GetValue(ItemWidthProperty);
+ set => SetValue(ItemWidthProperty, value);
+ }
+
+ public double ItemHeight
+ {
+ get => GetValue(ItemHeightProperty);
+ set => SetValue(ItemHeightProperty, value);
+ }
+
+ public IBrush? SelectionBrush
+ {
+ get => GetValue(SelectionBrushProperty);
+ set => SetValue(SelectionBrushProperty, value);
+ }
+
+ public IBrush? CaretBrush
+ {
+ get => GetValue(CaretBrushProperty);
+ set => SetValue(CaretBrushProperty, value);
+ }
+
+ public bool IsSafeEnabled
+ {
+ get => GetValue(IsSafeEnabledProperty);
+ set => SetValue(IsSafeEnabledProperty, value);
+ }
+
+ public string UnsafePassword
+ {
+ get => GetValue(UnsafePasswordProperty) ?? string.Empty;
+ set => SetValue(UnsafePasswordProperty, value);
+ }
+
+ public string Password
+ {
+ get => GetValue(PasswordProperty) ?? string.Empty;
+ set => SetValue(PasswordProperty, value);
+ }
+
+ protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
+ {
+ base.OnApplyTemplate(e);
+
+ _panel = e.NameScope.Find(ElementPanel);
+ UpdateItems();
+
+ var initialPassword = string.IsNullOrEmpty(Password) ? _cachedPassword : Password;
+ if (!string.IsNullOrEmpty(initialPassword))
+ {
+ ApplyPasswordToItems(initialPassword);
+ }
+
+ OnIsSafeEnabledChanged(IsSafeEnabled);
+ }
+
+ protected override void OnGotFocus(GotFocusEventArgs e)
+ {
+ base.OnGotFocus(e);
+ FocusPasswordBox();
+ }
+
+ private void FocusPasswordBox()
+ {
+ if (_textBoxes.Count == 0)
+ {
+ return;
+ }
+
+ if (_textBoxes.Any(box => box.IsFocused))
+ {
+ return;
+ }
+
+ var index = Math.Clamp(_inputIndex, 0, _textBoxes.Count - 1);
+ _textBoxes[index].Focus();
+ _textBoxes[index].SelectAll();
+ }
+
+ private void UpdateItems()
+ {
+ if (_panel == null)
+ {
+ return;
+ }
+
+ _panel.Children.Clear();
+ _textBoxes.Clear();
+ _inputIndex = 0;
+
+ var length = Math.Max(Length, MinLength);
+ for (var i = 0; i < length; i++)
+ {
+ var textBox = CreateTextBox();
+ _textBoxes.Add(textBox);
+ _panel.Children.Add(textBox);
+ }
+
+ ApplyItemVisualProperties();
+ }
+
+ private TextBox CreateTextBox()
+ {
+ var textBox = new TextBox
+ {
+ MaxLength = 1,
+ HorizontalContentAlignment = HorizontalAlignment.Center,
+ VerticalContentAlignment = VerticalAlignment.Center,
+ TextAlignment = TextAlignment.Center,
+ Padding = default
+ };
+
+ textBox.TextChanged += TextBoxOnTextChanged;
+ textBox.GotFocus += TextBoxOnGotFocus;
+ textBox.AddHandler(InputElement.KeyDownEvent, TextBoxOnKeyDown, RoutingStrategies.Tunnel, handledEventsToo: true);
+
+ return textBox;
+ }
+
+ private void ApplyItemVisualProperties()
+ {
+ foreach (var textBox in _textBoxes)
+ {
+ textBox.Margin = ItemMargin;
+ textBox.Width = ItemWidth;
+ textBox.Height = ItemHeight;
+ textBox.PasswordChar = PasswordChar;
+ textBox.Foreground = Foreground;
+ textBox.SelectionBrush = SelectionBrush;
+ textBox.CaretBrush = CaretBrush;
+ }
+ }
+
+ private void TextBoxOnGotFocus(object? sender, GotFocusEventArgs e)
+ {
+ if (sender is not TextBox textBox)
+ {
+ return;
+ }
+
+ _inputIndex = _textBoxes.IndexOf(textBox);
+ textBox.SelectAll();
+ }
+
+ private void TextBoxOnTextChanged(object? sender, TextChangedEventArgs e)
+ {
+ if (_isInternalAction || sender is not TextBox textBox)
+ {
+ return;
+ }
+
+ var index = _textBoxes.IndexOf(textBox);
+ if (index < 0)
+ {
+ return;
+ }
+
+ if (!string.IsNullOrEmpty(textBox.Text) && textBox.Text!.Length > 1)
+ {
+ _isInternalAction = true;
+ textBox.Text = textBox.Text[^1].ToString();
+ _isInternalAction = false;
+ }
+
+ _inputIndex = index;
+
+ if (!string.IsNullOrEmpty(textBox.Text))
+ {
+ MoveToNext(index);
+ }
+
+ SyncPasswordFromItems();
+
+ if (_textBoxes.Count > 0 && _textBoxes.All(item => !string.IsNullOrEmpty(item.Text)))
+ {
+ RaiseEvent(new RoutedEventArgs(CompletedEvent, this));
+ Dispatcher.UIThread.Post(() => TopLevel.GetTopLevel(this)?.FocusManager?.ClearFocus(), DispatcherPriority.Background);
+ }
+ }
+
+ private void TextBoxOnKeyDown(object? sender, KeyEventArgs e)
+ {
+ if (sender is not TextBox textBox)
+ {
+ return;
+ }
+
+ var index = _textBoxes.IndexOf(textBox);
+ if (index < 0)
+ {
+ return;
+ }
+
+ switch (e.Key)
+ {
+ case Key.Left:
+ MoveFocus(Math.Max(index - 1, 0));
+ e.Handled = true;
+ break;
+ case Key.Right:
+ MoveFocus(Math.Min(index + 1, _textBoxes.Count - 1));
+ e.Handled = true;
+ break;
+ case Key.Back:
+ HandleBackspace(index, textBox);
+ e.Handled = true;
+ break;
+ case Key.Delete:
+ HandleDelete(index, textBox);
+ e.Handled = true;
+ break;
+ }
+ }
+
+ private void HandleBackspace(int index, TextBox textBox)
+ {
+ var target = Math.Max(index - 1, 0);
+
+ _isInternalAction = true;
+ if (string.IsNullOrEmpty(textBox.Text) && index > 0)
+ {
+ _textBoxes[target].Clear();
+ }
+ else
+ {
+ textBox.Clear();
+ }
+ _isInternalAction = false;
+
+ MoveFocusDeferred(target);
+ SyncPasswordFromItems();
+ }
+
+ private void HandleDelete(int index, TextBox textBox)
+ {
+ var target = Math.Max(index - 1, 0);
+
+ _isInternalAction = true;
+ if (string.IsNullOrEmpty(textBox.Text) && index > 0)
+ {
+ _textBoxes[target].Clear();
+ }
+ else
+ {
+ textBox.Clear();
+ }
+ _isInternalAction = false;
+
+ MoveFocusDeferred(target);
+ SyncPasswordFromItems();
+ }
+
+ private void MoveToNext(int index)
+ {
+ if (index >= _textBoxes.Count - 1)
+ {
+ _inputIndex = _textBoxes.Count - 1;
+ return;
+ }
+
+ MoveFocus(index + 1);
+ }
+
+ private void MoveFocus(int index)
+ {
+ if (_textBoxes.Count == 0)
+ {
+ return;
+ }
+
+ _inputIndex = Math.Clamp(index, 0, _textBoxes.Count - 1);
+ var textBox = _textBoxes[_inputIndex];
+ textBox.Focus();
+ textBox.SelectAll();
+ }
+
+ private void MoveFocusDeferred(int index)
+ {
+ _inputIndex = Math.Clamp(index, 0, Math.Max(_textBoxes.Count - 1, 0));
+ Dispatcher.UIThread.Post(() => MoveFocus(_inputIndex), DispatcherPriority.Input);
+ }
+
+ private void SyncPasswordFromItems()
+ {
+ var password = string.Concat(_textBoxes.Select(box => box.Text ?? string.Empty));
+ _cachedPassword = password;
+
+ _isInternalAction = true;
+ SetCurrentValue(PasswordProperty, password);
+ _isInternalAction = false;
+
+ UpdateUnsafePassword();
+ }
+
+ private void ApplyPasswordToItems(string password)
+ {
+ _cachedPassword = password;
+
+ if (_textBoxes.Count == 0)
+ {
+ return;
+ }
+
+ _isInternalAction = true;
+
+ var limited = password.Length > _textBoxes.Count ? password[.._textBoxes.Count] : password;
+ for (var i = 0; i < _textBoxes.Count; i++)
+ {
+ _textBoxes[i].Text = i < limited.Length ? limited[i].ToString() : string.Empty;
+ }
+
+ _isInternalAction = false;
+ _inputIndex = Math.Min(limited.Length, _textBoxes.Count - 1);
+ }
+
+ private void OnIsSafeEnabledChanged(bool isSafeEnabled)
+ {
+ if (isSafeEnabled)
+ {
+ _isInternalAction = true;
+ SetCurrentValue(UnsafePasswordProperty, string.Empty);
+ _isInternalAction = false;
+ return;
+ }
+
+ UpdateUnsafePassword();
+ }
+
+ private void UpdateUnsafePassword()
+ {
+ _isInternalAction = true;
+ SetCurrentValue(UnsafePasswordProperty, IsSafeEnabled ? string.Empty : (_textBoxes.Count == 0 ? _cachedPassword : string.Concat(_textBoxes.Select(item => item.Text ?? string.Empty))));
+ _isInternalAction = false;
+ }
+}
diff --git a/src/Avalonia/HandyControl_Avalonia/Themes/Styles/MergedStyles.axaml b/src/Avalonia/HandyControl_Avalonia/Themes/Styles/MergedStyles.axaml
index 16d30524..f1beca3f 100644
--- a/src/Avalonia/HandyControl_Avalonia/Themes/Styles/MergedStyles.axaml
+++ b/src/Avalonia/HandyControl_Avalonia/Themes/Styles/MergedStyles.axaml
@@ -59,6 +59,7 @@
+
diff --git a/src/Avalonia/HandyControl_Avalonia/Themes/Styles/PinBox.axaml b/src/Avalonia/HandyControl_Avalonia/Themes/Styles/PinBox.axaml
new file mode 100644
index 00000000..62e8c880
--- /dev/null
+++ b/src/Avalonia/HandyControl_Avalonia/Themes/Styles/PinBox.axaml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+