From 8578160fbcc4ded746b8e6f3da3ee5259df1b420 Mon Sep 17 00:00:00 2001 From: Sergey Mikolaytis Date: Sat, 11 Dec 2021 03:32:16 +0300 Subject: [PATCH 01/14] [OSX] Refactor Native App Menu. Move default menu initialization code from ObjC to C#. (#6909) * [NativeMenu] [Refactoring] Move Default Menu creation from native lib to C# (C# side of code) * fix return type for IAvnApplicationCommands * [Native] menu refactoring (ObjC side) * fix nullref * minor refactor Co-authored-by: Jumar Macato <16554748+jmacato@users.noreply.github.com> Co-authored-by: Dan Walmsley --- native/Avalonia.Native/src/OSX/app.mm | 27 +++++++ native/Avalonia.Native/src/OSX/common.h | 15 +++- native/Avalonia.Native/src/OSX/main.mm | 44 +++++------ native/Avalonia.Native/src/OSX/menu.mm | 53 ++------------ .../Platform/INativeApplicationCommands.cs | 12 +++ .../AvaloniaNativeMenuExporter.cs | 73 +++++++++++++++++-- src/Avalonia.Native/AvaloniaNativePlatform.cs | 5 +- src/Avalonia.Native/IAvnMenuItem.cs | 5 ++ .../MacOSNativeMenuCommands.cs | 35 +++++++++ src/Avalonia.Native/avn.idl | 11 ++- 10 files changed, 198 insertions(+), 82 deletions(-) create mode 100644 src/Avalonia.Controls/Platform/INativeApplicationCommands.cs create mode 100644 src/Avalonia.Native/MacOSNativeMenuCommands.cs diff --git a/native/Avalonia.Native/src/OSX/app.mm b/native/Avalonia.Native/src/OSX/app.mm index e1972b22f4..79175d9ff1 100644 --- a/native/Avalonia.Native/src/OSX/app.mm +++ b/native/Avalonia.Native/src/OSX/app.mm @@ -97,3 +97,30 @@ extern void InitializeAvnApp(IAvnApplicationEvents* events) id delegate = [[AvnAppDelegate alloc] initWithEvents:events]; [app setDelegate:delegate]; } + +HRESULT AvnApplicationCommands::HideApp() +{ + START_COM_CALL; + [[NSApplication sharedApplication] hide:[NSApp delegate]]; + return S_OK; +} + +HRESULT AvnApplicationCommands::ShowAll() +{ + START_COM_CALL; + [[NSApplication sharedApplication] unhideAllApplications:[NSApp delegate]]; + return S_OK; +} + +HRESULT AvnApplicationCommands::HideOthers() +{ + START_COM_CALL; + [[NSApplication sharedApplication] hideOtherApplications:[NSApp delegate]]; + return S_OK; +} + + +extern IAvnApplicationCommands* CreateApplicationCommands() +{ + return new AvnApplicationCommands(); +} diff --git a/native/Avalonia.Native/src/OSX/common.h b/native/Avalonia.Native/src/OSX/common.h index 8896fbe88b..126c9aa87b 100644 --- a/native/Avalonia.Native/src/OSX/common.h +++ b/native/Avalonia.Native/src/OSX/common.h @@ -25,12 +25,12 @@ extern IAvnMenu* CreateAppMenu(IAvnMenuEvents* events); extern IAvnTrayIcon* CreateTrayIcon(); extern IAvnMenuItem* CreateAppMenuItem(); extern IAvnMenuItem* CreateAppMenuItemSeparator(); +extern IAvnApplicationCommands* CreateApplicationCommands(); extern IAvnNativeControlHost* CreateNativeControlHost(NSView* parent); extern void SetAppMenu (NSString* appName, IAvnMenu* appMenu); +extern void SetServicesMenu (IAvnMenu* menu); extern IAvnMenu* GetAppMenu (); extern NSMenuItem* GetAppMenuItem (); -extern void SetAutoGenerateDefaultAppMenuItems (bool enabled); -extern bool GetAutoGenerateDefaultAppMenuItems (); extern void InitializeAvnApp(IAvnApplicationEvents* events); extern NSApplicationActivationPolicy AvnDesiredActivationPolicy; @@ -67,4 +67,15 @@ public: ~AvnInsidePotentialDeadlock(); }; + +class AvnApplicationCommands : public ComSingleObject +{ +public: + FORWARD_IUNKNOWN() + + virtual HRESULT HideApp() override; + virtual HRESULT ShowAll() override; + virtual HRESULT HideOthers() override; +}; + #endif diff --git a/native/Avalonia.Native/src/OSX/main.mm b/native/Avalonia.Native/src/OSX/main.mm index eeaaecfdbd..69f2995847 100644 --- a/native/Avalonia.Native/src/OSX/main.mm +++ b/native/Avalonia.Native/src/OSX/main.mm @@ -2,7 +2,6 @@ #define COM_GUIDS_MATERIALIZE #include "common.h" -static bool s_generateDefaultAppMenuItems = true; static NSString* s_appTitle = @"Avalonia"; // Copyright (c) 2011 The Chromium Authors. All rights reserved. @@ -134,16 +133,6 @@ public: } } - virtual HRESULT SetDisableDefaultApplicationMenuItems (bool enabled) override - { - START_COM_CALL; - - @autoreleasepool - { - SetAutoGenerateDefaultAppMenuItems(!enabled); - return S_OK; - } - } }; /// See "Using POSIX Threads in a Cocoa Application" section here: @@ -357,6 +346,29 @@ public: return S_OK; } } + + virtual HRESULT SetServicesMenu (IAvnMenu* servicesMenu) override + { + START_COM_CALL; + + @autoreleasepool + { + ::SetServicesMenu(servicesMenu); + return S_OK; + } + } + + virtual HRESULT CreateApplicationCommands (IAvnApplicationCommands** ppv) override + { + START_COM_CALL; + + @autoreleasepool + { + *ppv = ::CreateApplicationCommands(); + return S_OK; + } + } + }; extern "C" IAvaloniaNativeFactory* CreateAvaloniaNative() @@ -410,13 +422,3 @@ CGFloat PrimaryDisplayHeight() { return NSMaxY([[[NSScreen screens] firstObject] frame]); } - -void SetAutoGenerateDefaultAppMenuItems (bool enabled) -{ - s_generateDefaultAppMenuItems = enabled; -} - -bool GetAutoGenerateDefaultAppMenuItems () -{ - return s_generateDefaultAppMenuItems; -} diff --git a/native/Avalonia.Native/src/OSX/menu.mm b/native/Avalonia.Native/src/OSX/menu.mm index 38f8c2a7cb..2dbe76bc6d 100644 --- a/native/Avalonia.Native/src/OSX/menu.mm +++ b/native/Avalonia.Native/src/OSX/menu.mm @@ -490,53 +490,6 @@ extern void SetAppMenu (NSString* appName, IAvnMenu* menu) { [s_appMenuItem setSubmenu:[NSMenu new]]; } - - auto appMenu = [s_appMenuItem submenu]; - - if(GetAutoGenerateDefaultAppMenuItems()) - { - [appMenu addItem:[NSMenuItem separatorItem]]; - - // Services item and menu - auto servicesItem = [[NSMenuItem alloc] init]; - servicesItem.title = @"Services"; - NSMenu *servicesMenu = [[NSMenu alloc] initWithTitle:@"Services"]; - servicesItem.submenu = servicesMenu; - [NSApplication sharedApplication].servicesMenu = servicesMenu; - [appMenu addItem:servicesItem]; - - [appMenu addItem:[NSMenuItem separatorItem]]; - - // Hide Application - auto hideItem = [[NSMenuItem alloc] initWithTitle:[@"Hide " stringByAppendingString:appName] action:@selector(hide:) keyEquivalent:@"h"]; - - [appMenu addItem:hideItem]; - - // Hide Others - auto hideAllOthersItem = [[NSMenuItem alloc] initWithTitle:@"Hide Others" - action:@selector(hideOtherApplications:) - keyEquivalent:@"h"]; - - hideAllOthersItem.keyEquivalentModifierMask = NSEventModifierFlagCommand | NSEventModifierFlagOption; - [appMenu addItem:hideAllOthersItem]; - - // Show All - auto showAllItem = [[NSMenuItem alloc] initWithTitle:@"Show All" - action:@selector(unhideAllApplications:) - keyEquivalent:@""]; - - [appMenu addItem:showAllItem]; - - [appMenu addItem:[NSMenuItem separatorItem]]; - - // Quit Application - auto quitItem = [[NSMenuItem alloc] init]; - quitItem.title = [@"Quit " stringByAppendingString:appName]; - quitItem.keyEquivalent = @"q"; - quitItem.target = [AvnWindow class]; - quitItem.action = @selector(closeAll); - [appMenu addItem:quitItem]; - } } else { @@ -544,6 +497,12 @@ extern void SetAppMenu (NSString* appName, IAvnMenu* menu) } } +extern void SetServicesMenu (IAvnMenu* menu) +{ + auto nativeMenu = dynamic_cast(menu); + [NSApplication sharedApplication].servicesMenu = nativeMenu->GetNative(); +} + extern IAvnMenu* GetAppMenu () { return s_appMenu; diff --git a/src/Avalonia.Controls/Platform/INativeApplicationCommands.cs b/src/Avalonia.Controls/Platform/INativeApplicationCommands.cs new file mode 100644 index 0000000000..6720b8aa2c --- /dev/null +++ b/src/Avalonia.Controls/Platform/INativeApplicationCommands.cs @@ -0,0 +1,12 @@ +namespace Avalonia.Controls.Platform +{ + /// + /// Native Menu Default Application Commands + /// + public interface INativeApplicationCommands + { + void HideApp(); + void ShowAll(); + void HideOthers(); + } +} diff --git a/src/Avalonia.Native/AvaloniaNativeMenuExporter.cs b/src/Avalonia.Native/AvaloniaNativeMenuExporter.cs index 1582f794ae..b9d8fd3711 100644 --- a/src/Avalonia.Native/AvaloniaNativeMenuExporter.cs +++ b/src/Avalonia.Native/AvaloniaNativeMenuExporter.cs @@ -3,6 +3,7 @@ using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls.Platform; using Avalonia.Dialogs; +using Avalonia.Input; using Avalonia.Native.Interop; using Avalonia.Native.Interop.Impl; using Avalonia.Threading; @@ -18,11 +19,13 @@ namespace Avalonia.Native private NativeMenu _menu; private __MicroComIAvnMenuProxy _nativeMenu; private readonly IAvnTrayIcon _trayIcon; + private readonly IAvnApplicationCommands _applicationCommands; public AvaloniaNativeMenuExporter(IAvnWindow nativeWindow, IAvaloniaNativeFactory factory) { _factory = factory; _nativeWindow = nativeWindow; + _applicationCommands = _factory.CreateApplicationCommands(); DoLayoutReset(); } @@ -30,6 +33,7 @@ namespace Avalonia.Native public AvaloniaNativeMenuExporter(IAvaloniaNativeFactory factory) { _factory = factory; + _applicationCommands = _factory.CreateApplicationCommands(); DoLayoutReset(); } @@ -38,7 +42,8 @@ namespace Avalonia.Native { _factory = factory; _trayIcon = trayIcon; - + _applicationCommands = _factory.CreateApplicationCommands(); + DoLayoutReset(); } @@ -60,15 +65,11 @@ namespace Avalonia.Native } } - private static NativeMenu CreateDefaultAppMenu() + private NativeMenu CreateDefaultAppMenu() { var result = new NativeMenu(); - var aboutItem = new NativeMenuItem - { - Header = "About Avalonia", - }; - + var aboutItem = new NativeMenuItem("About Avalonia"); aboutItem.Click += async (sender, e) => { var dialog = new AboutAvaloniaDialog(); @@ -77,9 +78,65 @@ namespace Avalonia.Native await dialog.ShowDialog(mainWindow); }; - result.Add(aboutItem); + var macOpts = AvaloniaLocator.Current.GetService(); + if (macOpts == null || !macOpts.DisableDefaultApplicationMenuItems) + { + result.Add(new NativeMenuItemSeparator()); + + var servicesMenu = new NativeMenuItem("Services"); + servicesMenu.Menu = new NativeMenu + { + [MacOSNativeMenuCommands.IsServicesSubmenuProperty] = true + }; + result.Add(servicesMenu); + + result.Add(new NativeMenuItemSeparator()); + + var hideItem = new NativeMenuItem("Hide " + Application.Current.Name) + { + Gesture = new KeyGesture(Key.H, KeyModifiers.Meta) + }; + hideItem.Click += (sender, args) => + { + _applicationCommands.HideApp(); + }; + result.Add(hideItem); + + + var hideOthersItem = new NativeMenuItem("Hide Others") + { + Gesture = new KeyGesture(Key.Q, KeyModifiers.Meta | KeyModifiers.Alt) + }; + hideOthersItem.Click += (sender, args) => + { + _applicationCommands.HideOthers(); + }; + result.Add(hideOthersItem); + + + var showAllItem = new NativeMenuItem("Show All"); + showAllItem.Click += (sender, args) => + { + _applicationCommands.ShowAll(); + }; + result.Add(showAllItem); + + result.Add(new NativeMenuItemSeparator()); + + var quitItem = new NativeMenuItem("Quit") + { + Gesture = new KeyGesture(Key.Q, KeyModifiers.Meta) + }; + quitItem.Click += (sender, args) => + { + _applicationCommands.ShowAll(); + }; + result.Add(quitItem); + } + + return result; } diff --git a/src/Avalonia.Native/AvaloniaNativePlatform.cs b/src/Avalonia.Native/AvaloniaNativePlatform.cs index eaf4d0e2e4..522db1b334 100644 --- a/src/Avalonia.Native/AvaloniaNativePlatform.cs +++ b/src/Avalonia.Native/AvaloniaNativePlatform.cs @@ -93,8 +93,6 @@ namespace Avalonia.Native var macOpts = AvaloniaLocator.Current.GetService(); _factory.MacOptions.SetShowInDock(macOpts?.ShowInDock != false ? 1 : 0); - _factory.MacOptions.SetDisableDefaultApplicationMenuItems( - macOpts?.DisableDefaultApplicationMenuItems == true ? 1 : 0); } AvaloniaLocator.CurrentMutable @@ -112,7 +110,8 @@ namespace Avalonia.Native .Bind().ToConstant(new PlatformHotkeyConfiguration(KeyModifiers.Meta, wholeWordTextActionModifiers: KeyModifiers.Alt)) .Bind().ToConstant(new MacOSMountedVolumeInfoProvider()) .Bind().ToConstant(new AvaloniaNativeDragSource(_factory)) - .Bind().ToConstant(applicationPlatform); + .Bind().ToConstant(applicationPlatform) + .Bind().ToConstant(new MacOSNativeMenuCommands(_factory.CreateApplicationCommands())); var hotkeys = AvaloniaLocator.Current.GetService(); hotkeys.MoveCursorToTheStartOfLine.Add(new KeyGesture(Key.Left, hotkeys.CommandModifiers)); diff --git a/src/Avalonia.Native/IAvnMenuItem.cs b/src/Avalonia.Native/IAvnMenuItem.cs index ca99cbea4b..de3be6142e 100644 --- a/src/Avalonia.Native/IAvnMenuItem.cs +++ b/src/Avalonia.Native/IAvnMenuItem.cs @@ -150,6 +150,11 @@ namespace Avalonia.Native.Interop.Impl { _subMenu = __MicroComIAvnMenuProxy.Create(factory); + if (item.Menu.GetValue(MacOSNativeMenuCommands.IsServicesSubmenuProperty)) + { + factory.SetServicesMenu(_subMenu); + } + _subMenu.Initialize(exporter, item.Menu, item.Header); SetSubMenu(_subMenu); diff --git a/src/Avalonia.Native/MacOSNativeMenuCommands.cs b/src/Avalonia.Native/MacOSNativeMenuCommands.cs new file mode 100644 index 0000000000..6d467d307b --- /dev/null +++ b/src/Avalonia.Native/MacOSNativeMenuCommands.cs @@ -0,0 +1,35 @@ +using Avalonia.Controls; +using Avalonia.Controls.Platform; +using Avalonia.Native.Interop; + +namespace Avalonia.Native +{ + internal class MacOSNativeMenuCommands : INativeApplicationCommands + { + private readonly IAvnApplicationCommands _commands; + + public MacOSNativeMenuCommands(IAvnApplicationCommands commands) + { + _commands = commands; + } + + public void HideApp() + { + _commands.HideApp(); + } + + public void ShowAll() + { + _commands.ShowAll(); + } + + public void HideOthers() + { + _commands.HideOthers(); + } + + + public static readonly AttachedProperty IsServicesSubmenuProperty = + AvaloniaProperty.RegisterAttached("IsServicesSubmenu", false); + } +} diff --git a/src/Avalonia.Native/avn.idl b/src/Avalonia.Native/avn.idl index 00c54750a4..112b4f636c 100644 --- a/src/Avalonia.Native/avn.idl +++ b/src/Avalonia.Native/avn.idl @@ -424,10 +424,12 @@ interface IAvaloniaNativeFactory : IUnknown HRESULT CreateCursorFactory(IAvnCursorFactory** ppv); HRESULT ObtainGlDisplay(IAvnGlDisplay** ppv); HRESULT SetAppMenu(IAvnMenu* menu); + HRESULT SetServicesMenu(IAvnMenu* menu); HRESULT CreateMenu(IAvnMenuEvents* cb, IAvnMenu** ppv); HRESULT CreateMenuItem(IAvnMenuItem** ppv); HRESULT CreateMenuItemSeparator(IAvnMenuItem** ppv); HRESULT CreateTrayIcon(IAvnTrayIcon** ppv); + HRESULT CreateApplicationCommands(IAvnApplicationCommands** ppv); } [uuid(233e094f-9b9f-44a3-9a6e-6948bbdd9fb1)] @@ -539,7 +541,6 @@ interface IAvnMacOptions : IUnknown { HRESULT SetShowInDock(int show); HRESULT SetApplicationTitle(char* utf8string); - HRESULT SetDisableDefaultApplicationMenuItems(bool enabled); } [uuid(04c1b049-1f43-418a-9159-cae627ec1367)] @@ -753,3 +754,11 @@ interface IAvnApplicationEvents : IUnknown void FilesOpened (IAvnStringArray* urls); bool TryShutdown(); } + +[uuid(b4284791-055b-4313-8c2e-50f0a8c72ce9)] +interface IAvnApplicationCommands : IUnknown +{ + HRESULT HideApp(); + HRESULT ShowAll(); + HRESULT HideOthers(); +} From dd61cb9b32f1646110ca5ea5db57de99044d4939 Mon Sep 17 00:00:00 2001 From: Sergey Mikolaytis Date: Sat, 11 Dec 2021 04:15:09 +0300 Subject: [PATCH 02/14] [Menu] [Performance] Improve Menu item can execute logic. Make it work like in WPF (#6438) * [MenuItem] [Performance] Evaluate CanExecute on menu show and only if menu is visible * [Revert] a change from another PR * Update tests/Avalonia.Controls.UnitTests/MenuItemTests.cs Co-authored-by: Steven Kirk * Update tests/Avalonia.Controls.UnitTests/MenuItemTests.cs Co-authored-by: Steven Kirk * fix typo * [MenuItem] fix IsEffectivelyEnabled and cover with tests Co-authored-by: Steven Kirk Co-authored-by: Max Katz --- src/Avalonia.Controls/MenuItem.cs | 63 ++++--- .../Platform/DefaultMenuInteractionHandler.cs | 2 +- .../MenuItemTests.cs | 161 +++++++++++++++++- 3 files changed, 203 insertions(+), 23 deletions(-) diff --git a/src/Avalonia.Controls/MenuItem.cs b/src/Avalonia.Controls/MenuItem.cs index 0bead04982..b661bcfe33 100644 --- a/src/Avalonia.Controls/MenuItem.cs +++ b/src/Avalonia.Controls/MenuItem.cs @@ -107,6 +107,7 @@ namespace Avalonia.Controls private ICommand? _command; private bool _commandCanExecute = true; + private bool _commandBindingError; private Popup? _popup; private KeyGesture? _hotkey; private bool _isEmbeddedInMenu; @@ -379,6 +380,8 @@ namespace Avalonia.Controls { Command.CanExecuteChanged += CanExecuteChanged; } + + TryUpdateCanExecute(); var parent = Parent; @@ -498,13 +501,11 @@ namespace Avalonia.Controls base.UpdateDataValidation(property, value); if (property == CommandProperty) { - if (value.Type == BindingValueType.BindingError) + _commandBindingError = value.Type == BindingValueType.BindingError; + if (_commandBindingError && _commandCanExecute) { - if (_commandCanExecute) - { - _commandCanExecute = false; - UpdateIsEffectivelyEnabled(); - } + _commandCanExecute = false; + UpdateIsEffectivelyEnabled(); } } } @@ -526,22 +527,20 @@ namespace Avalonia.Controls /// The event args. private static void CommandChanged(AvaloniaPropertyChangedEventArgs e) { - if (e.Sender is MenuItem menuItem) + if (e.Sender is MenuItem menuItem && + ((ILogical)menuItem).IsAttachedToLogicalTree) { - if (((ILogical)menuItem).IsAttachedToLogicalTree) + if (e.OldValue is ICommand oldCommand) { - if (e.OldValue is ICommand oldCommand) - { - oldCommand.CanExecuteChanged -= menuItem.CanExecuteChanged; - } + oldCommand.CanExecuteChanged -= menuItem.CanExecuteChanged; + } - if (e.NewValue is ICommand newCommand) - { - newCommand.CanExecuteChanged += menuItem.CanExecuteChanged; - } + if (e.NewValue is ICommand newCommand) + { + newCommand.CanExecuteChanged += menuItem.CanExecuteChanged; } - menuItem.CanExecuteChanged(menuItem, EventArgs.Empty); + menuItem.TryUpdateCanExecute(); } } @@ -553,7 +552,7 @@ namespace Avalonia.Controls { if (e.Sender is MenuItem menuItem) { - menuItem.CanExecuteChanged(menuItem, EventArgs.Empty); + menuItem.TryUpdateCanExecute(); } } @@ -564,8 +563,29 @@ namespace Avalonia.Controls /// The event args. private void CanExecuteChanged(object sender, EventArgs e) { - var canExecute = Command == null || Command.CanExecute(CommandParameter); + TryUpdateCanExecute(); + } + /// + /// Tries to evaluate CanExecute value of a Command if menu is opened + /// + private void TryUpdateCanExecute() + { + if (Command == null) + { + _commandCanExecute = !_commandBindingError; + UpdateIsEffectivelyEnabled(); + return; + } + + //Perf optimization - only raise CanExecute event if the menu is open + if (!((ILogical)this).IsAttachedToLogicalTree || + Parent is MenuItem { IsSubMenuOpen: false }) + { + return; + } + + var canExecute = Command.CanExecute(CommandParameter); if (canExecute != _commandCanExecute) { _commandCanExecute = canExecute; @@ -635,6 +655,11 @@ namespace Avalonia.Controls if (value) { + foreach (var item in Items.OfType()) + { + item.TryUpdateCanExecute(); + } + RaiseEvent(new RoutedEventArgs(SubmenuOpenedEvent)); IsSelected = true; PseudoClasses.Add(":open"); diff --git a/src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs b/src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs index 5f82e28722..a2bdcd1ea8 100644 --- a/src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs +++ b/src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs @@ -116,7 +116,7 @@ namespace Avalonia.Controls.Platform protected IMenu? Menu { get; private set; } - public static TimeSpan MenuShowDelay { get; set; } = TimeSpan.FromMilliseconds(400); + protected static TimeSpan MenuShowDelay { get; } = TimeSpan.FromMilliseconds(400); protected internal virtual void GotFocus(object sender, GotFocusEventArgs e) { diff --git a/tests/Avalonia.Controls.UnitTests/MenuItemTests.cs b/tests/Avalonia.Controls.UnitTests/MenuItemTests.cs index ebe471f303..db31d22b4f 100644 --- a/tests/Avalonia.Controls.UnitTests/MenuItemTests.cs +++ b/tests/Avalonia.Controls.UnitTests/MenuItemTests.cs @@ -2,15 +2,21 @@ using System.Collections.Generic; using System.Text; using System.Windows.Input; +using Avalonia.Collections; +using Avalonia.Controls.Primitives; using Avalonia.Data; using Avalonia.Input; +using Avalonia.Platform; using Avalonia.UnitTests; +using Moq; using Xunit; namespace Avalonia.Controls.UnitTests { public class MenuItemTests { + private Mock popupImpl; + [Fact] public void Header_Of_Minus_Should_Apply_Separator_Pseudoclass() { @@ -79,7 +85,7 @@ namespace Avalonia.Controls.UnitTests } [Fact] - public void MenuItem_Is_Enabled_When_Bound_Command_Is_Added() + public void MenuItem_Is_Enabled_When_Added_To_Logical_Tree_And_Bound_Command_Is_Added() { var viewModel = new { @@ -91,7 +97,8 @@ namespace Avalonia.Controls.UnitTests DataContext = new object(), [!MenuItem.CommandProperty] = new Binding("Command"), }; - + var root = new TestRoot { Child = target }; + Assert.True(target.IsEnabled); Assert.False(target.IsEffectivelyEnabled); @@ -158,10 +165,11 @@ namespace Avalonia.Controls.UnitTests } [Fact] - public void MenuItem_Invokes_CanExecute_When_CommandParameter_Changed() + public void MenuItem_Invokes_CanExecute_When_Added_To_Logical_Tree_And_CommandParameter_Changed() { var command = new TestCommand(p => p is bool value && value); var target = new MenuItem { Command = command }; + var root = new TestRoot { Child = target }; target.CommandParameter = true; Assert.True(target.IsEffectivelyEnabled); @@ -169,6 +177,151 @@ namespace Avalonia.Controls.UnitTests target.CommandParameter = false; Assert.False(target.IsEffectivelyEnabled); } + + [Fact] + public void MenuItem_Does_Not_Invoke_CanExecute_When_ContextMenu_Closed() + { + using (Application()) + { + var canExecuteCallCount = 0; + var command = new TestCommand(_ => + { + canExecuteCallCount++; + return true; + }); + var target = new MenuItem(); + var contextMenu = new ContextMenu { Items = new AvaloniaList { target } }; + var window = new Window { Content = new Panel { ContextMenu = contextMenu } }; + window.ApplyTemplate(); + window.Presenter.ApplyTemplate(); + + Assert.True(target.IsEffectivelyEnabled); + target.Command = command; + Assert.Equal(0, canExecuteCallCount); + + target.CommandParameter = false; + Assert.Equal(0, canExecuteCallCount); + + command.RaiseCanExecuteChanged(); + Assert.Equal(0, canExecuteCallCount); + + contextMenu.Open(); + Assert.Equal(2, canExecuteCallCount);//2 because popup is changing logical child + + command.RaiseCanExecuteChanged(); + Assert.Equal(3, canExecuteCallCount); + + target.CommandParameter = true; + Assert.Equal(4, canExecuteCallCount); + } + } + + [Fact] + public void MenuItem_Does_Not_Invoke_CanExecute_When_MenuFlyout_Closed() + { + using (Application()) + { + var canExecuteCallCount = 0; + var command = new TestCommand(_ => + { + canExecuteCallCount++; + return true; + }); + var target = new MenuItem(); + var flyout = new MenuFlyout { Items = new AvaloniaList { target } }; + var button = new Button { Flyout = flyout }; + var window = new Window { Content = button }; + window.ApplyTemplate(); + window.Presenter.ApplyTemplate(); + + Assert.True(target.IsEffectivelyEnabled); + target.Command = command; + Assert.Equal(0, canExecuteCallCount); + + target.CommandParameter = false; + Assert.Equal(0, canExecuteCallCount); + + command.RaiseCanExecuteChanged(); + Assert.Equal(0, canExecuteCallCount); + + flyout.ShowAt(button); + Assert.Equal(2, canExecuteCallCount); + + command.RaiseCanExecuteChanged(); + Assert.Equal(3, canExecuteCallCount); + + target.CommandParameter = true; + Assert.Equal(4, canExecuteCallCount); + } + } + + [Fact] + public void MenuItem_Does_Not_Invoke_CanExecute_When_Parent_MenuItem_Closed() + { + using (Application()) + { + var canExecuteCallCount = 0; + var command = new TestCommand(_ => + { + canExecuteCallCount++; + return true; + }); + var target = new MenuItem(); + var parentMenuItem = new MenuItem { Items = new AvaloniaList { target } }; + var contextMenu = new ContextMenu { Items = new AvaloniaList { parentMenuItem } }; + var window = new Window { Content = new Panel { ContextMenu = contextMenu } }; + window.ApplyTemplate(); + window.Presenter.ApplyTemplate(); + contextMenu.Open(); + + Assert.True(target.IsEffectivelyEnabled); + target.Command = command; + Assert.Equal(0, canExecuteCallCount); + + target.CommandParameter = false; + Assert.Equal(0, canExecuteCallCount); + + command.RaiseCanExecuteChanged(); + Assert.Equal(0, canExecuteCallCount); + + try + { + parentMenuItem.IsSubMenuOpen = true; + } + catch (InvalidOperationException) + { + //popup host creation failed exception + } + Assert.Equal(1, canExecuteCallCount); + + command.RaiseCanExecuteChanged(); + Assert.Equal(2, canExecuteCallCount); + + target.CommandParameter = true; + Assert.Equal(3, canExecuteCallCount); + } + } + private IDisposable Application() + { + var screen = new PixelRect(new PixelPoint(), new PixelSize(100, 100)); + var screenImpl = new Mock(); + screenImpl.Setup(x => x.ScreenCount).Returns(1); + screenImpl.Setup(X => X.AllScreens).Returns( new[] { new Screen(1, screen, screen, true) }); + + var windowImpl = MockWindowingPlatform.CreateWindowMock(); + popupImpl = MockWindowingPlatform.CreatePopupMock(windowImpl.Object); + popupImpl.SetupGet(x => x.RenderScaling).Returns(1); + windowImpl.Setup(x => x.CreatePopup()).Returns(popupImpl.Object); + + windowImpl.Setup(x => x.Screen).Returns(screenImpl.Object); + + var services = TestServices.StyledWindow.With( + inputManager: new InputManager(), + windowImpl: windowImpl.Object, + windowingPlatform: new MockWindowingPlatform(() => windowImpl.Object, x => popupImpl.Object)); + + return UnitTestApplication.Start(services); + } private class TestCommand : ICommand { @@ -198,6 +351,8 @@ namespace Avalonia.Controls.UnitTests public bool CanExecute(object parameter) => _canExecute(parameter); public void Execute(object parameter) => _execute(parameter); + + public void RaiseCanExecuteChanged() => _canExecuteChanged?.Invoke(this, EventArgs.Empty); } } } From c0a294e1fbd6d6619bef39a312ded5abe2b66236 Mon Sep 17 00:00:00 2001 From: Dan Walmsley Date: Sat, 11 Dec 2021 17:43:38 +0000 Subject: [PATCH 03/14] fix memory issues in wasm apps. --- src/Web/Avalonia.Web.Blazor/Avalonia.Web.Blazor.props.xml | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 src/Web/Avalonia.Web.Blazor/Avalonia.Web.Blazor.props.xml diff --git a/src/Web/Avalonia.Web.Blazor/Avalonia.Web.Blazor.props.xml b/src/Web/Avalonia.Web.Blazor/Avalonia.Web.Blazor.props.xml new file mode 100644 index 0000000000..81666106b4 --- /dev/null +++ b/src/Web/Avalonia.Web.Blazor/Avalonia.Web.Blazor.props.xml @@ -0,0 +1,7 @@ + + + 16777216 + false + false + + From 28a2a2fc8eeacf5f68a7bb40526b76f3a407a2e9 Mon Sep 17 00:00:00 2001 From: Sergey Mikolaytis Date: Sun, 12 Dec 2021 04:53:24 +0300 Subject: [PATCH 04/14] Windows IME (#7007) * win32 ime wip * ime window starts tracking the cursor, but coords are wrong * fix win32 ime cursor coord * win32-ime lang-specific behaviors * track language id in WindowImpl * lowercase dllimport * create initial ime on window creation * InputMethodManager: connect to client even if im is absent at the moment * proposal: IKeyboardDevice.NotifyInputMethodUpdated * finalizing * ime: allow client to request active state change * remove backward incompatible ActiveState. * InputMethodManager: NotifyInputMethodUpdated: filter the window of current focused element * [IME] [Windows] ability to enable/disable IME for any InputElement * [IME] [Windows] Refactor Imm32InputMethod - create a single one for dispatcher. Also change a method of enabling/disabling IME to work like in WPF. * [IME] [Windows] Fix IME after dialog show not working - active window context is not applied. * [IME] [Windows] fix intermediate input position * [IME] [Windows] PreEdit font size is applied * [IME] [Windows] Make MoveImeWindow code to be exact like in chrome - fix a lot of possible issues. Added comments. Minor Refactoring * [IME] [Windows] Refactor caret management, improve deactivation, remove comments * [IME] [Windows] Remove redundant api changes (request from @kekekeks) * Fix .sln and ApiCompatBesaline.txt redundant changes. * [Windows] [IME] move IsInputMethodEnabled subscription to InputMethodManager, Move check for IsInputMethodEnabled before TextInputMethodClientRequestedEvent query * [IME] [Windows] remove redundant SetActive(false) call, because it's called in Client setter * remove redundant change Co-authored-by: Yatao Li Co-authored-by: Max Katz --- samples/ControlCatalog/Pages/TextBoxPage.xaml | 15 +- src/Avalonia.Controls/TextBox.cs | 2 +- .../TextBoxTextInputMethodClient.cs | 26 +- src/Avalonia.Input/InputMethod.cs | 32 +++ .../TextInput/InputMethodManager.cs | 35 ++- .../Avalonia.Win32/Input/Imm32CaretManager.cs | 38 +++ .../Avalonia.Win32/Input/Imm32InputMethod.cs | 231 ++++++++++++++++++ .../Interop/UnmanagedMethods.cs | 106 ++++++++ .../Avalonia.Win32/WindowImpl.AppWndProc.cs | 45 ++++ src/Windows/Avalonia.Win32/WindowImpl.cs | 9 +- 10 files changed, 525 insertions(+), 14 deletions(-) create mode 100644 src/Avalonia.Input/InputMethod.cs create mode 100644 src/Windows/Avalonia.Win32/Input/Imm32CaretManager.cs create mode 100644 src/Windows/Avalonia.Win32/Input/Imm32InputMethod.cs diff --git a/samples/ControlCatalog/Pages/TextBoxPage.xaml b/samples/ControlCatalog/Pages/TextBoxPage.xaml index 233b309caf..0a5ccdcfff 100644 --- a/samples/ControlCatalog/Pages/TextBoxPage.xaml +++ b/samples/ControlCatalog/Pages/TextBoxPage.xaml @@ -18,7 +18,7 @@ Custom context flyout - + @@ -47,6 +47,19 @@ SelectionStart="5" SelectionEnd="22" SelectionBrush="Green" SelectionForegroundBrush="Yellow"/> + + + + diff --git a/src/Avalonia.Controls/TextBox.cs b/src/Avalonia.Controls/TextBox.cs index 00fc6002d1..32428bea53 100644 --- a/src/Avalonia.Controls/TextBox.cs +++ b/src/Avalonia.Controls/TextBox.cs @@ -533,7 +533,7 @@ namespace Avalonia.Controls protected override void OnApplyTemplate(TemplateAppliedEventArgs e) { _presenter = e.NameScope.Get("PART_TextPresenter"); - _imClient.SetPresenter(_presenter); + _imClient.SetPresenter(_presenter, this); if (IsFocused) { _presenter?.ShowCaret(); diff --git a/src/Avalonia.Controls/TextBoxTextInputMethodClient.cs b/src/Avalonia.Controls/TextBoxTextInputMethodClient.cs index c5a729afae..334db2cafd 100644 --- a/src/Avalonia.Controls/TextBoxTextInputMethodClient.cs +++ b/src/Avalonia.Controls/TextBoxTextInputMethodClient.cs @@ -1,5 +1,6 @@ using System; using Avalonia.Controls.Presenters; +using Avalonia.Input; using Avalonia.Input.TextInput; using Avalonia.VisualTree; @@ -7,9 +8,26 @@ namespace Avalonia.Controls { internal class TextBoxTextInputMethodClient : ITextInputMethodClient { + private InputElement _parent; private TextPresenter _presenter; private IDisposable _subscription; - public Rect CursorRectangle => _presenter?.GetCursorRectangle() ?? default; + public Rect CursorRectangle + { + get + { + if (_parent == null || _presenter == null) + { + return default; + } + var transform = _presenter.TransformToVisual(_parent); + if (transform == null) + { + return default; + } + return _presenter.GetCursorRectangle().TransformToAABB(transform.Value); + } + } + public event EventHandler CursorRectangleChanged; public IVisual TextViewVisual => _presenter; public event EventHandler TextViewVisualChanged; @@ -23,9 +41,11 @@ namespace Avalonia.Controls public string TextAfterCursor => null; private void OnCaretIndexChanged(int index) => CursorRectangleChanged?.Invoke(this, EventArgs.Empty); - - public void SetPresenter(TextPresenter presenter) + + + public void SetPresenter(TextPresenter presenter, InputElement parent) { + _parent = parent; _subscription?.Dispose(); _subscription = null; _presenter = presenter; diff --git a/src/Avalonia.Input/InputMethod.cs b/src/Avalonia.Input/InputMethod.cs new file mode 100644 index 0000000000..8098b18c47 --- /dev/null +++ b/src/Avalonia.Input/InputMethod.cs @@ -0,0 +1,32 @@ +namespace Avalonia.Input +{ + public class InputMethod + { + /// + /// A dependency property that enables alternative text inputs. + /// + public static readonly AvaloniaProperty IsInputMethodEnabledProperty = + AvaloniaProperty.RegisterAttached("IsInputMethodEnabled", true); + + /// + /// Setter for IsInputMethodEnabled AvaloniaProperty + /// + public static void SetIsInputMethodEnabled(InputElement target, bool value) + { + target.SetValue(IsInputMethodEnabledProperty, value); + } + + /// + /// Getter for IsInputMethodEnabled AvaloniaProperty + /// + public static bool GetIsInputMethodEnabled(InputElement target) + { + return target.GetValue(IsInputMethodEnabledProperty); + } + + private InputMethod() + { + + } + } +} diff --git a/src/Avalonia.Input/TextInput/InputMethodManager.cs b/src/Avalonia.Input/TextInput/InputMethodManager.cs index 207ba6096e..dafd397348 100644 --- a/src/Avalonia.Input/TextInput/InputMethodManager.cs +++ b/src/Avalonia.Input/TextInput/InputMethodManager.cs @@ -8,9 +8,14 @@ namespace Avalonia.Input.TextInput private ITextInputMethodImpl? _im; private IInputElement? _focusedElement; private ITextInputMethodClient? _client; + private IDisposable? _subscribeDisposable; private readonly TransformTrackingHelper _transformTracker = new TransformTrackingHelper(); - public TextInputMethodManager() => _transformTracker.MatrixChanged += UpdateCursorRect; + public TextInputMethodManager() + { + _transformTracker.MatrixChanged += UpdateCursorRect; + InputMethod.IsInputMethodEnabledProperty.Changed.Subscribe(OnIsInputMethodEnabledChanged); + } private ITextInputMethodClient? Client { @@ -40,6 +45,7 @@ namespace Avalonia.Input.TextInput _im?.SetOptions(optionsQuery); _transformTracker?.SetVisual(_client?.TextViewVisual); UpdateCursorRect(); + _im?.SetActive(true); } else @@ -50,6 +56,14 @@ namespace Avalonia.Input.TextInput } } + private void OnIsInputMethodEnabledChanged(AvaloniaPropertyChangedEventArgs obj) + { + if (ReferenceEquals(obj.Sender, _focusedElement)) + { + TryFindAndApplyClient(); + } + } + private void OnTextViewVisualChanged(object sender, EventArgs e) => _transformTracker.SetVisual(_client?.TextViewVisual); @@ -57,6 +71,7 @@ namespace Avalonia.Input.TextInput { if (_im == null || _client == null || _focusedElement?.VisualRoot == null) return; + var transform = _focusedElement.TransformToVisual(_focusedElement.VisualRoot); if (transform == null) _im.SetCursorRect(default); @@ -75,17 +90,23 @@ namespace Avalonia.Input.TextInput if(_focusedElement == element) return; _focusedElement = element; - + var inputMethod = (element?.VisualRoot as ITextInputMethodRoot)?.InputMethod; - if(_im != inputMethod) + if (_im != inputMethod) _im?.SetActive(false); _im = inputMethod; - - if (_focusedElement == null || _im == null) + + TryFindAndApplyClient(); + } + + private void TryFindAndApplyClient() + { + if (_focusedElement is not InputElement focused || + _im == null || + !InputMethod.GetIsInputMethodEnabled(focused)) { Client = null; - _im?.SetActive(false); return; } @@ -93,7 +114,7 @@ namespace Avalonia.Input.TextInput { RoutedEvent = InputElement.TextInputMethodClientRequestedEvent }; - + _focusedElement.RaiseEvent(clientQuery); Client = clientQuery.Client; } diff --git a/src/Windows/Avalonia.Win32/Input/Imm32CaretManager.cs b/src/Windows/Avalonia.Win32/Input/Imm32CaretManager.cs new file mode 100644 index 0000000000..38605efa22 --- /dev/null +++ b/src/Windows/Avalonia.Win32/Input/Imm32CaretManager.cs @@ -0,0 +1,38 @@ +using System; +using static Avalonia.Win32.Interop.UnmanagedMethods; + +namespace Avalonia.Win32.Input +{ + internal struct Imm32CaretManager + { + private bool _isCaretCreated; + + public void TryCreate(int _langId, IntPtr hwnd) + { + if (!_isCaretCreated) + { + if (_langId == LANG_ZH || _langId == LANG_JA) + { + _isCaretCreated = CreateCaret(hwnd, IntPtr.Zero, 2, 10); + } + } + } + + public void TryMove(int x, int y) + { + if (_isCaretCreated) + { + SetCaretPos(x, y); + } + } + + public void TryDestroy() + { + if (_isCaretCreated) + { + DestroyCaret(); + _isCaretCreated = false; + } + } + } +} diff --git a/src/Windows/Avalonia.Win32/Input/Imm32InputMethod.cs b/src/Windows/Avalonia.Win32/Input/Imm32InputMethod.cs new file mode 100644 index 0000000000..71e33554f1 --- /dev/null +++ b/src/Windows/Avalonia.Win32/Input/Imm32InputMethod.cs @@ -0,0 +1,231 @@ +using System; +using Avalonia.Input.TextInput; +using Avalonia.Threading; + +using static Avalonia.Win32.Interop.UnmanagedMethods; + +namespace Avalonia.Win32.Input +{ + /// + /// A Windows input method editor based on Windows Input Method Manager (IMM32). + /// + class Imm32InputMethod : ITextInputMethodImpl + { + public IntPtr HWND { get; private set; } + private IntPtr _defaultImc; + private WindowImpl _parent; + private bool _active; + private bool _showCompositionWindow; + private Imm32CaretManager _caretManager = new(); + private bool _showCandidateList; + private ushort _langId; + private const int _caretMargin = 1; + + public void SetLanguageAndWindow(WindowImpl parent, IntPtr hwnd, IntPtr HKL) + { + if (HWND != hwnd) + { + _defaultImc = IntPtr.Zero; + } + HWND = hwnd; + _parent = parent; + _active = false; + _langId = PRIMARYLANGID(LGID(HKL)); + _showCompositionWindow = true; + _showCandidateList = true; + + IsComposing = false; + } + + //Dependant on CurrentThread. When Avalonia will support Multiple Dispatchers - + //every Dispatcher should have their own InputMethod. + public static Imm32InputMethod Current { get; } = new Imm32InputMethod(); + + private IntPtr DefaultImc + { + get + { + if (_defaultImc == IntPtr.Zero && + HWND != IntPtr.Zero) + { + _defaultImc = ImmGetContext(HWND); + ImmReleaseContext(HWND, _defaultImc); + } + + if (_defaultImc == IntPtr.Zero) + { + _defaultImc = ImmCreateContext(); + } + + return _defaultImc; + } + } + + public void Reset() + { + if (IsComposing) + { + Dispatcher.UIThread.Post(() => + { + ImmNotifyIME(DefaultImc, NI_COMPOSITIONSTR, CPS_COMPLETE, 0); + ImmReleaseContext(HWND, DefaultImc); + IsComposing = false; + }); + } + } + + public void SetActive(bool active) + { + _active = active; + Dispatcher.UIThread.Post(() => + { + if (active) + { + if (DefaultImc != IntPtr.Zero) + { + _caretManager.TryCreate(_langId, HWND); + // Load the default IME context. + // NOTE(hbono) + // IMM ignores this call if the IME context is loaded. Therefore, we do + // not have to check whether or not the IME context is loaded. + ImmAssociateContext(HWND, _defaultImc); + } + } + else + { + // A renderer process have moved its input focus to a password input + // when there is an ongoing composition, e.g. a user has clicked a + // mouse button and selected a password input while composing a text. + // For this case, we have to complete the ongoing composition and + // clean up the resources attached to this object BEFORE DISABLING THE IME. + if (IsComposing) + { + ImmNotifyIME(DefaultImc, NI_COMPOSITIONSTR, CPS_COMPLETE, 0); + ImmReleaseContext(HWND, DefaultImc); + IsComposing = false; + } + ImmAssociateContext(HWND, IntPtr.Zero); + _caretManager.TryDestroy(); + } + }); + } + + public void SetCursorRect(Rect rect) + { + var focused = GetActiveWindow() == HWND; + if (!focused) + { + return; + } + Dispatcher.UIThread.Post(() => + { + IntPtr himc = DefaultImc; + if (himc == IntPtr.Zero) + { + return; + } + + MoveImeWindow(rect, himc); + ImmReleaseContext(HWND, himc); + }); + } + + // see: https://chromium.googlesource.com/experimental/chromium/src/+/bf09a5036ccfb77d2277247c66dc55daf41df3fe/chrome/browser/ime_input.cc + // see: https://engine.chinmaygarde.com/window__win32_8cc_source.html + private void MoveImeWindow(Rect rect, IntPtr himc) + { + var p1 = rect.TopLeft; + var p2 = rect.BottomRight; + var s = _parent?.DesktopScaling ?? 1; + var (x1, y1, x2, y2) = ((int) (p1.X * s), (int) (p1.Y * s), (int) (p2.X * s), (int) (p2.Y * s)); + + if (!_showCompositionWindow && + _langId == LANG_ZH) + { + // Chinese IMEs ignore function calls to ::ImmSetCandidateWindow() + // when a user disables TSF (Text Service Framework) and CUAS (Cicero + // Unaware Application Support). + // On the other hand, when a user enables TSF and CUAS, Chinese IMEs + // ignore the position of the current system caret and uses the + // parameters given to ::ImmSetCandidateWindow() with its 'dwStyle' + // parameter CFS_CANDIDATEPOS. + // Therefore, we do not only call ::ImmSetCandidateWindow() but also + // set the positions of the temporary system caret. + var candidateForm = new CANDIDATEFORM + { + dwIndex = 0, + dwStyle = CFS_CANDIDATEPOS, + ptCurrentPos = new POINT {X = x2, Y = y2} + }; + ImmSetCandidateWindow(himc, ref candidateForm); + } + + _caretManager.TryMove(x2, y2); + + if (_showCompositionWindow) + { + ConfigureCompositionWindow(x1, y1, himc, y2 - y1); + // Don't need to set the position of candidate window. + return; + } + + if (_langId == LANG_KO) + { + // Chinese IMEs and Japanese IMEs require the upper-left corner of + // the caret to move the position of their candidate windows. + // On the other hand, Korean IMEs require the lower-left corner of the + // caret to move their candidate windows. + y2 += _caretMargin; + } + + // Need to return here since some Chinese IMEs would stuck if set + // candidate window position with CFS_EXCLUDE style. + if (_langId == LANG_ZH) + { + return; + } + + // Japanese IMEs and Korean IMEs also use the rectangle given to + // ::ImmSetCandidateWindow() with its 'dwStyle' parameter CFS_EXCLUDE + // to move their candidate windows when a user disables TSF and CUAS. + // Therefore, we also set this parameter here. + var excludeRectangle = new CANDIDATEFORM + { + dwIndex = 0, + dwStyle = CFS_EXCLUDE, + ptCurrentPos = new POINT {X = x1, Y = y1}, + rcArea = new RECT {left = x1, top = y1, right = x2, bottom = y2 + _caretMargin} + }; + ImmSetCandidateWindow(himc, ref excludeRectangle); + } + + private static void ConfigureCompositionWindow(int x1, int y1, IntPtr himc, int height) + { + var compForm = new COMPOSITIONFORM + { + dwStyle = CFS_POINT, + ptCurrentPos = new POINT {X = x1, Y = y1}, + }; + ImmSetCompositionWindow(himc, ref compForm); + + var logFont = new LOGFONT() + { + lfHeight = height, + lfQuality = 5 //CLEARTYPE_QUALITY + }; + ImmSetCompositionFont(himc, ref logFont); + } + + public void SetOptions(TextInputOptionsQueryEventArgs options) + { + // we're skipping this. not usable on windows + } + + public bool IsComposing { get; set; } + + ~Imm32InputMethod() + { + _caretManager.TryDestroy(); + } + } +} diff --git a/src/Windows/Avalonia.Win32/Interop/UnmanagedMethods.cs b/src/Windows/Avalonia.Win32/Interop/UnmanagedMethods.cs index 3b2b99fb0c..c74c5fbc01 100644 --- a/src/Windows/Avalonia.Win32/Interop/UnmanagedMethods.cs +++ b/src/Windows/Avalonia.Win32/Interop/UnmanagedMethods.cs @@ -1551,6 +1551,112 @@ namespace Avalonia.Win32.Interop [DllImport("user32.dll")] internal static extern int SetWindowCompositionAttribute(IntPtr hwnd, ref WindowCompositionAttributeData data); + [DllImport("imm32.dll", SetLastError = true)] + public static extern IntPtr ImmGetContext(IntPtr hWnd); + [DllImport("imm32.dll", SetLastError = true)] + public static extern IntPtr ImmAssociateContext(IntPtr hWnd, IntPtr hIMC); + [DllImport("imm32.dll", SetLastError = true)] + public static extern IntPtr ImmCreateContext(); + [DllImport("imm32.dll")] + public static extern bool ImmReleaseContext(IntPtr hWnd, IntPtr hIMC); + [DllImport("imm32.dll")] + public static extern bool ImmSetOpenStatus(IntPtr hIMC, bool flag); + [DllImport("imm32.dll")] + public static extern bool ImmSetActiveContext(IntPtr hIMC, bool flag); + [DllImport("imm32.dll")] + public static extern bool ImmSetStatusWindowPos(IntPtr hIMC, ref POINT lpptPos); + [DllImport("imm32.dll")] + public static extern bool ImmIsIME(IntPtr HKL); + [DllImport("imm32.dll")] + public static extern bool ImmSetCandidateWindow(IntPtr hIMC, ref CANDIDATEFORM lpCandidate); + [DllImport("imm32.dll")] + public static extern bool ImmSetCompositionWindow(IntPtr hIMC, ref COMPOSITIONFORM lpComp); + [DllImport("imm32.dll")] + public static extern bool ImmSetCompositionFont(IntPtr hIMC, ref LOGFONT lf); + [DllImport("imm32.dll")] + public static extern bool ImmNotifyIME(IntPtr hIMC, int dwAction, int dwIndex, int dwValue); + [DllImport("user32.dll")] + public static extern bool CreateCaret(IntPtr hwnd, IntPtr hBitmap, int nWidth, int nHeight); + [DllImport("user32.dll")] + public static extern bool SetCaretPos(int X, int Y); + [DllImport("user32.dll")] + public static extern bool DestroyCaret(); + [DllImport("user32.dll")] + public static extern IntPtr GetKeyboardLayout(int idThread); + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern int LCIDToLocaleName(uint Locale, StringBuilder lpName, int cchName, int dwFlags); + + public static uint MAKELCID(uint lgid, uint srtid) + { + return (((uint)(ushort)srtid) << 16) | + ((ushort)lgid); + } + + public static ushort PRIMARYLANGID(uint lgid) + { + return (ushort)(lgid & 0x3ff); + } + + public static uint LGID(IntPtr HKL) + { + return (uint)(HKL.ToInt32() & 0xffff); + } + + public const int SORT_DEFAULT = 0; + public const int LANG_ZH = 0x0004; + public const int LANG_JA = 0x0011; + public const int LANG_KO = 0x0012; + + public const int CFS_FORCE_POSITION = 0x0020; + public const int CFS_CANDIDATEPOS = 0x0040; + public const int CFS_EXCLUDE = 0x0080; + public const int CFS_POINT = 0x0002; + public const int CFS_RECT = 0x0001; + public const uint ISC_SHOWUICOMPOSITIONWINDOW = 0x80000000; + + public const int NI_COMPOSITIONSTR = 21; + public const int CPS_COMPLETE = 1; + public const int CPS_CONVERT = 2; + public const int CPS_REVERT = 3; + public const int CPS_CANCEL = 4; + + [StructLayout(LayoutKind.Sequential)] + internal struct CANDIDATEFORM + { + public int dwIndex; + public int dwStyle; + public POINT ptCurrentPos; + public RECT rcArea; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct COMPOSITIONFORM + { + public int dwStyle; + public POINT ptCurrentPos; + public RECT rcArea; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct LOGFONT + { + public int lfHeight; + public int lfWidth; + public int lfEscapement; + public int lfOrientation; + public int lfWeight; + public byte lfItalic; + public byte lfUnderline; + public byte lfStrikeOut; + public byte lfCharSet; + public byte lfOutPrecision; + public byte lfClipPrecision; + public byte lfQuality; + public byte lfPitchAndFamily; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] + public string lfFaceName; + } + [StructLayout(LayoutKind.Sequential)] internal struct WindowCompositionAttributeData { diff --git a/src/Windows/Avalonia.Win32/WindowImpl.AppWndProc.cs b/src/Windows/Avalonia.Win32/WindowImpl.AppWndProc.cs index eaf6b47f42..89d5009da5 100644 --- a/src/Windows/Avalonia.Win32/WindowImpl.AppWndProc.cs +++ b/src/Windows/Avalonia.Win32/WindowImpl.AppWndProc.cs @@ -1,6 +1,7 @@ using System; using System.Diagnostics.CodeAnalysis; using System.Runtime.InteropServices; +using System.Text; using Avalonia.Controls; using Avalonia.Controls.Remote; using Avalonia.Input; @@ -34,6 +35,7 @@ namespace Avalonia.Win32 case WindowActivate.WA_CLICKACTIVE: { Activated?.Invoke(); + UpdateInputMethod(GetKeyboardLayout(0)); break; } @@ -472,6 +474,35 @@ namespace Avalonia.Win32 case WindowsMessage.WM_KILLFOCUS: LostFocus?.Invoke(); break; + + case WindowsMessage.WM_INPUTLANGCHANGE: + { + UpdateInputMethod(lParam); + // call DefWindowProc to pass to all children + break; + } + case WindowsMessage.WM_IME_SETCONTEXT: + { + // TODO if we implement preedit, disable the composition window: + // lParam = new IntPtr((int)(((uint)lParam.ToInt64()) & ~ISC_SHOWUICOMPOSITIONWINDOW)); + UpdateInputMethod(GetKeyboardLayout(0)); + break; + } + case WindowsMessage.WM_IME_CHAR: + case WindowsMessage.WM_IME_COMPOSITION: + case WindowsMessage.WM_IME_COMPOSITIONFULL: + case WindowsMessage.WM_IME_CONTROL: + case WindowsMessage.WM_IME_KEYDOWN: + case WindowsMessage.WM_IME_KEYUP: + case WindowsMessage.WM_IME_NOTIFY: + case WindowsMessage.WM_IME_SELECT: + break; + case WindowsMessage.WM_IME_STARTCOMPOSITION: + Imm32InputMethod.Current.IsComposing = true; + break; + case WindowsMessage.WM_IME_ENDCOMPOSITION: + Imm32InputMethod.Current.IsComposing = false; + break; } #if USE_MANAGED_DRAG @@ -500,6 +531,20 @@ namespace Avalonia.Win32 } } + private void UpdateInputMethod(IntPtr hkl) + { + // note: for non-ime language, also create it so that emoji panel tracks cursor + var langid = LGID(hkl); + if (langid == _langid && Imm32InputMethod.Current.HWND == Hwnd) + { + return; + } + _langid = langid; + + Imm32InputMethod.Current.SetLanguageAndWindow(this, Hwnd, hkl); + + } + private static int ToInt32(IntPtr ptr) { if (IntPtr.Size == 4) diff --git a/src/Windows/Avalonia.Win32/WindowImpl.cs b/src/Windows/Avalonia.Win32/WindowImpl.cs index 0a031cd5bf..4c3165eaf9 100644 --- a/src/Windows/Avalonia.Win32/WindowImpl.cs +++ b/src/Windows/Avalonia.Win32/WindowImpl.cs @@ -6,6 +6,7 @@ using Avalonia.Controls; using Avalonia.Controls.Platform; using Avalonia.Input; using Avalonia.Input.Raw; +using Avalonia.Input.TextInput; using Avalonia.OpenGL; using Avalonia.OpenGL.Angle; using Avalonia.OpenGL.Egl; @@ -25,7 +26,8 @@ namespace Avalonia.Win32 /// Window implementation for Win32 platform. /// public partial class WindowImpl : IWindowImpl, EglGlPlatformSurface.IEglWindowGlPlatformSurfaceInfo, - ITopLevelImplWithNativeControlHost + ITopLevelImplWithNativeControlHost, + ITopLevelImplWithTextInputMethod { private static readonly List s_instances = new List(); @@ -87,6 +89,7 @@ namespace Avalonia.Win32 private bool _isCloseRequested; private bool _shown; private bool _hiddenWindowIsParent; + private uint _langid; public WindowImpl() { @@ -122,7 +125,7 @@ namespace Avalonia.Win32 CreateWindow(); _framebuffer = new FramebufferManager(_hwnd); - + UpdateInputMethod(GetKeyboardLayout(0)); if (glPlatform != null) { if (_isUsingComposition) @@ -1353,5 +1356,7 @@ namespace Avalonia.Win32 public void Dispose() => _owner._resizeReason = _restore; } + + public ITextInputMethodImpl TextInputMethod => Imm32InputMethod.Current; } } From fed8ee70760e1bdde218701f5c56d79c9aee5783 Mon Sep 17 00:00:00 2001 From: Dan Walmsley Date: Sun, 12 Dec 2021 14:07:53 +0000 Subject: [PATCH 05/14] fix incorrect extension of props file for Avalonia.Web.Blazor --- .../{Avalonia.Web.Blazor.props.xml => Avalonia.Web.Blazor.props} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/Web/Avalonia.Web.Blazor/{Avalonia.Web.Blazor.props.xml => Avalonia.Web.Blazor.props} (100%) diff --git a/src/Web/Avalonia.Web.Blazor/Avalonia.Web.Blazor.props.xml b/src/Web/Avalonia.Web.Blazor/Avalonia.Web.Blazor.props similarity index 100% rename from src/Web/Avalonia.Web.Blazor/Avalonia.Web.Blazor.props.xml rename to src/Web/Avalonia.Web.Blazor/Avalonia.Web.Blazor.props From 8836a9e208638005ed4114ec6ca011882a96933b Mon Sep 17 00:00:00 2001 From: Max Katz Date: Sun, 12 Dec 2021 20:44:28 -0500 Subject: [PATCH 06/14] Adaptive design for ControlCatalog sample pages --- samples/ControlCatalog/App.xaml | 3 + samples/ControlCatalog/Pages/AcrylicPage.xaml | 264 ++++++++++-------- .../Pages/AutoCompleteBoxPage.xaml | 125 ++++----- samples/ControlCatalog/Pages/BorderPage.xaml | 5 +- samples/ControlCatalog/Pages/ButtonPage.xaml | 1 - .../Pages/ButtonSpinnerPage.xaml | 1 - .../Pages/CalendarDatePickerPage.xaml | 1 - .../ControlCatalog/Pages/CalendarPage.xaml | 55 ++-- samples/ControlCatalog/Pages/CanvasPage.xaml | 1 - .../ControlCatalog/Pages/CarouselPage.xaml | 13 +- .../ControlCatalog/Pages/CheckBoxPage.xaml | 1 - .../ControlCatalog/Pages/ComboBoxPage.xaml | 3 +- .../Pages/ContextFlyoutPage.xaml | 1 - .../ControlCatalog/Pages/ContextMenuPage.xaml | 1 - samples/ControlCatalog/Pages/CursorPage.xaml | 1 - .../ControlCatalog/Pages/DataGridPage.xaml | 4 +- .../Pages/DateTimePickerPage.xaml | 1 - samples/ControlCatalog/Pages/DialogsPage.xaml | 3 - .../ControlCatalog/Pages/DragAndDropPage.xaml | 71 +++-- .../ControlCatalog/Pages/ExpanderPage.xaml | 1 - samples/ControlCatalog/Pages/ImagePage.xaml | 1 - .../Pages/ItemsRepeaterPage.xaml | 1 - samples/ControlCatalog/Pages/ListBoxPage.xaml | 1 - samples/ControlCatalog/Pages/MenuPage.xaml | 8 +- .../Pages/NotificationsPage.xaml | 1 - .../Pages/NumericUpDownPage.xaml | 11 +- .../ControlCatalog/Pages/ProgressBarPage.xaml | 1 - .../ControlCatalog/Pages/RadioButtonPage.xaml | 1 - .../Pages/RelativePanelPage.axaml | 3 + .../Pages/ScrollViewerPage.xaml | 1 - samples/ControlCatalog/Pages/SliderPage.xaml | 1 - .../ControlCatalog/Pages/TabControlPage.xaml | 6 - .../ControlCatalog/Pages/TabStripPage.xaml | 1 - .../ControlCatalog/Pages/TextBlockPage.xaml | 137 +++++---- samples/ControlCatalog/Pages/TextBoxPage.xaml | 40 ++- .../Pages/ToggleSwitchPage.xaml | 3 +- samples/ControlCatalog/Pages/ToolTipPage.xaml | 1 - .../ControlCatalog/Pages/TreeViewPage.xaml | 1 - samples/ControlCatalog/Pages/ViewboxPage.xaml | 1 - 39 files changed, 390 insertions(+), 386 deletions(-) diff --git a/samples/ControlCatalog/App.xaml b/samples/ControlCatalog/App.xaml index 6e57686e00..7ae7b4d3bb 100644 --- a/samples/ControlCatalog/App.xaml +++ b/samples/ControlCatalog/App.xaml @@ -5,6 +5,9 @@ x:CompileBindings="True" x:Class="ControlCatalog.App"> + + + + + - + + + + + + + + + + + + + + + + + + + - + - - - - - - - - - - - - + + + + + - - - - - - - - - - - - - - - - - - - - + + + + + - - - - - - + + + + + - - - - - + + + + + - - - - - - + + + + + - - - - - - - - - - - + + + + + - - - - - - + + + + + - + - + - - + + + + + + + + + + + diff --git a/samples/ControlCatalog/Pages/AutoCompleteBoxPage.xaml b/samples/ControlCatalog/Pages/AutoCompleteBoxPage.xaml index 1a53217842..46f3705ffd 100644 --- a/samples/ControlCatalog/Pages/AutoCompleteBoxPage.xaml +++ b/samples/ControlCatalog/Pages/AutoCompleteBoxPage.xaml @@ -1,73 +1,72 @@ - - - AutoCompleteBox + d:DesignHeight="600" + d:DesignWidth="400"> + A control into which the user can input text - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/ControlCatalog/Pages/BorderPage.xaml b/samples/ControlCatalog/Pages/BorderPage.xaml index bfd14cc627..bfc4f86698 100644 --- a/samples/ControlCatalog/Pages/BorderPage.xaml +++ b/samples/ControlCatalog/Pages/BorderPage.xaml @@ -1,8 +1,11 @@ - Border A control which decorates a child with a border and background - Button A button control - ButtonSpinner The ButtonSpinner control allows you to add button spinners to any element and then respond to the Spin event to manipulate that element. diff --git a/samples/ControlCatalog/Pages/CalendarDatePickerPage.xaml b/samples/ControlCatalog/Pages/CalendarDatePickerPage.xaml index 107472105a..3e50bf8a08 100644 --- a/samples/ControlCatalog/Pages/CalendarDatePickerPage.xaml +++ b/samples/ControlCatalog/Pages/CalendarDatePickerPage.xaml @@ -2,7 +2,6 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" x:Class="ControlCatalog.Pages.CalendarDatePickerPage"> - CalendarDatePicker A control for selecting dates with a calendar drop-down - Calendar A calendar control for selecting dates - - - - - + + + + + + + + + - - + + + - + - - + - - + + + - + - - + - + SelectionMode="SingleDate" /> + + - - + diff --git a/samples/ControlCatalog/Pages/CanvasPage.xaml b/samples/ControlCatalog/Pages/CanvasPage.xaml index d154e717a4..0c0d897f04 100644 --- a/samples/ControlCatalog/Pages/CanvasPage.xaml +++ b/samples/ControlCatalog/Pages/CanvasPage.xaml @@ -2,7 +2,6 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" x:Class="ControlCatalog.Pages.CanvasPage"> - Canvas A panel which lays out its children by explicit coordinates diff --git a/samples/ControlCatalog/Pages/CarouselPage.xaml b/samples/ControlCatalog/Pages/CarouselPage.xaml index edd692a57e..4a53c9026f 100644 --- a/samples/ControlCatalog/Pages/CarouselPage.xaml +++ b/samples/ControlCatalog/Pages/CarouselPage.xaml @@ -2,14 +2,15 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" x:Class="ControlCatalog.Pages.CarouselPage"> - Carousel An items control that displays its items as pages that fill the control. - - - + @@ -17,10 +18,10 @@ - - + Transition diff --git a/samples/ControlCatalog/Pages/CheckBoxPage.xaml b/samples/ControlCatalog/Pages/CheckBoxPage.xaml index 769ef26699..2f60fc5dae 100644 --- a/samples/ControlCatalog/Pages/CheckBoxPage.xaml +++ b/samples/ControlCatalog/Pages/CheckBoxPage.xaml @@ -2,7 +2,6 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" x:Class="ControlCatalog.Pages.CheckBoxPage"> - CheckBox A check box control - ComboBox A drop-down list. + MaxWidth="660"> - Context Flyout A right click Flyout that can be applied to any control. diff --git a/samples/ControlCatalog/Pages/ContextMenuPage.xaml b/samples/ControlCatalog/Pages/ContextMenuPage.xaml index 7e564efafc..1e120aab6d 100644 --- a/samples/ControlCatalog/Pages/ContextMenuPage.xaml +++ b/samples/ControlCatalog/Pages/ContextMenuPage.xaml @@ -2,7 +2,6 @@ xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> - Context Menu A right click menu that can be applied to any control. diff --git a/samples/ControlCatalog/Pages/CursorPage.xaml b/samples/ControlCatalog/Pages/CursorPage.xaml index a28039ea3f..25abc426a9 100644 --- a/samples/ControlCatalog/Pages/CursorPage.xaml +++ b/samples/ControlCatalog/Pages/CursorPage.xaml @@ -3,7 +3,6 @@ x:Class="ControlCatalog.Pages.CursorPage"> - Cursor Defines a cursor (mouse pointer) diff --git a/samples/ControlCatalog/Pages/DataGridPage.xaml b/samples/ControlCatalog/Pages/DataGridPage.xaml index 9c502b7414..63e873d9b5 100644 --- a/samples/ControlCatalog/Pages/DataGridPage.xaml +++ b/samples/ControlCatalog/Pages/DataGridPage.xaml @@ -19,7 +19,6 @@ - DataGrid A control for displaying and interacting with a data source. @@ -35,12 +34,13 @@ - + diff --git a/samples/ControlCatalog/Pages/DateTimePickerPage.xaml b/samples/ControlCatalog/Pages/DateTimePickerPage.xaml index 45056a9a76..29cd939520 100644 --- a/samples/ControlCatalog/Pages/DateTimePickerPage.xaml +++ b/samples/ControlCatalog/Pages/DateTimePickerPage.xaml @@ -6,7 +6,6 @@ mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="ControlCatalog.Pages.DateTimePickerPage"> - DatePicker and TimePicker diff --git a/samples/ControlCatalog/Pages/DialogsPage.xaml b/samples/ControlCatalog/Pages/DialogsPage.xaml index a910962dde..6ac9dcfe22 100644 --- a/samples/ControlCatalog/Pages/DialogsPage.xaml +++ b/samples/ControlCatalog/Pages/DialogsPage.xaml @@ -2,9 +2,6 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" x:Class="ControlCatalog.Pages.DialogsPage"> - - Use filters diff --git a/samples/ControlCatalog/Pages/DragAndDropPage.xaml b/samples/ControlCatalog/Pages/DragAndDropPage.xaml index cd8bf78c88..1bf8bfa99b 100644 --- a/samples/ControlCatalog/Pages/DragAndDropPage.xaml +++ b/samples/ControlCatalog/Pages/DragAndDropPage.xaml @@ -1,32 +1,45 @@ - - - Drag+Drop - Example of Drag+Drop capabilities + + + Example of Drag+Drop capabilities - - - - Drag Me - - - Drag Me (custom) - - - + + + + Drag Me + + + Drag Me (custom) + + + - - Drop some text or files here (Copy) - - - Drop some text or files here (Move) - - - + + + Drop some text or files here (Copy) + + + Drop some text or files here (Move) + + + + diff --git a/samples/ControlCatalog/Pages/ExpanderPage.xaml b/samples/ControlCatalog/Pages/ExpanderPage.xaml index 605eff4fce..cef473af04 100644 --- a/samples/ControlCatalog/Pages/ExpanderPage.xaml +++ b/samples/ControlCatalog/Pages/ExpanderPage.xaml @@ -2,7 +2,6 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" x:Class="ControlCatalog.Pages.ExpanderPage"> - Expander Expands to show nested content - Image Displays an image diff --git a/samples/ControlCatalog/Pages/ItemsRepeaterPage.xaml b/samples/ControlCatalog/Pages/ItemsRepeaterPage.xaml index 4d0bd663df..8305d72d1f 100644 --- a/samples/ControlCatalog/Pages/ItemsRepeaterPage.xaml +++ b/samples/ControlCatalog/Pages/ItemsRepeaterPage.xaml @@ -43,7 +43,6 @@ - ItemsRepeater A data-driven collection control that incorporates a flexible layout system, custom views, and virtualization. diff --git a/samples/ControlCatalog/Pages/ListBoxPage.xaml b/samples/ControlCatalog/Pages/ListBoxPage.xaml index b36629fb2a..41658329df 100644 --- a/samples/ControlCatalog/Pages/ListBoxPage.xaml +++ b/samples/ControlCatalog/Pages/ListBoxPage.xaml @@ -13,7 +13,6 @@ - ListBox Hosts a collection of ListBoxItem. Each 5th item is highlighted with nth-child(5n+3) and nth-last-child(5n+4) rules. diff --git a/samples/ControlCatalog/Pages/MenuPage.xaml b/samples/ControlCatalog/Pages/MenuPage.xaml index 2c09cb9b4d..0e5b49914f 100644 --- a/samples/ControlCatalog/Pages/MenuPage.xaml +++ b/samples/ControlCatalog/Pages/MenuPage.xaml @@ -2,16 +2,12 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" x:Class="ControlCatalog.Pages.MenuPage"> - Menu Exported menu fallback (Should be only visible on platforms without desktop-global menu bar) A window menu - + Defined in XAML @@ -85,6 +81,6 @@ - + diff --git a/samples/ControlCatalog/Pages/NotificationsPage.xaml b/samples/ControlCatalog/Pages/NotificationsPage.xaml index 94e2314dc7..d48b338fe8 100644 --- a/samples/ControlCatalog/Pages/NotificationsPage.xaml +++ b/samples/ControlCatalog/Pages/NotificationsPage.xaml @@ -2,7 +2,6 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" x:Class="ControlCatalog.Pages.NotificationsPage"> - Notifications