mirror of
https://github.com/4sval/FModel.git
synced 2026-04-18 15:47:43 -05:00
feat(P2-006): migrate HotkeyTextBox, FilterableComboBox, and Hotkey to Avalonia (#73)
* feat(P2-006): migrate HotkeyTextBox, FilterableComboBox, and Hotkey to Avalonia (#19) Hotkey.cs (Framework): - Replace System.Windows.Input (Key, ModifierKeys, Keyboard.Modifiers) with Avalonia.Input (Key, KeyModifiers) - ModifierKeys.Windows → KeyModifiers.Meta - IsTriggered(Key) → IsTriggered(Key, KeyModifiers): receives explicit modifiers from the caller's KeyEventArgs instead of the WPF global Keyboard.Modifiers static (which does not exist in Avalonia) UserSettings.cs (Settings): - Replace using System.Windows / System.Windows.Input with Avalonia.Controls / Avalonia.Input - ModifierKeys.Control → KeyModifiers.Control on the three default hotkey values (AssetAddTab, AssetRemoveTab, AddAudio) HotkeyTextBox.cs: - WPF TextBox / DependencyProperty / FrameworkPropertyMetadata replaced by Avalonia TextBox / StyledProperty<Hotkey> with BindingMode.TwoWay - Changed property-changed hook to AddClassHandler static ctor pattern - OnPreviewKeyDown → OnKeyDown; Keyboard.Modifiers → e.KeyModifiers - Key.System / e.SystemKey handling removed (not applicable on Linux X11/Wayland; Alt key arrives directly via e.Key in Avalonia) - Key.OemClear removed (not in Avalonia.Input.Key enum) - Key.DeadCharProcessed removed (not in Avalonia.Input.Key enum) - IsReadOnlyCaretVisible / IsUndoEnabled removed (no Avalonia equivalent) - ContextMenu set to null instead of collapsed (no default ContextMenu) FilterableComboBox.cs: - WPF ComboBox / DependencyProperty / CollectionViewSource replaced by Avalonia ComboBox / StyledProperty / manual filter-list pattern - CollectionViewSource.GetDefaultView().Filter → store original IEnumerable, rebuild filtered List<object> in ApplyFilter() on each text change; _isUpdatingItems flag prevents recursion - OnPreviewKeyDown → OnKeyDown - OnPreviewLostKeyboardFocus(KeyboardFocusChangedEventArgs) → OnLostFocus + TopLevel.FocusManager check to avoid false commits when focus moves between ComboBox and its own TextBox - GetTemplateChild in Loaded handler → OnApplyTemplate NameScope.Find - TextBoxBaseUserChangeTracker: PreviewTextInput→TextInput, PreviewKeyDown→KeyDown, PreviewKeyUp→KeyUp; SelectionLength replaced with SelectionEnd - SelectionStart (Avalonia TextBox API) - StaysOpenOnEdit / IsTextSearchEnabled removed (not in Avalonia ComboBox) MainWindow.xaml.cs: - Update 7 IsTriggered(e.Key) call sites to IsTriggered(e.Key, e.KeyModifiers) Closes #19 * fix(P2-006): address #19 review findings and PR comments Hotkey.cs: - [M1] IsTriggered: modifiers.HasFlag(Modifiers) → modifiers == Modifiers (HasFlag(None) is always true, so any key combo with no modifiers requirement would incorrectly fire for every key press with modifiers) - [S1] Add [JsonConverter(typeof(StringEnumConverter))] on Key and Modifiers properties so they serialise as strings rather than integers; Avalonia.Input.Key integer values differ from System.Windows.Input.Key values (e.g. Key.A: WPF=44, Avalonia=65), so integer serialisation would load wrong keys from existing AppSettings.json on first launch HotkeyTextBox.cs: - [C1]/PR-1 Move e.Handled = true into capturing cases only (Delete/Back/ Escape clears hotkey; default captures new hotkey). Modifier-only keys, Tab/Enter/Space without modifiers now propagate unhandled so Tab focus navigation works correctly. (OnPreviewKeyDown does not exist as a virtual override in Avalonia TextBox; OnKeyDown is correct.) - [m1] Remove dead HasKeyChar method (never called) FilterableComboBox.cs: - [M2] OnLostFocus: replace reference equality to _editableTextBox with IsVisualAncestorOf(focused) (covers all template parts including the dropdown popup, not just the TextBox part) - [M3] AttachSourceFilter: reset _currentFilter = string.Empty before ApplyFilter() so a stale filter is not applied to a new source - [m2]/PR-6 Remove dead FilterItem method - [m3] FreezeTextBoxState: wrap action() in try/finally so _textBoxFrozen is always reset even if action() throws - PR-3 Subscribe to INotifyCollectionChanged on the new _originalSource in AttachSourceFilter; unsubscribe from the old one in OnPropertyChanged before replacing _originalSource. This keeps the filtered ItemsSource live when items are added/removed in the bound collection (e.g. GameSelectorViewModel.DetectedDirectories). - PR-4 Normalize tb.Text to string.Empty in OnUserTextChanged before Length/Substring to avoid NullReferenceException on empty TextBox - PR-5 Use string.Contains(filter, StringComparison.OrdinalIgnoreCase) in ApplyFilter instead of ToLower().Contains(); removes per-item allocations and makes matching locale-independent - Store _currentFilter without lower-casing (OrdinalIgnoreCase does not need pre-normalised input) AudioPlayer.xaml.cs: - PR-2 Update 4× IsTriggered(e.Key) → IsTriggered(e.Key, e.KeyModifiers) (missed call sites from original migration; old 1-arg overload removed) * fix(P2-006): address second-pass #19 review findings FilterableComboBox.cs: - [M1] Add OnDetachedFromVisualTree: unsubscribe from INotifyCollectionChanged.CollectionChanged when the control is removed from the visual tree. Without this, a long-lived source collection (e.g. GameSelectorViewModel.DetectedDirectories) would hold a strong reference to the FilterableComboBox instance via the event delegate, preventing GC of the control after removal. - [m2] Remove dead/inverted _lastText == currentText clause from TextBoxUserChangeTracker.TextChanged handler. TextChanged never fires with equal text in Avalonia, so the clause never executed; removing it eliminates confusing dead code (the equality check was logically inverted relative to its intended meaning). Hotkey.cs: - [S1] Add comment documenting StringEnumConverter round-trip behaviour for [Flags] KeyModifiers values (Newtonsoft serialises them as comma-separated strings, e.g. "Control, Shift", and round-trips correctly through the same format). * fix(P2-006): address third-pass #19 review findings FilterableComboBox.cs: - [m1] Remove dead _lastText field and its two assignment sites (constructor init + TextChanged update). The field became unreachable after the [m2] fix removed the only read site (_lastText == text predicate). No behavioral change. Hotkey.cs: - Fix typo in comment: "combinationsthrough" → "combinations through" * fix(P2-006): use SetCurrentValue in ApplyFilter to preserve XAML binding FilterableComboBox.cs: - ApplyFilter previously called ItemsSource = ... which invokes SetValue at LocalValue priority, clearing any XAML binding on ItemsSource. If the bound ViewModel property is later updated (e.g. the VM replaces DetectedDirectories), the severed binding means OnPropertyChanged never fires and the new source is never captured. - Switch to SetCurrentValue(ItemsSourceProperty, filtered) which writes the filtered list at LocalValue priority without removing the binding, so ViewModel-driven ItemsSource replacements continue to flow through normally after filtering has been applied.
This commit is contained in:
parent
bc74c35dc1
commit
2081c8af7d
|
|
@ -1,49 +1,56 @@
|
|||
using System.Text;
|
||||
using System.Windows.Input;
|
||||
using Avalonia.Input;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Converters;
|
||||
|
||||
namespace FModel.Framework;
|
||||
|
||||
public class Hotkey : ViewModel
|
||||
{
|
||||
private Key _key;
|
||||
[JsonConverter(typeof(StringEnumConverter))]
|
||||
public Key Key
|
||||
{
|
||||
get => _key;
|
||||
set => SetProperty(ref _key, value);
|
||||
}
|
||||
|
||||
private ModifierKeys _modifiers;
|
||||
public ModifierKeys Modifiers
|
||||
// StringEnumConverter serialises KeyModifiers as a comma-separated string (e.g. "Control, Shift").
|
||||
// Newtonsoft correctly round-trips [Flags] combinations through the same format it produces on write.
|
||||
private KeyModifiers _modifiers;
|
||||
[JsonConverter(typeof(StringEnumConverter))]
|
||||
public KeyModifiers Modifiers
|
||||
{
|
||||
get => _modifiers;
|
||||
set => SetProperty(ref _modifiers, value);
|
||||
}
|
||||
|
||||
public Hotkey(Key key, ModifierKeys modifiers = ModifierKeys.None)
|
||||
public Hotkey(Key key, KeyModifiers modifiers = KeyModifiers.None)
|
||||
{
|
||||
Key = key;
|
||||
Modifiers = modifiers;
|
||||
}
|
||||
|
||||
public bool IsTriggered(Key e)
|
||||
/// <summary>Returns true when <paramref name="key"/> and <paramref name="modifiers"/> match this hotkey.</summary>
|
||||
public bool IsTriggered(Key key, KeyModifiers modifiers)
|
||||
{
|
||||
return e == Key && Keyboard.Modifiers.HasFlag(Modifiers);
|
||||
return key == Key && modifiers == Modifiers;
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
var str = new StringBuilder();
|
||||
|
||||
if (Modifiers.HasFlag(ModifierKeys.Control))
|
||||
if (Modifiers.HasFlag(KeyModifiers.Control))
|
||||
str.Append("Ctrl + ");
|
||||
if (Modifiers.HasFlag(ModifierKeys.Shift))
|
||||
if (Modifiers.HasFlag(KeyModifiers.Shift))
|
||||
str.Append("Shift + ");
|
||||
if (Modifiers.HasFlag(ModifierKeys.Alt))
|
||||
if (Modifiers.HasFlag(KeyModifiers.Alt))
|
||||
str.Append("Alt + ");
|
||||
if (Modifiers.HasFlag(ModifierKeys.Windows))
|
||||
if (Modifiers.HasFlag(KeyModifiers.Meta))
|
||||
str.Append("Win + ");
|
||||
|
||||
str.Append(Key);
|
||||
return str.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -276,19 +276,19 @@ public partial class MainWindow : Window
|
|||
}
|
||||
else if (_applicationView.Status.IsReady
|
||||
&& UserSettings.Default.FeaturePreviewNewAssetExplorer
|
||||
&& UserSettings.Default.SwitchAssetExplorer.IsTriggered(e.Key))
|
||||
&& UserSettings.Default.SwitchAssetExplorer.IsTriggered(e.Key, e.KeyModifiers))
|
||||
_applicationView.IsAssetsExplorerVisible = !_applicationView.IsAssetsExplorerVisible;
|
||||
else if (UserSettings.Default.AssetAddTab.IsTriggered(e.Key))
|
||||
else if (UserSettings.Default.AssetAddTab.IsTriggered(e.Key, e.KeyModifiers))
|
||||
_applicationView.CUE4Parse.TabControl.AddTab();
|
||||
else if (UserSettings.Default.AssetRemoveTab.IsTriggered(e.Key))
|
||||
else if (UserSettings.Default.AssetRemoveTab.IsTriggered(e.Key, e.KeyModifiers))
|
||||
_applicationView.CUE4Parse.TabControl.RemoveTab();
|
||||
else if (UserSettings.Default.AssetLeftTab.IsTriggered(e.Key))
|
||||
else if (UserSettings.Default.AssetLeftTab.IsTriggered(e.Key, e.KeyModifiers))
|
||||
_applicationView.CUE4Parse.TabControl.GoLeftTab();
|
||||
else if (UserSettings.Default.AssetRightTab.IsTriggered(e.Key))
|
||||
else if (UserSettings.Default.AssetRightTab.IsTriggered(e.Key, e.KeyModifiers))
|
||||
_applicationView.CUE4Parse.TabControl.GoRightTab();
|
||||
else if (UserSettings.Default.DirLeftTab.IsTriggered(e.Key) && _applicationView.SelectedLeftTabIndex > 0)
|
||||
else if (UserSettings.Default.DirLeftTab.IsTriggered(e.Key, e.KeyModifiers) && _applicationView.SelectedLeftTabIndex > 0)
|
||||
_applicationView.SelectedLeftTabIndex--;
|
||||
else if (UserSettings.Default.DirRightTab.IsTriggered(e.Key) && _applicationView.SelectedLeftTabIndex < LeftTabControl.Items.Count - 1)
|
||||
else if (UserSettings.Default.DirRightTab.IsTriggered(e.Key, e.KeyModifiers) && _applicationView.SelectedLeftTabIndex < LeftTabControl.Items.Count - 1)
|
||||
_applicationView.SelectedLeftTabIndex++;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using CUE4Parse.UE4.Assets.Exports.Material;
|
||||
using CUE4Parse.UE4.Assets.Exports.Nanite;
|
||||
using CUE4Parse.UE4.Versions;
|
||||
|
|
@ -342,21 +342,21 @@ namespace FModel.Settings
|
|||
set => SetProperty(ref _assetRightTab, value);
|
||||
}
|
||||
|
||||
private Hotkey _assetAddTab = new(Key.T, ModifierKeys.Control);
|
||||
private Hotkey _assetAddTab = new(Key.T, KeyModifiers.Control);
|
||||
public Hotkey AssetAddTab
|
||||
{
|
||||
get => _assetAddTab;
|
||||
set => SetProperty(ref _assetAddTab, value);
|
||||
}
|
||||
|
||||
private Hotkey _assetRemoveTab = new(Key.W, ModifierKeys.Control);
|
||||
private Hotkey _assetRemoveTab = new(Key.W, KeyModifiers.Control);
|
||||
public Hotkey AssetRemoveTab
|
||||
{
|
||||
get => _assetRemoveTab;
|
||||
set => SetProperty(ref _assetRemoveTab, value);
|
||||
}
|
||||
|
||||
private Hotkey _addAudio = new(Key.N, ModifierKeys.Control);
|
||||
private Hotkey _addAudio = new(Key.N, KeyModifiers.Control);
|
||||
public Hotkey AddAudio
|
||||
{
|
||||
get => _addAudio;
|
||||
|
|
|
|||
|
|
@ -55,15 +55,15 @@ public partial class AudioPlayer : Window
|
|||
if (e.Source is TextBox)
|
||||
return;
|
||||
|
||||
if (UserSettings.Default.AddAudio.IsTriggered(e.Key))
|
||||
if (UserSettings.Default.AddAudio.IsTriggered(e.Key, e.KeyModifiers))
|
||||
{
|
||||
// TODO(P4-004): OpenFileDialog (Microsoft.Win32) not available on Linux — replace with StorageProvider
|
||||
}
|
||||
else if (UserSettings.Default.PlayPauseAudio.IsTriggered(e.Key))
|
||||
else if (UserSettings.Default.PlayPauseAudio.IsTriggered(e.Key, e.KeyModifiers))
|
||||
_applicationView.AudioPlayer.PlayPauseOnStart();
|
||||
else if (UserSettings.Default.PreviousAudio.IsTriggered(e.Key))
|
||||
else if (UserSettings.Default.PreviousAudio.IsTriggered(e.Key, e.KeyModifiers))
|
||||
_applicationView.AudioPlayer.Previous();
|
||||
else if (UserSettings.Default.NextAudio.IsTriggered(e.Key))
|
||||
else if (UserSettings.Default.NextAudio.IsTriggered(e.Key, e.KeyModifiers))
|
||||
_applicationView.AudioPlayer.Next();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,94 +1,267 @@
|
|||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Data;
|
||||
using System.Windows.Input;
|
||||
using System.Collections.Specialized;
|
||||
using System.Linq;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Data;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.VisualTree;
|
||||
|
||||
namespace FModel.Views.Resources.Controls;
|
||||
|
||||
/// <summary>
|
||||
/// https://stackoverflow.com/a/58066259/13389331
|
||||
/// An editable, filterable <see cref="ComboBox"/> for Avalonia.
|
||||
/// Based on https://stackoverflow.com/a/58066259/13389331, ported from WPF.
|
||||
/// </summary>
|
||||
public class FilterableComboBox : ComboBox
|
||||
{
|
||||
/// <summary>
|
||||
/// If true, on lost focus or enter key pressed, checks the text in the combobox. If the text is not present
|
||||
/// in the list, it leaves it blank.
|
||||
/// If true, on lost focus or Enter pressed, clears the text if it is not present in the list.
|
||||
/// </summary>
|
||||
public bool OnlyValuesInList {
|
||||
get => (bool)GetValue(OnlyValuesInListProperty);
|
||||
public static readonly StyledProperty<bool> OnlyValuesInListProperty =
|
||||
AvaloniaProperty.Register<FilterableComboBox, bool>(nameof(OnlyValuesInList));
|
||||
|
||||
public bool OnlyValuesInList
|
||||
{
|
||||
get => GetValue(OnlyValuesInListProperty);
|
||||
set => SetValue(OnlyValuesInListProperty, value);
|
||||
}
|
||||
public static readonly DependencyProperty OnlyValuesInListProperty =
|
||||
DependencyProperty.Register(nameof(OnlyValuesInList), typeof(bool), typeof(FilterableComboBox));
|
||||
|
||||
/// <summary>
|
||||
/// Selected item, changes only on lost focus or enter key pressed
|
||||
/// Selected item — updates only when focus leaves or Enter is pressed.
|
||||
/// </summary>
|
||||
public object EffectivelySelectedItem {
|
||||
get => (bool)GetValue(EffectivelySelectedItemProperty);
|
||||
public static readonly StyledProperty<object> EffectivelySelectedItemProperty =
|
||||
AvaloniaProperty.Register<FilterableComboBox, object>(
|
||||
nameof(EffectivelySelectedItem),
|
||||
defaultBindingMode: BindingMode.TwoWay);
|
||||
|
||||
public object EffectivelySelectedItem
|
||||
{
|
||||
get => GetValue(EffectivelySelectedItemProperty);
|
||||
set => SetValue(EffectivelySelectedItemProperty, value);
|
||||
}
|
||||
public static readonly DependencyProperty EffectivelySelectedItemProperty =
|
||||
DependencyProperty.Register(nameof(EffectivelySelectedItem), typeof(object), typeof(FilterableComboBox));
|
||||
|
||||
private string CurrentFilter = string.Empty;
|
||||
private bool TextBoxFreezed;
|
||||
protected TextBox EditableTextBox => GetTemplateChild("PART_EditableTextBox") as TextBox;
|
||||
private UserChange<bool> IsDropDownOpenUC;
|
||||
|
||||
/// <summary>
|
||||
/// Triggers on lost focus or enter key pressed, if the selected item changed since the last time focus was lost or enter was pressed.
|
||||
/// Fires on lost focus or Enter pressed when the selected item has changed since the last commit.
|
||||
/// </summary>
|
||||
public event Action<FilterableComboBox, object> SelectionEffectivelyChanged;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Private state
|
||||
// -----------------------------------------------------------------------
|
||||
private IEnumerable _originalSource;
|
||||
private bool _isUpdatingItems;
|
||||
private string _currentFilter = string.Empty;
|
||||
private bool _textBoxFrozen;
|
||||
private TextBox _editableTextBox;
|
||||
private bool _shouldTriggerSelectedItemChanged;
|
||||
|
||||
// Mirrors WPF UserChange<T>: wraps an action and tracks whether it is a programmatic change.
|
||||
private readonly UserChange<bool> _dropDownOpenUC;
|
||||
|
||||
public FilterableComboBox()
|
||||
{
|
||||
IsDropDownOpenUC = new UserChange<bool>(v => IsDropDownOpen = v);
|
||||
DropDownOpened += FilteredComboBox_DropDownOpened;
|
||||
_dropDownOpenUC = new UserChange<bool>(v => IsDropDownOpen = v);
|
||||
|
||||
Focusable = true;
|
||||
IsEditable = true;
|
||||
IsTextSearchEnabled = true;
|
||||
StaysOpenOnEdit = true;
|
||||
IsReadOnly = false;
|
||||
|
||||
Loaded += (s, e) => {
|
||||
if (EditableTextBox != null)
|
||||
new TextBoxBaseUserChangeTracker(EditableTextBox).UserTextChanged += FilteredComboBox_UserTextChange;
|
||||
};
|
||||
|
||||
SelectionChanged += (_, __) => shouldTriggerSelectedItemChanged = true;
|
||||
|
||||
DropDownOpened += OnDropDownOpened;
|
||||
SelectionChanged += (_, _) => _shouldTriggerSelectedItemChanged = true;
|
||||
SelectionEffectivelyChanged += (_, o) => EffectivelySelectedItem = o;
|
||||
}
|
||||
|
||||
protected override void OnPreviewKeyDown(KeyEventArgs e)
|
||||
// -----------------------------------------------------------------------
|
||||
// Template
|
||||
// -----------------------------------------------------------------------
|
||||
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
|
||||
{
|
||||
base.OnPreviewKeyDown(e);
|
||||
if (e.Key == Key.Down && !IsDropDownOpen) {
|
||||
base.OnApplyTemplate(e);
|
||||
|
||||
_editableTextBox = e.NameScope.Find<TextBox>("PART_EditableTextBox");
|
||||
if (_editableTextBox != null)
|
||||
new TextBoxUserChangeTracker(_editableTextBox).UserTextChanged += OnUserTextChanged;
|
||||
}
|
||||
|
||||
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
base.OnDetachedFromVisualTree(e);
|
||||
// Unsubscribe so the source collection does not root this control instance.
|
||||
if (_originalSource is INotifyCollectionChanged notify)
|
||||
notify.CollectionChanged -= OnSourceCollectionChanged;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// ItemsSource interception — capture original, rebuild filtered view
|
||||
// -----------------------------------------------------------------------
|
||||
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
|
||||
{
|
||||
base.OnPropertyChanged(change);
|
||||
|
||||
if (change.Property == ItemsSourceProperty && !_isUpdatingItems)
|
||||
{
|
||||
if (_originalSource is INotifyCollectionChanged old)
|
||||
old.CollectionChanged -= OnSourceCollectionChanged;
|
||||
_originalSource = change.GetNewValue<IEnumerable>();
|
||||
AttachSourceFilter();
|
||||
}
|
||||
}
|
||||
|
||||
private void AttachSourceFilter()
|
||||
{
|
||||
if (_originalSource is INotifyCollectionChanged notify)
|
||||
notify.CollectionChanged += OnSourceCollectionChanged;
|
||||
_currentFilter = string.Empty;
|
||||
ApplyFilter();
|
||||
}
|
||||
|
||||
private void OnSourceCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
|
||||
=> ApplyFilter();
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Key handling
|
||||
// -----------------------------------------------------------------------
|
||||
protected override void OnKeyDown(KeyEventArgs e)
|
||||
{
|
||||
base.OnKeyDown(e);
|
||||
|
||||
if (e.Key == Key.Down && !IsDropDownOpen)
|
||||
{
|
||||
IsDropDownOpen = true;
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (e.Key == Key.Escape) {
|
||||
else if (e.Key == Key.Escape)
|
||||
{
|
||||
ClearFilter();
|
||||
Text = "";
|
||||
IsDropDownOpen = true;
|
||||
}
|
||||
else if (e.Key == Key.Enter || e.Key == Key.Tab) {
|
||||
else if (e.Key is Key.Enter or Key.Tab)
|
||||
{
|
||||
CheckSelectedItem();
|
||||
TriggerSelectedItemChanged();
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnPreviewLostKeyboardFocus(KeyboardFocusChangedEventArgs e)
|
||||
// -----------------------------------------------------------------------
|
||||
// Focus handling — trigger effective selection on focus loss
|
||||
// -----------------------------------------------------------------------
|
||||
protected override void OnLostFocus(Avalonia.Interactivity.RoutedEventArgs e)
|
||||
{
|
||||
base.OnPreviewLostKeyboardFocus(e);
|
||||
CheckSelectedItem();
|
||||
if ((e.OldFocus == this || e.OldFocus == EditableTextBox) && e.NewFocus != this && e.NewFocus != EditableTextBox)
|
||||
base.OnLostFocus(e);
|
||||
|
||||
// Only commit when focus truly leaves the ComboBox subtree (covers all template parts).
|
||||
var focused = TopLevel.GetTopLevel(this)?.FocusManager?.GetFocusedElement() as Visual;
|
||||
if (focused == null || !this.IsVisualAncestorOf(focused))
|
||||
{
|
||||
CheckSelectedItem();
|
||||
TriggerSelectedItemChanged();
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Filter helpers
|
||||
// -----------------------------------------------------------------------
|
||||
private void OnDropDownOpened(object sender, EventArgs e)
|
||||
{
|
||||
if (_dropDownOpenUC.IsUserChange)
|
||||
ClearFilter();
|
||||
}
|
||||
|
||||
private void OnUserTextChanged(object sender, EventArgs e)
|
||||
{
|
||||
if (_textBoxFrozen)
|
||||
return;
|
||||
|
||||
var tb = _editableTextBox;
|
||||
if (tb == null)
|
||||
return;
|
||||
|
||||
var text = tb.Text ?? string.Empty;
|
||||
var selLen = tb.SelectionEnd - tb.SelectionStart;
|
||||
_currentFilter = (tb.SelectionStart + selLen == text.Length)
|
||||
? text.Substring(0, tb.SelectionStart)
|
||||
: text;
|
||||
|
||||
RefreshFilter();
|
||||
}
|
||||
|
||||
public void ClearFilter()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_currentFilter))
|
||||
return;
|
||||
_currentFilter = "";
|
||||
ApplyFilter();
|
||||
}
|
||||
|
||||
private void RefreshFilter()
|
||||
{
|
||||
if (_originalSource == null)
|
||||
return;
|
||||
|
||||
FreezeTextBoxState(() =>
|
||||
{
|
||||
var wasOpen = IsDropDownOpen;
|
||||
// Close then re-open to force list refresh (mirrors the WPF view.Refresh() trick).
|
||||
_dropDownOpenUC.Set(false);
|
||||
ApplyFilter();
|
||||
|
||||
if (!string.IsNullOrEmpty(_currentFilter) || wasOpen)
|
||||
_dropDownOpenUC.Set(true);
|
||||
|
||||
// Try to restore SelectedItem if text matches an item..
|
||||
if (SelectedItem == null)
|
||||
{
|
||||
foreach (var item in _originalSource)
|
||||
if (item?.ToString() == Text)
|
||||
{
|
||||
SelectedItem = item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void ApplyFilter()
|
||||
{
|
||||
_isUpdatingItems = true;
|
||||
try
|
||||
{
|
||||
var filtered = string.IsNullOrEmpty(_currentFilter)
|
||||
? _originalSource?.Cast<object>().ToList()
|
||||
: _originalSource?.Cast<object>()
|
||||
.Where(x => x?.ToString()?.Contains(_currentFilter, StringComparison.OrdinalIgnoreCase) == true)
|
||||
.ToList();
|
||||
// Use SetCurrentValue so the XAML binding on ItemsSource is preserved;
|
||||
// a plain property assignment clears the binding at LocalValue priority.
|
||||
SetCurrentValue(ItemsSourceProperty, (IEnumerable?)filtered);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isUpdatingItems = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void FreezeTextBoxState(Action action)
|
||||
{
|
||||
_textBoxFrozen = true;
|
||||
var tb = _editableTextBox;
|
||||
var text = Text;
|
||||
var selStart = tb?.SelectionStart ?? 0;
|
||||
var selEnd = tb?.SelectionEnd ?? 0;
|
||||
try
|
||||
{
|
||||
action();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Text = text;
|
||||
if (tb != null)
|
||||
{ tb.SelectionStart = selStart; tb.SelectionEnd = selEnd; }
|
||||
_textBoxFrozen = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void CheckSelectedItem()
|
||||
|
|
@ -97,177 +270,86 @@ public class FilterableComboBox : ComboBox
|
|||
Text = SelectedItem?.ToString() ?? "";
|
||||
}
|
||||
|
||||
private bool shouldTriggerSelectedItemChanged = false;
|
||||
private void TriggerSelectedItemChanged()
|
||||
{
|
||||
if (shouldTriggerSelectedItemChanged) {
|
||||
SelectionEffectivelyChanged?.Invoke(this, SelectedItem);
|
||||
shouldTriggerSelectedItemChanged = false;
|
||||
}
|
||||
if (!_shouldTriggerSelectedItemChanged)
|
||||
return;
|
||||
SelectionEffectivelyChanged?.Invoke(this, SelectedItem);
|
||||
_shouldTriggerSelectedItemChanged = false;
|
||||
}
|
||||
|
||||
public void ClearFilter()
|
||||
// -----------------------------------------------------------------------
|
||||
// Inner helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Detects user-driven text changes in a <see cref="TextBox"/> vs. programmatic ones.
|
||||
/// </summary>
|
||||
private sealed class TextBoxUserChangeTracker
|
||||
{
|
||||
if (string.IsNullOrEmpty(CurrentFilter)) return;
|
||||
CurrentFilter = "";
|
||||
CollectionViewSource.GetDefaultView(ItemsSource).Refresh();
|
||||
}
|
||||
private readonly TextBox _textBox;
|
||||
private readonly List<Key> _pressedKeys = [];
|
||||
private bool _isTextInput;
|
||||
|
||||
private void FilteredComboBox_DropDownOpened(object sender, EventArgs e)
|
||||
{
|
||||
if (IsDropDownOpenUC.IsUserChange)
|
||||
ClearFilter();
|
||||
}
|
||||
|
||||
private void FilteredComboBox_UserTextChange(object sender, EventArgs e)
|
||||
{
|
||||
if (TextBoxFreezed) return;
|
||||
var tb = EditableTextBox;
|
||||
if (tb.SelectionStart + tb.SelectionLength == tb.Text.Length)
|
||||
CurrentFilter = tb.Text.Substring(0, tb.SelectionStart).ToLower();
|
||||
else
|
||||
CurrentFilter = tb.Text.ToLower();
|
||||
RefreshFilter();
|
||||
}
|
||||
|
||||
protected override void OnItemsSourceChanged(IEnumerable oldValue, IEnumerable newValue)
|
||||
{
|
||||
if (newValue != null) {
|
||||
var view = CollectionViewSource.GetDefaultView(newValue);
|
||||
view.Filter += FilterItem;
|
||||
}
|
||||
|
||||
if (oldValue != null) {
|
||||
var view = CollectionViewSource.GetDefaultView(oldValue);
|
||||
if (view != null) view.Filter -= FilterItem;
|
||||
}
|
||||
|
||||
base.OnItemsSourceChanged(oldValue, newValue);
|
||||
}
|
||||
|
||||
private void RefreshFilter()
|
||||
{
|
||||
if (ItemsSource == null) return;
|
||||
|
||||
var view = CollectionViewSource.GetDefaultView(ItemsSource);
|
||||
FreezTextBoxState(() => {
|
||||
var isDropDownOpen = IsDropDownOpen;
|
||||
//always hide because showing it enables the user to pick with up and down keys, otherwise it's not working because of the glitch in view.Refresh()
|
||||
IsDropDownOpenUC.Set(false);
|
||||
view.Refresh();
|
||||
|
||||
if (!string.IsNullOrEmpty(CurrentFilter) || isDropDownOpen)
|
||||
IsDropDownOpenUC.Set(true);
|
||||
|
||||
if (SelectedItem == null) {
|
||||
foreach (var itm in ItemsSource)
|
||||
if (itm.ToString() == Text) {
|
||||
SelectedItem = itm;
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void FreezTextBoxState(Action action)
|
||||
{
|
||||
TextBoxFreezed = true;
|
||||
var tb = EditableTextBox;
|
||||
var text = Text;
|
||||
var selStart = tb.SelectionStart;
|
||||
var selLen = tb.SelectionLength;
|
||||
action();
|
||||
Text = text;
|
||||
tb.SelectionStart = selStart;
|
||||
tb.SelectionLength = selLen;
|
||||
TextBoxFreezed = false;
|
||||
}
|
||||
|
||||
private bool FilterItem(object value)
|
||||
{
|
||||
if (value == null) return false;
|
||||
if (CurrentFilter.Length == 0) return true;
|
||||
|
||||
return value.ToString().ToLower().Contains(CurrentFilter);
|
||||
}
|
||||
|
||||
private class TextBoxBaseUserChangeTracker
|
||||
{
|
||||
private bool IsTextInput { get; set; }
|
||||
|
||||
public TextBox TextBoxBase { get; set; }
|
||||
private List<Key> PressedKeys = new List<Key>();
|
||||
public event EventHandler UserTextChanged;
|
||||
private string LastText;
|
||||
|
||||
public TextBoxBaseUserChangeTracker(TextBox textBoxBase)
|
||||
public TextBoxUserChangeTracker(TextBox textBox)
|
||||
{
|
||||
TextBoxBase = textBoxBase;
|
||||
LastText = TextBoxBase.ToString();
|
||||
_textBox = textBox;
|
||||
|
||||
textBoxBase.PreviewTextInput += (s, e) => {
|
||||
IsTextInput = true;
|
||||
};
|
||||
textBox.TextInput += (_, _) => _isTextInput = true;
|
||||
|
||||
textBoxBase.TextChanged += (s, e) => {
|
||||
var isUserChange = PressedKeys.Count > 0 || IsTextInput || LastText == TextBoxBase.ToString();
|
||||
IsTextInput = false;
|
||||
LastText = TextBoxBase.ToString();
|
||||
textBox.TextChanged += (_, e) =>
|
||||
{
|
||||
var isUserChange = _pressedKeys.Count > 0 || _isTextInput;
|
||||
_isTextInput = false;
|
||||
if (isUserChange)
|
||||
UserTextChanged?.Invoke(this, e);
|
||||
};
|
||||
|
||||
textBoxBase.PreviewKeyDown += (s, e) => {
|
||||
switch (e.Key) {
|
||||
case Key.Back:
|
||||
case Key.Space:
|
||||
if (!PressedKeys.Contains(e.Key))
|
||||
PressedKeys.Add(e.Key);
|
||||
break;
|
||||
}
|
||||
if (e.Key == Key.Back) {
|
||||
var textBox = textBoxBase as TextBox;
|
||||
if (textBox.SelectionStart > 0 && textBox.SelectionLength > 0 && (textBox.SelectionStart + textBox.SelectionLength) == textBox.Text.Length) {
|
||||
textBox.SelectionStart--;
|
||||
textBox.SelectionLength++;
|
||||
textBox.KeyDown += (_, e) =>
|
||||
{
|
||||
if (e.Key is Key.Back or Key.Space && !_pressedKeys.Contains(e.Key))
|
||||
_pressedKeys.Add(e.Key);
|
||||
|
||||
// Extend back-selection to include previous char (mirrors WPF PreviewKeyDown Back handling).
|
||||
if (e.Key == Key.Back)
|
||||
{
|
||||
var selLen = _textBox.SelectionEnd - _textBox.SelectionStart;
|
||||
if (_textBox.SelectionStart > 0 && selLen > 0 &&
|
||||
_textBox.SelectionEnd == (_textBox.Text?.Length ?? 0))
|
||||
{
|
||||
_textBox.SelectionStart--;
|
||||
// SelectionEnd stays the same — length effectively +1.
|
||||
e.Handled = true;
|
||||
UserTextChanged?.Invoke(this, e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
textBoxBase.PreviewKeyUp += (s, e) => {
|
||||
if (PressedKeys.Contains(e.Key))
|
||||
PressedKeys.Remove(e.Key);
|
||||
};
|
||||
textBox.KeyUp += (_, e) => _pressedKeys.Remove(e.Key);
|
||||
|
||||
textBoxBase.LostFocus += (s, e) => {
|
||||
PressedKeys.Clear();
|
||||
IsTextInput = false;
|
||||
textBox.LostFocus += (_, _) =>
|
||||
{
|
||||
_pressedKeys.Clear();
|
||||
_isTextInput = false;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private class UserChange<T>
|
||||
/// <summary>Wraps an action and records whether it is a user-initiated change.</summary>
|
||||
private sealed class UserChange<T>
|
||||
{
|
||||
private Action<T> action;
|
||||
|
||||
private readonly Action<T> _action;
|
||||
public bool IsUserChange { get; private set; } = true;
|
||||
|
||||
public UserChange(Action<T> action)
|
||||
{
|
||||
this.action = action;
|
||||
}
|
||||
public UserChange(Action<T> action) => _action = action;
|
||||
|
||||
public void Set(T val)
|
||||
{
|
||||
try {
|
||||
IsUserChange = false;
|
||||
action(val);
|
||||
}
|
||||
finally {
|
||||
IsUserChange = true;
|
||||
}
|
||||
try
|
||||
{ IsUserChange = false; _action(val); }
|
||||
finally { IsUserChange = true; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,74 +1,59 @@
|
|||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Data;
|
||||
using Avalonia.Input;
|
||||
using FModel.Framework;
|
||||
|
||||
namespace FModel.Views.Resources.Controls;
|
||||
|
||||
/// <summary>
|
||||
/// https://tyrrrz.me/blog/hotkey-editor-control-in-wpf
|
||||
/// Read-only TextBox that displays and captures a keyboard hotkey.
|
||||
/// Based on https://tyrrrz.me/blog/hotkey-editor-control-in-wpf, ported to Avalonia.
|
||||
/// </summary>
|
||||
public class HotkeyTextBox : TextBox
|
||||
{
|
||||
public static readonly StyledProperty<Hotkey> HotKeyProperty =
|
||||
AvaloniaProperty.Register<HotkeyTextBox, Hotkey>(
|
||||
nameof(HotKey),
|
||||
defaultValue: new Hotkey(Key.None),
|
||||
defaultBindingMode: BindingMode.TwoWay);
|
||||
|
||||
public Hotkey HotKey
|
||||
{
|
||||
get => (Hotkey) GetValue(HotKeyProperty);
|
||||
get => GetValue(HotKeyProperty);
|
||||
set => SetValue(HotKeyProperty, value);
|
||||
}
|
||||
|
||||
public static readonly DependencyProperty HotKeyProperty = DependencyProperty.Register("HotKey", typeof(Hotkey),
|
||||
typeof(HotkeyTextBox), new FrameworkPropertyMetadata(new Hotkey(Key.None), FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, HotKeyChanged));
|
||||
|
||||
private static void HotKeyChanged(DependencyObject sender, DependencyPropertyChangedEventArgs e)
|
||||
static HotkeyTextBox()
|
||||
{
|
||||
if (sender is not HotkeyTextBox control) return;
|
||||
control.Text = control.HotKey.ToString();
|
||||
HotKeyProperty.Changed.AddClassHandler<HotkeyTextBox>(
|
||||
(control, _) => control.Text = control.HotKey.ToString());
|
||||
}
|
||||
|
||||
public HotkeyTextBox()
|
||||
{
|
||||
IsReadOnly = true;
|
||||
IsReadOnlyCaretVisible = false;
|
||||
IsUndoEnabled = false;
|
||||
|
||||
if (ContextMenu != null)
|
||||
ContextMenu.Visibility = Visibility.Collapsed;
|
||||
|
||||
// Remove the default context menu (Cut/Copy/Paste are meaningless on a read-only hotkey box).
|
||||
ContextMenu = null;
|
||||
Text = HotKey.ToString();
|
||||
}
|
||||
|
||||
private static bool HasKeyChar(Key key) =>
|
||||
// A - Z
|
||||
key is >= Key.A and <= Key.Z or
|
||||
// 0 - 9
|
||||
Key.D0 and <= Key.D9 or
|
||||
// Numpad 0 - 9
|
||||
Key.NumPad0 and <= Key.NumPad9 or
|
||||
// The rest
|
||||
Key.OemQuestion or Key.OemQuotes or Key.OemPlus or Key.OemOpenBrackets or Key.OemCloseBrackets or Key.OemMinus or Key.DeadCharProcessed or Key.Oem1 or Key.Oem5 or Key.Oem7 or Key.OemPeriod or Key.OemComma or Key.Add or Key.Divide or Key.Multiply or Key.Subtract or Key.Oem102 or Key.Decimal;
|
||||
|
||||
protected override void OnPreviewKeyDown(KeyEventArgs e)
|
||||
protected override void OnKeyDown(KeyEventArgs e)
|
||||
{
|
||||
e.Handled = true;
|
||||
|
||||
// Get modifiers and key data
|
||||
var modifiers = Keyboard.Modifiers;
|
||||
var modifiers = e.KeyModifiers;
|
||||
var key = e.Key;
|
||||
|
||||
switch (key)
|
||||
{
|
||||
// If nothing was pressed - return
|
||||
// Nothing pressed.
|
||||
case Key.None:
|
||||
return;
|
||||
// If Alt is used as modifier - the key needs to be extracted from SystemKey
|
||||
case Key.System:
|
||||
key = e.SystemKey;
|
||||
break;
|
||||
// If Delete/Backspace/Escape is pressed without modifiers - clear current value and return
|
||||
case Key.Delete or Key.Back or Key.Escape when modifiers == ModifierKeys.None:
|
||||
// Delete / Backspace / Escape without modifiers → clear the hotkey.
|
||||
case Key.Delete or Key.Back or Key.Escape when modifiers == KeyModifiers.None:
|
||||
HotKey = new Hotkey(Key.None);
|
||||
e.Handled = true;
|
||||
return;
|
||||
// If the only key pressed is one of the modifier keys - return
|
||||
// Modifier-only key presses are not valid hotkeys — let them propagate.
|
||||
case Key.LeftCtrl:
|
||||
case Key.RightCtrl:
|
||||
case Key.LeftAlt:
|
||||
|
|
@ -78,15 +63,14 @@ public class HotkeyTextBox : TextBox
|
|||
case Key.LWin:
|
||||
case Key.RWin:
|
||||
case Key.Clear:
|
||||
case Key.OemClear:
|
||||
case Key.Apps:
|
||||
// If Enter/Space/Tab is pressed without modifiers - return
|
||||
case Key.Enter or Key.Space or Key.Tab when modifiers == ModifierKeys.None:
|
||||
// Enter / Space / Tab without modifiers — let them propagate (Tab focus navigation, etc.).
|
||||
case Key.Enter or Key.Space or Key.Tab when modifiers == KeyModifiers.None:
|
||||
return;
|
||||
default:
|
||||
// Set value
|
||||
HotKey = new Hotkey(key, modifiers);
|
||||
e.Handled = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user