diff --git a/packages/Avalonia/AvaloniaBuildTasks.targets b/packages/Avalonia/AvaloniaBuildTasks.targets index d3750935f9..9ed50e6137 100644 --- a/packages/Avalonia/AvaloniaBuildTasks.targets +++ b/packages/Avalonia/AvaloniaBuildTasks.targets @@ -39,6 +39,17 @@ Condition="'$(ApplicationIcon)' != ''" /> + + + + + + <_AvaloniaResourceOnly Include="@(AvaloniaResource)" + Exclude="@(AvaloniaXaml)" /> + + + + diff --git a/src/Android/Avalonia.Android/AvaloniaView.Input.cs b/src/Android/Avalonia.Android/AvaloniaView.Input.cs index c829be56be..4862daaf0b 100644 --- a/src/Android/Avalonia.Android/AvaloniaView.Input.cs +++ b/src/Android/Avalonia.Android/AvaloniaView.Input.cs @@ -27,7 +27,13 @@ namespace Avalonia.Android protected override bool DispatchHoverEvent(MotionEvent? e) { - return _accessHelper.DispatchHoverEvent(e!) || base.DispatchHoverEvent(e); + var res = _view.PointerHelper.DispatchMotionEvent(e, out var callBase); + if (res == false) + callBase = !_accessHelper.DispatchHoverEvent(e!) && callBase; + + var baseResult = callBase && base.DispatchHoverEvent(e); + + return res ?? baseResult; } protected override bool DispatchGenericPointerEvent(MotionEvent? e) diff --git a/src/Avalonia.Base/Media/Fonts/SystemFontCollection.cs b/src/Avalonia.Base/Media/Fonts/SystemFontCollection.cs index c7b013729c..390f4f4719 100644 --- a/src/Avalonia.Base/Media/Fonts/SystemFontCollection.cs +++ b/src/Avalonia.Base/Media/Fonts/SystemFontCollection.cs @@ -67,6 +67,10 @@ namespace Avalonia.Media.Fonts //Add to cache with platform typeface family name first TryAddGlyphTypeface(platformTypeface.FamilyName, key, glyphTypeface); + + // Then the requested family name + if (familyName != platformTypeface.FamilyName) + TryAddGlyphTypeface(familyName, key, glyphTypeface); //Add to cache if (!TryAddGlyphTypeface(glyphTypeface)) diff --git a/src/Avalonia.Controls/Automation/Peers/ComboBoxAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/ComboBoxAutomationPeer.cs index 5d71e7a8e4..7f629db9e2 100644 --- a/src/Avalonia.Controls/Automation/Peers/ComboBoxAutomationPeer.cs +++ b/src/Avalonia.Controls/Automation/Peers/ComboBoxAutomationPeer.cs @@ -22,19 +22,28 @@ namespace Avalonia.Automation.Peers public bool ShowsMenu => true; public void Collapse() => Owner.IsDropDownOpen = false; public void Expand() => Owner.IsDropDownOpen = true; - bool IValueProvider.IsReadOnly => true; + bool IValueProvider.IsReadOnly => !Owner.IsEditable; string? IValueProvider.Value { get { + if (Owner.IsEditable) + return Owner.Text; + var selection = GetSelection(); return selection.Count == 1 ? selection[0].GetName() : null; } } - - void IValueProvider.SetValue(string? value) => throw new NotSupportedException(); - + + void IValueProvider.SetValue(string? value) + { + if (!Owner.IsEditable) + throw new InvalidOperationException("Cannot set the value of a non-editable ComboBox."); + + Owner.SetCurrentValue(ComboBox.TextProperty, value); + } + protected override AutomationControlType GetAutomationControlTypeCore() { return AutomationControlType.ComboBox; @@ -69,6 +78,23 @@ namespace Avalonia.Automation.Peers ToState((bool)e.OldValue!), ToState((bool)e.NewValue!)); } + else if (e.Property == ComboBox.TextProperty && Owner.IsEditable) + { + RaisePropertyChangedEvent( + ValuePatternIdentifiers.ValueProperty, + e.OldValue as string, + e.NewValue as string); + } + else if (e.Property == ComboBox.IsEditableProperty) + { + RaisePropertyChangedEvent( + ValuePatternIdentifiers.IsReadOnlyProperty, + !(bool)e.OldValue!, + !(bool)e.NewValue!); + + // The Value pattern switches between SelectedItem name and Text when IsEditable changes. + RaisePropertyChangedEvent(ValuePatternIdentifiers.ValueProperty, null, null); + } } private static ExpandCollapseState ToState(bool value) diff --git a/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiAccessibleHandler.cs b/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiAccessibleHandler.cs index 4e674a9ed5..ecca97de79 100644 --- a/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiAccessibleHandler.cs +++ b/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiAccessibleHandler.cs @@ -150,6 +150,10 @@ namespace Avalonia.FreeDesktop.AtSpi.Handlers if (!string.IsNullOrEmpty(accessKey)) attrs["access-key"] = accessKey; + var placeholderText = node.Peer.GetPlaceholderText(); + if (!string.IsNullOrEmpty(placeholderText)) + attrs["placeholder-text"] = placeholderText; + return ValueTask.FromResult(attrs); } diff --git a/src/Avalonia.FreeDesktop/DBusIme/DBusTextInputMethodBase.cs b/src/Avalonia.FreeDesktop/DBusIme/DBusTextInputMethodBase.cs index cfd28b29aa..236276f805 100644 --- a/src/Avalonia.FreeDesktop/DBusIme/DBusTextInputMethodBase.cs +++ b/src/Avalonia.FreeDesktop/DBusIme/DBusTextInputMethodBase.cs @@ -64,9 +64,9 @@ namespace Avalonia.FreeDesktop.DBusIme _watchCts = new CancellationTokenSource(); var cancellationToken = _watchCts.Token; foreach (var name in _knownNames) - WatchNameAsync(name); + _ = WatchNameAsync(name); - async void WatchNameAsync(string name) + async Task WatchNameAsync(string name) { try { @@ -99,10 +99,13 @@ namespace Avalonia.FreeDesktop.DBusIme OnOwnerChanged(name, owner); } } - catch (Exception e) when (!cancellationToken.IsCancellationRequested) + catch (Exception e) { - Logger.TryGet(LogEventLevel.Error, LogArea.FreeDesktopPlatform) - ?.Log(this, $"WatchNameOwner for '{name}' failed: {e}"); + if (!cancellationToken.IsCancellationRequested) + { + Logger.TryGet(LogEventLevel.Error, LogArea.FreeDesktopPlatform) + ?.Log(this, $"WatchNameOwner for '{name}' failed: {e}"); + } } } } diff --git a/src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml b/src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml index 80778a50db..f7d04ccd9b 100644 --- a/src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml +++ b/src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml @@ -1651,7 +1651,7 @@ - + diff --git a/src/Avalonia.Themes.Simple/Accents/Base.xaml b/src/Avalonia.Themes.Simple/Accents/Base.xaml index c81ba824d8..8b10f2735e 100644 --- a/src/Avalonia.Themes.Simple/Accents/Base.xaml +++ b/src/Avalonia.Themes.Simple/Accents/Base.xaml @@ -90,7 +90,7 @@ - + diff --git a/src/tools/Avalonia.Generators/Avalonia.Generators.props b/src/tools/Avalonia.Generators/Avalonia.Generators.props index 7eb3adff3d..3712b390ee 100644 --- a/src/tools/Avalonia.Generators/Avalonia.Generators.props +++ b/src/tools/Avalonia.Generators/Avalonia.Generators.props @@ -9,7 +9,9 @@ true + + @@ -17,10 +19,9 @@ + + + + - - - - - diff --git a/tests/Avalonia.Controls.UnitTests/Automation/ComboBoxAutomationPeerTests.cs b/tests/Avalonia.Controls.UnitTests/Automation/ComboBoxAutomationPeerTests.cs new file mode 100644 index 0000000000..fde3f2f8bd --- /dev/null +++ b/tests/Avalonia.Controls.UnitTests/Automation/ComboBoxAutomationPeerTests.cs @@ -0,0 +1,78 @@ +using Avalonia.Automation; +using Avalonia.Automation.Peers; +using Avalonia.Automation.Provider; +using Avalonia.UnitTests; +using Xunit; + +namespace Avalonia.Controls.UnitTests.Automation; + +public class ComboBoxAutomationPeerTests : ScopedTestBase +{ + private static IValueProvider Value(ComboBox comboBox) + => Assert.IsAssignableFrom(ControlAutomationPeer.CreatePeerForElement(comboBox)); + + [Fact] + public void Non_Editable_ComboBox_Is_ReadOnly() + { + var provider = Value(new ComboBox()); + + Assert.True(provider.IsReadOnly); + } + + [Fact] + public void Editable_ComboBox_Is_Not_ReadOnly() + { + var provider = Value(new ComboBox { IsEditable = true }); + + Assert.False(provider.IsReadOnly); + } + + [Fact] + public void Value_Returns_Text_When_Editable() + { + var provider = Value(new ComboBox { IsEditable = true, Text = "hello" }); + + Assert.Equal("hello", provider.Value); + } + + [Fact] + public void SetValue_Updates_Text_When_Editable() + { + var comboBox = new ComboBox { IsEditable = true }; + var provider = Value(comboBox); + + provider.SetValue("typed"); + + Assert.Equal("typed", comboBox.Text); + Assert.Equal("typed", provider.Value); + } + + [Fact] + public void SetValue_Throws_When_Not_Editable() + { + var provider = Value(new ComboBox()); + + Assert.Throws(() => provider.SetValue("x")); + } + + [Fact] + public void Text_Change_Raises_Value_PropertyChanged() + { + var comboBox = new ComboBox { IsEditable = true }; + var peer = ControlAutomationPeer.CreatePeerForElement(comboBox); + + var raised = 0; + peer.PropertyChanged += (_, e) => + { + if (e.Property == ValuePatternIdentifiers.ValueProperty) + { + Assert.Equal("abc", e.NewValue); + raised++; + } + }; + + comboBox.Text = "abc"; + + Assert.Equal(1, raised); + } +} diff --git a/tests/Avalonia.Skia.UnitTests/Media/FontManagerTests.cs b/tests/Avalonia.Skia.UnitTests/Media/FontManagerTests.cs index b0d6e1bfd1..4f171b29ad 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/FontManagerTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/FontManagerTests.cs @@ -1,5 +1,8 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.IO; using Avalonia.Fonts.Inter; using Avalonia.Logging; using Avalonia.Media; @@ -643,5 +646,108 @@ namespace Avalonia.Skia.UnitTests.Media Assert.True(FontManager.Current.TryGetGlyphTypeface(new Typeface("Inter", FontStyle.Normal, (FontWeight)650), out var nearestMatchTypeface)); Assert.Same(boldGlyphTypeface, nearestMatchTypeface); } + + [Fact] + public void TryGetGlyphTypeface_Should_Cache_Matched_GlyphTypeface_Under_Requested_FamilyName() + { + var fontManagerImpl = new FamilyRemappingFontManagerImpl("NotInstalled", "Noto Mono"); + using var app = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface.With(fontManagerImpl: fontManagerImpl)); + + // "NotInstalled" is not installed, so the platform substitutes it with a different + // family ("Noto Mono"), much like requesting "Arial" yields "Liberation Sans" on some Linux distributions. + Assert.True(FontManager.Current.TryGetGlyphTypeface(new Typeface("NotInstalled"), out var first)); + Assert.Equal("Noto Mono", first.FamilyName); + + // The substitute should now be cached under the requested "NotInstalled" name, so a second + // lookup must resolve from the cache instead of asking the platform again. + Assert.True(FontManager.Current.TryGetGlyphTypeface(new Typeface("NotInstalled"), out var second)); + Assert.Same(first, second); + Assert.Equal(1, fontManagerImpl.RequestedFamilyCreateCount); + } + + /// + /// A font manager whose every by-name lookup resolves to a single matched font whose family name + /// differs from the requested one. + /// + private sealed class FamilyRemappingFontManagerImpl(string requestedFamilyName, string matchedFamilyName) + : IFontManagerImpl, IDisposable + { + public int RequestedFamilyCreateCount { get; private set; } + + public string GetDefaultFontFamilyName() + => matchedFamilyName; + + public string[] GetInstalledFontFamilyNames(bool checkForUpdates = false) + => [matchedFamilyName]; + + public bool TryCreateGlyphTypeface( + string familyName, + FontStyle style, + FontWeight weight, + FontStretch stretch, + [NotNullWhen(true)] out IPlatformTypeface? platformTypeface) + { + if (string.Equals(familyName, requestedFamilyName, StringComparison.OrdinalIgnoreCase)) + RequestedFamilyCreateCount++; + + platformTypeface = new SkiaTypeface(CreateMatchedTypeface(), FontSimulations.None); + return true; + } + + public bool TryCreateGlyphTypeface( + Stream stream, + FontSimulations fontSimulations, + [NotNullWhen(true)] out IPlatformTypeface? platformTypeface) + { + platformTypeface = new SkiaTypeface(SKTypeface.FromStream(stream), fontSimulations); + return true; + } + + public bool TryMatchCharacter( + int codepoint, + FontStyle fontStyle, + FontWeight fontWeight, + FontStretch fontStretch, + string? familyName, + CultureInfo? culture, + [NotNullWhen(true)] out IPlatformTypeface? platformTypeface) + { + platformTypeface = null; + return false; + } + + public bool TryGetFamilyTypefaces(string familyName, [NotNullWhen(true)] out IReadOnlyList? familyTypefaces) + { + familyTypefaces = null; + return false; + } + + private SKTypeface CreateMatchedTypeface() + { + var assetLoader = AvaloniaLocator.Current.GetRequiredService(); + + // LoadFontAssets ignores the family fragment and returns every embedded font asset, + // so pick the one whose family name matches the substitute we want to return. + foreach (var fontAsset in FontFamilyLoader.LoadFontAssets(new Uri(s_fontUri))) + { + var stream = assetLoader.Open(fontAsset); + var typeface = SKTypeface.FromStream(stream); + + if (typeface is not null && + string.Equals(typeface.FamilyName, matchedFamilyName, StringComparison.OrdinalIgnoreCase)) + { + return typeface; + } + + typeface?.Dispose(); + } + + throw new InvalidOperationException($"Could not load the '{matchedFamilyName}' font asset."); + } + + public void Dispose() + { + } + } } }