diff --git a/demo/Ursa.Demo/Pages/IPv4BoxDemo.axaml b/demo/Ursa.Demo/Pages/IPv4BoxDemo.axaml new file mode 100644 index 0000000..0d674dd --- /dev/null +++ b/demo/Ursa.Demo/Pages/IPv4BoxDemo.axaml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/demo/Ursa.Demo/Pages/IPv4BoxDemo.axaml.cs b/demo/Ursa.Demo/Pages/IPv4BoxDemo.axaml.cs new file mode 100644 index 0000000..c865da8 --- /dev/null +++ b/demo/Ursa.Demo/Pages/IPv4BoxDemo.axaml.cs @@ -0,0 +1,29 @@ +using System; +using System.Net; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Ursa.Demo.Pages; + +public partial class IPv4BoxDemo : UserControl +{ + public IPv4BoxDemo() + { + InitializeComponent(); + DataContext = new IPv4DemoViewMode(); + } +} + +public partial class IPv4DemoViewMode: ObservableObject +{ + [ObservableProperty] + private IPAddress? _address; + + public void ChangeAddress() + { + long l = Random.Shared.NextInt64(0x00000000FFFFFFFF); + Address = new IPAddress(l); + } +} \ No newline at end of file diff --git a/demo/Ursa.Demo/Ursa.Demo.csproj b/demo/Ursa.Demo/Ursa.Demo.csproj index 272045d..101380b 100644 --- a/demo/Ursa.Demo/Ursa.Demo.csproj +++ b/demo/Ursa.Demo/Ursa.Demo.csproj @@ -23,7 +23,7 @@ - + diff --git a/demo/Ursa.Demo/Views/MainWindow.axaml b/demo/Ursa.Demo/Views/MainWindow.axaml index a5c4284..613691a 100644 --- a/demo/Ursa.Demo/Views/MainWindow.axaml +++ b/demo/Ursa.Demo/Views/MainWindow.axaml @@ -29,6 +29,9 @@ + + + diff --git a/src/Ursa.Themes.Semi/Controls/IPv4Box.axaml b/src/Ursa.Themes.Semi/Controls/IPv4Box.axaml new file mode 100644 index 0000000..4a55d21 --- /dev/null +++ b/src/Ursa.Themes.Semi/Controls/IPv4Box.axaml @@ -0,0 +1,127 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Ursa.Themes.Semi/Controls/_index.axaml b/src/Ursa.Themes.Semi/Controls/_index.axaml index 8b9ac73..f0db1d0 100644 --- a/src/Ursa.Themes.Semi/Controls/_index.axaml +++ b/src/Ursa.Themes.Semi/Controls/_index.axaml @@ -3,5 +3,6 @@ + diff --git a/src/Ursa.Themes.Semi/Themes/Dark/IPv4Box.axaml b/src/Ursa.Themes.Semi/Themes/Dark/IPv4Box.axaml new file mode 100644 index 0000000..b567dd0 --- /dev/null +++ b/src/Ursa.Themes.Semi/Themes/Dark/IPv4Box.axaml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/Ursa.Themes.Semi/Themes/Dark/_index.axaml b/src/Ursa.Themes.Semi/Themes/Dark/_index.axaml index 8b9ac73..f0db1d0 100644 --- a/src/Ursa.Themes.Semi/Themes/Dark/_index.axaml +++ b/src/Ursa.Themes.Semi/Themes/Dark/_index.axaml @@ -3,5 +3,6 @@ + diff --git a/src/Ursa.Themes.Semi/Themes/Light/IPv4Box.axaml b/src/Ursa.Themes.Semi/Themes/Light/IPv4Box.axaml new file mode 100644 index 0000000..5c3fb6b --- /dev/null +++ b/src/Ursa.Themes.Semi/Themes/Light/IPv4Box.axaml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/Ursa.Themes.Semi/Themes/Light/_index.axaml b/src/Ursa.Themes.Semi/Themes/Light/_index.axaml index 8b9ac73..f0db1d0 100644 --- a/src/Ursa.Themes.Semi/Themes/Light/_index.axaml +++ b/src/Ursa.Themes.Semi/Themes/Light/_index.axaml @@ -3,5 +3,6 @@ + diff --git a/src/Ursa.Themes.Semi/Themes/Shared/IPv4Box.axaml b/src/Ursa.Themes.Semi/Themes/Shared/IPv4Box.axaml new file mode 100644 index 0000000..3f808ba --- /dev/null +++ b/src/Ursa.Themes.Semi/Themes/Shared/IPv4Box.axaml @@ -0,0 +1,8 @@ + + + 32 + 24 + 40 + 1 + 3 + diff --git a/src/Ursa.Themes.Semi/Themes/Shared/_index.axaml b/src/Ursa.Themes.Semi/Themes/Shared/_index.axaml index 8b9ac73..f0db1d0 100644 --- a/src/Ursa.Themes.Semi/Themes/Shared/_index.axaml +++ b/src/Ursa.Themes.Semi/Themes/Shared/_index.axaml @@ -3,5 +3,6 @@ + diff --git a/src/Ursa/Controls/IPv4Box.cs b/src/Ursa/Controls/IPv4Box.cs new file mode 100644 index 0000000..f70b8eb --- /dev/null +++ b/src/Ursa/Controls/IPv4Box.cs @@ -0,0 +1,580 @@ +using System.Diagnostics; +using System.Net; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Metadata; +using Avalonia.Controls.Presenters; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Input.Platform; +using Avalonia.Interactivity; +using Avalonia.Media; +using Avalonia.Media.TextFormatting; +// ReSharper disable InconsistentNaming + +namespace Ursa.Controls; + +public enum IPv4BoxInputMode +{ + Normal, + // In fast mode, automatically move to next session after 3 digits input. + Fast, +} + +[TemplatePart(PART_FirstTextPresenter, typeof(TextPresenter))] +[TemplatePart(PART_SecondTextPresenter, typeof(TextPresenter))] +[TemplatePart(PART_ThirdTextPresenter, typeof(TextPresenter))] +[TemplatePart(PART_FourthTextPresenter, typeof(TextPresenter))] +public class IPv4Box: TemplatedControl +{ + public const string PART_FirstTextPresenter = "PART_FirstTextPresenter"; + public const string PART_SecondTextPresenter = "PART_SecondTextPresenter"; + public const string PART_ThirdTextPresenter = "PART_ThirdTextPresenter"; + public const string PART_FourthTextPresenter = "PART_FourthTextPresenter"; + private TextPresenter? _firstText; + private TextPresenter? _secondText; + private TextPresenter? _thirdText; + private TextPresenter? _fourthText; + private byte? _firstByte; + private byte? _secondByte; + private byte? _thirdByte; + private byte? _fourthByte; + private readonly TextPresenter?[] _presenters = new TextPresenter?[4]; + private TextPresenter? _currentActivePresenter; + + public static readonly StyledProperty IPAddressProperty = AvaloniaProperty.Register( + nameof(IPAddress)); + public IPAddress? IPAddress + { + get => GetValue(IPAddressProperty); + set => SetValue(IPAddressProperty, value); + } + + public static readonly StyledProperty TextAlignmentProperty = + TextBox.TextAlignmentProperty.AddOwner(); + public TextAlignment TextAlignment + { + get => GetValue(TextAlignmentProperty); + set => SetValue(TextAlignmentProperty, value); + } + + public static readonly StyledProperty SelectionBrushProperty = + TextBox.SelectionBrushProperty.AddOwner(); + public IBrush? SelectionBrush + { + get => GetValue(SelectionBrushProperty); + set => SetValue(SelectionBrushProperty, value); + } + + public static readonly StyledProperty SelectionForegroundBrushProperty = + TextBox.SelectionForegroundBrushProperty.AddOwner(); + public IBrush? SelectionForegroundBrush + { + get => GetValue(SelectionForegroundBrushProperty); + set => SetValue(SelectionForegroundBrushProperty, value); + } + + public static readonly StyledProperty CaretBrushProperty = TextBox.CaretBrushProperty.AddOwner(); + public IBrush? CaretBrush + { + get => GetValue(CaretBrushProperty); + set => SetValue(CaretBrushProperty, value); + } + + public static readonly StyledProperty ShowLeadingZeroProperty = AvaloniaProperty.Register( + nameof(ShowLeadingZero)); + public bool ShowLeadingZero + { + get => GetValue(ShowLeadingZeroProperty); + set => SetValue(ShowLeadingZeroProperty, value); + } + + public static readonly StyledProperty InputModeProperty = AvaloniaProperty.Register( + nameof(InputMode)); + public IPv4BoxInputMode InputMode + { + get => GetValue(InputModeProperty); + set => SetValue(InputModeProperty, value); + } + + static IPv4Box() + { + ShowLeadingZeroProperty.Changed.AddClassHandler((o, e) => o.OnFormatChange(e)); + IPAddressProperty.Changed.AddClassHandler((o, e) => o.OnIPChanged(e)); + } + + #region Overrides + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + _firstText = e.NameScope.Find(PART_FirstTextPresenter); + _secondText = e.NameScope.Find(PART_SecondTextPresenter); + _thirdText = e.NameScope.Find(PART_ThirdTextPresenter); + _fourthText = e.NameScope.Find(PART_FourthTextPresenter); + _presenters[0] = _firstText; + _presenters[1] = _secondText; + _presenters[2] = _thirdText; + _presenters[3] = _fourthText; + } + + protected override void OnKeyDown(KeyEventArgs e) + { + if (_currentActivePresenter is null) return; + var keymap = AvaloniaLocator.Current.GetRequiredService(); + bool Match(List gestures) => gestures.Any(g => g.Matches(e)); + if (e.Key == Key.Enter) + { + ParseBytes(ShowLeadingZero); + SetIPAddressInternal(); + base.OnKeyDown(e); + return; + } + if (Match(keymap.SelectAll)) + { + _currentActivePresenter.SelectionStart = 0; + _currentActivePresenter.SelectionEnd = _currentActivePresenter.Text?.Length ?? 0; + return; + } + else if (Match(keymap.Copy)) + { + Copy(); + } + else if (Match(keymap.Paste)) + { + Paste(); + } + if (e.Key == Key.Tab) + { + _currentActivePresenter?.HideCaret(); + _currentActivePresenter.ClearSelection(); + if (Equals(_currentActivePresenter, _fourthText)) + { + base.OnKeyDown(e); + return; + } + MoveToNextPresenter(_currentActivePresenter, true); + _currentActivePresenter?.ShowCaret(); + e.Handled = true; + } + else if (e.Key == Key.OemPeriod || e.Key == Key.Decimal) + { + if (string.IsNullOrEmpty(_currentActivePresenter.Text)) + { + base.OnKeyDown(e); + return; + } + _currentActivePresenter?.HideCaret(); + _currentActivePresenter.ClearSelection(); + if (Equals(_currentActivePresenter, _fourthText)) + { + base.OnKeyDown(e); + return; + } + MoveToNextPresenter(_currentActivePresenter, false); + _currentActivePresenter?.ShowCaret(); + _currentActivePresenter.MoveCaretToStart(); + e.Handled = true; + } + else if (e.Key == Key.Back) + { + DeleteImplementation(_currentActivePresenter); + } + else if (e.Key == Key.Right ) + { + OnPressRightKey(); + } + else if (e.Key == Key.Left) + { + OnPressLeftKey(); + } + else + { + base.OnKeyDown(e); + } + } + + protected override void OnTextInput(TextInputEventArgs e) + { + if (e.Handled) return; + string? s = e.Text; + if (string.IsNullOrEmpty(s)) return; + if (!char.IsNumber(s![0])) return; + if (_currentActivePresenter != null) + { + int index = _currentActivePresenter.CaretIndex; + string? oldText = _currentActivePresenter.Text; + if (oldText is null) + { + _currentActivePresenter.Text = s; + _currentActivePresenter.MoveCaretHorizontal(); + } + else + { + _currentActivePresenter.DeleteSelection(); + _currentActivePresenter.ClearSelection(); + oldText = _currentActivePresenter.Text; + + string newText = string.IsNullOrEmpty(oldText) + ? s + : oldText?.Substring(0, index) + s + oldText?.Substring(Math.Min(index, oldText.Length)); + if (newText.Length > 3) + { + newText = newText.Substring(0, 3); + } + _currentActivePresenter.Text = newText; + _currentActivePresenter.MoveCaretHorizontal(); + if (_currentActivePresenter.CaretIndex == 3 && InputMode == IPv4BoxInputMode.Fast) + { + _currentActivePresenter.HideCaret(); + bool success = MoveToNextPresenter(_currentActivePresenter, true); + _currentActivePresenter.ShowCaret(); + if (success) + { + _currentActivePresenter.SelectAll(); + _currentActivePresenter.MoveCaretToStart(); + } + } + } + } + } + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + Point position = e.GetPosition(_firstText); + foreach (var presenter in _presenters) + { + if (presenter?.Bounds.Contains(position)??false) + { + if (e.ClickCount == 1) + { + presenter.ShowCaret(); + _currentActivePresenter = presenter; + var caretPosition = position.WithX(position.X - presenter.Bounds.X); + presenter.MoveCaretToPoint(caretPosition); + } + else if (e.ClickCount == 2) + { + presenter.SelectAll(); + presenter.MoveCaretToEnd(); + } + } + else + { + presenter?.HideCaret(); + presenter.ClearSelection(); + } + } + Debug.WriteLine(_currentActivePresenter?.Name); + base.OnPointerPressed(e); + } + + protected override void OnLostFocus(RoutedEventArgs e) + { + foreach (var pre in _presenters) + { + pre?.HideCaret(); + pre.ClearSelection(); + } + _currentActivePresenter = null; + ParseBytes(ShowLeadingZero); + SetIPAddressInternal(); + } + + protected override void OnGotFocus(GotFocusEventArgs e) + { + _currentActivePresenter = _firstText; + if (_currentActivePresenter is null) + { + base.OnGotFocus(e); + return; + } + _currentActivePresenter.ShowCaret(); + _currentActivePresenter.MoveCaretToStart(); + base.OnGotFocus(e); + } + #endregion + + private void OnFormatChange(AvaloniaPropertyChangedEventArgs arg) + { + bool showLeadingZero = arg.GetNewValue(); + ParseBytes(showLeadingZero); + } + + private void OnIPChanged(AvaloniaPropertyChangedEventArgs arg) + { + IPAddress? address = arg.GetNewValue(); + if (address is null) + { + foreach (var presenter in _presenters) + { + if(presenter!=null) presenter.Text = string.Empty; + } + ParseBytes(ShowLeadingZero); + } + else + { + var sections = address.ToString().Split('.'); + for (int i = 0; i < 4; i++) + { + var presenter = _presenters[i]; + if (presenter != null) + { + presenter.Text = sections[i]; + } + } + ParseBytes(ShowLeadingZero); + } + } + + private void ParseBytes(bool showLeadingZero) + { + string format = showLeadingZero ? "D3" : ""; + if (string.IsNullOrEmpty(_firstText?.Text) && string.IsNullOrEmpty(_secondText?.Text) && string.IsNullOrEmpty(_thirdText?.Text) && string.IsNullOrEmpty(_fourthText?.Text)) + { + _firstByte = null; + _secondByte = null; + _thirdByte = null; + _fourthByte = null; + return; + } + _firstByte = byte.TryParse(_firstText?.Text, out byte b1) ? b1 : (byte)0; + _secondByte = byte.TryParse(_secondText?.Text, out byte b2) ? b2 : (byte)0; + _thirdByte = byte.TryParse(_thirdText?.Text, out byte b3) ? b3 : (byte)0; + _fourthByte = byte.TryParse(_fourthText?.Text, out byte b4) ? b4 : (byte)0; + if (_firstText != null) _firstText.Text = _firstByte?.ToString(format); + if (_secondText != null) _secondText.Text = _secondByte?.ToString(format); + if (_thirdText != null) _thirdText.Text = _thirdByte?.ToString(format); + if (_fourthText != null) _fourthText.Text = _fourthByte?.ToString(format); + } + + + + private bool MoveToNextPresenter(TextPresenter? presenter, bool selectAllAfterMove) + { + if (presenter is null) return false; + if (Equals(presenter, _fourthText)) return false; + presenter.ClearSelection(); + if (Equals(presenter, _firstText)) _currentActivePresenter = _secondText; + else if (Equals(presenter, _secondText)) _currentActivePresenter = _thirdText; + else if (Equals(presenter, _thirdText)) _currentActivePresenter = _fourthText; + if(selectAllAfterMove) _currentActivePresenter.SelectAll(); + return true; + } + + private bool MoveToPreviousTextPresenter(TextPresenter? presenter) + { + if (presenter is null) return false; + if (Equals(presenter, _firstText)) return false; + presenter.ClearSelection(); + if (Equals(presenter, _fourthText)) _currentActivePresenter = _thirdText; + else if (Equals(presenter, _thirdText)) _currentActivePresenter = _secondText; + else if (Equals(presenter, _secondText)) _currentActivePresenter = _firstText; + return true; + } + + public void Clear() + { + foreach (var presenter in _presenters) + { + if (presenter != null) presenter.Text = null; + } + + _firstByte = null; + _secondByte = null; + _thirdByte = null; + _fourthByte = null; + IPAddress = null; + } + + private void SetIPAddressInternal() + { + if (_firstByte is null && _secondByte is null && _thirdByte is null && _fourthByte is null) + { + IPAddress = null; + return; + } + long address = 0; + address += _firstByte??0; + address += (_secondByte??0) << 8; + address += (_thirdByte??0) << 16; + address += ((long?)_fourthByte ?? 0) << 24; + try + { + IPAddress = new IPAddress(address); + } + catch + { + IPAddress = null; + } + } + + private void DeleteImplementation(TextPresenter? presenter) + { + if(presenter is null) return; + var oldText = presenter.Text; + if (presenter.SelectionStart != presenter.SelectionEnd) + { + presenter.DeleteSelection(); + presenter.ClearSelection(); + } + else if (string.IsNullOrWhiteSpace(oldText) || presenter.CaretIndex == 0) + { + presenter.HideCaret(); + MoveToPreviousTextPresenter(presenter); + if (_currentActivePresenter != null) + { + _currentActivePresenter.ShowCaret(); + _currentActivePresenter.MoveCaretToEnd(); + } + } + else + { + int index = presenter.CaretIndex; + string newText = oldText?.Substring(0, index - 1) + oldText?.Substring(Math.Min(index, oldText.Length)); + presenter.MoveCaretHorizontal(LogicalDirection.Backward); + presenter.Text = newText; + } + } + + private void OnPressRightKey() + { + if (_currentActivePresenter is null) return; + if (_currentActivePresenter.IsTextSelected()) + { + int end = _currentActivePresenter.SelectionEnd; + _currentActivePresenter.ClearSelection(); + _currentActivePresenter.MoveCaretToTextPosition(end); + return; + } + if (_currentActivePresenter.CaretIndex >= _currentActivePresenter.Text?.Length) + { + _currentActivePresenter.HideCaret(); + bool success = MoveToNextPresenter(_currentActivePresenter, false); + _currentActivePresenter.ClearSelection(); + _currentActivePresenter.ShowCaret(); + if (success) + { + _currentActivePresenter.MoveCaretToStart(); + } + } + else + { + _currentActivePresenter.ClearSelection(); + _currentActivePresenter.CaretIndex++; + } + } + + private void OnPressLeftKey() + { + if (_currentActivePresenter is null) return; + if (_currentActivePresenter.IsTextSelected()) + { + int start = _currentActivePresenter.SelectionStart; + _currentActivePresenter.ClearSelection(); + _currentActivePresenter.MoveCaretToTextPosition(start); + return; + } + if (_currentActivePresenter.CaretIndex == 0) + { + _currentActivePresenter.HideCaret(); + bool success = MoveToPreviousTextPresenter(_currentActivePresenter); + _currentActivePresenter.ClearSelection(); + _currentActivePresenter.ShowCaret(); + if (success) + { + _currentActivePresenter.MoveCaretToEnd(); + } + } + else + { + _currentActivePresenter.ClearSelection(); + _currentActivePresenter.CaretIndex--; + } + } + + public async void Copy() + { + string s = string.Join(".", _firstText?.Text, _secondText?.Text, _thirdText?.Text, _fourthText?.Text); + IClipboard? clipboard = AvaloniaLocator.Current.GetService(); + clipboard?.SetTextAsync(s); + } + + public static KeyGesture? CopyKeyGesture { get; } = AvaloniaLocator.Current.GetService()?.Copy.FirstOrDefault(); + public static KeyGesture? PasteKeyGesture { get; } = AvaloniaLocator.Current.GetService()?.Paste.FirstOrDefault(); + public static KeyGesture? CutKeyGesture { get; } = AvaloniaLocator.Current.GetService()?.Cut.FirstOrDefault(); + + public async void Paste() + { + IClipboard? clipboard = AvaloniaLocator.Current.GetService(); + if (clipboard is null) return; + string s = await clipboard.GetTextAsync(); + if (IPAddress.TryParse(s, out var address)) + { + IPAddress = address; + } + } + + public async void Cut() + { + IClipboard? clipboard = AvaloniaLocator.Current.GetService(); + if(clipboard is null) return; + string s = string.Join(".", _firstText?.Text, _secondText?.Text, _thirdText?.Text, _fourthText?.Text); + await clipboard.SetTextAsync(s); + Clear(); + } +} + +public static class TextPresenterHelper +{ + public static void MoveCaretToStart(this TextPresenter? presenter) + { + if (presenter is null) return; + presenter.MoveCaretToTextPosition(0); + } + + public static void MoveCaretToEnd(this TextPresenter? presenter) + { + if(presenter is null) return; + presenter.MoveCaretToTextPosition(presenter.Text?.Length ?? 0); + } + + public static void ClearSelection(this TextPresenter? presenter) + { + if (presenter is null) return; + presenter.SelectionStart = 0; + presenter.SelectionEnd = 0; + } + + public static void SelectAll(this TextPresenter? presenter) + { + if(presenter is null) return; + if(presenter.Text is null) return; + presenter.SelectionStart = 0; + presenter.SelectionEnd = presenter.Text.Length; + } + + public static void DeleteSelection(this TextPresenter? presenter) + { + if (presenter is null) return; + int selectionStart = presenter.SelectionStart; + int selectionEnd = presenter.SelectionEnd; + if (selectionStart != selectionEnd) + { + var start = Math.Min(selectionStart, selectionEnd); + var end = Math.Max(selectionStart, selectionEnd); + var text = presenter.Text; + + string newText = text is null + ? string.Empty + : text.Substring(0, start) + text.Substring(Math.Min(end, text.Length)); + presenter.Text = newText; + presenter.MoveCaretToTextPosition(start); + } + } + + public static bool IsTextSelected(this TextPresenter? presenter) + { + if (presenter is null) return false; + return presenter.SelectionStart != presenter.SelectionEnd; + } +} \ No newline at end of file diff --git a/src/Ursa/Ursa.csproj b/src/Ursa/Ursa.csproj index c6df5d3..fb43200 100644 --- a/src/Ursa/Ursa.csproj +++ b/src/Ursa/Ursa.csproj @@ -12,4 +12,8 @@ + + + +