diff --git a/Avalonia.Desktop.slnf b/Avalonia.Desktop.slnf index d4cde99240..76620e8b93 100644 --- a/Avalonia.Desktop.slnf +++ b/Avalonia.Desktop.slnf @@ -38,13 +38,14 @@ "src\\Markup\\Avalonia.Markup.Xaml\\Avalonia.Markup.Xaml.csproj", "src\\Markup\\Avalonia.Markup\\Avalonia.Markup.csproj", "src\\Skia\\Avalonia.Skia\\Avalonia.Skia.csproj", - "src\\Windows\\Avalonia.Direct2D1\\Avalonia.Direct2D1.csproj", - "src\\Windows\\Avalonia.Win32.Interop\\Avalonia.Win32.Interop.csproj", - "src\\Windows\\Avalonia.Win32\\Avalonia.Win32.csproj", + "src\\tools\\Avalonia.Generators\\Avalonia.Generators.csproj", "src\\tools\\Avalonia.Generators\\Avalonia.Generators.csproj", "src\\tools\\DevAnalyzers\\DevAnalyzers.csproj", "src\\tools\\DevGenerators\\DevGenerators.csproj", "src\\tools\\PublicAnalyzers\\Avalonia.Analyzers.csproj", + "src\\Windows\\Avalonia.Direct2D1\\Avalonia.Direct2D1.csproj", + "src\\Windows\\Avalonia.Win32.Interop\\Avalonia.Win32.Interop.csproj", + "src\\Windows\\Avalonia.Win32\\Avalonia.Win32.csproj", "tests\\Avalonia.Base.UnitTests\\Avalonia.Base.UnitTests.csproj", "tests\\Avalonia.Benchmarks\\Avalonia.Benchmarks.csproj", "tests\\Avalonia.Controls.DataGrid.UnitTests\\Avalonia.Controls.DataGrid.UnitTests.csproj", diff --git a/Avalonia.sln b/Avalonia.sln index b21df07628..f33b782479 100644 --- a/Avalonia.sln +++ b/Avalonia.sln @@ -244,13 +244,21 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.Controls.ItemsRepe EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.Controls.ItemsRepeater.UnitTests", "tests\Avalonia.Controls.ItemsRepeater.UnitTests\Avalonia.Controls.ItemsRepeater.UnitTests.csproj", "{F4E36AA8-814E-4704-BC07-291F70F45193}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Avalonia.Generators", "src\tools\Avalonia.Generators\Avalonia.Generators.csproj", "{DDA28789-C21A-4654-86CE-D01E81F095C5}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.Generators", "src\tools\Avalonia.Generators\Avalonia.Generators.csproj", "{DDA28789-C21A-4654-86CE-D01E81F095C5}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Avalonia.Generators.Tests", "tests\Avalonia.Generators.Tests\Avalonia.Generators.Tests.csproj", "{2D7C812B-7E73-4252-8EFD-BC8A4D5CCB9F}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.Generators.Tests", "tests\Avalonia.Generators.Tests\Avalonia.Generators.Tests.csproj", "{2D7C812B-7E73-4252-8EFD-BC8A4D5CCB9F}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Avalonia.Fonts.Inter", "src\Avalonia.Fonts.Inter\Avalonia.Fonts.Inter.csproj", "{13F1135D-BA1A-435C-9C5B-A368D1D63DE4}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.Fonts.Inter", "src\Avalonia.Fonts.Inter\Avalonia.Fonts.Inter.csproj", "{13F1135D-BA1A-435C-9C5B-A368D1D63DE4}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Generators.Sandbox", "samples\Generators.Sandbox\Generators.Sandbox.csproj", "{A82AD1BC-EBE6-4FC3-A13B-D52A50297533}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Generators.Sandbox", "samples\Generators.Sandbox\Generators.Sandbox.csproj", "{A82AD1BC-EBE6-4FC3-A13B-D52A50297533}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SafeAreaDemo", "samples\SafeAreaDemo\SafeAreaDemo.csproj", "{6B60A970-D5D2-49C2-8BAB-F9C7973B74B6}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SafeAreaDemo.Android", "samples\SafeAreaDemo.Android\SafeAreaDemo.Android.csproj", "{22E3BC08-EAF7-4889-BDC4-B4D3046C4E2D}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SafeAreaDemo.Desktop", "samples\SafeAreaDemo.Desktop\SafeAreaDemo.Desktop.csproj", "{4CDAD037-34A2-4CCF-A03A-C6C7B988A572}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SafeAreaDemo.iOS", "samples\SafeAreaDemo.iOS\SafeAreaDemo.iOS.csproj", "{FC956F9A-4C3A-4A1A-ACDD-BB54DCB661DD}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -595,6 +603,26 @@ Global {A82AD1BC-EBE6-4FC3-A13B-D52A50297533}.Debug|Any CPU.Build.0 = Debug|Any CPU {A82AD1BC-EBE6-4FC3-A13B-D52A50297533}.Release|Any CPU.ActiveCfg = Release|Any CPU {A82AD1BC-EBE6-4FC3-A13B-D52A50297533}.Release|Any CPU.Build.0 = Release|Any CPU + {6B60A970-D5D2-49C2-8BAB-F9C7973B74B6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6B60A970-D5D2-49C2-8BAB-F9C7973B74B6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6B60A970-D5D2-49C2-8BAB-F9C7973B74B6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6B60A970-D5D2-49C2-8BAB-F9C7973B74B6}.Release|Any CPU.Build.0 = Release|Any CPU + {22E3BC08-EAF7-4889-BDC4-B4D3046C4E2D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {22E3BC08-EAF7-4889-BDC4-B4D3046C4E2D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {22E3BC08-EAF7-4889-BDC4-B4D3046C4E2D}.Debug|Any CPU.Deploy.0 = Debug|Any CPU + {22E3BC08-EAF7-4889-BDC4-B4D3046C4E2D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {22E3BC08-EAF7-4889-BDC4-B4D3046C4E2D}.Release|Any CPU.Build.0 = Release|Any CPU + {22E3BC08-EAF7-4889-BDC4-B4D3046C4E2D}.Release|Any CPU.Deploy.0 = Release|Any CPU + {4CDAD037-34A2-4CCF-A03A-C6C7B988A572}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4CDAD037-34A2-4CCF-A03A-C6C7B988A572}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4CDAD037-34A2-4CCF-A03A-C6C7B988A572}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4CDAD037-34A2-4CCF-A03A-C6C7B988A572}.Release|Any CPU.Build.0 = Release|Any CPU + {FC956F9A-4C3A-4A1A-ACDD-BB54DCB661DD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FC956F9A-4C3A-4A1A-ACDD-BB54DCB661DD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FC956F9A-4C3A-4A1A-ACDD-BB54DCB661DD}.Debug|Any CPU.Deploy.0 = Debug|Any CPU + {FC956F9A-4C3A-4A1A-ACDD-BB54DCB661DD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FC956F9A-4C3A-4A1A-ACDD-BB54DCB661DD}.Release|Any CPU.Build.0 = Release|Any CPU + {FC956F9A-4C3A-4A1A-ACDD-BB54DCB661DD}.Release|Any CPU.Deploy.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -661,10 +689,14 @@ Global {75C47156-C5D8-44BC-A5A7-E8657C2248D6} = {9B9E3891-2366-4253-A952-D08BCEB71098} {C810060E-3809-4B74-A125-F11533AF9C1B} = {9B9E3891-2366-4253-A952-D08BCEB71098} {C692FE73-43DB-49CE-87FC-F03ED61F25C9} = {4ED8B739-6F4E-4CD4-B993-545E6B5CE637} + {F4E36AA8-814E-4704-BC07-291F70F45193} = {C5A00AC3-B34C-4564-9BDD-2DA473EF4D8B} {DDA28789-C21A-4654-86CE-D01E81F095C5} = {4ED8B739-6F4E-4CD4-B993-545E6B5CE637} {2D7C812B-7E73-4252-8EFD-BC8A4D5CCB9F} = {C5A00AC3-B34C-4564-9BDD-2DA473EF4D8B} - {F4E36AA8-814E-4704-BC07-291F70F45193} = {C5A00AC3-B34C-4564-9BDD-2DA473EF4D8B} {A82AD1BC-EBE6-4FC3-A13B-D52A50297533} = {9B9E3891-2366-4253-A952-D08BCEB71098} + {6B60A970-D5D2-49C2-8BAB-F9C7973B74B6} = {9B9E3891-2366-4253-A952-D08BCEB71098} + {22E3BC08-EAF7-4889-BDC4-B4D3046C4E2D} = {9B9E3891-2366-4253-A952-D08BCEB71098} + {4CDAD037-34A2-4CCF-A03A-C6C7B988A572} = {9B9E3891-2366-4253-A952-D08BCEB71098} + {FC956F9A-4C3A-4A1A-ACDD-BB54DCB661DD} = {9B9E3891-2366-4253-A952-D08BCEB71098} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {87366D66-1391-4D90-8999-95A620AD786A} diff --git a/native/Avalonia.Native/src/OSX/platformthreading.mm b/native/Avalonia.Native/src/OSX/platformthreading.mm index 6d5bd4aa02..d80df68fea 100644 --- a/native/Avalonia.Native/src/OSX/platformthreading.mm +++ b/native/Avalonia.Native/src/OSX/platformthreading.mm @@ -1,193 +1,266 @@ #include "common.h" class PlatformThreadingInterface; + + +class LoopCancellation : public ComSingleObject +{ +public: + FORWARD_IUNKNOWN() + + bool Running = false; + bool Cancelled = false; + bool IsApp = false; + + virtual void Cancel() override + { + Cancelled = true; + if(Running) + { + Running = false; + if(![NSThread isMainThread]) + { + AddRef(); + dispatch_async(dispatch_get_main_queue(), ^{ + if(Release() == 0) + return; + Cancel(); + }); + return; + }; + if(IsApp) + [NSApp stop:nil]; + else + { + // Wakeup the event loop + NSEvent* event = [NSEvent otherEventWithType:NSEventTypeApplicationDefined + location:NSMakePoint(0, 0) + modifierFlags:0 + timestamp:0 + windowNumber:0 + context:nil + subtype:0 + data1:0 + data2:0]; + [NSApp postEvent:event atStart:YES]; + } + } + }; +}; + +// CFRunLoopTimerSetNextFireDate docs recommend to "create a repeating timer with an initial +// firing time in the distant future (or the initial firing time) and a very large repeat +// interval—on the order of decades or more" +static double distantFutureInterval = (double)50*365*24*3600; + @interface Signaler : NSObject --(void) setParent: (PlatformThreadingInterface*)parent; --(void) signal: (int) priority; +-(void) setEvents:(IAvnPlatformThreadingInterfaceEvents*) events; +-(void) updateTimer:(int)ms; -(Signaler*) init; +-(void) destroyObserver; +-(void) signal; @end -@implementation ActionCallback +@implementation Signaler { - ComPtr _callback; + ComPtr _events; + bool _wakeupDelegateSent; + bool _signaled; + bool _backgroundProcessingRequested; + CFRunLoopObserverRef _observer; + CFRunLoopTimerRef _timer; +} +- (void) checkSignaled +{ + bool signaled; + @synchronized (self) { + signaled = _signaled; + _signaled = false; + } + if(signaled) + { + _events->Signaled(); + } } -- (ActionCallback*) initWithCallback: (IAvnActionCallback*) callback + +- (Signaler*) init { - _callback = callback; + _observer = CFRunLoopObserverCreateWithHandler(nil, + kCFRunLoopBeforeSources + | kCFRunLoopAfterWaiting + | kCFRunLoopBeforeWaiting + , + true, 0, + ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) { + if(activity == kCFRunLoopBeforeWaiting) + { + bool triggerProcessing; + @synchronized (self) { + triggerProcessing = self->_backgroundProcessingRequested; + self->_backgroundProcessingRequested = false; + } + if(triggerProcessing) + self->_events->ReadyForBackgroundProcessing(); + } + [self checkSignaled]; + }); + CFRunLoopAddObserver(CFRunLoopGetMain(), _observer, kCFRunLoopCommonModes); + + + _timer = CFRunLoopTimerCreateWithHandler(nil, CFAbsoluteTimeGetCurrent() + distantFutureInterval, distantFutureInterval, 0, 0, ^(CFRunLoopTimerRef timer) { + self->_events->Timer(); + }); + + CFRunLoopAddTimer(CFRunLoopGetMain(), _timer, kCFRunLoopCommonModes); + return self; } -- (void) action +- (void) destroyObserver { - _callback->Run(); + if(_observer != nil) + { + CFRunLoopObserverInvalidate(_observer); + CFRelease(_observer); + _observer = nil; + } + + if(_timer != nil) + { + CFRunLoopTimerInvalidate(_timer); + CFRelease(_timer); + _timer = nil; + } } +-(void) updateTimer:(int)ms +{ + if(_timer == nil) + return; + double interval = ms < 0 ? distantFutureInterval : ((double)ms / 1000); + CFRunLoopTimerSetTolerance(_timer, 0); + CFRunLoopTimerSetNextFireDate(_timer, CFAbsoluteTimeGetCurrent() + interval); +} -@end +- (void) setEvents: (IAvnPlatformThreadingInterfaceEvents*) events +{ + _events = events; +} -class TimerWrapper : public ComUnknownObject +- (void) signal { - NSTimer* _timer; -public: - TimerWrapper(IAvnActionCallback* callback, int ms) - { - auto cb = [[ActionCallback alloc] initWithCallback:callback]; - _timer = [NSTimer scheduledTimerWithTimeInterval:(NSTimeInterval)(double)ms/1000 target:cb selector:@selector(action) userInfo:nullptr repeats:true]; + @synchronized (self) { + if(_signaled) + return; + _signaled = true; + dispatch_async(dispatch_get_main_queue(), ^{ + [self checkSignaled]; + }); + CFRunLoopWakeUp(CFRunLoopGetMain()); } - - virtual ~TimerWrapper() - { - [_timer invalidate]; +} + +- (void) requestBackgroundProcessing +{ + @synchronized (self) { + if(_backgroundProcessingRequested) + return; + _backgroundProcessingRequested = true; + dispatch_async(dispatch_get_main_queue(), ^{ + // This is needed to wakeup the loop if we are called from inside of BeforeWait hook + }); } -}; + + +} +@end class PlatformThreadingInterface : public ComSingleObject { private: + ComPtr _events; Signaler* _signaler; - bool _wasRunningAtLeastOnce = false; - - class LoopCancellation : public ComSingleObject - { - public: - FORWARD_IUNKNOWN() - - bool Running = false; - bool Cancelled = false; - - virtual void Cancel() override - { - Cancelled = true; - if(Running) - { - Running = false; - dispatch_async(dispatch_get_main_queue(), ^{ - [[NSApplication sharedApplication] stop:nil]; - NSEvent* event = [NSEvent otherEventWithType:NSEventTypeApplicationDefined - location:NSMakePoint(0, 0) - modifierFlags:0 - timestamp:0 - windowNumber:0 - context:nil - subtype:0 - data1:0 - data2:0]; - [NSApp postEvent:event atStart:YES]; - }); - } - } - - }; - + CFRunLoopObserverRef _observer = nil; public: FORWARD_IUNKNOWN() - ComPtr SignaledCallback; - PlatformThreadingInterface() { _signaler = [Signaler new]; - [_signaler setParent:this]; - } + }; ~PlatformThreadingInterface() { - if(_signaler) - [_signaler setParent: NULL]; - _signaler = NULL; + [_signaler destroyObserver]; } - virtual bool GetCurrentThreadIsLoopThread() override + bool GetCurrentThreadIsLoopThread() override { return [NSThread isMainThread]; - } - virtual void SetSignaledCallback(IAvnSignaledCallback* cb) override + }; + + + + void SetEvents(IAvnPlatformThreadingInterfaceEvents *cb) override { - SignaledCallback = cb; - } - virtual IAvnLoopCancellation* CreateLoopCancellation() override + _events = cb; + [_signaler setEvents:cb]; + }; + + IAvnLoopCancellation *CreateLoopCancellation() override { return new LoopCancellation(); - } + }; - virtual HRESULT RunLoop(IAvnLoopCancellation* cancel) override + void RunLoop(IAvnLoopCancellation *cancel) override { START_COM_CALL; - auto can = dynamic_cast(cancel); if(can->Cancelled) - return S_OK; - if(_wasRunningAtLeastOnce) - return E_FAIL; + return; can->Running = true; - _wasRunningAtLeastOnce = true; - [NSApp run]; - return S_OK; - } + if(![NSApp isRunning]) + { + can->IsApp = true; + [NSApp run]; + return; + } + else + { + while(!can->Cancelled) + { + @autoreleasepool + { + NSEvent* ev = [NSApp + nextEventMatchingMask:NSEventMaskAny + untilDate: [NSDate dateWithTimeIntervalSinceNow:1] + inMode:NSDefaultRunLoopMode + dequeue:true]; + if(ev != NULL) + [NSApp sendEvent:ev]; + } + } + } + }; - virtual void Signal(int priority) override + void Signal() override { - [_signaler signal:priority]; - } + [_signaler signal]; + }; - virtual IUnknown* StartTimer(int priority, int ms, IAvnActionCallback* callback) override - { - @autoreleasepool { - - return new TimerWrapper(callback, ms); - } - } -}; - -@implementation Signaler - -PlatformThreadingInterface* _parent = 0; -bool _signaled = 0; -NSArray* _modes; - --(Signaler*) init -{ - if(self = [super init]) + void UpdateTimer(int ms) override { - _modes = [NSArray arrayWithObjects: NSDefaultRunLoopMode, NSEventTrackingRunLoopMode, NSModalPanelRunLoopMode, NSRunLoopCommonModes, NSConnectionReplyMode, nil]; - } - return self; -} - --(void) perform -{ - ComPtr cb; - @synchronized (self) { - _signaled = false; - if(_parent != NULL) - cb = _parent->SignaledCallback; - } - if(cb != nullptr) - cb->Signaled(0, false); -} - --(void) setParent:(PlatformThreadingInterface *)parent -{ - @synchronized (self) { - _parent = parent; - } -} - --(void) signal: (int) priority -{ - - @synchronized (self) { - if(_signaled) - return; - _signaled = true; - [self performSelector:@selector(perform) onThread:[NSThread mainThread] withObject:NULL waitUntilDone:false modes:_modes]; + [_signaler updateTimer:ms]; + }; + + void RequestBackgroundProcessing() override { + [_signaler requestBackgroundProcessing]; } -} -@end - + +}; extern IAvnPlatformThreadingInterface* CreatePlatformThreading() { diff --git a/samples/BindingDemo/MainWindow.xaml b/samples/BindingDemo/MainWindow.xaml index 08ac0426ea..d1c65ca73b 100644 --- a/samples/BindingDemo/MainWindow.xaml +++ b/samples/BindingDemo/MainWindow.xaml @@ -75,11 +75,11 @@ - + - + diff --git a/samples/ControlCatalog.Android/MainActivity.cs b/samples/ControlCatalog.Android/MainActivity.cs index f6fa07dbde..486d14661e 100644 --- a/samples/ControlCatalog.Android/MainActivity.cs +++ b/samples/ControlCatalog.Android/MainActivity.cs @@ -5,7 +5,7 @@ using Avalonia.Android; namespace ControlCatalog.Android { - [Activity(Label = "ControlCatalog.Android", Theme = "@style/MyTheme.Main", Icon = "@drawable/icon", LaunchMode = LaunchMode.SingleTop, ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize)] + [Activity(Label = "ControlCatalog.Android", Theme = "@style/MyTheme.Main", Icon = "@drawable/icon", LaunchMode = LaunchMode.SingleTop, ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)] public class MainActivity : AvaloniaMainActivity { } diff --git a/samples/ControlCatalog.Android/Resources/values-night/colors.xml b/samples/ControlCatalog.Android/Resources/values-night/colors.xml new file mode 100644 index 0000000000..3d47b6fc58 --- /dev/null +++ b/samples/ControlCatalog.Android/Resources/values-night/colors.xml @@ -0,0 +1,4 @@ + + + #212121 + diff --git a/samples/ControlCatalog/MainView.xaml b/samples/ControlCatalog/MainView.xaml index 9f06525821..7ed2d67379 100644 --- a/samples/ControlCatalog/MainView.xaml +++ b/samples/ControlCatalog/MainView.xaml @@ -147,9 +147,6 @@ - - - diff --git a/samples/ControlCatalog/Pages/ClipboardPage.xaml.cs b/samples/ControlCatalog/Pages/ClipboardPage.xaml.cs index b6b2ac7181..089d1af197 100644 --- a/samples/ControlCatalog/Pages/ClipboardPage.xaml.cs +++ b/samples/ControlCatalog/Pages/ClipboardPage.xaml.cs @@ -32,13 +32,13 @@ namespace ControlCatalog.Pages private async void CopyText(object? sender, RoutedEventArgs args) { - if (Application.Current!.Clipboard is { } clipboard && ClipboardContent is { } clipboardContent) + if (TopLevel.GetTopLevel(this)?.Clipboard is { } clipboard && ClipboardContent is { } clipboardContent) await clipboard.SetTextAsync(clipboardContent.Text ?? String.Empty); } private async void PasteText(object? sender, RoutedEventArgs args) { - if (Application.Current!.Clipboard is { } clipboard) + if (TopLevel.GetTopLevel(this)?.Clipboard is { } clipboard) { ClipboardContent.Text = await clipboard.GetTextAsync(); } @@ -46,7 +46,7 @@ namespace ControlCatalog.Pages private async void CopyTextDataObject(object? sender, RoutedEventArgs args) { - if (Application.Current!.Clipboard is { } clipboard) + if (TopLevel.GetTopLevel(this)?.Clipboard is { } clipboard) { var dataObject = new DataObject(); dataObject.Set(DataFormats.Text, ClipboardContent.Text ?? string.Empty); @@ -56,7 +56,7 @@ namespace ControlCatalog.Pages private async void PasteTextDataObject(object? sender, RoutedEventArgs args) { - if (Application.Current!.Clipboard is { } clipboard) + if (TopLevel.GetTopLevel(this)?.Clipboard is { } clipboard) { ClipboardContent.Text = await clipboard.GetDataAsync(DataFormats.Text) as string ?? string.Empty; } @@ -64,7 +64,7 @@ namespace ControlCatalog.Pages private async void CopyFilesDataObject(object? sender, RoutedEventArgs args) { - if (Application.Current!.Clipboard is { } clipboard) + if (TopLevel.GetTopLevel(this)?.Clipboard is { } clipboard) { var storageProvider = TopLevel.GetTopLevel(this)!.StorageProvider; var filesPath = (ClipboardContent.Text ?? string.Empty) @@ -110,7 +110,7 @@ namespace ControlCatalog.Pages private async void PasteFilesDataObject(object? sender, RoutedEventArgs args) { - if (Application.Current!.Clipboard is { } clipboard) + if (TopLevel.GetTopLevel(this)?.Clipboard is { } clipboard) { var files = await clipboard.GetDataAsync(DataFormats.Files) as IEnumerable; @@ -120,7 +120,7 @@ namespace ControlCatalog.Pages private async void GetFormats(object sender, RoutedEventArgs args) { - if (Application.Current!.Clipboard is { } clipboard) + if (TopLevel.GetTopLevel(this)?.Clipboard is { } clipboard) { var formats = await clipboard.GetFormatsAsync(); ClipboardContent.Text = string.Join(Environment.NewLine, formats); @@ -129,7 +129,7 @@ namespace ControlCatalog.Pages private async void Clear(object sender, RoutedEventArgs args) { - if (Application.Current!.Clipboard is { } clipboard) + if (TopLevel.GetTopLevel(this)?.Clipboard is { } clipboard) { await clipboard.ClearAsync(); } diff --git a/samples/ControlCatalog/Pages/ColorPickerPage.xaml b/samples/ControlCatalog/Pages/ColorPickerPage.xaml index 649256ba83..b759720cf2 100644 --- a/samples/ControlCatalog/Pages/ColorPickerPage.xaml +++ b/samples/ControlCatalog/Pages/ColorPickerPage.xaml @@ -28,6 +28,41 @@ + + + 24 + 18 + 12 + 9 + + + + + + + + + + + + + + + + + + + + + - - Scrollviewer can snap supported content both vertically and horizontally. Snapping occurs from scrolling with touch or pen. - - - - - - - - - - - - - - - - Vertical Snapping - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Horizontal Snapping - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/samples/ControlCatalog/Pages/ScrollSnapPage.xaml.cs b/samples/ControlCatalog/Pages/ScrollSnapPage.xaml.cs deleted file mode 100644 index 384dc67c66..0000000000 --- a/samples/ControlCatalog/Pages/ScrollSnapPage.xaml.cs +++ /dev/null @@ -1,68 +0,0 @@ -using System.Collections.Generic; -using Avalonia.Controls; -using Avalonia.Controls.Primitives; -using Avalonia.Markup.Xaml; -using MiniMvvm; - -namespace ControlCatalog.Pages -{ - public class ScrollSnapPageViewModel : ViewModelBase - { - private SnapPointsType _snapPointsType; - private SnapPointsAlignment _snapPointsAlignment; - private bool _areSnapPointsRegular; - - public ScrollSnapPageViewModel() - { - - AvailableSnapPointsType = new List() - { - SnapPointsType.None, - SnapPointsType.Mandatory, - SnapPointsType.MandatorySingle - }; - - AvailableSnapPointsAlignment = new List() - { - SnapPointsAlignment.Near, - SnapPointsAlignment.Center, - SnapPointsAlignment.Far, - }; - } - - public bool AreSnapPointsRegular - { - get => _areSnapPointsRegular; - set => this.RaiseAndSetIfChanged(ref _areSnapPointsRegular, value); - } - - public SnapPointsType SnapPointsType - { - get => _snapPointsType; - set => this.RaiseAndSetIfChanged(ref _snapPointsType, value); - } - - public SnapPointsAlignment SnapPointsAlignment - { - get => _snapPointsAlignment; - set => this.RaiseAndSetIfChanged(ref _snapPointsAlignment, value); - } - public List AvailableSnapPointsType { get; } - public List AvailableSnapPointsAlignment { get; } - } - - public class ScrollSnapPage : UserControl - { - public ScrollSnapPage() - { - this.InitializeComponent(); - - DataContext = new ScrollSnapPageViewModel(); - } - - private void InitializeComponent() - { - AvaloniaXamlLoader.Load(this); - } - } -} diff --git a/samples/ControlCatalog/Pages/ScrollViewerPage.xaml b/samples/ControlCatalog/Pages/ScrollViewerPage.xaml index 1a3d61eb85..4af61c3399 100644 --- a/samples/ControlCatalog/Pages/ScrollViewerPage.xaml +++ b/samples/ControlCatalog/Pages/ScrollViewerPage.xaml @@ -3,35 +3,267 @@ xmlns:pages="using:ControlCatalog.Pages" x:Class="ControlCatalog.Pages.ScrollViewerPage" x:DataType="pages:ScrollViewerPageViewModel"> - - Allows for horizontal and vertical content scrolling. Supports snapping on touch and pointer wheel scrolling. - - - - - - - - - - - - - - - + + + + Allows for horizontal and vertical content scrolling. Supports snapping on touch and pointer wheel scrolling. + + + + + + + + + + + + + + + + + + + + + + + + + Scrollviewer can snap supported content both vertically and horizontally. Snapping occurs from scrolling with touch or pen. + + + + + + + - - - - - + + + + + + + + Vertical Snapping + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Horizontal Snapping + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/ControlCatalog/Pages/ScrollViewerPage.xaml.cs b/samples/ControlCatalog/Pages/ScrollViewerPage.xaml.cs index a097f1f951..7082ca1bf6 100644 --- a/samples/ControlCatalog/Pages/ScrollViewerPage.xaml.cs +++ b/samples/ControlCatalog/Pages/ScrollViewerPage.xaml.cs @@ -12,6 +12,9 @@ namespace ControlCatalog.Pages private bool _enableInertia; private ScrollBarVisibility _horizontalScrollVisibility; private ScrollBarVisibility _verticalScrollVisibility; + private SnapPointsType _snapPointsType; + private SnapPointsAlignment _snapPointsAlignment; + private bool _areSnapPointsRegular; public ScrollViewerPageViewModel() { @@ -23,6 +26,20 @@ namespace ControlCatalog.Pages ScrollBarVisibility.Disabled, }; + AvailableSnapPointsType = new List() + { + SnapPointsType.None, + SnapPointsType.Mandatory, + SnapPointsType.MandatorySingle + }; + + AvailableSnapPointsAlignment = new List() + { + SnapPointsAlignment.Near, + SnapPointsAlignment.Center, + SnapPointsAlignment.Far, + }; + HorizontalScrollVisibility = ScrollBarVisibility.Auto; VerticalScrollVisibility = ScrollBarVisibility.Auto; AllowAutoHide = true; @@ -54,6 +71,26 @@ namespace ControlCatalog.Pages } public List AvailableVisibility { get; } + + public bool AreSnapPointsRegular + { + get => _areSnapPointsRegular; + set => this.RaiseAndSetIfChanged(ref _areSnapPointsRegular, value); + } + + public SnapPointsType SnapPointsType + { + get => _snapPointsType; + set => this.RaiseAndSetIfChanged(ref _snapPointsType, value); + } + + public SnapPointsAlignment SnapPointsAlignment + { + get => _snapPointsAlignment; + set => this.RaiseAndSetIfChanged(ref _snapPointsAlignment, value); + } + public List AvailableSnapPointsType { get; } + public List AvailableSnapPointsAlignment { get; } } public class ScrollViewerPage : UserControl diff --git a/samples/ControlCatalog/Pages/TabControlPage.xaml b/samples/ControlCatalog/Pages/TabControlPage.xaml index a775056ebe..3a2464e9fd 100644 --- a/samples/ControlCatalog/Pages/TabControlPage.xaml +++ b/samples/ControlCatalog/Pages/TabControlPage.xaml @@ -51,7 +51,7 @@ Text="From DataTemplate"> diff --git a/samples/RenderDemo/Pages/AnimationsPage.xaml b/samples/RenderDemo/Pages/AnimationsPage.xaml index 3f89a9d5f7..d764af8978 100644 --- a/samples/RenderDemo/Pages/AnimationsPage.xaml +++ b/samples/RenderDemo/Pages/AnimationsPage.xaml @@ -308,6 +308,41 @@ + + @@ -332,6 +367,11 @@ + + + Drop + Shadow + diff --git a/samples/SafeAreaDemo.Android/Icon.png b/samples/SafeAreaDemo.Android/Icon.png new file mode 100644 index 0000000000..41a2a618fb Binary files /dev/null and b/samples/SafeAreaDemo.Android/Icon.png differ diff --git a/samples/SafeAreaDemo.Android/MainActivity.cs b/samples/SafeAreaDemo.Android/MainActivity.cs new file mode 100644 index 0000000000..b0f0a6e419 --- /dev/null +++ b/samples/SafeAreaDemo.Android/MainActivity.cs @@ -0,0 +1,11 @@ +using Android.App; +using Android.Content.PM; +using Avalonia.Android; + +namespace SafeAreaDemo.Android +{ + [Activity(Label = "SafeAreaDemo.Android", Theme = "@style/MyTheme.NoActionBar", Icon = "@drawable/icon", LaunchMode = LaunchMode.SingleTop, ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)] + public class MainActivity : AvaloniaMainActivity + { + } +} diff --git a/samples/SafeAreaDemo.Android/Properties/AndroidManifest.xml b/samples/SafeAreaDemo.Android/Properties/AndroidManifest.xml new file mode 100644 index 0000000000..b6a5777e03 --- /dev/null +++ b/samples/SafeAreaDemo.Android/Properties/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/samples/SafeAreaDemo.Android/Resources/drawable/splash_screen.xml b/samples/SafeAreaDemo.Android/Resources/drawable/splash_screen.xml new file mode 100644 index 0000000000..2e920b4b3b --- /dev/null +++ b/samples/SafeAreaDemo.Android/Resources/drawable/splash_screen.xml @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/samples/SafeAreaDemo.Android/Resources/values/colors.xml b/samples/SafeAreaDemo.Android/Resources/values/colors.xml new file mode 100644 index 0000000000..59279d5d32 --- /dev/null +++ b/samples/SafeAreaDemo.Android/Resources/values/colors.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + diff --git a/samples/SafeAreaDemo.Android/Resources/values/styles.xml b/samples/SafeAreaDemo.Android/Resources/values/styles.xml new file mode 100644 index 0000000000..2759d2904a --- /dev/null +++ b/samples/SafeAreaDemo.Android/Resources/values/styles.xml @@ -0,0 +1,17 @@ + + + + + + + + + + diff --git a/samples/SafeAreaDemo.Android/SafeAreaDemo.Android.csproj b/samples/SafeAreaDemo.Android/SafeAreaDemo.Android.csproj new file mode 100644 index 0000000000..f5d2af79d0 --- /dev/null +++ b/samples/SafeAreaDemo.Android/SafeAreaDemo.Android.csproj @@ -0,0 +1,24 @@ + + + Exe + net7.0-android + 21 + enable + com.avalonia.safeareademo + 1 + 1.0 + apk + False + + + + + Resources\drawable\Icon.png + + + + + + + + diff --git a/samples/SafeAreaDemo.Android/SplashActivity.cs b/samples/SafeAreaDemo.Android/SplashActivity.cs new file mode 100644 index 0000000000..621ad1c675 --- /dev/null +++ b/samples/SafeAreaDemo.Android/SplashActivity.cs @@ -0,0 +1,30 @@ +using Android.App; +using Android.Content; +using Android.OS; +using Avalonia; +using Avalonia.Android; +using Application = Android.App.Application; + +namespace SafeAreaDemo.Android +{ + [Activity(Theme = "@style/MyTheme.Splash", MainLauncher = true, NoHistory = true)] + public class SplashActivity : AvaloniaSplashActivity + { + protected override AppBuilder CustomizeAppBuilder(AppBuilder builder) + { + return base.CustomizeAppBuilder(builder); + } + + protected override void OnCreate(Bundle? savedInstanceState) + { + base.OnCreate(savedInstanceState); + } + + protected override void OnResume() + { + base.OnResume(); + + StartActivity(new Intent(Application.Context, typeof(MainActivity))); + } + } +} diff --git a/samples/SafeAreaDemo.Desktop/Program.cs b/samples/SafeAreaDemo.Desktop/Program.cs new file mode 100644 index 0000000000..b07682e8c8 --- /dev/null +++ b/samples/SafeAreaDemo.Desktop/Program.cs @@ -0,0 +1,21 @@ +using Avalonia; +using System; + +namespace SafeAreaDemo.Desktop +{ + internal class Program + { + // Initialization code. Don't use any Avalonia, third-party APIs or any + // SynchronizationContext-reliant code before AppMain is called: things aren't initialized + // yet and stuff might break. + [STAThread] + public static void Main(string[] args) => BuildAvaloniaApp() + .StartWithClassicDesktopLifetime(args); + + // Avalonia configuration, don't remove; also used by visual designer. + public static AppBuilder BuildAvaloniaApp() + => AppBuilder.Configure() + .UsePlatformDetect() + .LogToTrace(); + } +} diff --git a/samples/SafeAreaDemo.Desktop/SafeAreaDemo.Desktop.csproj b/samples/SafeAreaDemo.Desktop/SafeAreaDemo.Desktop.csproj new file mode 100644 index 0000000000..619209892d --- /dev/null +++ b/samples/SafeAreaDemo.Desktop/SafeAreaDemo.Desktop.csproj @@ -0,0 +1,24 @@ + + + WinExe + + net7.0 + enable + true + + + + app.manifest + + + + + + + + + + + + diff --git a/samples/SafeAreaDemo.Desktop/app.manifest b/samples/SafeAreaDemo.Desktop/app.manifest new file mode 100644 index 0000000000..f0a4b00175 --- /dev/null +++ b/samples/SafeAreaDemo.Desktop/app.manifest @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + diff --git a/samples/SafeAreaDemo.iOS/AppDelegate.cs b/samples/SafeAreaDemo.iOS/AppDelegate.cs new file mode 100644 index 0000000000..6990435d78 --- /dev/null +++ b/samples/SafeAreaDemo.iOS/AppDelegate.cs @@ -0,0 +1,17 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.iOS; +using Avalonia.Media; +using Foundation; +using UIKit; + +namespace SafeAreaDemo.iOS +{ + // The UIApplicationDelegate for the application. This class is responsible for launching the + // User Interface of the application, as well as listening (and optionally responding) to + // application events from iOS. + [Register("AppDelegate")] + public partial class AppDelegate : AvaloniaAppDelegate + { + } +} diff --git a/samples/SafeAreaDemo.iOS/Entitlements.plist b/samples/SafeAreaDemo.iOS/Entitlements.plist new file mode 100644 index 0000000000..0c67376eba --- /dev/null +++ b/samples/SafeAreaDemo.iOS/Entitlements.plist @@ -0,0 +1,5 @@ + + + + + diff --git a/samples/SafeAreaDemo.iOS/Info.plist b/samples/SafeAreaDemo.iOS/Info.plist new file mode 100644 index 0000000000..ec04bd5a87 --- /dev/null +++ b/samples/SafeAreaDemo.iOS/Info.plist @@ -0,0 +1,47 @@ + + + + + CFBundleDisplayName + SafeAreaDemo + CFBundleIdentifier + companyName.SafeAreaDemo + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1.0 + LSRequiresIPhoneOS + + MinimumOSVersion + 10.0 + UIDeviceFamily + + 1 + 2 + + UILaunchStoryboardName + LaunchScreen + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIStatusBarHidden + + UIViewControllerBasedStatusBarAppearance + + + diff --git a/samples/SafeAreaDemo.iOS/Main.cs b/samples/SafeAreaDemo.iOS/Main.cs new file mode 100644 index 0000000000..1c76dc6bc4 --- /dev/null +++ b/samples/SafeAreaDemo.iOS/Main.cs @@ -0,0 +1,15 @@ +using UIKit; + +namespace SafeAreaDemo.iOS +{ + public class Application + { + // This is the main entry point of the application. + static void Main(string[] args) + { + // if you want to use a different Application Delegate class from "AppDelegate" + // you can specify it here. + UIApplication.Main(args, null, typeof(AppDelegate)); + } + } +} \ No newline at end of file diff --git a/samples/SafeAreaDemo.iOS/Resources/LaunchScreen.xib b/samples/SafeAreaDemo.iOS/Resources/LaunchScreen.xib new file mode 100644 index 0000000000..c6dd636c46 --- /dev/null +++ b/samples/SafeAreaDemo.iOS/Resources/LaunchScreen.xib @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/SafeAreaDemo.iOS/SafeAreaDemo.iOS.csproj b/samples/SafeAreaDemo.iOS/SafeAreaDemo.iOS.csproj new file mode 100644 index 0000000000..71365fe07d --- /dev/null +++ b/samples/SafeAreaDemo.iOS/SafeAreaDemo.iOS.csproj @@ -0,0 +1,18 @@ + + + Exe + net7.0-ios + 10.0 + manual + enable + iossimulator-x64 + + + + + + + + + + diff --git a/samples/SafeAreaDemo/App.xaml b/samples/SafeAreaDemo/App.xaml new file mode 100644 index 0000000000..f5ffbdb32a --- /dev/null +++ b/samples/SafeAreaDemo/App.xaml @@ -0,0 +1,15 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/samples/SafeAreaDemo/App.xaml.cs b/samples/SafeAreaDemo/App.xaml.cs new file mode 100644 index 0000000000..e23cb0e04a --- /dev/null +++ b/samples/SafeAreaDemo/App.xaml.cs @@ -0,0 +1,36 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using SafeAreaDemo.ViewModels; +using SafeAreaDemo.Views; + +namespace SafeAreaDemo +{ + public partial class App : Application + { + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.MainWindow = new MainWindow + { + DataContext = new MainViewModel() + }; + } + else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform) + { + singleViewPlatform.MainView = new MainView + { + DataContext = new MainViewModel() + }; + } + + base.OnFrameworkInitializationCompleted(); + } + } +} \ No newline at end of file diff --git a/samples/SafeAreaDemo/Assets/avalonia-logo.ico b/samples/SafeAreaDemo/Assets/avalonia-logo.ico new file mode 100644 index 0000000000..da8d49ff9b Binary files /dev/null and b/samples/SafeAreaDemo/Assets/avalonia-logo.ico differ diff --git a/samples/SafeAreaDemo/SafeAreaDemo.csproj b/samples/SafeAreaDemo/SafeAreaDemo.csproj new file mode 100644 index 0000000000..20bc5ec8fe --- /dev/null +++ b/samples/SafeAreaDemo/SafeAreaDemo.csproj @@ -0,0 +1,27 @@ + + + net7.0 + enable + latest + true + + + + + + %(Filename) + + + Designer + + + + + + + + + + + + diff --git a/samples/SafeAreaDemo/ViewLocator.cs b/samples/SafeAreaDemo/ViewLocator.cs new file mode 100644 index 0000000000..4f71fdbe9c --- /dev/null +++ b/samples/SafeAreaDemo/ViewLocator.cs @@ -0,0 +1,31 @@ +using System; +using Avalonia.Controls; +using Avalonia.Controls.Templates; +using MiniMvvm; + +namespace SafeAreaDemo +{ + public class ViewLocator : IDataTemplate + { + public Control? Build(object? data) + { + if (data is null) + return null; + + var name = data.GetType().FullName!.Replace("ViewModel", "View"); + var type = Type.GetType(name); + + if (type != null) + { + return (Control)Activator.CreateInstance(type)!; + } + + return new TextBlock { Text = name }; + } + + public bool Match(object? data) + { + return data is ViewModelBase; + } + } +} diff --git a/samples/SafeAreaDemo/ViewModels/MainViewModel.cs b/samples/SafeAreaDemo/ViewModels/MainViewModel.cs new file mode 100644 index 0000000000..fe58567171 --- /dev/null +++ b/samples/SafeAreaDemo/ViewModels/MainViewModel.cs @@ -0,0 +1,112 @@ +using Avalonia; +using Avalonia.Controls.Platform; +using MiniMvvm; + +namespace SafeAreaDemo.ViewModels +{ + public class MainViewModel : ViewModelBase + { + private bool _useSafeArea = true; + private bool _fullscreen; + private IInsetsManager? _insetsManager; + private bool _hideSystemBars; + + public Thickness SafeAreaPadding + { + get + { + return _insetsManager?.SafeAreaPadding ?? default; + } + } + + public Thickness ViewPadding + { + get + { + return _useSafeArea ? SafeAreaPadding : default; + } + } + + public bool UseSafeArea + { + get => _useSafeArea; + set + { + _useSafeArea = value; + + this.RaisePropertyChanged(); + + RaiseSafeAreaChanged(); + } + } + + public bool Fullscreen + { + get => _fullscreen; + set + { + _fullscreen = value; + + if (_insetsManager != null) + { + _insetsManager.DisplayEdgeToEdge = value; + } + + this.RaisePropertyChanged(); + + RaiseSafeAreaChanged(); + } + } + + public bool HideSystemBars + { + get => _hideSystemBars; + set + { + _hideSystemBars = value; + + if (_insetsManager != null) + { + _insetsManager.IsSystemBarVisible = !value; + } + + this.RaisePropertyChanged(); + + RaiseSafeAreaChanged(); + } + } + + internal IInsetsManager? InsetsManager + { + get => _insetsManager; + set + { + if (_insetsManager != null) + { + _insetsManager.SafeAreaChanged -= InsetsManager_SafeAreaChanged; + } + + _insetsManager = value; + + if (_insetsManager != null) + { + _insetsManager.SafeAreaChanged += InsetsManager_SafeAreaChanged; + + _insetsManager.DisplayEdgeToEdge = _fullscreen; + _insetsManager.IsSystemBarVisible = !_hideSystemBars; + } + } + } + + private void InsetsManager_SafeAreaChanged(object? sender, SafeAreaChangedArgs e) + { + RaiseSafeAreaChanged(); + } + + private void RaiseSafeAreaChanged() + { + this.RaisePropertyChanged(nameof(SafeAreaPadding)); + this.RaisePropertyChanged(nameof(ViewPadding)); + } + } +} diff --git a/samples/SafeAreaDemo/Views/MainView.xaml b/samples/SafeAreaDemo/Views/MainView.xaml new file mode 100644 index 0000000000..a8f7c2e735 --- /dev/null +++ b/samples/SafeAreaDemo/Views/MainView.xaml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + Fullscreen + Use Safe Area + Hide System Bars + + + + + + + diff --git a/samples/SafeAreaDemo/Views/MainView.xaml.cs b/samples/SafeAreaDemo/Views/MainView.xaml.cs new file mode 100644 index 0000000000..2b651225e7 --- /dev/null +++ b/samples/SafeAreaDemo/Views/MainView.xaml.cs @@ -0,0 +1,25 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using SafeAreaDemo.ViewModels; + +namespace SafeAreaDemo.Views +{ + public partial class MainView : UserControl + { + public MainView() + { + AvaloniaXamlLoader.Load(this); + } + + protected override void OnLoaded() + { + base.OnLoaded(); + + var insetsManager = TopLevel.GetTopLevel(this)?.InsetsManager; + if (insetsManager != null && DataContext is MainViewModel viewModel) + { + viewModel.InsetsManager = insetsManager; + } + } + } +} diff --git a/samples/SafeAreaDemo/Views/MainWindow.xaml b/samples/SafeAreaDemo/Views/MainWindow.xaml new file mode 100644 index 0000000000..ccd3028bb9 --- /dev/null +++ b/samples/SafeAreaDemo/Views/MainWindow.xaml @@ -0,0 +1,12 @@ + + + diff --git a/samples/SafeAreaDemo/Views/MainWindow.xaml.cs b/samples/SafeAreaDemo/Views/MainWindow.xaml.cs new file mode 100644 index 0000000000..de8f2b05ca --- /dev/null +++ b/samples/SafeAreaDemo/Views/MainWindow.xaml.cs @@ -0,0 +1,13 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace SafeAreaDemo.Views +{ + public partial class MainWindow : Window + { + public MainWindow() + { + AvaloniaXamlLoader.Load(this); + } + } +} diff --git a/samples/VirtualizationDemo/App.axaml b/samples/VirtualizationDemo/App.axaml new file mode 100644 index 0000000000..f5f06ffb6a --- /dev/null +++ b/samples/VirtualizationDemo/App.axaml @@ -0,0 +1,14 @@ + + + + + + + + + + + + diff --git a/samples/VirtualizationDemo/App.axaml.cs b/samples/VirtualizationDemo/App.axaml.cs new file mode 100644 index 0000000000..5ac5c9a92b --- /dev/null +++ b/samples/VirtualizationDemo/App.axaml.cs @@ -0,0 +1,20 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; + +namespace VirtualizationDemo; + +public partial class App : Application +{ + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + desktop.MainWindow = new MainWindow(); + base.OnFrameworkInitializationCompleted(); + } +} diff --git a/samples/VirtualizationDemo/App.xaml b/samples/VirtualizationDemo/App.xaml deleted file mode 100644 index eb5f0e4dca..0000000000 --- a/samples/VirtualizationDemo/App.xaml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/samples/VirtualizationDemo/App.xaml.cs b/samples/VirtualizationDemo/App.xaml.cs deleted file mode 100644 index 81b80c1f40..0000000000 --- a/samples/VirtualizationDemo/App.xaml.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Avalonia; -using Avalonia.Controls.ApplicationLifetimes; -using Avalonia.Markup.Xaml; - -namespace VirtualizationDemo -{ - public class App : Application - { - public override void Initialize() - { - AvaloniaXamlLoader.Load(this); - } - - public override void OnFrameworkInitializationCompleted() - { - if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) - desktop.MainWindow = new MainWindow(); - base.OnFrameworkInitializationCompleted(); - } - } -} diff --git a/samples/VirtualizationDemo/Assets/chat.json b/samples/VirtualizationDemo/Assets/chat.json new file mode 100644 index 0000000000..cc628b534a --- /dev/null +++ b/samples/VirtualizationDemo/Assets/chat.json @@ -0,0 +1,190 @@ +{ + "chat": [ + { + "sender": "Alice", + "message": "Hey Bob! How was your weekend?", + "timestamp": "2023-04-01T10:00:00" + }, + { + "sender": "Bob", + "message": "It was great, thanks for asking. I went on a camping trip with some friends. How about you?", + "timestamp": "2023-04-01T10:01:00" + }, + { + "sender": "Alice", + "message": "My weekend was pretty chill. I just stayed home and caught up on some TV shows.", + "timestamp": "2023-04-01T10:03:00" + }, + { + "sender": "Bob", + "message": "That sounds relaxing. What shows did you watch?", + "timestamp": "2023-04-01T10:05:00" + }, + { + "sender": "Alice", + "message": "I watched the new season of 'Stranger Things' and started watching 'Ozark'. Have you seen them?", + "timestamp": "2023-04-01T10:07:00" + }, + { + "sender": "Bob", + "message": "Yeah, I've seen both of those. They're really good! What do you think of them so far?", + "timestamp": "2023-04-01T10:10:00" + }, + { + "sender": "Alice", + "message": "I'm really enjoying 'Stranger Things', but 'Ozark' is a bit darker than I expected. I'm only a few episodes in though, so we'll see how it goes.", + "timestamp": "2023-04-01T10:12:00" + }, + { + "sender": "Bob", + "message": "Yeah, 'Ozark' can be intense at times, but it's really well done. Keep watching, it gets even better.", + "timestamp": "2023-04-01T10:15:00" + }, + { + "sender": "Alice", + "message": "Thanks for the recommendation, I'll definitely keep watching. So, how's work been for you lately?", + "timestamp": "2023-04-01T10:20:00" + }, + { + "sender": "Bob", + "message": "It's been pretty busy, but I'm managing. How about you?", + "timestamp": "2023-04-01T10:22:00" + }, + { + "sender": "Alice", + "message": "Same here, things have been pretty hectic. But it keeps us on our toes, right?", + "timestamp": "2023-04-01T10:25:00" + }, + { + "sender": "Bob", + "message": "Absolutely. Hey, have you heard about the new project we're starting next week?", + "timestamp": "2023-04-01T10:30:00" + }, + { + "sender": "Alice", + "message": "No, I haven't. What's it about?", + "timestamp": "2023-04-01T10:32:00" + }, + { + "sender": "Bob", + "message": "It's a big project for a new client, and it's going to require a lot of extra hours from all of us. But the pay is going to be great,so it's definitely worth the extra effort. I'll fill you in on the details later, but for now, let's just enjoy our coffee break, shall we?", + "timestamp": "2023-04-01T10:35:00" + }, + { + "sender": "Alice", + "message": "Sounds good to me. I could use a break right about now.", + "timestamp": "2023-04-01T10:40:00" + }, + { + "sender": "Bob", + "message": "Me too. So, have you tried the new caf� down the street yet?", + "timestamp": "2023-04-01T10:45:00" + }, + { + "sender": "Alice", + "message": "No, I haven't. Is it any good?", + "timestamp": "2023-04-01T10:47:00" + }, + { + "sender": "Bob", + "message": "It's really good! They have the best croissants I've ever tasted.", + "timestamp": "2023-04-01T10:50:00" + }, + { + "sender": "Alice", + "message": "Hmm, I'll have to try it out sometime. Do they have any vegan options?", + "timestamp": "2023-04-01T10:52:00" + }, + { + "sender": "Bob", + "message": "I'm not sure, but I think they do. You should ask them the next time you go there.", + "timestamp": "2023-04-01T10:55:00" + }, + { + "sender": "Alice", + "message": "Thanks for the suggestion. I'm always looking for good vegan options around here.", + "timestamp": "2023-04-01T11:00:00" + }, + { + "sender": "Bob", + "message": "No problem. So, have you made any plans for the weekend yet?", + "timestamp": "2023-04-01T11:05:00" + }, + { + "sender": "Alice", + "message": "Not yet. I was thinking of maybe going for a hike or something. What about you?", + "timestamp": "2023-04-01T11:07:00" + }, + { + "sender": "Bob", + "message": "I haven't made any plans either. Maybe we could do something together?", + "timestamp": "2023-04-01T11:10:00" + }, + { + "sender": "Alice", + "message": "That sounds like a great idea! Let's plan on it.", + "timestamp": "2023-04-01T11:12:00" + }, + { + "sender": "Bob", + "message": "Awesome. I'll check out some hiking trails and let you know which ones look good.", + "timestamp": "2023-04-01T11:15:00" + }, + { + "sender": "Alice", + "message": "Sounds good. I can't wait!", + "timestamp": "2023-04-01T11:20:00" + }, + { + "sender": "John", + "message": "Hey Lisa, how was your day?", + "timestamp": "2023-04-01T18:00:00" + }, + { + "sender": "Lisa", + "message": "It was good, thanks for asking. How about you?", + "timestamp": "2023-04-01T18:05:00" + }, + { + "sender": "John", + "message": "Eh, it was alright. Work was pretty busy, but nothing too crazy.", + "timestamp": "2023-04-01T18:10:00" + }, + { + "sender": "Lisa", + "message": "Yeah, I know what you mean. My boss has been on my case lately about meeting our deadlines.", + "timestamp": "2023-04-01T18:15:00" + }, + { + "sender": "John", + "message": "That sucks. Are you feeling stressed out?", + "timestamp": "2023-04-01T18:20:00" + }, + { + "sender": "Lisa", + "message": "A little bit, yeah. But I'm trying to stay positive and focus on getting my work done.", + "timestamp": "2023-04-01T18:25:00" + }, + { + "sender": "John", + "message": "That's a good attitude to have. Have you tried doing some meditation or other relaxation techniques?", + "timestamp": "2023-04-01T18:30:00" + }, + { + "sender": "Lisa", + "message": "I haven't, but I've been thinking about it. Do you have any suggestions?", + "timestamp": "2023-04-01T18:35:00" + }, + { + "sender": "John", + "message": "Sure, I could send you some links to guided meditations that I've found helpful. And there are also some great apps out there that can help you with relaxation.", + "timestamp": "2023-04-01T18:40:00" + }, + { + "sender": "Lisa", + "message": "That would be awesome, thanks so much!", + "timestamp": "2023-04-01T18:45:00" + } + ] +} + diff --git a/samples/VirtualizationDemo/MainWindow.axaml b/samples/VirtualizationDemo/MainWindow.axaml new file mode 100644 index 0000000000..04e75450bf --- /dev/null +++ b/samples/VirtualizationDemo/MainWindow.axaml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + diff --git a/samples/VirtualizationDemo/MainWindow.axaml.cs b/samples/VirtualizationDemo/MainWindow.axaml.cs new file mode 100644 index 0000000000..533dc00aa1 --- /dev/null +++ b/samples/VirtualizationDemo/MainWindow.axaml.cs @@ -0,0 +1,15 @@ +using Avalonia; +using Avalonia.Controls; +using VirtualizationDemo.ViewModels; + +namespace VirtualizationDemo; + +public partial class MainWindow : Window +{ + public MainWindow() + { + InitializeComponent(); + this.AttachDevTools(); + DataContext = new MainWindowViewModel(); + } +} diff --git a/samples/VirtualizationDemo/MainWindow.xaml b/samples/VirtualizationDemo/MainWindow.xaml deleted file mode 100644 index 235f3ef2cc..0000000000 --- a/samples/VirtualizationDemo/MainWindow.xaml +++ /dev/null @@ -1,64 +0,0 @@ - - - - - - - - - Horiz. ScrollBar - - Vert. ScrollBar - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/samples/VirtualizationDemo/MainWindow.xaml.cs b/samples/VirtualizationDemo/MainWindow.xaml.cs deleted file mode 100644 index cea200dcec..0000000000 --- a/samples/VirtualizationDemo/MainWindow.xaml.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Avalonia; -using Avalonia.Controls; -using Avalonia.Markup.Xaml; -using VirtualizationDemo.ViewModels; - -namespace VirtualizationDemo -{ - public class MainWindow : Window - { - public MainWindow() - { - this.InitializeComponent(); - this.AttachDevTools(); - DataContext = new MainWindowViewModel(); - } - - private void InitializeComponent() - { - AvaloniaXamlLoader.Load(this); - } - } -} diff --git a/samples/VirtualizationDemo/Models/Chat.cs b/samples/VirtualizationDemo/Models/Chat.cs new file mode 100644 index 0000000000..813e8650f5 --- /dev/null +++ b/samples/VirtualizationDemo/Models/Chat.cs @@ -0,0 +1,23 @@ +using System; +using System.IO; +using System.Text.Json; + +namespace VirtualizationDemo.Models; + +public class ChatFile +{ + public ChatMessage[]? Chat { get; set; } + + public static ChatFile Load(string path) + { + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }; + + using var s = File.OpenRead(path); + return JsonSerializer.Deserialize(s, options)!; + } +} + +public record ChatMessage(string Sender, string Message, DateTimeOffset Timestamp); diff --git a/samples/VirtualizationDemo/Program.cs b/samples/VirtualizationDemo/Program.cs index febda46450..87212b6daa 100644 --- a/samples/VirtualizationDemo/Program.cs +++ b/samples/VirtualizationDemo/Program.cs @@ -1,15 +1,14 @@ using Avalonia; -namespace VirtualizationDemo +namespace VirtualizationDemo; + +class Program { - class Program - { - public static AppBuilder BuildAvaloniaApp() - => AppBuilder.Configure() - .UsePlatformDetect() - .LogToTrace(); + public static AppBuilder BuildAvaloniaApp() + => AppBuilder.Configure() + .UsePlatformDetect() + .LogToTrace(); - public static int Main(string[] args) - => BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); - } + public static int Main(string[] args) + => BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); } diff --git a/samples/VirtualizationDemo/ViewModels/ChatPageViewModel.cs b/samples/VirtualizationDemo/ViewModels/ChatPageViewModel.cs new file mode 100644 index 0000000000..c0abe62bd5 --- /dev/null +++ b/samples/VirtualizationDemo/ViewModels/ChatPageViewModel.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.ObjectModel; +using System.IO; +using VirtualizationDemo.Models; + +namespace VirtualizationDemo.ViewModels; + +public class ChatPageViewModel +{ + public ChatPageViewModel() + { + var chat = ChatFile.Load(Path.Combine("Assets", "chat.json")); + Messages = new(chat.Chat ?? Array.Empty()); + } + + public ObservableCollection Messages { get; } +} diff --git a/samples/VirtualizationDemo/ViewModels/ExpanderItemViewModel.cs b/samples/VirtualizationDemo/ViewModels/ExpanderItemViewModel.cs new file mode 100644 index 0000000000..a17fc2d303 --- /dev/null +++ b/samples/VirtualizationDemo/ViewModels/ExpanderItemViewModel.cs @@ -0,0 +1,21 @@ +using MiniMvvm; + +namespace VirtualizationDemo.ViewModels; + +public class ExpanderItemViewModel : ViewModelBase +{ + private string? _header; + private bool _isExpanded; + + public string? Header + { + get => _header; + set => RaiseAndSetIfChanged(ref _header, value); + } + + public bool IsExpanded + { + get => _isExpanded; + set => RaiseAndSetIfChanged(ref _isExpanded, value); + } +} diff --git a/samples/VirtualizationDemo/ViewModels/ExpanderPageViewModel.cs b/samples/VirtualizationDemo/ViewModels/ExpanderPageViewModel.cs new file mode 100644 index 0000000000..f2807a803b --- /dev/null +++ b/samples/VirtualizationDemo/ViewModels/ExpanderPageViewModel.cs @@ -0,0 +1,17 @@ +using System.Collections.ObjectModel; +using System.Linq; + +namespace VirtualizationDemo.ViewModels; + +internal class ExpanderPageViewModel +{ + public ExpanderPageViewModel() + { + Items = new(Enumerable.Range(0, 100).Select(x => new ExpanderItemViewModel + { + Header = $"Item {x}", + })); + } + + public ObservableCollection Items { get; set; } +} diff --git a/samples/VirtualizationDemo/ViewModels/ItemViewModel.cs b/samples/VirtualizationDemo/ViewModels/ItemViewModel.cs deleted file mode 100644 index 9ba505ffe5..0000000000 --- a/samples/VirtualizationDemo/ViewModels/ItemViewModel.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System; -using MiniMvvm; - -namespace VirtualizationDemo.ViewModels -{ - internal class ItemViewModel : ViewModelBase - { - private string _prefix; - private int _index; - private double _height = double.NaN; - - public ItemViewModel(int index, string prefix = "Item") - { - _prefix = prefix; - _index = index; - } - - public string Header => $"{_prefix} {_index}"; - - public double Height - { - get => _height; - set => this.RaiseAndSetIfChanged(ref _height, value); - } - } -} diff --git a/samples/VirtualizationDemo/ViewModels/MainWindowViewModel.cs b/samples/VirtualizationDemo/ViewModels/MainWindowViewModel.cs index 96dbbc1a83..478e40187e 100644 --- a/samples/VirtualizationDemo/ViewModels/MainWindowViewModel.cs +++ b/samples/VirtualizationDemo/ViewModels/MainWindowViewModel.cs @@ -1,160 +1,10 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reactive; -using Avalonia.Collections; -using Avalonia.Controls; -using Avalonia.Controls.Primitives; -using Avalonia.Layout; -using Avalonia.Controls.Selection; -using MiniMvvm; +using MiniMvvm; -namespace VirtualizationDemo.ViewModels -{ - internal class MainWindowViewModel : ViewModelBase - { - private int _itemCount = 200; - private string _newItemString = "New Item"; - private int _newItemIndex; - private AvaloniaList _items; - private string _prefix = "Item"; - private ScrollBarVisibility _horizontalScrollBarVisibility = ScrollBarVisibility.Auto; - private ScrollBarVisibility _verticalScrollBarVisibility = ScrollBarVisibility.Auto; - private Orientation _orientation = Orientation.Vertical; - - public MainWindowViewModel() - { - this.WhenAnyValue(x => x.ItemCount).Subscribe(ResizeItems); - RecreateCommand = MiniCommand.Create(() => Recreate()); - - AddItemCommand = MiniCommand.Create(() => AddItem()); - - RemoveItemCommand = MiniCommand.Create(() => Remove()); - - SelectFirstCommand = MiniCommand.Create(() => SelectItem(0)); - - SelectLastCommand = MiniCommand.Create(() => SelectItem(Items.Count - 1)); - } - - public string NewItemString - { - get { return _newItemString; } - set { this.RaiseAndSetIfChanged(ref _newItemString, value); } - } - - public int ItemCount - { - get { return _itemCount; } - set { this.RaiseAndSetIfChanged(ref _itemCount, value); } - } - - public SelectionModel Selection { get; } = new SelectionModel(); - - public AvaloniaList Items - { - get { return _items; } - private set { this.RaiseAndSetIfChanged(ref _items, value); } - } - - public Orientation Orientation - { - get { return _orientation; } - set { this.RaiseAndSetIfChanged(ref _orientation, value); } - } - - public IEnumerable Orientations => - Enum.GetValues(typeof(Orientation)).Cast(); - - public ScrollBarVisibility HorizontalScrollBarVisibility - { - get { return _horizontalScrollBarVisibility; } - set { this.RaiseAndSetIfChanged(ref _horizontalScrollBarVisibility, value); } - } +namespace VirtualizationDemo.ViewModels; - public ScrollBarVisibility VerticalScrollBarVisibility - { - get { return _verticalScrollBarVisibility; } - set { this.RaiseAndSetIfChanged(ref _verticalScrollBarVisibility, value); } - } - - public IEnumerable ScrollBarVisibilities => - Enum.GetValues(typeof(ScrollBarVisibility)).Cast(); - - public MiniCommand AddItemCommand { get; private set; } - public MiniCommand RecreateCommand { get; private set; } - public MiniCommand RemoveItemCommand { get; private set; } - public MiniCommand SelectFirstCommand { get; private set; } - public MiniCommand SelectLastCommand { get; private set; } - - public void RandomizeSize() - { - var random = new Random(); - - foreach (var i in Items) - { - i.Height = random.Next(240) + 10; - } - } - - public void ResetSize() - { - foreach (var i in Items) - { - i.Height = double.NaN; - } - } - - private void ResizeItems(int count) - { - if (Items == null) - { - var items = Enumerable.Range(0, count) - .Select(x => new ItemViewModel(x)); - Items = new AvaloniaList(items); - } - else if (count > Items.Count) - { - var items = Enumerable.Range(Items.Count, count - Items.Count) - .Select(x => new ItemViewModel(x)); - Items.AddRange(items); - } - else if (count < Items.Count) - { - Items.RemoveRange(count, Items.Count - count); - } - } - - private void AddItem() - { - var index = Items.Count; - - if (Selection.SelectedItems.Count > 0) - { - index = Selection.SelectedIndex; - } - - Items.Insert(index, new ItemViewModel(_newItemIndex++, NewItemString)); - } - - private void Remove() - { - if (Selection.SelectedItems.Count > 0) - { - Items.RemoveAll(Selection.SelectedItems.ToList()); - } - } - - private void Recreate() - { - _prefix = _prefix == "Item" ? "Recreated" : "Item"; - var items = Enumerable.Range(0, _itemCount) - .Select(x => new ItemViewModel(x, _prefix)); - Items = new AvaloniaList(items); - } - - private void SelectItem(int index) - { - Selection.SelectedIndex = index; - } - } +internal class MainWindowViewModel : ViewModelBase +{ + public PlaygroundPageViewModel Playground { get; } = new(); + public ChatPageViewModel Chat { get; } = new(); + public ExpanderPageViewModel Expanders { get; } = new(); } diff --git a/samples/VirtualizationDemo/ViewModels/PlaygroundItemViewModel.cs b/samples/VirtualizationDemo/ViewModels/PlaygroundItemViewModel.cs new file mode 100644 index 0000000000..584ef4600b --- /dev/null +++ b/samples/VirtualizationDemo/ViewModels/PlaygroundItemViewModel.cs @@ -0,0 +1,17 @@ +using MiniMvvm; + +namespace VirtualizationDemo.ViewModels; + +public class PlaygroundItemViewModel : ViewModelBase +{ + private string? _header; + + public PlaygroundItemViewModel(int index) => Header = $"Item {index}"; + public PlaygroundItemViewModel(string? header) => Header = header; + + public string? Header + { + get => _header; + set => RaiseAndSetIfChanged(ref _header, value); + } +} diff --git a/samples/VirtualizationDemo/ViewModels/PlaygroundPageViewModel.cs b/samples/VirtualizationDemo/ViewModels/PlaygroundPageViewModel.cs new file mode 100644 index 0000000000..98ab91b0a6 --- /dev/null +++ b/samples/VirtualizationDemo/ViewModels/PlaygroundPageViewModel.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Selection; +using MiniMvvm; + +namespace VirtualizationDemo.ViewModels; + +public class PlaygroundPageViewModel : ViewModelBase +{ + private SelectionMode _selectionMode = SelectionMode.Multiple; + private int _scrollToIndex = 500; + private string? _newItemHeader = "New Item 1"; + + public PlaygroundPageViewModel() + { + Items = new(Enumerable.Range(0, 1000).Select(x => new PlaygroundItemViewModel(x))); + Selection = new(); + } + + public ObservableCollection Items { get; } + + public bool Multiple + { + get => _selectionMode.HasAnyFlag(SelectionMode.Multiple); + set => SetSelectionMode(SelectionMode.Multiple, value); + } + + public bool Toggle + { + get => _selectionMode.HasAnyFlag(SelectionMode.Toggle); + set => SetSelectionMode(SelectionMode.Toggle, value); + } + + public bool AlwaysSelected + { + get => _selectionMode.HasAnyFlag(SelectionMode.AlwaysSelected); + set => SetSelectionMode(SelectionMode.AlwaysSelected, value); + } + + public SelectionModel Selection { get; } + + public SelectionMode SelectionMode + { + get => _selectionMode; + set => RaiseAndSetIfChanged(ref _selectionMode, value); + } + + public int ScrollToIndex + { + get => _scrollToIndex; + set => RaiseAndSetIfChanged(ref _scrollToIndex, value); + } + + public string? NewItemHeader + { + get => _newItemHeader; + set => RaiseAndSetIfChanged(ref _newItemHeader, value); + } + + public void ExecuteScrollToIndex() + { + Selection.Select(ScrollToIndex); + } + + public void RandomizeScrollToIndex() + { + var rnd = new Random(); + ScrollToIndex = rnd.Next(Items.Count); + } + + public void AddAtSelectedIndex() + { + if (Selection.SelectedIndex == -1) + return; + Items.Insert(Selection.SelectedIndex, new(NewItemHeader)); + } + + public void DeleteSelectedItem() + { + var count = Selection.Count; + for (var i = count - 1; i >= 0; i--) + Items.RemoveAt(Selection.SelectedIndexes[i]); + } + + private void SetSelectionMode(SelectionMode mode, bool value) + { + if (value) + SelectionMode |= mode; + else + SelectionMode &= ~mode; + } +} diff --git a/samples/VirtualizationDemo/Views/ChatPageView.axaml b/samples/VirtualizationDemo/Views/ChatPageView.axaml new file mode 100644 index 0000000000..fc182f15ae --- /dev/null +++ b/samples/VirtualizationDemo/Views/ChatPageView.axaml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/samples/VirtualizationDemo/Views/ChatPageView.axaml.cs b/samples/VirtualizationDemo/Views/ChatPageView.axaml.cs new file mode 100644 index 0000000000..b5c90db69c --- /dev/null +++ b/samples/VirtualizationDemo/Views/ChatPageView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace VirtualizationDemo.Views; + +public partial class ChatPageView : UserControl +{ + public ChatPageView() + { + InitializeComponent(); + } +} diff --git a/samples/VirtualizationDemo/Views/ExpanderPageView.axaml b/samples/VirtualizationDemo/Views/ExpanderPageView.axaml new file mode 100644 index 0000000000..972d885229 --- /dev/null +++ b/samples/VirtualizationDemo/Views/ExpanderPageView.axaml @@ -0,0 +1,18 @@ + + + + + + + + + + + diff --git a/samples/VirtualizationDemo/Views/ExpanderPageView.axaml.cs b/samples/VirtualizationDemo/Views/ExpanderPageView.axaml.cs new file mode 100644 index 0000000000..df3689cf24 --- /dev/null +++ b/samples/VirtualizationDemo/Views/ExpanderPageView.axaml.cs @@ -0,0 +1,13 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace VirtualizationDemo.Views; + +public partial class ExpanderPageView : UserControl +{ + public ExpanderPageView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/samples/VirtualizationDemo/Views/PlaygroundPageView.axaml b/samples/VirtualizationDemo/Views/PlaygroundPageView.axaml new file mode 100644 index 0000000000..52bc6fd27a --- /dev/null +++ b/samples/VirtualizationDemo/Views/PlaygroundPageView.axaml @@ -0,0 +1,66 @@ + + + + + + + + Multiple + Toggle + AlwaysSelected + AutoScrollToSelectedItem + WrapSelection + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/VirtualizationDemo/Views/PlaygroundPageView.axaml.cs b/samples/VirtualizationDemo/Views/PlaygroundPageView.axaml.cs new file mode 100644 index 0000000000..5282475778 --- /dev/null +++ b/samples/VirtualizationDemo/Views/PlaygroundPageView.axaml.cs @@ -0,0 +1,44 @@ +using System; +using System.Linq; +using System.Threading; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using Avalonia.Threading; + +namespace VirtualizationDemo.Views; + +public partial class PlaygroundPageView : UserControl +{ + private DispatcherTimer _timer; + + public PlaygroundPageView() + { + InitializeComponent(); + + _timer = new DispatcherTimer + { + Interval = TimeSpan.FromMilliseconds(500), + }; + + _timer.Tick += TimerTick; + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + _timer.Start(); + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + _timer.Stop(); + } + + private void TimerTick(object? sender, EventArgs e) + { + var message = $"Realized {list.GetRealizedContainers().Count()} of {list.ItemsPanelRoot?.Children.Count}"; + itemCount.Text = message; + } +} diff --git a/samples/VirtualizationDemo/VirtualizationDemo.csproj b/samples/VirtualizationDemo/VirtualizationDemo.csproj index 81b30c6cbe..3ac7aab589 100644 --- a/samples/VirtualizationDemo/VirtualizationDemo.csproj +++ b/samples/VirtualizationDemo/VirtualizationDemo.csproj @@ -1,19 +1,24 @@  - Exe + WinExe net6.0 + true + + + - - + + + + + PreserveNewest + - - - - - + + diff --git a/src/Android/Avalonia.Android/AndroidInputMethod.cs b/src/Android/Avalonia.Android/AndroidInputMethod.cs index 27dcfe8645..2bf3486ca5 100644 --- a/src/Android/Avalonia.Android/AndroidInputMethod.cs +++ b/src/Android/Avalonia.Android/AndroidInputMethod.cs @@ -1,14 +1,11 @@ using System; using Android.Content; using Android.Runtime; -using Android.Text; using Android.Views; using Android.Views.InputMethods; using Avalonia.Android.Platform.SkiaPlatform; using Avalonia.Controls.Presenters; -using Avalonia.Input; using Avalonia.Input.TextInput; -using Avalonia.Reactive; namespace Avalonia.Android { @@ -99,6 +96,9 @@ namespace Avalonia.Android { _host.InitEditorInfo((topLevel, outAttrs) => { + if (_client == null) + return null; + _inputConnection = new AvaloniaInputConnection(topLevel, this); outAttrs.InputType = options.ContentType switch diff --git a/src/Android/Avalonia.Android/AndroidPlatform.cs b/src/Android/Avalonia.Android/AndroidPlatform.cs index daecb58a60..984d3ed5fc 100644 --- a/src/Android/Avalonia.Android/AndroidPlatform.cs +++ b/src/Android/Avalonia.Android/AndroidPlatform.cs @@ -38,7 +38,6 @@ namespace Avalonia.Android Options = AvaloniaLocator.Current.GetService() ?? new AndroidPlatformOptions(); AvaloniaLocator.CurrentMutable - .Bind().ToTransient() .Bind().ToTransient() .Bind().ToConstant(new WindowingPlatformStub()) .Bind().ToSingleton() diff --git a/src/Android/Avalonia.Android/AndroidThreadingInterface.cs b/src/Android/Avalonia.Android/AndroidThreadingInterface.cs index 152076013f..c85d5b1343 100644 --- a/src/Android/Avalonia.Android/AndroidThreadingInterface.cs +++ b/src/Android/Avalonia.Android/AndroidThreadingInterface.cs @@ -21,8 +21,6 @@ namespace Avalonia.Android _handler = new Handler(App.Context.MainLooper); } - public void RunLoop(CancellationToken cancellationToken) => throw new NotSupportedException(); - public IDisposable StartTimer(DispatcherPriority priority, TimeSpan interval, Action tick) { if (interval.TotalMilliseconds < 10) diff --git a/src/Android/Avalonia.Android/AvaloniaMainActivity.cs b/src/Android/Avalonia.Android/AvaloniaMainActivity.cs index eb4b6bf6a0..b2cd150933 100644 --- a/src/Android/Avalonia.Android/AvaloniaMainActivity.cs +++ b/src/Android/Avalonia.Android/AvaloniaMainActivity.cs @@ -32,10 +32,6 @@ namespace Avalonia.Android { lifetime.View = View; } - - Window?.ClearFlags(WindowManagerFlags.TranslucentStatus); - Window?.AddFlags(WindowManagerFlags.DrawsSystemBarBackgrounds); - base.OnCreate(savedInstanceState); SetContentView(View); diff --git a/src/Android/Avalonia.Android/Platform/AndroidInsetsManager.cs b/src/Android/Avalonia.Android/Platform/AndroidInsetsManager.cs index 35d1b06e6a..251a177432 100644 --- a/src/Android/Avalonia.Android/Platform/AndroidInsetsManager.cs +++ b/src/Android/Avalonia.Android/Platform/AndroidInsetsManager.cs @@ -2,11 +2,10 @@ using System.Collections.Generic; using Android.OS; using Android.Views; -using AndroidX.AppCompat.App; using AndroidX.Core.View; using Avalonia.Android.Platform.SkiaPlatform; using Avalonia.Controls.Platform; -using static Avalonia.Controls.Platform.IInsetsManager; +using Avalonia.Media; namespace Avalonia.Android.Platform { @@ -20,6 +19,7 @@ namespace Avalonia.Android.Platform private bool? _systemUiVisibility; private SystemBarTheme? _statusBarTheme; private bool? _isDefaultSystemBarLightTheme; + private Color? _systemBarColor; public event EventHandler SafeAreaChanged; @@ -36,6 +36,16 @@ namespace Avalonia.Android.Platform } WindowCompat.SetDecorFitsSystemWindows(_activity.Window, !value); + + if(value) + { + _activity.Window.AddFlags(WindowManagerFlags.TranslucentStatus); + _activity.Window.AddFlags(WindowManagerFlags.TranslucentNavigation); + } + else + { + SystemBarColor = _systemBarColor; + } } } @@ -71,7 +81,7 @@ namespace Avalonia.Android.Platform var renderScaling = _topLevel.RenderScaling; var inset = insets.GetInsets( - (DisplayEdgeToEdge ? + (_displayEdgeToEdge ? WindowInsetsCompat.Type.StatusBars() | WindowInsetsCompat.Type.NavigationBars() | WindowInsetsCompat.Type.DisplayCutout() : 0) | WindowInsetsCompat.Type.Ime()); @@ -81,8 +91,8 @@ namespace Avalonia.Android.Platform return new Thickness(inset.Left / renderScaling, inset.Top / renderScaling, inset.Right / renderScaling, - (imeInset.Bottom > 0 && ((_usesLegacyLayouts && !DisplayEdgeToEdge) || !_usesLegacyLayouts) ? - imeInset.Bottom - navBarInset.Bottom : + (imeInset.Bottom > 0 && ((_usesLegacyLayouts && !_displayEdgeToEdge) || !_usesLegacyLayouts) ? + imeInset.Bottom - (_displayEdgeToEdge ? 0 : navBarInset.Bottom) : inset.Bottom) / renderScaling); } @@ -93,6 +103,7 @@ namespace Avalonia.Android.Platform public WindowInsetsCompat OnApplyWindowInsets(View v, WindowInsetsCompat insets) { NotifySafeAreaChanged(SafeAreaPadding); + insets = ViewCompat.OnApplyWindowInsets(v, insets); return insets; } @@ -146,8 +157,6 @@ namespace Avalonia.Android.Platform compat.AppearanceLightStatusBars = value == Controls.Platform.SystemBarTheme.Light; compat.AppearanceLightNavigationBars = value == Controls.Platform.SystemBarTheme.Light; - - AppCompatDelegate.DefaultNightMode = isDefault ? AppCompatDelegate.ModeNightFollowSystem : compat.AppearanceLightStatusBars ? AppCompatDelegate.ModeNightNo : AppCompatDelegate.ModeNightYes; } } @@ -190,10 +199,36 @@ namespace Avalonia.Android.Platform } } + public Color? SystemBarColor + { + get => _systemBarColor; + set + { + _systemBarColor = value; + + if (_systemBarColor is { } color && !_displayEdgeToEdge && _activity.Window != null) + { + _activity.Window.ClearFlags(WindowManagerFlags.TranslucentStatus); + _activity.Window.ClearFlags(WindowManagerFlags.TranslucentNavigation); + _activity.Window.AddFlags(WindowManagerFlags.DrawsSystemBarBackgrounds); + + var androidColor = global::Android.Graphics.Color.Argb(color.A, color.R, color.G, color.B); + _activity.Window.SetStatusBarColor(androidColor); + + if (Build.VERSION.SdkInt >= BuildVersionCodes.O) + { + // As we can only change the navigation bar's foreground api 26 and newer, we only change the background color if running on those versions + _activity.Window.SetNavigationBarColor(androidColor); + } + } + } + } + internal void ApplyStatusBarState() { IsSystemBarVisible = _systemUiVisibility; SystemBarTheme = _statusBarTheme; + SystemBarColor = _systemBarColor; } private class InsetsAnimationCallback : WindowInsetsAnimationCompat.Callback diff --git a/src/Android/Avalonia.Android/Platform/ClipboardImpl.cs b/src/Android/Avalonia.Android/Platform/ClipboardImpl.cs index d1a116345b..8564363889 100644 --- a/src/Android/Avalonia.Android/Platform/ClipboardImpl.cs +++ b/src/Android/Avalonia.Android/Platform/ClipboardImpl.cs @@ -2,32 +2,26 @@ using System; using System.Threading.Tasks; using Android.Content; -using Android.Runtime; -using Android.Views; using Avalonia.Input; using Avalonia.Input.Platform; -using Avalonia.Platform; namespace Avalonia.Android.Platform { internal class ClipboardImpl : IClipboard { - private Context context = (AvaloniaLocator.Current.GetService() as View).Context; + private ClipboardManager? _clipboardManager; - private ClipboardManager ClipboardManager + internal ClipboardImpl(ClipboardManager? value) { - get - { - return this.context.GetSystemService(Context.ClipboardService).JavaCast(); - } + _clipboardManager = value; } public Task GetTextAsync() { - if (ClipboardManager.HasPrimaryClip) + if (_clipboardManager?.HasPrimaryClip == true) { - return Task.FromResult(ClipboardManager.PrimaryClip.GetItemAt(0).Text); + return Task.FromResult(_clipboardManager.PrimaryClip.GetItemAt(0).Text); } return Task.FromResult(null); @@ -35,15 +29,25 @@ namespace Avalonia.Android.Platform public Task SetTextAsync(string text) { + if(_clipboardManager == null) + { + return Task.CompletedTask; + } + ClipData clip = ClipData.NewPlainText("text", text); - ClipboardManager.PrimaryClip = clip; + _clipboardManager.PrimaryClip = clip; return Task.FromResult(null); } public Task ClearAsync() { - ClipboardManager.PrimaryClip = null; + if (_clipboardManager == null) + { + return Task.CompletedTask; + } + + _clipboardManager.PrimaryClip = null; return Task.FromResult(null); } diff --git a/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs b/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs index b8d80a50ff..126c488d59 100644 --- a/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs +++ b/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs @@ -3,9 +3,13 @@ using System.Collections.Generic; using Android.App; using Android.Content; using Android.Graphics; +using Android.Graphics.Drawables; +using Android.OS; using Android.Runtime; +using Android.Text; using Android.Views; using Android.Views.InputMethods; +using AndroidX.AppCompat.App; using Avalonia.Android.Platform.Specific; using Avalonia.Android.Platform.Specific.Helpers; using Avalonia.Android.Platform.Storage; @@ -13,6 +17,7 @@ using Avalonia.Controls; using Avalonia.Controls.Platform; using Avalonia.Controls.Platform.Surfaces; using Avalonia.Input; +using Avalonia.Input.Platform; using Avalonia.Input.Raw; using Avalonia.Input.TextInput; using Avalonia.OpenGL.Egl; @@ -22,13 +27,7 @@ using Avalonia.Platform.Storage; using Avalonia.Rendering; using Avalonia.Rendering.Composition; using Java.Lang; -using Java.Util; -using Math = System.Math; -using AndroidRect = Android.Graphics.Rect; -using Window = Android.Views.Window; -using Android.Graphics.Drawables; -using Android.OS; -using Android.Text; +using ClipboardManager = Android.Content.ClipboardManager; namespace Avalonia.Android.Platform.SkiaPlatform { @@ -44,6 +43,7 @@ namespace Avalonia.Android.Platform.SkiaPlatform private readonly IStorageProvider _storageProvider; private readonly ISystemNavigationManagerImpl _systemNavigationManager; private readonly AndroidInsetsManager _insetsManager; + private readonly ClipboardImpl _clipboard; private ViewImpl _view; public TopLevelImpl(AvaloniaView avaloniaView, bool placeOnTop = false) @@ -54,6 +54,7 @@ namespace Avalonia.Android.Platform.SkiaPlatform _pointerHelper = new AndroidMotionEventsHelper(this); _gl = new EglGlPlatformSurface(this); _framebuffer = new FramebufferManager(this); + _clipboard = new ClipboardImpl(avaloniaView.Context?.GetSystemService(Context.ClipboardService).JavaCast()); RenderScaling = _view.Scaling; @@ -286,6 +287,8 @@ namespace Avalonia.Android.Platform.SkiaPlatform _ => null, }; } + + AppCompatDelegate.DefaultNightMode = themeVariant == PlatformThemeVariant.Light ? AppCompatDelegate.ModeNightNo : AppCompatDelegate.ModeNightYes; } public AcrylicPlatformCompensationLevels AcrylicCompensationLevels => new AcrylicPlatformCompensationLevels(1, 1, 1); @@ -408,6 +411,11 @@ namespace Avalonia.Android.Platform.SkiaPlatform return _insetsManager; } + if(featureType == typeof(IClipboard)) + { + return _clipboard; + } + return null; } } diff --git a/src/Avalonia.Base/AvaloniaObjectExtensions.cs b/src/Avalonia.Base/AvaloniaObjectExtensions.cs index 9fbf680a5c..0c22213d33 100644 --- a/src/Avalonia.Base/AvaloniaObjectExtensions.cs +++ b/src/Avalonia.Base/AvaloniaObjectExtensions.cs @@ -1,6 +1,6 @@ using System; -using Avalonia.Reactive; using Avalonia.Data; +using Avalonia.Reactive; namespace Avalonia { @@ -34,8 +34,8 @@ namespace Avalonia /// public static IObservable GetObservable(this AvaloniaObject o, AvaloniaProperty property) { - return new AvaloniaPropertyObservable( - o ?? throw new ArgumentNullException(nameof(o)), + return new AvaloniaPropertyObservable( + o ?? throw new ArgumentNullException(nameof(o)), property ?? throw new ArgumentNullException(nameof(property))); } @@ -54,11 +54,23 @@ namespace Avalonia /// public static IObservable GetObservable(this AvaloniaObject o, AvaloniaProperty property) { - return new AvaloniaPropertyObservable( + return new AvaloniaPropertyObservable( o ?? throw new ArgumentNullException(nameof(o)), property ?? throw new ArgumentNullException(nameof(property))); } + /// + /// + /// + /// A method which is executed to convert each property value to . + public static IObservable GetObservable(this AvaloniaObject o, AvaloniaProperty property, Func converter) + { + return new AvaloniaPropertyObservable( + o ?? throw new ArgumentNullException(nameof(o)), + property ?? throw new ArgumentNullException(nameof(property)), + converter ?? throw new ArgumentNullException(nameof(converter))); + } + /// /// Gets an observable for an . /// @@ -75,7 +87,7 @@ namespace Avalonia this AvaloniaObject o, AvaloniaProperty property) { - return new AvaloniaPropertyBindingObservable( + return new AvaloniaPropertyBindingObservable( o ?? throw new ArgumentNullException(nameof(o)), property ?? throw new ArgumentNullException(nameof(property))); } @@ -97,12 +109,27 @@ namespace Avalonia this AvaloniaObject o, AvaloniaProperty property) { - return new AvaloniaPropertyBindingObservable( + return new AvaloniaPropertyBindingObservable( o ?? throw new ArgumentNullException(nameof(o)), property ?? throw new ArgumentNullException(nameof(property))); } + /// + /// + /// + /// A method which is executed to convert each property value to . + public static IObservable> GetBindingObservable( + this AvaloniaObject o, + AvaloniaProperty property, + Func converter) + { + return new AvaloniaPropertyBindingObservable( + o ?? throw new ArgumentNullException(nameof(o)), + property ?? throw new ArgumentNullException(nameof(property)), + converter ?? throw new ArgumentNullException(nameof(converter))); + } + /// /// Gets an observable that listens for property changed events for an /// . @@ -338,7 +365,7 @@ namespace Avalonia return InstancedBinding.OneWay(_source); } } - + private class ClassHandlerObserver : IObserver> { private readonly Action> _action; diff --git a/src/Avalonia.Base/AvaloniaProperty.cs b/src/Avalonia.Base/AvaloniaProperty.cs index e98d9f0517..c57131f7b5 100644 --- a/src/Avalonia.Base/AvaloniaProperty.cs +++ b/src/Avalonia.Base/AvaloniaProperty.cs @@ -499,6 +499,12 @@ namespace Avalonia /// The object instance. internal abstract void RouteClearValue(AvaloniaObject o); + /// + /// Routes an untyped CoerceValue call on a property with its default value to a typed call. + /// + /// The object instance. + internal abstract void RouteCoerceDefaultValue(AvaloniaObject o); + /// /// Routes an untyped GetValue call to a typed call. /// diff --git a/src/Avalonia.Base/CombinedGeometry.cs b/src/Avalonia.Base/CombinedGeometry.cs index 8f080d05c7..4b5866519b 100644 --- a/src/Avalonia.Base/CombinedGeometry.cs +++ b/src/Avalonia.Base/CombinedGeometry.cs @@ -152,19 +152,15 @@ namespace Avalonia.Media var g1 = Geometry1; var g2 = Geometry2; - if (g1 is object && g2 is object) + if (g1?.PlatformImpl != null && g2?.PlatformImpl != null) { var factory = AvaloniaLocator.Current.GetRequiredService(); - return factory.CreateCombinedGeometry(GeometryCombineMode, g1, g2); + return factory.CreateCombinedGeometry(GeometryCombineMode, g1.PlatformImpl, g2.PlatformImpl); } - else if (GeometryCombineMode == GeometryCombineMode.Intersect) - return null; - else if (g1 is object) - return g1.PlatformImpl; - else if (g2 is object) - return g2.PlatformImpl; - else + + if (GeometryCombineMode == GeometryCombineMode.Intersect) return null; + return g1?.PlatformImpl ?? g2?.PlatformImpl; } } } diff --git a/src/Avalonia.Base/Controls/ResourceDictionary.cs b/src/Avalonia.Base/Controls/ResourceDictionary.cs index 5123803f6e..231a19baab 100644 --- a/src/Avalonia.Base/Controls/ResourceDictionary.cs +++ b/src/Avalonia.Base/Controls/ResourceDictionary.cs @@ -15,6 +15,7 @@ namespace Avalonia.Controls /// public class ResourceDictionary : IResourceDictionary { + private object? lastDeferredItemKey; private Dictionary? _inner; private IResourceHost? _owner; private AvaloniaList? _mergedDictionaries; @@ -241,12 +242,27 @@ namespace Avalonia.Controls { if (value is DeferredItem deffered) { - _inner[key] = value = deffered.Factory(null) switch + // Avoid simple reentrancy, which could commonly occur on redefining the resource. + if (lastDeferredItemKey == key) { - ITemplateResult t => t.Result, - object v => v, - _ => null, - }; + value = null; + return false; + } + + try + { + lastDeferredItemKey = key; + _inner[key] = value = deffered.Factory(null) switch + { + ITemplateResult t => t.Result, + { } v => v, + _ => null, + }; + } + finally + { + lastDeferredItemKey = null; + } } return true; } diff --git a/src/Avalonia.Base/Data/Core/AvaloniaPropertyAccessorNode.cs b/src/Avalonia.Base/Data/Core/AvaloniaPropertyAccessorNode.cs index 92fc843394..4bf24e901e 100644 --- a/src/Avalonia.Base/Data/Core/AvaloniaPropertyAccessorNode.cs +++ b/src/Avalonia.Base/Data/Core/AvaloniaPropertyAccessorNode.cs @@ -40,7 +40,7 @@ namespace Avalonia.Data.Core { if (reference.TryGetTarget(out var target) && target is AvaloniaObject obj) { - _subscription = new AvaloniaPropertyObservable(obj, _property).Subscribe(ValueChanged); + _subscription = new AvaloniaPropertyObservable(obj, _property).Subscribe(ValueChanged); } else { diff --git a/src/Avalonia.Base/DirectPropertyBase.cs b/src/Avalonia.Base/DirectPropertyBase.cs index 94dfaaab01..7e5a962157 100644 --- a/src/Avalonia.Base/DirectPropertyBase.cs +++ b/src/Avalonia.Base/DirectPropertyBase.cs @@ -117,6 +117,11 @@ namespace Avalonia o.ClearValue(this); } + internal override void RouteCoerceDefaultValue(AvaloniaObject o) + { + // Do nothing. + } + /// internal override object? RouteGetValue(AvaloniaObject o) { diff --git a/src/Avalonia.Base/Input/GestureRecognizers/ScrollGestureRecognizer.cs b/src/Avalonia.Base/Input/GestureRecognizers/ScrollGestureRecognizer.cs index 1ad2f292ca..b510d44e63 100644 --- a/src/Avalonia.Base/Input/GestureRecognizers/ScrollGestureRecognizer.cs +++ b/src/Avalonia.Base/Input/GestureRecognizers/ScrollGestureRecognizer.cs @@ -4,14 +4,17 @@ using Avalonia.Threading; namespace Avalonia.Input.GestureRecognizers { - public class ScrollGestureRecognizer - : StyledElement, // It's not an "element" in any way, shape or form, but TemplateBinding refuse to work otherwise - IGestureRecognizer + public class ScrollGestureRecognizer : AvaloniaObject, IGestureRecognizer { // Pixels per second speed that is considered to be the stop of inertial scroll internal const double InertialScrollSpeedEnd = 5; public const double InertialResistance = 0.15; + private bool _canHorizontallyScroll; + private bool _canVerticallyScroll; + private bool _isScrollInertiaEnabled; + private int _scrollStartDistance = 30; + private bool _scrolling; private Point _trackedRootPoint; private IPointer? _tracking; @@ -28,34 +31,39 @@ namespace Avalonia.Input.GestureRecognizers /// /// Defines the property. /// - public static readonly StyledProperty CanHorizontallyScrollProperty = - AvaloniaProperty.Register(nameof(CanHorizontallyScroll)); + public static readonly DirectProperty CanHorizontallyScrollProperty = + AvaloniaProperty.RegisterDirect(nameof(CanHorizontallyScroll), + o => o.CanHorizontallyScroll, (o, v) => o.CanHorizontallyScroll = v); /// /// Defines the property. /// - public static readonly StyledProperty CanVerticallyScrollProperty = - AvaloniaProperty.Register(nameof(CanVerticallyScroll)); + public static readonly DirectProperty CanVerticallyScrollProperty = + AvaloniaProperty.RegisterDirect(nameof(CanVerticallyScroll), + o => o.CanVerticallyScroll, (o, v) => o.CanVerticallyScroll = v); /// /// Defines the property. /// - public static readonly StyledProperty IsScrollInertiaEnabledProperty = - AvaloniaProperty.Register(nameof(IsScrollInertiaEnabled)); + public static readonly DirectProperty IsScrollInertiaEnabledProperty = + AvaloniaProperty.RegisterDirect(nameof(IsScrollInertiaEnabled), + o => o.IsScrollInertiaEnabled, (o,v) => o.IsScrollInertiaEnabled = v); /// /// Defines the property. /// - public static readonly StyledProperty ScrollStartDistanceProperty = - AvaloniaProperty.Register(nameof(ScrollStartDistance), 30); - + public static readonly DirectProperty ScrollStartDistanceProperty = + AvaloniaProperty.RegisterDirect(nameof(ScrollStartDistance), + o => o.ScrollStartDistance, (o, v) => o.ScrollStartDistance = v, + unsetValue: 30); + /// /// Gets or sets a value indicating whether the content can be scrolled horizontally. /// public bool CanHorizontallyScroll { - get => GetValue(CanHorizontallyScrollProperty); - set => SetValue(CanHorizontallyScrollProperty, value); + get => _canHorizontallyScroll; + set => SetAndRaise(CanHorizontallyScrollProperty, ref _canHorizontallyScroll, value); } /// @@ -63,17 +71,17 @@ namespace Avalonia.Input.GestureRecognizers /// public bool CanVerticallyScroll { - get => GetValue(CanVerticallyScrollProperty); - set => SetValue(CanVerticallyScrollProperty, value); + get => _canVerticallyScroll; + set => SetAndRaise(CanVerticallyScrollProperty, ref _canVerticallyScroll, value); } - + /// /// Gets or sets whether the gesture should include inertia in it's behavior. /// public bool IsScrollInertiaEnabled { - get => GetValue(IsScrollInertiaEnabledProperty); - set => SetValue(IsScrollInertiaEnabledProperty, value); + get => _isScrollInertiaEnabled; + set => SetAndRaise(IsScrollInertiaEnabledProperty, ref _isScrollInertiaEnabled, value); } /// @@ -81,10 +89,9 @@ namespace Avalonia.Input.GestureRecognizers /// public int ScrollStartDistance { - get => GetValue(ScrollStartDistanceProperty); - set => SetValue(ScrollStartDistanceProperty, value); - } - + get => _scrollStartDistance; + set => SetAndRaise(ScrollStartDistanceProperty, ref _scrollStartDistance, value); + } public void Initialize(IInputElement target, IGestureRecognizerActionsDispatcher actions) { diff --git a/src/Avalonia.Base/Layout/LayoutManager.cs b/src/Avalonia.Base/Layout/LayoutManager.cs index c4742bcba4..e16be3fa85 100644 --- a/src/Avalonia.Base/Layout/LayoutManager.cs +++ b/src/Avalonia.Base/Layout/LayoutManager.cs @@ -269,21 +269,25 @@ namespace Avalonia.Layout } } - private void Measure(Layoutable control) + private bool Measure(Layoutable control) { + if (!control.IsVisible || !control.IsAttachedToVisualTree) + return false; + // Controls closest to the visual root need to be arranged first. We don't try to store // ordered invalidation lists, instead we traverse the tree upwards, measuring the // controls closest to the root first. This has been shown by benchmarks to be the // fastest and most memory-efficient algorithm. if (control.VisualParent is Layoutable parent) { - Measure(parent); + if (!Measure(parent)) + return false; } // If the control being measured has IsMeasureValid == true here then its measure was // handed by an ancestor and can be ignored. The measure may have also caused the // control to be removed. - if (!control.IsMeasureValid && control.IsAttachedToVisualTree) + if (!control.IsMeasureValid) { if (control is ILayoutRoot root) { @@ -294,16 +298,22 @@ namespace Avalonia.Layout control.Measure(control.PreviousMeasure.Value); } } + + return true; } - private void Arrange(Layoutable control) + private bool Arrange(Layoutable control) { + if (!control.IsVisible || !control.IsAttachedToVisualTree) + return false; + if (control.VisualParent is Layoutable parent) { - Arrange(parent); + if (!Arrange(parent)) + return false; } - if (!control.IsArrangeValid && control.IsAttachedToVisualTree) + if (control.IsMeasureValid && !control.IsArrangeValid) { if (control is IEmbeddedLayoutRoot embeddedRoot) control.Arrange(new Rect(embeddedRoot.AllocatedSize)); @@ -316,6 +326,8 @@ namespace Avalonia.Layout control.Arrange(control.PreviousArrange.Value); } } + + return true; } private void QueueLayoutPass() diff --git a/src/Avalonia.Base/Layout/Layoutable.cs b/src/Avalonia.Base/Layout/Layoutable.cs index ea84dc84bd..ed88b73149 100644 --- a/src/Avalonia.Base/Layout/Layoutable.cs +++ b/src/Avalonia.Base/Layout/Layoutable.cs @@ -125,7 +125,7 @@ namespace Avalonia.Layout AvaloniaProperty.Register(nameof(VerticalAlignment)); /// - /// Defines the property. + /// Defines the property. /// public static readonly StyledProperty UseLayoutRoundingProperty = AvaloniaProperty.Register(nameof(UseLayoutRounding), defaultValue: true, inherits: true); diff --git a/src/Avalonia.Base/Media/Color.cs b/src/Avalonia.Base/Media/Color.cs index 74e70b2a14..f06f272e51 100644 --- a/src/Avalonia.Base/Media/Color.cs +++ b/src/Avalonia.Base/Media/Color.cs @@ -309,7 +309,7 @@ namespace Avalonia.Media if (input.Length == 3 || input.Length == 4) { var extendedLength = 2 * input.Length; - + #if !BUILDTASK Span extended = stackalloc char[extendedLength]; #else diff --git a/src/Avalonia.Base/Media/DrawingContext.cs b/src/Avalonia.Base/Media/DrawingContext.cs index f2106f2f86..18d6968168 100644 --- a/src/Avalonia.Base/Media/DrawingContext.cs +++ b/src/Avalonia.Base/Media/DrawingContext.cs @@ -132,7 +132,7 @@ namespace Avalonia.Media double radiusX = 0, double radiusY = 0, BoxShadows boxShadows = default) { - if (brush == null && !PenIsVisible(pen)) + if (brush == null && !PenIsVisible(pen) && boxShadows.Count == 0) return; if (!MathUtilities.IsZero(radiusX)) { @@ -160,7 +160,7 @@ namespace Avalonia.Media /// public void DrawRectangle(IBrush? brush, IPen? pen, RoundedRect rrect, BoxShadows boxShadows = default) { - if (brush == null && !PenIsVisible(pen)) + if (brush == null && !PenIsVisible(pen) && boxShadows.Count == 0) return; DrawRectangleCore(brush, pen, rrect, boxShadows); } diff --git a/src/Avalonia.Base/Media/Effects/BlurEffect.cs b/src/Avalonia.Base/Media/Effects/BlurEffect.cs new file mode 100644 index 0000000000..47c86e4e42 --- /dev/null +++ b/src/Avalonia.Base/Media/Effects/BlurEffect.cs @@ -0,0 +1,22 @@ +using System; +// ReSharper disable CheckNamespace +namespace Avalonia.Media; + +public class BlurEffect : Effect, IBlurEffect, IMutableEffect +{ + public static readonly StyledProperty RadiusProperty = AvaloniaProperty.Register( + nameof(Radius), 5); + + public double Radius + { + get => GetValue(RadiusProperty); + set => SetValue(RadiusProperty, value); + } + + static BlurEffect() + { + AffectsRender(RadiusProperty); + } + + public IImmutableEffect ToImmutable() => new ImmutableBlurEffect(Radius); +} \ No newline at end of file diff --git a/src/Avalonia.Base/Media/Effects/DropShadowEffect.cs b/src/Avalonia.Base/Media/Effects/DropShadowEffect.cs new file mode 100644 index 0000000000..ea931c0a8c --- /dev/null +++ b/src/Avalonia.Base/Media/Effects/DropShadowEffect.cs @@ -0,0 +1,104 @@ +// ReSharper disable once CheckNamespace + +using System; +// ReSharper disable CheckNamespace + +namespace Avalonia.Media; + +public abstract class DropShadowEffectBase : Effect +{ + public static readonly StyledProperty BlurRadiusProperty = + AvaloniaProperty.Register( + nameof(BlurRadius), 5); + + public double BlurRadius + { + get => GetValue(BlurRadiusProperty); + set => SetValue(BlurRadiusProperty, value); + } + + public static readonly StyledProperty ColorProperty = AvaloniaProperty.Register( + nameof(Color), Colors.Black); + + public Color Color + { + get => GetValue(ColorProperty); + set => SetValue(ColorProperty, value); + } + + public static readonly StyledProperty OpacityProperty = + AvaloniaProperty.Register( + nameof(Opacity), 1); + + public double Opacity + { + get => GetValue(OpacityProperty); + set => SetValue(OpacityProperty, value); + } + + static DropShadowEffectBase() + { + AffectsRender(BlurRadiusProperty, ColorProperty, OpacityProperty); + } +} + +public class DropShadowEffect : DropShadowEffectBase, IDropShadowEffect, IMutableEffect +{ + public static readonly StyledProperty OffsetXProperty = AvaloniaProperty.Register( + nameof(OffsetX), 3.5355); + + public double OffsetX + { + get => GetValue(OffsetXProperty); + set => SetValue(OffsetXProperty, value); + } + + public static readonly StyledProperty OffsetYProperty = AvaloniaProperty.Register( + nameof(OffsetY), 3.5355); + + public double OffsetY + { + get => GetValue(OffsetYProperty); + set => SetValue(OffsetYProperty, value); + } + + static DropShadowEffect() + { + AffectsRender(OffsetXProperty, OffsetYProperty); + } + + public IImmutableEffect ToImmutable() + { + return new ImmutableDropShadowEffect(OffsetX, OffsetY, BlurRadius, Color, Opacity); + } +} + +/// +/// This class is compatible with WPF's DropShadowEffect and provides Direction and ShadowDepth properties instead of OffsetX/OffsetY +/// +public class DropShadowDirectionEffect : DropShadowEffectBase, IDirectionDropShadowEffect, IMutableEffect +{ + public static readonly StyledProperty ShadowDepthProperty = + AvaloniaProperty.Register( + nameof(ShadowDepth), 5); + + public double ShadowDepth + { + get => GetValue(ShadowDepthProperty); + set => SetValue(ShadowDepthProperty, value); + } + + public static readonly StyledProperty DirectionProperty = AvaloniaProperty.Register( + nameof(Direction), 315); + + public double Direction + { + get => GetValue(DirectionProperty); + set => SetValue(DirectionProperty, value); + } + + public double OffsetX => Math.Cos(Direction * Math.PI / 180) * ShadowDepth; + public double OffsetY => Math.Sin(Direction * Math.PI / 180) * ShadowDepth; + + public IImmutableEffect ToImmutable() => new ImmutableDropShadowDirectionEffect(OffsetX, OffsetY, BlurRadius, Color, Opacity); +} \ No newline at end of file diff --git a/src/Avalonia.Base/Media/Effects/Effect.cs b/src/Avalonia.Base/Media/Effects/Effect.cs new file mode 100644 index 0000000000..182e8613f8 --- /dev/null +++ b/src/Avalonia.Base/Media/Effects/Effect.cs @@ -0,0 +1,93 @@ +using System; +using Avalonia.Animation; +using Avalonia.Animation.Animators; +using Avalonia.Reactive; +using Avalonia.Rendering.Composition.Expressions; +using Avalonia.Utilities; + +// ReSharper disable once CheckNamespace +namespace Avalonia.Media; + +public class Effect : Animatable, IAffectsRender +{ + /// + /// Marks a property as affecting the brush's visual representation. + /// + /// The properties. + /// + /// After a call to this method in a brush's static constructor, any change to the + /// property will cause the event to be raised on the brush. + /// + protected static void AffectsRender(params AvaloniaProperty[] properties) + where T : Effect + { + var invalidateObserver = new AnonymousObserver( + static e => (e.Sender as T)?.RaiseInvalidated(EventArgs.Empty)); + + foreach (var property in properties) + { + property.Changed.Subscribe(invalidateObserver); + } + } + + /// + /// Raises the event. + /// + /// The event args. + protected void RaiseInvalidated(EventArgs e) => Invalidated?.Invoke(this, e); + + /// + public event EventHandler? Invalidated; + + + static Exception ParseError(string s) => throw new ArgumentException("Unable to parse effect: " + s); + public static IEffect Parse(string s) + { + var span = s.AsSpan(); + var r = new TokenParser(span); + if (r.TryConsume("blur")) + { + if (!r.TryConsume('(') || !r.TryParseDouble(out var radius) || !r.TryConsume(')') || !r.IsEofWithWhitespace()) + throw ParseError(s); + return new ImmutableBlurEffect(radius); + } + + + if (r.TryConsume("drop-shadow")) + { + if (!r.TryConsume('(') || !r.TryParseDouble(out var offsetX) + || !r.TryParseDouble(out var offsetY)) + throw ParseError(s); + double blurRadius = 0; + var color = Colors.Black; + if (!r.TryConsume(')')) + { + if (!r.TryParseDouble(out blurRadius) || blurRadius < 0) + throw ParseError(s); + if (!r.TryConsume(')')) + { + var endOfExpression = s.LastIndexOf(")", StringComparison.Ordinal); + if (endOfExpression == -1) + throw ParseError(s); + + if (!new TokenParser(span.Slice(endOfExpression + 1)).IsEofWithWhitespace()) + throw ParseError(s); + + if (!Color.TryParse(span.Slice(r.Position, endOfExpression - r.Position).TrimEnd(), out color)) + throw ParseError(s); + return new ImmutableDropShadowEffect(offsetX, offsetY, blurRadius, color, 1); + } + } + if (!r.IsEofWithWhitespace()) + throw ParseError(s); + return new ImmutableDropShadowEffect(offsetX, offsetY, blurRadius, color, 1); + } + + throw ParseError(s); + } + + static Effect() + { + EffectAnimator.EnsureRegistered(); + } +} \ No newline at end of file diff --git a/src/Avalonia.Base/Media/Effects/EffectAnimator.cs b/src/Avalonia.Base/Media/Effects/EffectAnimator.cs new file mode 100644 index 0000000000..70d359911b --- /dev/null +++ b/src/Avalonia.Base/Media/Effects/EffectAnimator.cs @@ -0,0 +1,131 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using Avalonia.Data; +using Avalonia.Logging; +using Avalonia.Media; + +// ReSharper disable once CheckNamespace +namespace Avalonia.Animation.Animators; + +public class EffectAnimator : Animator +{ + public override IDisposable? Apply(Animation animation, Animatable control, IClock? clock, + IObservable match, Action? onComplete) + { + if (TryCreateAnimator(out var animator) + || TryCreateAnimator(out animator)) + return animator.Apply(animation, control, clock, match, onComplete); + + Logger.TryGet(LogEventLevel.Error, LogArea.Animations)?.Log( + this, + "The animation's keyframe value types set is not supported."); + + return base.Apply(animation, control, clock, match, onComplete); + } + + private bool TryCreateAnimator([NotNullWhen(true)] out IAnimator? animator) + where TAnimator : EffectAnimatorBase, new() where TInterface : class, IEffect + { + TAnimator? createdAnimator = null; + foreach (var keyFrame in this) + { + if (keyFrame.Value is TInterface) + { + createdAnimator ??= new TAnimator() + { + Property = Property + }; + createdAnimator.Add(new AnimatorKeyFrame(typeof(TAnimator), () => new TAnimator(), keyFrame.Cue, + keyFrame.KeySpline) + { + Value = keyFrame.Value + }); + } + else + { + animator = null; + return false; + } + } + + animator = createdAnimator; + return animator != null; + } + + /// + /// Fallback implementation of animation. + /// + public override IEffect? Interpolate(double progress, IEffect? oldValue, IEffect? newValue) => progress >= 0.5 ? newValue : oldValue; + + private static bool s_Registered; + public static void EnsureRegistered() + { + if(s_Registered) + return; + s_Registered = true; + Animation.RegisterAnimator(prop => + typeof(IEffect).IsAssignableFrom(prop.PropertyType)); + } +} + +public abstract class EffectAnimatorBase : Animator where T : class, IEffect? +{ + public override IDisposable BindAnimation(Animatable control, IObservable instance) + { + if (Property is null) + { + throw new InvalidOperationException("Animator has no property specified."); + } + + return control.Bind((AvaloniaProperty)Property, instance, BindingPriority.Animation); + } + + protected abstract T Interpolate(double progress, T oldValue, T newValue); + public override IEffect? Interpolate(double progress, IEffect? oldValue, IEffect? newValue) + { + var old = oldValue as T; + var n = newValue as T; + if (old == null || n == null) + return progress >= 0.5 ? newValue : oldValue; + return Interpolate(progress, old, n); + } +} + +public class BlurEffectAnimator : EffectAnimatorBase +{ + private static readonly DoubleAnimator s_doubleAnimator = new DoubleAnimator(); + + protected override IBlurEffect Interpolate(double progress, IBlurEffect oldValue, IBlurEffect newValue) + { + return new ImmutableBlurEffect( + s_doubleAnimator.Interpolate(progress, oldValue.Radius, newValue.Radius)); + } +} + +public class DropShadowEffectAnimator : EffectAnimatorBase +{ + private static readonly DoubleAnimator s_doubleAnimator = new DoubleAnimator(); + + protected override IDropShadowEffect Interpolate(double progress, IDropShadowEffect oldValue, + IDropShadowEffect newValue) + { + var blur = s_doubleAnimator.Interpolate(progress, oldValue.BlurRadius, newValue.BlurRadius); + var color = ColorAnimator.InterpolateCore(progress, oldValue.Color, newValue.Color); + var opacity = s_doubleAnimator.Interpolate(progress, oldValue.Opacity, newValue.Opacity); + + if (oldValue is IDirectionDropShadowEffect oldDirection && newValue is IDirectionDropShadowEffect newDirection) + { + return new ImmutableDropShadowDirectionEffect( + s_doubleAnimator.Interpolate(progress, oldDirection.Direction, newDirection.Direction), + s_doubleAnimator.Interpolate(progress, oldDirection.ShadowDepth, newDirection.ShadowDepth), + blur, color, opacity + ); + } + + return new ImmutableDropShadowEffect( + s_doubleAnimator.Interpolate(progress, oldValue.OffsetX, newValue.OffsetX), + s_doubleAnimator.Interpolate(progress, oldValue.OffsetY, newValue.OffsetY), + blur, color, opacity + ); + } +} diff --git a/src/Avalonia.Base/Media/Effects/EffectConverter.cs b/src/Avalonia.Base/Media/Effects/EffectConverter.cs new file mode 100644 index 0000000000..6ec3bace03 --- /dev/null +++ b/src/Avalonia.Base/Media/Effects/EffectConverter.cs @@ -0,0 +1,18 @@ +using System; +using System.ComponentModel; +using System.Globalization; + +namespace Avalonia.Media; + +public class EffectConverter : TypeConverter +{ + public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType) + { + return sourceType == typeof(string); + } + + public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object? value) + { + return value is string s ? Effect.Parse(s) : null; + } +} \ No newline at end of file diff --git a/src/Avalonia.Base/Media/Effects/EffectExtesions.cs b/src/Avalonia.Base/Media/Effects/EffectExtesions.cs new file mode 100644 index 0000000000..adc287607b --- /dev/null +++ b/src/Avalonia.Base/Media/Effects/EffectExtesions.cs @@ -0,0 +1,56 @@ +using System; + +// ReSharper disable once CheckNamespace +namespace Avalonia.Media; + +public static class EffectExtensions +{ + static double AdjustPaddingRadius(double radius) + { + if (radius <= 0) + return 0; + return Math.Ceiling(radius) + 1; + } + internal static Thickness GetEffectOutputPadding(this IEffect? effect) + { + if (effect == null) + return default; + if (effect is IBlurEffect blur) + return new Thickness(AdjustPaddingRadius(blur.Radius)); + if (effect is IDropShadowEffect dropShadowEffect) + { + var radius = AdjustPaddingRadius(dropShadowEffect.BlurRadius); + var rc = new Rect(-radius, -radius, + radius * 2, radius * 2); + rc = rc.Translate(new(dropShadowEffect.OffsetX, dropShadowEffect.OffsetY)); + return new Thickness(Math.Max(0, 0 - rc.X), + Math.Max(0, 0 - rc.Y), Math.Max(0, rc.Right), Math.Max(0, rc.Bottom)); + } + + throw new ArgumentException("Unknown effect type: " + effect.GetType()); + } + + /// + /// Converts a effect to an immutable effect. + /// + /// The effect. + /// + /// The result of calling if the effect is mutable, + /// otherwise . + /// + public static IImmutableEffect ToImmutable(this IEffect effect) + { + _ = effect ?? throw new ArgumentNullException(nameof(effect)); + + return (effect as IMutableEffect)?.ToImmutable() ?? (IImmutableEffect)effect; + } + + internal static bool EffectEquals(this IImmutableEffect? immutable, IEffect? right) + { + if (immutable == null && right == null) + return true; + if (immutable != null && right != null) + return immutable.Equals(right); + return false; + } +} \ No newline at end of file diff --git a/src/Avalonia.Base/Media/Effects/EffectTransition.cs b/src/Avalonia.Base/Media/Effects/EffectTransition.cs new file mode 100644 index 0000000000..b2e0d07355 --- /dev/null +++ b/src/Avalonia.Base/Media/Effects/EffectTransition.cs @@ -0,0 +1,83 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using Avalonia.Animation.Animators; +using Avalonia.Animation.Easings; +using Avalonia.Media; + + +// ReSharper disable once CheckNamespace +namespace Avalonia.Animation; + +/// +/// Transition class that handles with type. +/// +public class EffectTransition : Transition +{ + private static readonly BlurEffectAnimator s_blurEffectAnimator = new(); + private static readonly DropShadowEffectAnimator s_dropShadowEffectAnimator = new(); + private static readonly ImmutableBlurEffect s_DefaultBlur = new ImmutableBlurEffect(0); + private static readonly ImmutableDropShadowDirectionEffect s_DefaultDropShadow = new(0, 0, 0, default, 0); + + bool TryWithAnimator( + IObservable progress, + TAnimator animator, + IEffect? oldValue, IEffect? newValue, TInterface defaultValue, [MaybeNullWhen(false)] out IObservable observable) + where TAnimator : EffectAnimatorBase where TInterface : class, IEffect + { + observable = null; + TInterface? oldI = null, newI = null; + if (oldValue is TInterface oi) + { + oldI = oi; + if (newValue is TInterface ni) + newI = ni; + else if (newValue == null) + newI = defaultValue; + else + return false; + } + else if (newValue is TInterface nv) + { + oldI = defaultValue; + newI = nv; + + } + else + return false; + + observable = new AnimatorTransitionObservable>(animator, progress, Easing, oldI, newI); + return true; + + } + + public override IObservable DoTransition(IObservable progress, IEffect? oldValue, IEffect? newValue) + { + if ((oldValue != null || newValue != null) + && ( + TryWithAnimator(progress, s_blurEffectAnimator, + oldValue, newValue, s_DefaultBlur, out var observable) + || TryWithAnimator(progress, s_dropShadowEffectAnimator, + oldValue, newValue, s_DefaultDropShadow, out observable) + )) + return observable; + + return new IncompatibleTransitionObservable(progress, Easing, oldValue, newValue); + } + + private sealed class IncompatibleTransitionObservable : TransitionObservableBase + { + private readonly IEffect? _from; + private readonly IEffect? _to; + + public IncompatibleTransitionObservable(IObservable progress, Easing easing, IEffect? from, IEffect? to) : base(progress, easing) + { + _from = from; + _to = to; + } + + protected override IEffect? ProduceValue(double progress) + { + return progress >= 0.5 ? _to : _from; + } + } +} \ No newline at end of file diff --git a/src/Avalonia.Base/Media/Effects/IBlurEffect.cs b/src/Avalonia.Base/Media/Effects/IBlurEffect.cs new file mode 100644 index 0000000000..716159747c --- /dev/null +++ b/src/Avalonia.Base/Media/Effects/IBlurEffect.cs @@ -0,0 +1,29 @@ +// ReSharper disable once CheckNamespace + +using Avalonia.Animation.Animators; + +namespace Avalonia.Media; + +public interface IBlurEffect : IEffect +{ + double Radius { get; } +} + +public class ImmutableBlurEffect : IBlurEffect, IImmutableEffect +{ + static ImmutableBlurEffect() + { + EffectAnimator.EnsureRegistered(); + } + + public ImmutableBlurEffect(double radius) + { + Radius = radius; + } + + public double Radius { get; } + + public bool Equals(IEffect? other) => + // ReSharper disable once CompareOfFloatsByEqualityOperator + other is IBlurEffect blur && blur.Radius == Radius; +} \ No newline at end of file diff --git a/src/Avalonia.Base/Media/Effects/IDropShadowEffect.cs b/src/Avalonia.Base/Media/Effects/IDropShadowEffect.cs new file mode 100644 index 0000000000..bb97410d7f --- /dev/null +++ b/src/Avalonia.Base/Media/Effects/IDropShadowEffect.cs @@ -0,0 +1,84 @@ +// ReSharper disable once CheckNamespace + +using System; +using Avalonia.Animation.Animators; + +namespace Avalonia.Media; + +public interface IDropShadowEffect : IEffect +{ + double OffsetX { get; } + double OffsetY { get; } + double BlurRadius { get; } + Color Color { get; } + double Opacity { get; } +} + +internal interface IDirectionDropShadowEffect : IDropShadowEffect +{ + double Direction { get; } + double ShadowDepth { get; } +} + +public class ImmutableDropShadowEffect : IDropShadowEffect, IImmutableEffect +{ + static ImmutableDropShadowEffect() + { + EffectAnimator.EnsureRegistered(); + } + + public ImmutableDropShadowEffect(double offsetX, double offsetY, double blurRadius, Color color, double opacity) + { + OffsetX = offsetX; + OffsetY = offsetY; + BlurRadius = blurRadius; + Color = color; + Opacity = opacity; + } + + public double OffsetX { get; } + public double OffsetY { get; } + public double BlurRadius { get; } + public Color Color { get; } + public double Opacity { get; } + public bool Equals(IEffect? other) + { + return other is IDropShadowEffect d + && d.OffsetX == OffsetX && d.OffsetY == OffsetY + && d.BlurRadius == BlurRadius + && d.Color == Color && d.Opacity == Opacity; + } +} + + +public class ImmutableDropShadowDirectionEffect : IDirectionDropShadowEffect, IImmutableEffect +{ + static ImmutableDropShadowDirectionEffect() + { + EffectAnimator.EnsureRegistered(); + } + + public ImmutableDropShadowDirectionEffect(double direction, double shadowDepth, double blurRadius, Color color, double opacity) + { + Direction = direction; + ShadowDepth = shadowDepth; + BlurRadius = blurRadius; + Color = color; + Opacity = opacity; + } + + public double OffsetX => Math.Cos(Direction * Math.PI / 180) * ShadowDepth; + public double OffsetY => Math.Sin(Direction * Math.PI / 180) * ShadowDepth; + public double Direction { get; } + public double ShadowDepth { get; } + public double BlurRadius { get; } + public Color Color { get; } + public double Opacity { get; } + public bool Equals(IEffect? other) + { + return other is IDropShadowEffect d + && d.OffsetX == OffsetX && d.OffsetY == OffsetY + && d.BlurRadius == BlurRadius + && d.Color == Color && d.Opacity == Opacity; + } +} \ No newline at end of file diff --git a/src/Avalonia.Base/Media/Effects/IEffect.cs b/src/Avalonia.Base/Media/Effects/IEffect.cs new file mode 100644 index 0000000000..698dccf1dd --- /dev/null +++ b/src/Avalonia.Base/Media/Effects/IEffect.cs @@ -0,0 +1,26 @@ +// ReSharper disable once CheckNamespace + +using System; +using System.ComponentModel; + +namespace Avalonia.Media; + +[TypeConverter(typeof(EffectConverter))] +public interface IEffect +{ + +} + +public interface IMutableEffect : IEffect, IAffectsRender +{ + /// + /// Creates an immutable clone of the effect. + /// + /// The immutable clone. + internal IImmutableEffect ToImmutable(); +} + +public interface IImmutableEffect : IEffect, IEquatable +{ + +} \ No newline at end of file diff --git a/src/Avalonia.Base/Media/FontManager.cs b/src/Avalonia.Base/Media/FontManager.cs index cc9bc6f30a..4425147098 100644 --- a/src/Avalonia.Base/Media/FontManager.cs +++ b/src/Avalonia.Base/Media/FontManager.cs @@ -30,13 +30,15 @@ namespace Avalonia.Media _fontFallbacks = options?.FontFallbacks; - DefaultFontFamilyName = options?.DefaultFamilyName ?? PlatformImpl.GetDefaultFontFamilyName(); + var defaultFontFamilyName = options?.DefaultFamilyName ?? PlatformImpl.GetDefaultFontFamilyName(); - if (string.IsNullOrEmpty(DefaultFontFamilyName)) + if (string.IsNullOrEmpty(defaultFontFamilyName)) { throw new InvalidOperationException("Default font family name can't be null or empty."); } + DefaultFontFamily = new FontFamily(defaultFontFamilyName); + AddFontCollection(new SystemFontCollection(this)); } @@ -65,9 +67,9 @@ namespace Avalonia.Media } /// - /// Gets the system's default font family's name. + /// Gets the system's default font family. /// - public string DefaultFontFamilyName + public FontFamily DefaultFontFamily { get; } @@ -93,6 +95,11 @@ namespace Avalonia.Media var fontFamily = typeface.FontFamily; + if(typeface.FontFamily.Name == FontFamily.DefaultFontFamilyName) + { + return TryGetGlyphTypeface(new Typeface(DefaultFontFamily, typeface.Style, typeface.Weight, typeface.Stretch), out glyphTypeface); + } + if (fontFamily.Key is FontFamilyKey key) { var source = key.Source; @@ -119,9 +126,7 @@ namespace Avalonia.Media } } - var familyName = fontFamily.FamilyNames.PrimaryFamilyName.ToUpperInvariant(); - - if (fontCollection != null && fontCollection.TryGetGlyphTypeface(familyName, + if (fontCollection != null && fontCollection.TryGetGlyphTypeface(fontFamily.FamilyNames.PrimaryFamilyName, typeface.Style, typeface.Weight, typeface.Stretch, out glyphTypeface)) { return true; @@ -133,15 +138,21 @@ namespace Avalonia.Media } } - foreach (var familyName in fontFamily.FamilyNames) + for (var i = 0; i < fontFamily.FamilyNames.Count; i++) { - if (SystemFonts.TryGetGlyphTypeface(familyName.ToUpperInvariant(), typeface.Style, typeface.Weight, typeface.Stretch, out glyphTypeface)) + var familyName = fontFamily.FamilyNames[i]; + + if (SystemFonts.TryGetGlyphTypeface(familyName, typeface.Style, typeface.Weight, typeface.Stretch, out glyphTypeface)) { - return true; + if (!fontFamily.FamilyNames.HasFallbacks || glyphTypeface.FamilyName != DefaultFontFamily.Name) + { + return true; + } } } - return TryGetGlyphTypeface(new Typeface(DefaultFontFamilyName, typeface.Style, typeface.Weight, typeface.Stretch), out glyphTypeface); + //Nothing was found so use the default + return TryGetGlyphTypeface(new Typeface(DefaultFontFamily, typeface.Style, typeface.Weight, typeface.Stretch), out glyphTypeface); } /// @@ -201,16 +212,37 @@ namespace Avalonia.Media { foreach (var fallback in _fontFallbacks) { - typeface = new Typeface(fallback.FontFamily, fontStyle, fontWeight, fontStretch); + if (fallback.UnicodeRange.IsInRange(codepoint)) + { + typeface = new Typeface(fallback.FontFamily, fontStyle, fontWeight, fontStretch); + + if (TryGetGlyphTypeface(typeface, out var glyphTypeface) && glyphTypeface.TryGetGlyph((uint)codepoint, out _)) + { + return true; + } + } + } + } - if (TryGetGlyphTypeface(typeface, out var glyphTypeface) && glyphTypeface.TryGetGlyph((uint)codepoint, out _)) + //Try to match against fallbacks first + if (fontFamily != null && fontFamily.FamilyNames.HasFallbacks) + { + for (int i = 1; i < fontFamily.FamilyNames.Count; i++) + { + var familyName = fontFamily.FamilyNames[i]; + + foreach (var fontCollection in _fontCollections.Values) { - return true; + if (fontCollection.TryMatchCharacter(codepoint, fontStyle, fontWeight, fontStretch, familyName, culture, out typeface)) + { + return true; + }; } } } - return PlatformImpl.TryMatchCharacter(codepoint, fontStyle, fontWeight, fontStretch, fontFamily, culture, out typeface); + //Try to find a match with the system font manager + return PlatformImpl.TryMatchCharacter(codepoint, fontStyle, fontWeight, fontStretch, culture, out typeface); } } } diff --git a/src/Avalonia.Base/Media/Fonts/EmbeddedFontCollection.cs b/src/Avalonia.Base/Media/Fonts/EmbeddedFontCollection.cs index 3350358d68..4d4751db02 100644 --- a/src/Avalonia.Base/Media/Fonts/EmbeddedFontCollection.cs +++ b/src/Avalonia.Base/Media/Fonts/EmbeddedFontCollection.cs @@ -3,14 +3,13 @@ using System.Collections; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Globalization; using Avalonia.Platform; namespace Avalonia.Media.Fonts { - public class EmbeddedFontCollection : IFontCollection + public class EmbeddedFontCollection : FontCollectionBase { - private readonly ConcurrentDictionary> _glyphTypefaceCache = new(); - private readonly List _fontFamilies = new List(1); private readonly Uri _key; @@ -24,13 +23,13 @@ namespace Avalonia.Media.Fonts _source = source; } - public Uri Key => _key; + public override Uri Key => _key; - public FontFamily this[int index] => _fontFamilies[index]; + public override FontFamily this[int index] => _fontFamilies[index]; - public int Count => _fontFamilies.Count; + public override int Count => _fontFamilies.Count; - public void Initialize(IFontManagerImpl fontManager) + public override void Initialize(IFontManagerImpl fontManager) { var assetLoader = AvaloniaLocator.Current.GetRequiredService(); @@ -42,13 +41,11 @@ namespace Avalonia.Media.Fonts if (fontManager.TryCreateGlyphTypeface(stream, out var glyphTypeface)) { - var familyName = glyphTypeface.FamilyName.ToUpperInvariant(); - - if (!_glyphTypefaceCache.TryGetValue(familyName, out var glyphTypefaces)) + if (!_glyphTypefaceCache.TryGetValue(glyphTypeface.FamilyName, out var glyphTypefaces)) { - glyphTypefaces = new ConcurrentDictionary(); + glyphTypefaces = new ConcurrentDictionary(); - if (_glyphTypefaceCache.TryAdd(familyName, glyphTypefaces)) + if (_glyphTypefaceCache.TryAdd(glyphTypeface.FamilyName, glyphTypefaces)) { _fontFamilies.Add(new FontFamily(_key, glyphTypeface.FamilyName)); } @@ -64,31 +61,10 @@ namespace Avalonia.Media.Fonts } } - public void Dispose() - { - foreach (var fontFamily in _fontFamilies) - { - if (_glyphTypefaceCache.TryGetValue(fontFamily.Name, out var glyphTypefaces)) - { - foreach (var glyphTypeface in glyphTypefaces.Values) - { - glyphTypeface.Dispose(); - } - } - } - - GC.SuppressFinalize(this); - } - - public IEnumerator GetEnumerator() => _fontFamilies.GetEnumerator(); - IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); - - public bool TryGetGlyphTypeface(string familyName, FontStyle style, FontWeight weight, + public override bool TryGetGlyphTypeface(string familyName, FontStyle style, FontWeight weight, FontStretch stretch, [NotNullWhen(true)] out IGlyphTypeface? glyphTypeface) { - familyName = familyName.ToUpperInvariant(); - var key = new FontCollectionKey(style, weight, stretch); if (_glyphTypefaceCache.TryGetValue(familyName, out var glyphTypefaces)) @@ -104,11 +80,9 @@ namespace Avalonia.Media.Fonts { var fontFamily = _fontFamilies[i]; - if (fontFamily.Name.ToUpperInvariant().StartsWith(familyName.ToUpperInvariant())) + if (fontFamily.Name.ToLower(CultureInfo.InvariantCulture).StartsWith(familyName.ToLower(CultureInfo.InvariantCulture))) { - familyName = fontFamily.Name.ToUpperInvariant(); - - if (_glyphTypefaceCache.TryGetValue(familyName, out glyphTypefaces) && + if (_glyphTypefaceCache.TryGetValue(fontFamily.Name, out glyphTypefaces) && TryGetNearestMatch(glyphTypefaces, key, out glyphTypeface)) { return true; @@ -121,175 +95,6 @@ namespace Avalonia.Media.Fonts return false; } - private static bool TryGetNearestMatch( - ConcurrentDictionary glyphTypefaces, - FontCollectionKey key, - [NotNullWhen(true)] out IGlyphTypeface? glyphTypeface) - { - if (glyphTypefaces.TryGetValue(key, out glyphTypeface)) - { - return true; - } - - if (key.Style != FontStyle.Normal) - { - key = key with { Style = FontStyle.Normal }; - } - - if (key.Stretch != FontStretch.Normal) - { - if (TryFindStretchFallback(glyphTypefaces, key, out glyphTypeface)) - { - return true; - } - - if (key.Weight != FontWeight.Normal) - { - if (TryFindStretchFallback(glyphTypefaces, key with { Weight = FontWeight.Normal }, out glyphTypeface)) - { - return true; - } - } - - key = key with { Stretch = FontStretch.Normal }; - } - - if (TryFindWeightFallback(glyphTypefaces, key, out glyphTypeface)) - { - return true; - } - - if (TryFindStretchFallback(glyphTypefaces, key, out glyphTypeface)) - { - return true; - } - - //Take the first glyph typeface we can find. - foreach (var typeface in glyphTypefaces.Values) - { - glyphTypeface = typeface; - - return true; - } - - return false; - } - - private static bool TryFindStretchFallback( - ConcurrentDictionary glyphTypefaces, - FontCollectionKey key, - [NotNullWhen(true)] out IGlyphTypeface? glyphTypeface) - { - glyphTypeface = null; - - var stretch = (int)key.Stretch; - - if (stretch < 5) - { - for (var i = 0; stretch + i < 9; i++) - { - if (glyphTypefaces.TryGetValue(key with { Stretch = (FontStretch)(stretch + i) }, out glyphTypeface)) - { - return true; - } - } - } - else - { - for (var i = 0; stretch - i > 1; i++) - { - if (glyphTypefaces.TryGetValue(key with { Stretch = (FontStretch)(stretch - i) }, out glyphTypeface)) - { - return true; - } - } - } - - return false; - } - - private static bool TryFindWeightFallback( - ConcurrentDictionary glyphTypefaces, - FontCollectionKey key, - [NotNullWhen(true)] out IGlyphTypeface? typeface) - { - typeface = null; - var weight = (int)key.Weight; - - //If the target weight given is between 400 and 500 inclusive - if (weight >= 400 && weight <= 500) - { - //Look for available weights between the target and 500, in ascending order. - for (var i = 0; weight + i <= 500; i += 50) - { - if (glyphTypefaces.TryGetValue(key with { Weight = (FontWeight)(weight + i) }, out typeface)) - { - return true; - } - } - - //If no match is found, look for available weights less than the target, in descending order. - for (var i = 0; weight - i >= 100; i += 50) - { - if (glyphTypefaces.TryGetValue(key with { Weight = (FontWeight)(weight - i) }, out typeface)) - { - return true; - } - } - - //If no match is found, look for available weights greater than 500, in ascending order. - for (var i = 0; weight + i <= 900; i += 50) - { - if (glyphTypefaces.TryGetValue(key with { Weight = (FontWeight)(weight + i) }, out typeface)) - { - return true; - } - } - } - - //If a weight less than 400 is given, look for available weights less than the target, in descending order. - if (weight < 400) - { - for (var i = 0; weight - i >= 100; i += 50) - { - if (glyphTypefaces.TryGetValue(key with { Weight = (FontWeight)(weight - i) }, out typeface)) - { - return true; - } - } - - //If no match is found, look for available weights less than the target, in descending order. - for (var i = 0; weight + i <= 900; i += 50) - { - if (glyphTypefaces.TryGetValue(key with { Weight = (FontWeight)(weight + i) }, out typeface)) - { - return true; - } - } - } - - //If a weight greater than 500 is given, look for available weights greater than the target, in ascending order. - if (weight > 500) - { - for (var i = 0; weight + i <= 900; i += 50) - { - if (glyphTypefaces.TryGetValue(key with { Weight = (FontWeight)(weight + i) }, out typeface)) - { - return true; - } - } - - //If no match is found, look for available weights less than the target, in descending order. - for (var i = 0; weight - i >= 100; i += 50) - { - if (glyphTypefaces.TryGetValue(key with { Weight = (FontWeight)(weight - i) }, out typeface)) - { - return true; - } - } - } - - return false; - } + public override IEnumerator GetEnumerator() => _fontFamilies.GetEnumerator(); } } diff --git a/src/Avalonia.Base/Media/Fonts/FontCollectionBase.cs b/src/Avalonia.Base/Media/Fonts/FontCollectionBase.cs new file mode 100644 index 0000000000..713b3dafcd --- /dev/null +++ b/src/Avalonia.Base/Media/Fonts/FontCollectionBase.cs @@ -0,0 +1,259 @@ +using System; +using System.Collections; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using Avalonia.Platform; + +namespace Avalonia.Media.Fonts +{ + public abstract class FontCollectionBase : IFontCollection + { + protected readonly ConcurrentDictionary> _glyphTypefaceCache = new(); + + public abstract Uri Key { get; } + + public abstract int Count { get; } + + public abstract FontFamily this[int index] { get; } + + public abstract bool TryGetGlyphTypeface(string familyName, FontStyle style, FontWeight weight, FontStretch stretch, + [NotNullWhen(true)] out IGlyphTypeface? glyphTypeface); + + public bool TryMatchCharacter(int codepoint, FontStyle style, FontWeight weight, FontStretch stretch, + string? familyName, CultureInfo? culture, out Typeface match) + { + match = default; + + if (string.IsNullOrEmpty(familyName)) + { + foreach (var typefaces in _glyphTypefaceCache.Values) + { + if (TryGetNearestMatch(typefaces, new FontCollectionKey { Style = style, Weight = weight, Stretch = stretch }, out var glyphTypeface)) + { + if (glyphTypeface.TryGetGlyph((uint)codepoint, out _)) + { + match = new Typeface(glyphTypeface.FamilyName, style, weight, stretch); + + return true; + } + } + } + } + else + { + if (TryGetGlyphTypeface(familyName, style, weight, stretch, out var glyphTypeface)) + { + if (glyphTypeface.TryGetGlyph((uint)codepoint, out _)) + { + match = new Typeface(familyName, style, weight, stretch); + + return true; + } + } + } + + return false; + } + + public abstract void Initialize(IFontManagerImpl fontManager); + + public abstract IEnumerator GetEnumerator(); + + void IDisposable.Dispose() + { + foreach (var glyphTypefaces in _glyphTypefaceCache.Values) + { + foreach (var pair in glyphTypefaces) + { + pair.Value?.Dispose(); + } + } + + GC.SuppressFinalize(this); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + internal static bool TryGetNearestMatch( + ConcurrentDictionary glyphTypefaces, + FontCollectionKey key, + [NotNullWhen(true)] out IGlyphTypeface? glyphTypeface) + { + if (glyphTypefaces.TryGetValue(key, out glyphTypeface) && glyphTypeface != null) + { + return true; + } + + if (key.Style != FontStyle.Normal) + { + key = key with { Style = FontStyle.Normal }; + } + + if (key.Stretch != FontStretch.Normal) + { + if (TryFindStretchFallback(glyphTypefaces, key, out glyphTypeface)) + { + return true; + } + + if (key.Weight != FontWeight.Normal) + { + if (TryFindStretchFallback(glyphTypefaces, key with { Weight = FontWeight.Normal }, out glyphTypeface)) + { + return true; + } + } + + key = key with { Stretch = FontStretch.Normal }; + } + + if (TryFindWeightFallback(glyphTypefaces, key, out glyphTypeface)) + { + return true; + } + + if (TryFindStretchFallback(glyphTypefaces, key, out glyphTypeface)) + { + return true; + } + + //Take the first glyph typeface we can find. + foreach (var typeface in glyphTypefaces.Values) + { + if(typeface != null) + { + glyphTypeface = typeface; + + return true; + } + } + + return false; + } + + internal static bool TryFindStretchFallback( + ConcurrentDictionary glyphTypefaces, + FontCollectionKey key, + [NotNullWhen(true)] out IGlyphTypeface? glyphTypeface) + { + glyphTypeface = null; + + var stretch = (int)key.Stretch; + + if (stretch < 5) + { + for (var i = 0; stretch + i < 9; i++) + { + if (glyphTypefaces.TryGetValue(key with { Stretch = (FontStretch)(stretch + i) }, out glyphTypeface) && glyphTypeface != null) + { + return true; + } + } + } + else + { + for (var i = 0; stretch - i > 1; i++) + { + if (glyphTypefaces.TryGetValue(key with { Stretch = (FontStretch)(stretch - i) }, out glyphTypeface) && glyphTypeface != null) + { + return true; + } + } + } + + return false; + } + + internal static bool TryFindWeightFallback( + ConcurrentDictionary glyphTypefaces, + FontCollectionKey key, + [NotNullWhen(true)] out IGlyphTypeface? glyphTypeface) + { + glyphTypeface = null; + var weight = (int)key.Weight; + + //If the target weight given is between 400 and 500 inclusive + if (weight >= 400 && weight <= 500) + { + //Look for available weights between the target and 500, in ascending order. + for (var i = 0; weight + i <= 500; i += 50) + { + if (glyphTypefaces.TryGetValue(key with { Weight = (FontWeight)(weight + i) }, out glyphTypeface) && glyphTypeface != null) + { + return true; + } + } + + //If no match is found, look for available weights less than the target, in descending order. + for (var i = 0; weight - i >= 100; i += 50) + { + if (glyphTypefaces.TryGetValue(key with { Weight = (FontWeight)(weight - i) }, out glyphTypeface) && glyphTypeface != null) + { + return true; + } + } + + //If no match is found, look for available weights greater than 500, in ascending order. + for (var i = 0; weight + i <= 900; i += 50) + { + if (glyphTypefaces.TryGetValue(key with { Weight = (FontWeight)(weight + i) }, out glyphTypeface) && glyphTypeface != null) + { + return true; + } + } + } + + //If a weight less than 400 is given, look for available weights less than the target, in descending order. + if (weight < 400) + { + for (var i = 0; weight - i >= 100; i += 50) + { + if (glyphTypefaces.TryGetValue(key with { Weight = (FontWeight)(weight - i) }, out glyphTypeface) && glyphTypeface != null) + { + return true; + } + } + + //If no match is found, look for available weights less than the target, in descending order. + for (var i = 0; weight + i <= 900; i += 50) + { + if (glyphTypefaces.TryGetValue(key with { Weight = (FontWeight)(weight + i) }, out glyphTypeface) && glyphTypeface != null) + { + return true; + } + } + } + + //If a weight greater than 500 is given, look for available weights greater than the target, in ascending order. + if (weight > 500) + { + for (var i = 0; weight + i <= 900; i += 50) + { + if (glyphTypefaces.TryGetValue(key with { Weight = (FontWeight)(weight + i) }, out glyphTypeface) && glyphTypeface != null) + { + return true; + } + } + + //If no match is found, look for available weights less than the target, in descending order. + for (var i = 0; weight - i >= 100; i += 50) + { + if (glyphTypefaces.TryGetValue(key with { Weight = (FontWeight)(weight - i) }, out glyphTypeface) && glyphTypeface != null) + { + return true; + } + } + } + + return false; + } + } +} diff --git a/src/Avalonia.Base/Media/Fonts/IFontCollection.cs b/src/Avalonia.Base/Media/Fonts/IFontCollection.cs index 814230bcf3..1a30f168f1 100644 --- a/src/Avalonia.Base/Media/Fonts/IFontCollection.cs +++ b/src/Avalonia.Base/Media/Fonts/IFontCollection.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Globalization; using Avalonia.Platform; namespace Avalonia.Media.Fonts @@ -29,5 +30,21 @@ namespace Avalonia.Media.Fonts /// Returns true if a glyph typface can be found; otherwise, false bool TryGetGlyphTypeface(string familyName, FontStyle style, FontWeight weight, FontStretch stretch, [NotNullWhen(true)] out IGlyphTypeface? glyphTypeface); + + /// + /// Tries to match a specified character to a that supports specified font properties. + /// + /// The codepoint to match against. + /// The font style. + /// The font weight. + /// The font stretch. + /// The family name. This is optional and used for fallback lookup. + /// The culture. + /// The matching . + /// + /// True, if the could match the character to specified parameters, False otherwise. + /// + bool TryMatchCharacter(int codepoint, FontStyle fontStyle, FontWeight fontWeight, + FontStretch fontStretch, string? familyName, CultureInfo? culture, out Typeface typeface); } } diff --git a/src/Avalonia.Base/Media/Fonts/SystemFontCollection.cs b/src/Avalonia.Base/Media/Fonts/SystemFontCollection.cs index 1687deb37b..2f2948cb3e 100644 --- a/src/Avalonia.Base/Media/Fonts/SystemFontCollection.cs +++ b/src/Avalonia.Base/Media/Fonts/SystemFontCollection.cs @@ -1,5 +1,4 @@ using System; -using System.Collections; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; @@ -7,10 +6,8 @@ using Avalonia.Platform; namespace Avalonia.Media.Fonts { - internal class SystemFontCollection : IFontCollection + internal class SystemFontCollection : FontCollectionBase { - private readonly ConcurrentDictionary> _glyphTypefaceCache = new(); - private readonly FontManager _fontManager; private readonly string[] _familyNames; @@ -20,9 +17,9 @@ namespace Avalonia.Media.Fonts _familyNames = fontManager.PlatformImpl.GetInstalledFontFamilyNames(); } - public Uri Key => FontManager.SystemFontsKey; + public override Uri Key => FontManager.SystemFontsKey; - public FontFamily this[int index] + public override FontFamily this[int index] { get { @@ -32,78 +29,41 @@ namespace Avalonia.Media.Fonts } } - public int Count => _familyNames.Length; + public override int Count => _familyNames.Length; - public bool TryGetGlyphTypeface(string familyName, FontStyle style, FontWeight weight, + public override bool TryGetGlyphTypeface(string familyName, FontStyle style, FontWeight weight, FontStretch stretch, [NotNullWhen(true)] out IGlyphTypeface? glyphTypeface) { - if (familyName == FontFamily.DefaultFontFamilyName) - { - familyName = _fontManager.DefaultFontFamilyName; - } - - familyName = familyName.ToUpperInvariant(); + glyphTypeface = null; var key = new FontCollectionKey(style, weight, stretch); - if (_glyphTypefaceCache.TryGetValue(familyName, out var glyphTypefaces)) - { - if (glyphTypefaces.TryGetValue(key, out glyphTypeface)) - { - return true; - } - else - { - if (_fontManager.PlatformImpl.TryCreateGlyphTypeface(familyName, style, weight, stretch, out glyphTypeface) && - glyphTypefaces.TryAdd(key, glyphTypeface)) - { - return true; - } - } - } + var glyphTypefaces = _glyphTypefaceCache.GetOrAdd(familyName, (key) => new ConcurrentDictionary()); - if (_fontManager.PlatformImpl.TryCreateGlyphTypeface(familyName, style, weight, stretch, out glyphTypeface)) + if (!glyphTypefaces.TryGetValue(key, out glyphTypeface)) { - glyphTypefaces = new ConcurrentDictionary(); + _fontManager.PlatformImpl.TryCreateGlyphTypeface(familyName, style, weight, stretch, out glyphTypeface); - if (glyphTypefaces.TryAdd(key, glyphTypeface) && _glyphTypefaceCache.TryAdd(familyName, glyphTypefaces)) + if (!glyphTypefaces.TryAdd(key, glyphTypeface)) { - return true; + return false; } } - return false; + return glyphTypeface != null; } - public void Initialize(IFontManagerImpl fontManager) + public override void Initialize(IFontManagerImpl fontManager) { //We initialize the system font collection during construction. } - IEnumerator IEnumerable.GetEnumerator() - { - return GetEnumerator(); - } - - public IEnumerator GetEnumerator() + public override IEnumerator GetEnumerator() { foreach (var familyName in _familyNames) { yield return new FontFamily(familyName); } } - - void IDisposable.Dispose() - { - foreach (var glyphTypefaces in _glyphTypefaceCache.Values) - { - foreach (var pair in glyphTypefaces) - { - pair.Value.Dispose(); - } - } - - GC.SuppressFinalize(this); - } } } diff --git a/src/Avalonia.Base/Media/GeometryGroup.cs b/src/Avalonia.Base/Media/GeometryGroup.cs index 0326e606f4..3e61413919 100644 --- a/src/Avalonia.Base/Media/GeometryGroup.cs +++ b/src/Avalonia.Base/Media/GeometryGroup.cs @@ -78,7 +78,10 @@ namespace Avalonia.Media { var factory = AvaloniaLocator.Current.GetRequiredService(); - return factory.CreateGeometryGroup(FillRule, _children); + var children = new IGeometryImpl?[_children.Count]; + for (var c = 0; c < _children.Count; c++) + children[c] = _children[c].PlatformImpl; + return factory.CreateGeometryGroup(FillRule, children!); } return null; diff --git a/src/Avalonia.Base/Media/HsvColor.cs b/src/Avalonia.Base/Media/HsvColor.cs index f97457c54d..df68252065 100644 --- a/src/Avalonia.Base/Media/HsvColor.cs +++ b/src/Avalonia.Base/Media/HsvColor.cs @@ -131,7 +131,7 @@ namespace Avalonia.Media /// /// /// - /// 0 is a shade of gray (no color). + /// 0 is fully white (or a shade of gray) and shows no color. /// 1 is the full color. /// /// diff --git a/src/Avalonia.Base/Media/Imaging/PixelFormatReaders.cs b/src/Avalonia.Base/Media/Imaging/PixelFormatReaders.cs index fc7c174ed6..c90c4cb5ac 100644 --- a/src/Avalonia.Base/Media/Imaging/PixelFormatReaders.cs +++ b/src/Avalonia.Base/Media/Imaging/PixelFormatReaders.cs @@ -228,6 +228,44 @@ static unsafe class PixelFormatReader public void Reset(IntPtr address) => _address = (Rgba64*)address; } + + public unsafe struct Rgb24PixelFormatReader : IPixelFormatReader + { + private byte* _address; + public Rgba8888Pixel ReadNext() + { + var addr = _address; + _address += 3; + return new Rgba8888Pixel + { + R = addr[0], + G = addr[1], + B = addr[2], + A = 255, + }; + } + + public void Reset(IntPtr address) => _address = (byte*)address; + } + + public unsafe struct Bgr24PixelFormatReader : IPixelFormatReader + { + private byte* _address; + public Rgba8888Pixel ReadNext() + { + var addr = _address; + _address += 3; + return new Rgba8888Pixel + { + R = addr[2], + G = addr[1], + B = addr[0], + A = 255, + }; + } + + public void Reset(IntPtr address) => _address = (byte*)address; + } public static void Transcode(IntPtr dst, IntPtr src, PixelSize size, int strideSrc, int strideDst, PixelFormat format) @@ -242,6 +280,10 @@ static unsafe class PixelFormatReader Transcode(dst, src, size, strideSrc, strideDst); else if (format == PixelFormats.Gray16) Transcode(dst, src, size, strideSrc, strideDst); + else if (format == PixelFormats.Rgb24) + Transcode(dst, src, size, strideSrc, strideDst); + else if (format == PixelFormats.Bgr24) + Transcode(dst, src, size, strideSrc, strideDst); else if (format == PixelFormats.Gray32Float) Transcode(dst, src, size, strideSrc, strideDst); else if (format == PixelFormats.Rgba64) @@ -258,7 +300,9 @@ static unsafe class PixelFormatReader || format == PixelFormats.Gray8 || format == PixelFormats.Gray16 || format == PixelFormats.Gray32Float - || format == PixelFormats.Rgba64; + || format == PixelFormats.Rgba64 + || format == PixelFormats.Bgr24 + || format == PixelFormats.Rgb24; } public static void Transcode(IntPtr dst, IntPtr src, PixelSize size, int strideSrc, int strideDst) where TReader : struct, IPixelFormatReader diff --git a/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs b/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs index a40cbf95ad..a609800fb8 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs @@ -57,21 +57,21 @@ namespace Avalonia.Media.TextFormatting switch (paragraphProperties.TextWrapping) { case TextWrapping.NoWrap: - { - var textLine = new TextLineImpl(shapedTextRuns.ToArray(), firstTextSourceIndex, - textSourceLength, - paragraphWidth, paragraphProperties, resolvedFlowDirection, nextLineBreak); + { + var textLine = new TextLineImpl(shapedTextRuns.ToArray(), firstTextSourceIndex, + textSourceLength, + paragraphWidth, paragraphProperties, resolvedFlowDirection, nextLineBreak); textLine.FinalizeLine(); - return textLine; - } + return textLine; + } case TextWrapping.WrapWithOverflow: case TextWrapping.Wrap: - { - return PerformTextWrapping(shapedTextRuns, false, firstTextSourceIndex, paragraphWidth, - paragraphProperties, resolvedFlowDirection, nextLineBreak, objectPool); - } + { + return PerformTextWrapping(shapedTextRuns, false, firstTextSourceIndex, paragraphWidth, + paragraphProperties, resolvedFlowDirection, nextLineBreak, objectPool); + } default: throw new ArgumentOutOfRangeException(nameof(paragraphProperties.TextWrapping)); } @@ -568,9 +568,9 @@ namespace Avalonia.Media.TextFormatting return false; } - private static bool TryMeasureLength(IReadOnlyList textRuns, double paragraphWidth, out int measuredLength) + private static int MeasureLength(IReadOnlyList textRuns, double paragraphWidth) { - measuredLength = 0; + var measuredLength = 0; var currentWidth = 0.0; for (var i = 0; i < textRuns.Count; ++i) @@ -583,25 +583,59 @@ namespace Avalonia.Media.TextFormatting { if (shapedTextCharacters.ShapedBuffer.Length > 0) { - var firstCluster = shapedTextCharacters.ShapedBuffer[0].GlyphCluster; - var lastCluster = firstCluster; + var runLength = 0; for (var j = 0; j < shapedTextCharacters.ShapedBuffer.Length; j++) { - var glyphInfo = shapedTextCharacters.ShapedBuffer[j]; + var currentInfo = shapedTextCharacters.ShapedBuffer[j]; + + var clusterWidth = currentInfo.GlyphAdvance; - if (currentWidth + glyphInfo.GlyphAdvance > paragraphWidth) + GlyphInfo nextInfo = default; + + while (j + 1 < shapedTextCharacters.ShapedBuffer.Length) { - measuredLength += Math.Max(0, lastCluster - firstCluster); + nextInfo = shapedTextCharacters.ShapedBuffer[j + 1]; + + if (currentInfo.GlyphCluster == nextInfo.GlyphCluster) + { + clusterWidth += nextInfo.GlyphAdvance; + + j++; + + continue; + } + + break; + } - return measuredLength != 0; + var clusterLength = Math.Max(0, nextInfo.GlyphCluster - currentInfo.GlyphCluster); + + if(clusterLength == 0) + { + clusterLength = currentRun.Length - runLength; + } + + if(clusterLength == 0) + { + clusterLength = shapedTextCharacters.GlyphRun.Metrics.FirstCluster + currentRun.Length - currentInfo.GlyphCluster; + } + + if (currentWidth + clusterWidth > paragraphWidth) + { + if (runLength == 0 && measuredLength == 0) + { + runLength = clusterLength; + } + + return measuredLength + runLength; } - lastCluster = glyphInfo.GlyphCluster; - currentWidth += glyphInfo.GlyphAdvance; + currentWidth += clusterWidth; + runLength += clusterLength; } - measuredLength += currentRun.Length; + measuredLength += runLength; } break; @@ -611,7 +645,7 @@ namespace Avalonia.Media.TextFormatting { if (currentWidth + drawableTextRun.Size.Width >= paragraphWidth) { - return measuredLength != 0; + return measuredLength; } measuredLength += currentRun.Length; @@ -628,7 +662,7 @@ namespace Avalonia.Media.TextFormatting } } - return measuredLength != 0; + return measuredLength; } /// @@ -675,9 +709,11 @@ namespace Avalonia.Media.TextFormatting return CreateEmptyTextLine(firstTextSourceIndex, paragraphWidth, paragraphProperties); } - if (!TryMeasureLength(textRuns, paragraphWidth, out var measuredLength)) + var measuredLength = MeasureLength(textRuns, paragraphWidth); + + if(measuredLength == 0) { - measuredLength = 1; + } var currentLength = 0; @@ -798,6 +834,12 @@ namespace Avalonia.Media.TextFormatting continue; } + //We don't want to surpass the measuredLength with trailing whitespace when we are in a right to left setting. + if(currentPosition > measuredLength && resolvedFlowDirection == FlowDirection.RightToLeft) + { + break; + } + measuredLength = currentPosition; break; diff --git a/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs b/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs index f426a20b2c..3264d5e88a 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs @@ -697,13 +697,25 @@ namespace Avalonia.Media.TextFormatting i = lastRunIndex; + //Possible overlap at runs of different direction if (directionalWidth == 0) { - continue; + //In case a run only contains a linebreak we don't want to skip it. + if (currentRun is ShapedTextRun shaped) + { + if(currentRun.Length - shaped.GlyphRun.Metrics.NewLineLength > 0) + { + continue; + } + } + else + { + continue; + } } - var coveredLength = 0; - TextBounds? textBounds = null; + int coveredLength; + TextBounds? textBounds; switch (currentDirection) { @@ -831,14 +843,25 @@ namespace Avalonia.Media.TextFormatting i = firstRunIndex; + //Possible overlap at runs of different direction if (directionalWidth == 0) { - continue; + //In case a run only contains a linebreak we don't want to skip it. + if (currentRun is ShapedTextRun shaped) + { + if (currentRun.Length - shaped.GlyphRun.Metrics.NewLineLength > 0) + { + continue; + } + } + else + { + continue; + } } - var coveredLength = 0; - TextBounds? textBounds = null; + int coveredLength; switch (currentDirection) { diff --git a/src/Avalonia.Base/Metadata/InheritDataTypeFromItemsAttribute.cs b/src/Avalonia.Base/Metadata/InheritDataTypeFromItemsAttribute.cs index e9bd6ab89f..fac8cd8737 100644 --- a/src/Avalonia.Base/Metadata/InheritDataTypeFromItemsAttribute.cs +++ b/src/Avalonia.Base/Metadata/InheritDataTypeFromItemsAttribute.cs @@ -9,7 +9,7 @@ namespace Avalonia.Metadata; /// A typical usage example is a ListBox control, where is defined on the ItemTemplate property, /// allowing the template to inherit the data type from the Items collection binding. /// -[AttributeUsage(AttributeTargets.Property, AllowMultiple = true, Inherited = true)] +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)] public sealed class InheritDataTypeFromItemsAttribute : Attribute { /// diff --git a/src/Avalonia.Base/Platform/IDrawingContextImpl.cs b/src/Avalonia.Base/Platform/IDrawingContextImpl.cs index ffdfa9aac1..1359ad6603 100644 --- a/src/Avalonia.Base/Platform/IDrawingContextImpl.cs +++ b/src/Avalonia.Base/Platform/IDrawingContextImpl.cs @@ -180,6 +180,12 @@ namespace Avalonia.Platform object? GetFeature(Type t); } + public interface IDrawingContextImplWithEffects + { + void PushEffect(IEffect effect); + void PopEffect(); + } + public static class DrawingContextImplExtensions { /// diff --git a/src/Avalonia.Base/Platform/IFontManagerImpl.cs b/src/Avalonia.Base/Platform/IFontManagerImpl.cs index 116f7cd6e2..222e7196bb 100644 --- a/src/Avalonia.Base/Platform/IFontManagerImpl.cs +++ b/src/Avalonia.Base/Platform/IFontManagerImpl.cs @@ -27,15 +27,13 @@ namespace Avalonia.Platform /// The font style. /// The font weight. /// The font stretch. - /// The font family. This is optional and used for fallback lookup. /// The culture. /// The matching typeface. /// /// True, if the could match the character to specified parameters, False otherwise. /// bool TryMatchCharacter(int codepoint, FontStyle fontStyle, - FontWeight fontWeight, FontStretch fontStretch, - FontFamily? fontFamily, CultureInfo? culture, out Typeface typeface); + FontWeight fontWeight, FontStretch fontStretch, CultureInfo? culture, out Typeface typeface); /// /// Tries to get a glyph typeface for specified parameters. diff --git a/src/Avalonia.Base/Platform/IPlatformRenderInterface.cs b/src/Avalonia.Base/Platform/IPlatformRenderInterface.cs index cfc7fac3ea..81fe2c046f 100644 --- a/src/Avalonia.Base/Platform/IPlatformRenderInterface.cs +++ b/src/Avalonia.Base/Platform/IPlatformRenderInterface.cs @@ -48,7 +48,7 @@ namespace Avalonia.Platform /// The fill rule. /// The geometries to group. /// A combined geometry. - IGeometryImpl CreateGeometryGroup(FillRule fillRule, IReadOnlyList children); + IGeometryImpl CreateGeometryGroup(FillRule fillRule, IReadOnlyList children); /// /// Creates a geometry group implementation. @@ -57,7 +57,7 @@ namespace Avalonia.Platform /// The first geometry. /// The second geometry. /// A combined geometry. - IGeometryImpl CreateCombinedGeometry(GeometryCombineMode combineMode, Geometry g1, Geometry g2); + IGeometryImpl CreateCombinedGeometry(GeometryCombineMode combineMode, IGeometryImpl g1, IGeometryImpl g2); /// /// Created a geometry implementation for the glyph run. diff --git a/src/Avalonia.Base/Platform/IPlatformThreadingInterface.cs b/src/Avalonia.Base/Platform/IPlatformThreadingInterface.cs index 3dbc7c1bb2..4fe4a2e6b9 100644 --- a/src/Avalonia.Base/Platform/IPlatformThreadingInterface.cs +++ b/src/Avalonia.Base/Platform/IPlatformThreadingInterface.cs @@ -11,8 +11,6 @@ namespace Avalonia.Platform [Unstable] public interface IPlatformThreadingInterface { - void RunLoop(CancellationToken cancellationToken); - /// /// Starts a timer. /// diff --git a/src/Avalonia.Base/Platform/PixelFormat.cs b/src/Avalonia.Base/Platform/PixelFormat.cs index 99fe17055d..95f49bdb25 100644 --- a/src/Avalonia.Base/Platform/PixelFormat.cs +++ b/src/Avalonia.Base/Platform/PixelFormat.cs @@ -13,7 +13,9 @@ namespace Avalonia.Platform Gray8, Gray16, Gray32Float, - Rgba64 + Rgba64, + Rgb24, + Bgr24 } public record struct PixelFormat @@ -35,6 +37,8 @@ namespace Avalonia.Platform else if (FormatEnum == PixelFormatEnum.Rgb565 || FormatEnum == PixelFormatEnum.Gray16) return 16; + else if (FormatEnum is PixelFormatEnum.Bgr24 or PixelFormatEnum.Rgb24) + return 24; else if (FormatEnum == PixelFormatEnum.Rgba64) return 64; @@ -70,5 +74,7 @@ namespace Avalonia.Platform public static PixelFormat Gray8 { get; } = new PixelFormat(PixelFormatEnum.Gray8); public static PixelFormat Gray16 { get; } = new PixelFormat(PixelFormatEnum.Gray16); public static PixelFormat Gray32Float { get; } = new PixelFormat(PixelFormatEnum.Gray32Float); + public static PixelFormat Rgb24 { get; } = new PixelFormat(PixelFormatEnum.Rgb24); + public static PixelFormat Bgr24 { get; } = new PixelFormat(PixelFormatEnum.Bgr24); } } diff --git a/src/Avalonia.Base/PropertyStore/EffectiveValue.cs b/src/Avalonia.Base/PropertyStore/EffectiveValue.cs index 11a4dd7893..a00a51e694 100644 --- a/src/Avalonia.Base/PropertyStore/EffectiveValue.cs +++ b/src/Avalonia.Base/PropertyStore/EffectiveValue.cs @@ -36,12 +36,23 @@ namespace Avalonia.PropertyStore /// public IValueEntry? BaseValueEntry { get; private set; } + /// + /// Gets a value indicating whether the property has a coercion function. + /// + public bool HasCoercion { get; protected set; } + /// /// Gets a value indicating whether the was overridden by a call to /// . /// public bool IsOverridenCurrentValue { get; set; } + /// + /// Gets a value indicating whether the is the result of the + /// + /// + public bool IsCoercedDefaultValue { get; set; } + /// /// Begins a reevaluation pass on the effective value. /// @@ -54,19 +65,42 @@ namespace Avalonia.PropertyStore /// public void BeginReevaluation(bool clearLocalValue = false) { - if (clearLocalValue || Priority != BindingPriority.LocalValue) + if (clearLocalValue || (Priority != BindingPriority.LocalValue && !IsOverridenCurrentValue)) Priority = BindingPriority.Unset; - if (clearLocalValue || BasePriority != BindingPriority.LocalValue) + if (clearLocalValue || (BasePriority != BindingPriority.LocalValue && !IsOverridenCurrentValue)) BasePriority = BindingPriority.Unset; } /// /// Ends a reevaluation pass on the effective value. /// + /// The associated value store. + /// The property being reevaluated. /// - /// This method unsubscribes from any unused value entries. + /// Handles coercing the default value if necessary. /// - public void EndReevaluation() + public void EndReevaluation(ValueStore owner, AvaloniaProperty property) + { + if (Priority == BindingPriority.Unset && HasCoercion) + CoerceDefaultValueAndRaise(owner, property); + } + + /// + /// Gets a value indicating whether the effective value represents the default value of the + /// property and can be removed. + /// + /// True if the effective value van be removed; otherwise false. + public bool CanRemove() + { + return Priority == BindingPriority.Unset && + !IsOverridenCurrentValue && + !IsCoercedDefaultValue; + } + + /// + /// Unsubscribes from any unused value entries. + /// + public void UnsubscribeIfNecessary() { if (Priority == BindingPriority.Unset) { @@ -130,6 +164,17 @@ namespace Avalonia.PropertyStore /// The property being cleared. public abstract void DisposeAndRaiseUnset(ValueStore owner, AvaloniaProperty property); + /// + /// Coerces the default value, raising + /// where necessary. + /// + /// The associated value store. + /// The property being coerced. + protected abstract void CoerceDefaultValueAndRaise(ValueStore owner, AvaloniaProperty property); + + /// + /// Gets the current effective value as a boxed value. + /// protected abstract object? GetBoxedValue(); protected void UpdateValueEntry(IValueEntry? entry, BindingPriority priority) diff --git a/src/Avalonia.Base/PropertyStore/EffectiveValue`1.cs b/src/Avalonia.Base/PropertyStore/EffectiveValue`1.cs index 330034f51d..b725326855 100644 --- a/src/Avalonia.Base/PropertyStore/EffectiveValue`1.cs +++ b/src/Avalonia.Base/PropertyStore/EffectiveValue`1.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using Avalonia.Data; -using static Avalonia.Rendering.Composition.Animations.PropertySetSnapshot; namespace Avalonia.PropertyStore { @@ -33,19 +32,16 @@ namespace Avalonia.PropertyStore if (_metadata.CoerceValue is { } coerce) { + HasCoercion = true; _uncommon = new() { _coerce = coerce, _uncoercedValue = value, _uncoercedBaseValue = value, }; - - Value = coerce(owner, value); - } - else - { - Value = value; } + + Value = value; } /// @@ -61,7 +57,7 @@ namespace Avalonia.PropertyStore Debug.Assert(priority != BindingPriority.LocalValue); UpdateValueEntry(value, priority); - SetAndRaiseCore(owner, (StyledProperty)value.Property, GetValue(value), priority, false); + SetAndRaiseCore(owner, (StyledProperty)value.Property, GetValue(value), priority); if (priority > BindingPriority.LocalValue && value.GetDataValidationState(out var state, out var error)) @@ -75,7 +71,7 @@ namespace Avalonia.PropertyStore StyledProperty property, T value) { - SetAndRaiseCore(owner, property, value, BindingPriority.LocalValue, false); + SetAndRaiseCore(owner, property, value, BindingPriority.LocalValue); } public void SetCurrentValueAndRaise( @@ -83,8 +79,15 @@ namespace Avalonia.PropertyStore StyledProperty property, T value) { - IsOverridenCurrentValue = true; - SetAndRaiseCore(owner, property, value, Priority, true); + SetAndRaiseCore(owner, property, value, Priority, isOverriddenCurrentValue: true); + } + + public void SetCoercedDefaultValueAndRaise( + ValueStore owner, + StyledProperty property, + T value) + { + SetAndRaiseCore(owner, property, value, Priority, isCoercedDefaultValue: true); } public bool TryGetBaseValue([MaybeNullWhen(false)] out T value) @@ -117,7 +120,7 @@ namespace Avalonia.PropertyStore Debug.Assert(Priority != BindingPriority.Animation); Debug.Assert(BasePriority != BindingPriority.Unset); UpdateValueEntry(null, BindingPriority.Animation); - SetAndRaiseCore(owner, (StyledProperty)property, _baseValue!, BasePriority, false); + SetAndRaiseCore(owner, (StyledProperty)property, _baseValue!, BasePriority); } public override void CoerceValue(ValueStore owner, AvaloniaProperty property) @@ -140,24 +143,24 @@ namespace Avalonia.PropertyStore var p = (StyledProperty)property; BindingPriority priority; - T oldValue; + T newValue; if (property.Inherits && owner.TryGetInheritedValue(property, out var i)) { - oldValue = ((EffectiveValue)i).Value; + newValue = ((EffectiveValue)i).Value; priority = BindingPriority.Inherited; } else { - oldValue = _metadata.DefaultValue; + newValue = _metadata.DefaultValue; priority = BindingPriority.Unset; } - if (!EqualityComparer.Default.Equals(oldValue, Value)) + if (!EqualityComparer.Default.Equals(newValue, Value)) { - owner.Owner.RaisePropertyChanged(p, Value, oldValue, priority, true); + owner.Owner.RaisePropertyChanged(p, Value, newValue, priority, true); if (property.Inherits) - owner.OnInheritedEffectiveValueDisposed(p, Value); + owner.OnInheritedEffectiveValueDisposed(p, Value, newValue); } if (ValueEntry?.GetDataValidationState(out _, out _) ?? @@ -168,6 +171,17 @@ namespace Avalonia.PropertyStore } } + protected override void CoerceDefaultValueAndRaise(ValueStore owner, AvaloniaProperty property) + { + Debug.Assert(_uncommon?._coerce is not null); + Debug.Assert(Priority == BindingPriority.Unset); + + var coercedDefaultValue = _uncommon!._coerce!(owner.Owner, _metadata.DefaultValue); + + if (!EqualityComparer.Default.Equals(_metadata.DefaultValue, coercedDefaultValue)) + SetCoercedDefaultValueAndRaise(owner, (StyledProperty)property, coercedDefaultValue); + } + protected override object? GetBoxedValue() => Value; private static T GetValue(IValueEntry entry) @@ -183,7 +197,8 @@ namespace Avalonia.PropertyStore StyledProperty property, T value, BindingPriority priority, - bool isOverriddenCurrentValue) + bool isOverriddenCurrentValue = false, + bool isCoercedDefaultValue = false) { var oldValue = Value; var valueChanged = false; @@ -191,6 +206,7 @@ namespace Avalonia.PropertyStore var v = value; IsOverridenCurrentValue = isOverriddenCurrentValue; + IsCoercedDefaultValue = isCoercedDefaultValue; if (_uncommon?._coerce is { } coerce) v = coerce(owner.Owner, value); diff --git a/src/Avalonia.Base/PropertyStore/ValueStore.cs b/src/Avalonia.Base/PropertyStore/ValueStore.cs index 53cd3ff307..2047f4d2d0 100644 --- a/src/Avalonia.Base/PropertyStore/ValueStore.cs +++ b/src/Avalonia.Base/PropertyStore/ValueStore.cs @@ -259,6 +259,27 @@ namespace Avalonia.PropertyStore { if (_effectiveValues.TryGetValue(property, out var v)) v.CoerceValue(this, property); + else + property.RouteCoerceDefaultValue(Owner); + } + + public void CoerceDefaultValue(StyledProperty property) + { + var metadata = property.GetMetadata(Owner.GetType()); + + if (metadata.CoerceValue is null) + return; + + var coercedDefaultValue = metadata.CoerceValue(Owner, metadata.DefaultValue); + + if (EqualityComparer.Default.Equals(metadata.DefaultValue, coercedDefaultValue)) + return; + + // We have a situation where the default value isn't valid according to the coerce + // function. In this case, we need to create an EffectiveValue entry. + var effectiveValue = CreateEffectiveValue(property); + AddEffectiveValue(property, effectiveValue); + effectiveValue.SetCoercedDefaultValueAndRaise(this, property, coercedDefaultValue); } public Optional GetBaseValue(StyledProperty property) @@ -395,7 +416,7 @@ namespace Avalonia.PropertyStore if (TryGetEffectiveValue(property, out var existing)) { if (priority <= existing.BasePriority) - ReevaluateEffectiveValue(property, existing); + ReevaluateEffectiveValue(property, existing, changedValueEntry: entry); } else { @@ -419,7 +440,9 @@ namespace Avalonia.PropertyStore ReevaluateEffectiveValue(property, current); } else + { ReevaluateEffectiveValues(); + } } /// @@ -481,7 +504,8 @@ namespace Avalonia.PropertyStore /// /// The property whose value changed. /// The old value of the property. - public void OnInheritedEffectiveValueDisposed(StyledProperty property, T oldValue) + /// The new value of the property. + public void OnInheritedEffectiveValueDisposed(StyledProperty property, T oldValue, T newValue) { Debug.Assert(property.Inherits); @@ -489,12 +513,11 @@ namespace Avalonia.PropertyStore if (children is not null) { - var defaultValue = property.GetDefaultValue(Owner.GetType()); var count = children.Count; for (var i = 0; i < count; ++i) { - children[i].GetValueStore().OnAncestorInheritedValueChanged(property, oldValue, defaultValue); + children[i].GetValueStore().OnAncestorInheritedValueChanged(property, oldValue, newValue); } } } @@ -774,6 +797,7 @@ namespace Avalonia.PropertyStore private void ReevaluateEffectiveValue( AvaloniaProperty property, EffectiveValue? current, + IValueEntry? changedValueEntry = null, bool ignoreLocalValue = false) { ++_isEvaluating; @@ -796,6 +820,12 @@ namespace Avalonia.PropertyStore { var frame = _frames[i]; var priority = frame.Priority; + + // Exit early if the current EffectiveValue has higher priority than this frame. + if (current?.Priority < priority && current?.BasePriority < priority) + break; + + // Try to get an entry from the frame for the property we're reevaluating. var foundEntry = frame.TryGetEntryIfActive(property, out var entry, out var activeChanged); // If the active state of the frame has changed since the last read, and @@ -803,20 +833,17 @@ namespace Avalonia.PropertyStore // effective values of all properties. if (activeChanged && frame.EntryCount > 1) { - ReevaluateEffectiveValues(); + ReevaluateEffectiveValues(changedValueEntry); return; } - // We're interested in the value if: - // - There is no current effective value, or - // - The value's priority is higher than the current effective value's priority, or - // - The value is a non-animation value and its priority is higher than the current - // effective value's base priority - var isRelevantPriority = current is null || - (priority < current.Priority && priority < current.BasePriority) || - (priority > BindingPriority.Animation && priority < current.BasePriority); - - if (foundEntry && isRelevantPriority && entry!.HasValue) + // If the frame has an entry for this property with a higher priority than the + // current effective value (and that entry has a value), then we have a new + // value for the property. Note that the check for entry.HasValue must be + // evaluated last as it can cause bindings to be subscribed. + if (foundEntry && + HasHigherPriority(entry!, priority, current, changedValueEntry) && + entry!.HasValue) { if (current is not null) { @@ -832,26 +859,27 @@ namespace Avalonia.PropertyStore if (generation != _frameGeneration) goto restart; - - if (current?.Priority < BindingPriority.Unset && - current?.BasePriority < BindingPriority.Unset) - break; } - if (current?.Priority == BindingPriority.Unset) + if (current is not null) { - if (current.BasePriority == BindingPriority.Unset) - { - RemoveEffectiveValue(property); - current.DisposeAndRaiseUnset(this, property); - } - else + current.EndReevaluation(this, property); + + if (current.CanRemove()) { - current.RemoveAnimationAndRaise(this, property); + if (current.BasePriority == BindingPriority.Unset) + { + RemoveEffectiveValue(property); + current.DisposeAndRaiseUnset(this, property); + } + else + { + current.RemoveAnimationAndRaise(this, property); + } } - } - current?.EndReevaluation(); + current.UnsubscribeIfNecessary(); + } } finally { @@ -859,7 +887,7 @@ namespace Avalonia.PropertyStore } } - private void ReevaluateEffectiveValues() + private void ReevaluateEffectiveValues(IValueEntry? changedValueEntry = null) { ++_isEvaluating; @@ -894,10 +922,9 @@ namespace Avalonia.PropertyStore { var entry = frame.GetEntry(j); var property = entry.Property; - - // Skip if we already have a value/base value for this property. - if (_effectiveValues.TryGetValue(property, out var effectiveValue) && - effectiveValue.BasePriority < BindingPriority.Unset) + _effectiveValues.TryGetValue(property, out var effectiveValue); + + if (!HasHigherPriority(entry, priority, effectiveValue, changedValueEntry)) continue; if (!entry.HasValue) @@ -924,7 +951,9 @@ namespace Avalonia.PropertyStore { _effectiveValues.GetKeyValue(i, out var key, out var e); - if (e.Priority == BindingPriority.Unset && !e.IsOverridenCurrentValue) + e.EndReevaluation(this, key); + + if (e.CanRemove()) { RemoveEffectiveValue(key, i); e.DisposeAndRaiseUnset(this, key); @@ -933,7 +962,7 @@ namespace Avalonia.PropertyStore break; } - e.EndReevaluation(); + e.UnsubscribeIfNecessary(); } } finally @@ -942,6 +971,37 @@ namespace Avalonia.PropertyStore } } + private static bool HasHigherPriority( + IValueEntry entry, + BindingPriority entryPriority, + EffectiveValue? current, + IValueEntry? changedValueEntry) + { + // Set the value if: there is no current effective value; or + if (current is null) + return true; + + // The value's priority is higher than the current effective value's priority; or + if (entryPriority < current.Priority && entryPriority < current.BasePriority) + return true; + + // - The value's priority is equal to the current effective value's priority + // - But the effective value was set via SetCurrentValue + // - As long as the SetCurrentValue wasn't overriding the value from the value entry under consideration + // - Or if it was, the value entry under consideration has changed; or + if (entryPriority == current.Priority && + current.IsOverridenCurrentValue && + (current.ValueEntry != entry || entry == changedValueEntry)) + return true; + + // The value is a non-animation value and its priority is higher than the current effective value's base + // priority. + if (entryPriority > BindingPriority.Animation && entryPriority < current.BasePriority) + return true; + + return false; + } + private bool TryGetEffectiveValue( AvaloniaProperty property, [NotNullWhen(true)] out EffectiveValue? value) diff --git a/src/Avalonia.Base/Reactive/AvaloniaPropertyBindingObservable.cs b/src/Avalonia.Base/Reactive/AvaloniaPropertyBindingObservable.cs index 0789684eff..fd68381d55 100644 --- a/src/Avalonia.Base/Reactive/AvaloniaPropertyBindingObservable.cs +++ b/src/Avalonia.Base/Reactive/AvaloniaPropertyBindingObservable.cs @@ -4,18 +4,21 @@ using Avalonia.Data; namespace Avalonia.Reactive { - internal class AvaloniaPropertyBindingObservable : LightweightObservableBase>, IDescription + internal class AvaloniaPropertyBindingObservable : LightweightObservableBase>, IDescription { private readonly WeakReference _target; private readonly AvaloniaProperty _property; - private BindingValue _value = BindingValue.Unset; + private readonly Func? _converter; + private BindingValue _value = BindingValue.Unset; public AvaloniaPropertyBindingObservable( AvaloniaObject target, - AvaloniaProperty property) + AvaloniaProperty property, + Func? converter = null) { _target = new WeakReference(target); _property = property; + _converter = converter; } public string Description => $"{_target.GetType().Name}.{_property.Name}"; @@ -24,8 +27,17 @@ namespace Avalonia.Reactive { if (_target.TryGetTarget(out var target)) { - _value = (T)target.GetValue(_property)!; - target.PropertyChanged += PropertyChanged; + if (_converter is { } converter) + { + var unconvertedValue = (TSource)target.GetValue(_property)!; + _value = converter(unconvertedValue); + target.PropertyChanged += PropertyChanged_WithConversion; + } + else + { + _value = (TResult)target.GetValue(_property)!; + target.PropertyChanged += PropertyChanged; + } } } @@ -33,11 +45,18 @@ namespace Avalonia.Reactive { if (_target.TryGetTarget(out var target)) { - target.PropertyChanged -= PropertyChanged; + if (_converter is not null) + { + target.PropertyChanged -= PropertyChanged_WithConversion; + } + else + { + target.PropertyChanged -= PropertyChanged; + } } } - protected override void Subscribed(IObserver> observer, bool first) + protected override void Subscribed(IObserver> observer, bool first) { if (_value.Type != BindingValueType.UnsetValue) { @@ -49,27 +68,59 @@ namespace Avalonia.Reactive { if (e.Property == _property) { - if (e is AvaloniaPropertyChangedEventArgs typedArgs) + if (e is AvaloniaPropertyChangedEventArgs typedArgs) { - var newValue = e.Sender.GetValue(typedArgs.Property); + PublishValue(e.Sender.GetValue(typedArgs.Property)); + } + else + { + PublishUntypedValue(e.Sender.GetValue(e.Property)); + } + } + } - if (!_value.HasValue || !EqualityComparer.Default.Equals(newValue, _value.Value)) - { - _value = newValue; - PublishNext(_value); - } + private void PropertyChanged_WithConversion(object? sender, AvaloniaPropertyChangedEventArgs e) + { + if (e.Property == _property) + { + if (e is AvaloniaPropertyChangedEventArgs typedArgs) + { + var newValueRaw = e.Sender.GetValue(typedArgs.Property); + + var newValue = _converter!(newValueRaw); + + PublishValue(newValue); } else { var newValue = e.Sender.GetValue(e.Property); - if (!Equals(newValue, _value)) + if (newValue is TSource source) { - _value = (T)newValue!; - PublishNext(_value); + newValue = _converter!(source); } + + PublishUntypedValue(newValue); } } } + + private void PublishValue(TResult newValue) + { + if (!_value.HasValue || !EqualityComparer.Default.Equals(newValue, _value.Value)) + { + _value = newValue; + PublishNext(_value); + } + } + + private void PublishUntypedValue(object? newValue) + { + if (!Equals(newValue, _value)) + { + _value = (TResult)newValue!; + PublishNext(_value); + } + } } } diff --git a/src/Avalonia.Base/Reactive/AvaloniaPropertyObservable.cs b/src/Avalonia.Base/Reactive/AvaloniaPropertyObservable.cs index a4fa587a50..0d40fa96e6 100644 --- a/src/Avalonia.Base/Reactive/AvaloniaPropertyObservable.cs +++ b/src/Avalonia.Base/Reactive/AvaloniaPropertyObservable.cs @@ -4,18 +4,21 @@ using Avalonia.Data; namespace Avalonia.Reactive { - internal class AvaloniaPropertyObservable : LightweightObservableBase, IDescription + internal class AvaloniaPropertyObservable : LightweightObservableBase, IDescription { private readonly WeakReference _target; private readonly AvaloniaProperty _property; - private Optional _value; + private readonly Func? _converter; + private Optional _value; public AvaloniaPropertyObservable( AvaloniaObject target, - AvaloniaProperty property) + AvaloniaProperty property, + Func? converter = null) { _target = new WeakReference(target); _property = property; + _converter = converter; } public string Description => $"{_target.GetType().Name}.{_property.Name}"; @@ -24,8 +27,17 @@ namespace Avalonia.Reactive { if (_target.TryGetTarget(out var target)) { - _value = (T)target.GetValue(_property)!; - target.PropertyChanged += PropertyChanged; + if (_converter is { } converter) + { + var unconvertedValue = (TSource)target.GetValue(_property)!; + _value = converter(unconvertedValue); + target.PropertyChanged += PropertyChanged_WithConversion; + } + else + { + _value = (TResult)target.GetValue(_property)!; + target.PropertyChanged += PropertyChanged; + } } } @@ -33,13 +45,20 @@ namespace Avalonia.Reactive { if (_target.TryGetTarget(out var target)) { - target.PropertyChanged -= PropertyChanged; + if (_converter is not null) + { + target.PropertyChanged -= PropertyChanged_WithConversion; + } + else + { + target.PropertyChanged -= PropertyChanged; + } } _value = default; } - protected override void Subscribed(IObserver observer, bool first) + protected override void Subscribed(IObserver observer, bool first) { if (_value.HasValue) observer.OnNext(_value.Value); @@ -49,23 +68,49 @@ namespace Avalonia.Reactive { if (e.Property == _property) { - T newValue; + TResult newValue; - if (e is AvaloniaPropertyChangedEventArgs typed) + if (e is AvaloniaPropertyChangedEventArgs typed) { newValue = AvaloniaObjectExtensions.GetValue(e.Sender, typed.Property); } else { - newValue = (T)e.Sender.GetValue(e.Property)!; + newValue = (TResult)e.Sender.GetValue(e.Property)!; } - if (!_value.HasValue || - !EqualityComparer.Default.Equals(newValue, _value.Value)) + PublishNewValue(newValue); + } + } + + private void PropertyChanged_WithConversion(object? sender, AvaloniaPropertyChangedEventArgs e) + { + if (e.Property == _property) + { + TSource newValueRaw; + + if (e is AvaloniaPropertyChangedEventArgs typed) { - _value = newValue; - PublishNext(_value.Value!); + newValueRaw = AvaloniaObjectExtensions.GetValue(e.Sender, typed.Property); } + else + { + newValueRaw = (TSource)e.Sender.GetValue(e.Property)!; + } + + var newValue = _converter!(newValueRaw); + + PublishNewValue(newValue); + } + } + + private void PublishNewValue(TResult newValue) + { + if (!_value.HasValue || + !EqualityComparer.Default.Equals(newValue, _value.Value)) + { + _value = newValue; + PublishNext(_value.Value!); } } } diff --git a/src/Avalonia.Base/Rect.cs b/src/Avalonia.Base/Rect.cs index cc030eea04..fc5d0fc043 100644 --- a/src/Avalonia.Base/Rect.cs +++ b/src/Avalonia.Base/Rect.cs @@ -526,6 +526,15 @@ namespace Avalonia } } + internal static Rect? Union(Rect? left, Rect? right) + { + if (left == null) + return right; + if (right == null) + return left; + return left.Value.Union(right.Value); + } + /// /// Returns a new with the specified X position. /// diff --git a/src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs b/src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs index df3a70b3e6..814ecdba29 100644 --- a/src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs +++ b/src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs @@ -252,8 +252,14 @@ public class CompositingRenderer : IRendererWithCompositor comp.Opacity = (float)visual.Opacity; comp.ClipToBounds = visual.ClipToBounds; comp.Clip = visual.Clip?.PlatformImpl; - comp.OpacityMask = visual.OpacityMask; - + + + if (!Equals(comp.OpacityMask, visual.OpacityMask)) + comp.OpacityMask = visual.OpacityMask?.ToImmutable(); + + if (!comp.Effect.EffectEquals(visual.Effect)) + comp.Effect = visual.Effect?.ToImmutable(); + var renderTransform = Matrix.Identity; if (visual.HasMirrorTransform) diff --git a/src/Avalonia.Base/Rendering/Composition/Expressions/TokenParser.cs b/src/Avalonia.Base/Rendering/Composition/Expressions/TokenParser.cs index bb7372c375..8ecc0028ce 100644 --- a/src/Avalonia.Base/Rendering/Composition/Expressions/TokenParser.cs +++ b/src/Avalonia.Base/Rendering/Composition/Expressions/TokenParser.cs @@ -29,6 +29,8 @@ namespace Avalonia.Rendering.Composition.Expressions } } + public bool NextIsWhitespace() => _s.Length > 0 && char.IsWhiteSpace(_s[0]); + static bool IsAlphaNumeric(char ch) => (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z'); @@ -238,6 +240,12 @@ namespace Avalonia.Rendering.Composition.Expressions len = c + 1; dotCount++; } + else if (ch == '-') + { + if (len != 0) + return false; + len = c + 1; + } else break; } @@ -254,7 +262,55 @@ namespace Avalonia.Rendering.Composition.Expressions Advance(len); return true; } + + public bool TryParseDouble(out double res) + { + res = 0; + SkipWhitespace(); + if (_s.Length == 0) + return false; + + var len = 0; + var dotCount = 0; + for (var c = 0; c < _s.Length; c++) + { + var ch = _s[c]; + if (ch >= '0' && ch <= '9') + len = c + 1; + else if (ch == '.' && dotCount == 0) + { + len = c + 1; + dotCount++; + } + else if (ch == '-') + { + if (len != 0) + return false; + len = c + 1; + } + else + break; + } + + var span = _s.Slice(0, len); + +#if NETSTANDARD2_0 + if (!double.TryParse(span.ToString(), NumberStyles.Number, CultureInfo.InvariantCulture, out res)) + return false; +#else + if (!double.TryParse(span, NumberStyles.Number, CultureInfo.InvariantCulture, out res)) + return false; +#endif + Advance(len); + return true; + } + public bool IsEofWithWhitespace() + { + SkipWhitespace(); + return Length == 0; + } + public override string ToString() => _s.ToString(); } diff --git a/src/Avalonia.Base/Rendering/Composition/Server/DrawingContextProxy.cs b/src/Avalonia.Base/Rendering/Composition/Server/DrawingContextProxy.cs index eaa9a70ca0..1ec1362a4c 100644 --- a/src/Avalonia.Base/Rendering/Composition/Server/DrawingContextProxy.cs +++ b/src/Avalonia.Base/Rendering/Composition/Server/DrawingContextProxy.cs @@ -18,7 +18,8 @@ namespace Avalonia.Rendering.Composition.Server; /// they have information about the full render transform (they are not) /// 2) Keeps the draw list for the VisualBrush contents of the current drawing operation. /// -internal class CompositorDrawingContextProxy : IDrawingContextImpl, IDrawingContextWithAcrylicLikeSupport +internal class CompositorDrawingContextProxy : IDrawingContextImpl, + IDrawingContextWithAcrylicLikeSupport, IDrawingContextImplWithEffects { private IDrawingContextImpl _impl; @@ -155,4 +156,16 @@ internal class CompositorDrawingContextProxy : IDrawingContextImpl, IDrawingCont if (_impl is IDrawingContextWithAcrylicLikeSupport acrylic) acrylic.DrawRectangle(material, rect); } + + public void PushEffect(IEffect effect) + { + if (_impl is IDrawingContextImplWithEffects effects) + effects.PushEffect(effect); + } + + public void PopEffect() + { + if (_impl is IDrawingContextImplWithEffects effects) + effects.PopEffect(); + } } diff --git a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionContainerVisual.cs b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionContainerVisual.cs index 19349a5196..b9e6833d21 100644 --- a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionContainerVisual.cs +++ b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionContainerVisual.cs @@ -1,4 +1,6 @@ +using System; using System.Numerics; +using Avalonia.Media; using Avalonia.Platform; // Special license applies License.md @@ -13,6 +15,8 @@ namespace Avalonia.Rendering.Composition.Server internal partial class ServerCompositionContainerVisual : ServerCompositionVisual { public ServerCompositionVisualCollection Children { get; private set; } = null!; + private Rect? _transformedContentBounds; + private IImmutableEffect? _oldEffect; protected override void RenderCore(CompositorDrawingContextProxy canvas, Rect currentTransformedClip) { @@ -24,18 +28,76 @@ namespace Avalonia.Rendering.Composition.Server } } - public override void Update(ServerCompositionTarget root) + public override UpdateResult Update(ServerCompositionTarget root) { - base.Update(root); + var (combinedBounds, oldInvalidated, newInvalidated) = base.Update(root); foreach (var child in Children) { if (child.AdornedVisual != null) root.EnqueueAdornerUpdate(child); else - child.Update(root); + { + var res = child.Update(root); + oldInvalidated |= res.InvalidatedOld; + newInvalidated |= res.InvalidatedNew; + combinedBounds = Rect.Union(combinedBounds, res.Bounds); + } } + + // If effect is changed, we need to clean both old and new bounds + var effectChanged = !Effect.EffectEquals(_oldEffect); + if (effectChanged) + oldInvalidated = newInvalidated = true; + + // Expand invalidated bounds to the whole content area since we don't actually know what is being sampled + // We also ignore clip for now since we don't have means to reset it? + if (_oldEffect != null && oldInvalidated && _transformedContentBounds.HasValue) + AddEffectPaddedDirtyRect(_oldEffect, _transformedContentBounds.Value); + + if (Effect != null && newInvalidated && combinedBounds.HasValue) + AddEffectPaddedDirtyRect(Effect, combinedBounds.Value); + + _oldEffect = Effect; + _transformedContentBounds = combinedBounds; IsDirtyComposition = false; + return new(_transformedContentBounds, oldInvalidated, newInvalidated); + } + + void AddEffectPaddedDirtyRect(IImmutableEffect effect, Rect transformedBounds) + { + var padding = effect.GetEffectOutputPadding(); + if (padding == default) + { + AddDirtyRect(transformedBounds); + return; + } + + // We are in a weird position here: bounds are in global coordinates while padding gets applied in local ones + // Since we have optimizations to AVOID recomputing transformed bounds and since visuals with effects are relatively rare + // we instead apply the transformation matrix to rescale the bounds + + + // If we only have translation and scale, just scale the padding + if (CombinedTransformMatrix is + { + M12: 0, M13: 0, M14: 0, + M21: 0, M23: 0, M24: 0, + M31: 0, M32: 0, M34: 0, + M43: 0, M44: 1 + }) + padding = new Thickness(padding.Left * CombinedTransformMatrix.M11, + padding.Top * CombinedTransformMatrix.M22, + padding.Right * CombinedTransformMatrix.M11, + padding.Bottom * CombinedTransformMatrix.M22); + else + { + // Conservatively use the transformed rect size + var transformedPaddingRect = new Rect().Inflate(padding).TransformToAABB(CombinedTransformMatrix); + padding = new(Math.Max(transformedPaddingRect.Width, transformedPaddingRect.Height)); + } + + AddDirtyRect(transformedBounds.Inflate(padding)); } partial void Initialize() diff --git a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual.cs b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual.cs index 6fb5ad3741..6e7ef85183 100644 --- a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual.cs +++ b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual.cs @@ -54,6 +54,9 @@ namespace Avalonia.Rendering.Composition.Server canvas.PostTransform = MatrixUtils.ToMatrix(transform); canvas.Transform = Matrix.Identity; + if (Effect != null) + canvas.PushEffect(Effect); + if (Opacity != 1) canvas.PushOpacity(Opacity, boundsRect); if (ClipToBounds && !HandlesClipToBounds) @@ -79,6 +82,9 @@ namespace Avalonia.Rendering.Composition.Server canvas.PopClip(); if (Opacity != 1) canvas.PopOpacity(); + + if (Effect != null) + canvas.PopEffect(); } protected virtual bool HandlesClipToBounds => false; @@ -101,10 +107,18 @@ namespace Avalonia.Rendering.Composition.Server public Matrix4x4 CombinedTransformMatrix { get; private set; } = Matrix4x4.Identity; public Matrix4x4 GlobalTransformMatrix { get; private set; } - public virtual void Update(ServerCompositionTarget root) + public record struct UpdateResult(Rect? Bounds, bool InvalidatedOld, bool InvalidatedNew) + { + public UpdateResult() : this(null, false, false) + { + + } + } + + public virtual UpdateResult Update(ServerCompositionTarget root) { if (Parent == null && Root == null) - return; + return default; var wasVisible = IsVisibleInFrame; @@ -146,6 +160,11 @@ namespace Avalonia.Rendering.Composition.Server GlobalTransformMatrix = newTransform; var ownBounds = OwnContentBounds; + + // Since padding is applied in the current visual's coordinate space we expand bounds before transforming them + if (Effect != null) + ownBounds = ownBounds.Inflate(Effect.GetEffectOutputPadding()); + if (ownBounds != _oldOwnContentBounds || positionChanged) { _oldOwnContentBounds = ownBounds; @@ -168,7 +187,7 @@ namespace Avalonia.Rendering.Composition.Server _combinedTransformedClipBounds = AdornedVisual?._combinedTransformedClipBounds - ?? Parent?._combinedTransformedClipBounds + ?? (Parent?.Effect == null ? Parent?._combinedTransformedClipBounds : null) ?? new Rect(Root!.Size); if (_transformedClipBounds != null) @@ -208,9 +227,10 @@ namespace Avalonia.Rendering.Composition.Server readback.Matrix = GlobalTransformMatrix; readback.TargetId = Root.Id; readback.Visible = IsHitTestVisibleInFrame; + return new(TransformedOwnContentBounds, invalidateNewBounds, invalidateOldBounds); } - void AddDirtyRect(Rect rc) + protected void AddDirtyRect(Rect rc) { if (rc == default) return; diff --git a/src/Avalonia.Base/Rendering/Composition/Transport/BatchStreamArrayPool.cs b/src/Avalonia.Base/Rendering/Composition/Transport/BatchStreamArrayPool.cs index f24f449551..8916983a5c 100644 --- a/src/Avalonia.Base/Rendering/Composition/Transport/BatchStreamArrayPool.cs +++ b/src/Avalonia.Base/Rendering/Composition/Transport/BatchStreamArrayPool.cs @@ -30,7 +30,8 @@ internal abstract class BatchStreamPoolBase : IDisposable GC.SuppressFinalize(needsFinalize); var updateRef = new WeakReference>(this); - if (AvaloniaLocator.Current.GetService() == null) + if (AvaloniaLocator.Current.GetService() == null + && AvaloniaLocator.Current.GetService() == null) _reclaimImmediately = true; else StartUpdateTimer(startTimer, updateRef); @@ -71,6 +72,11 @@ internal abstract class BatchStreamPoolBase : IDisposable protected abstract T CreateItem(); + protected virtual void ClearItem(T item) + { + + } + protected virtual void DestroyItem(T item) { @@ -93,6 +99,7 @@ internal abstract class BatchStreamPoolBase : IDisposable public void Return(T item) { + ClearItem(item); lock (_pool) { _usage--; @@ -137,7 +144,7 @@ internal sealed class BatchStreamObjectPool : BatchStreamPoolBase where return new T[ArraySize]; } - protected override void DestroyItem(T[] item) + protected override void ClearItem(T[] item) { Array.Clear(item, 0, item.Length); } diff --git a/src/Avalonia.Base/Rendering/SceneGraph/CustomDrawOperation.cs b/src/Avalonia.Base/Rendering/SceneGraph/CustomDrawOperation.cs index ff2616bfe4..8f5ccb4e51 100644 --- a/src/Avalonia.Base/Rendering/SceneGraph/CustomDrawOperation.cs +++ b/src/Avalonia.Base/Rendering/SceneGraph/CustomDrawOperation.cs @@ -13,7 +13,7 @@ namespace Avalonia.Rendering.SceneGraph Custom = custom; } - public override bool HitTest(Point p) => Custom.HitTest(p); + public override bool HitTestTransformed(Point p) => Custom.HitTest(p); public override void Render(IDrawingContextImpl context) { diff --git a/src/Avalonia.Base/Rendering/SceneGraph/DrawOperation.cs b/src/Avalonia.Base/Rendering/SceneGraph/DrawOperation.cs index 5b93cd8cfc..786ce28d06 100644 --- a/src/Avalonia.Base/Rendering/SceneGraph/DrawOperation.cs +++ b/src/Avalonia.Base/Rendering/SceneGraph/DrawOperation.cs @@ -37,5 +37,20 @@ namespace Avalonia.Rendering.SceneGraph } public Matrix Transform { get; } + + public sealed override bool HitTest(Point p) + { + if (Transform.IsIdentity) + return HitTestTransformed(p); + + if (!Transform.HasInverse) + return false; + + var transformedPoint = Transform.Invert().Transform(p); + + return HitTestTransformed(transformedPoint); + } + + public abstract bool HitTestTransformed(Point p); } } diff --git a/src/Avalonia.Base/Rendering/SceneGraph/EllipseNode.cs b/src/Avalonia.Base/Rendering/SceneGraph/EllipseNode.cs index d5f0270cb2..0a2b74e46a 100644 --- a/src/Avalonia.Base/Rendering/SceneGraph/EllipseNode.cs +++ b/src/Avalonia.Base/Rendering/SceneGraph/EllipseNode.cs @@ -43,7 +43,7 @@ namespace Avalonia.Rendering.SceneGraph public override void Render(IDrawingContextImpl context) => context.DrawEllipse(Brush, Pen, Rect); - public override bool HitTest(Point p) + public override bool HitTestTransformed(Point p) { var center = Rect.Center; diff --git a/src/Avalonia.Base/Rendering/SceneGraph/ExperimentalAcrylicNode.cs b/src/Avalonia.Base/Rendering/SceneGraph/ExperimentalAcrylicNode.cs index e1f79e0e10..22fc49d30e 100644 --- a/src/Avalonia.Base/Rendering/SceneGraph/ExperimentalAcrylicNode.cs +++ b/src/Avalonia.Base/Rendering/SceneGraph/ExperimentalAcrylicNode.cs @@ -65,6 +65,6 @@ namespace Avalonia.Rendering.SceneGraph } /// - public override bool HitTest(Point p) => Rect.Rect.ContainsExclusive(p); + public override bool HitTestTransformed(Point p) => Rect.Rect.ContainsExclusive(p); } } diff --git a/src/Avalonia.Base/Rendering/SceneGraph/GeometryNode.cs b/src/Avalonia.Base/Rendering/SceneGraph/GeometryNode.cs index f64a3e845d..48af3b0e6b 100644 --- a/src/Avalonia.Base/Rendering/SceneGraph/GeometryNode.cs +++ b/src/Avalonia.Base/Rendering/SceneGraph/GeometryNode.cs @@ -64,7 +64,7 @@ namespace Avalonia.Rendering.SceneGraph } /// - public override bool HitTest(Point p) + public override bool HitTestTransformed(Point p) { return (Brush != null && Geometry.FillContains(p)) || (Pen != null && Geometry.StrokeContains(Pen, p)); diff --git a/src/Avalonia.Base/Rendering/SceneGraph/GlyphRunNode.cs b/src/Avalonia.Base/Rendering/SceneGraph/GlyphRunNode.cs index 5b975e29e1..1c4e63b34a 100644 --- a/src/Avalonia.Base/Rendering/SceneGraph/GlyphRunNode.cs +++ b/src/Avalonia.Base/Rendering/SceneGraph/GlyphRunNode.cs @@ -53,7 +53,10 @@ namespace Avalonia.Rendering.SceneGraph } /// - public override bool HitTest(Point p) => Bounds.ContainsExclusive(p); + public override bool HitTestTransformed(Point p) + { + return GlyphRun.Item.Bounds.ContainsExclusive(p); + } public override void Dispose() { diff --git a/src/Avalonia.Base/Rendering/SceneGraph/ImageNode.cs b/src/Avalonia.Base/Rendering/SceneGraph/ImageNode.cs index dd9787e8d1..ac946cc8b2 100644 --- a/src/Avalonia.Base/Rendering/SceneGraph/ImageNode.cs +++ b/src/Avalonia.Base/Rendering/SceneGraph/ImageNode.cs @@ -94,7 +94,7 @@ namespace Avalonia.Rendering.SceneGraph } /// - public override bool HitTest(Point p) => DestRect.ContainsExclusive(p); + public override bool HitTestTransformed(Point p) => DestRect.ContainsExclusive(p); public override void Dispose() { diff --git a/src/Avalonia.Base/Rendering/SceneGraph/LineNode.cs b/src/Avalonia.Base/Rendering/SceneGraph/LineNode.cs index 61bffc3260..1ac6cffe0a 100644 --- a/src/Avalonia.Base/Rendering/SceneGraph/LineNode.cs +++ b/src/Avalonia.Base/Rendering/SceneGraph/LineNode.cs @@ -66,7 +66,7 @@ namespace Avalonia.Rendering.SceneGraph context.DrawLine(Pen, P1, P2); } - public override bool HitTest(Point p) + public override bool HitTestTransformed(Point p) { var halfThickness = Pen.Thickness / 2; var minX = Math.Min(P1.X, P2.X) - halfThickness; diff --git a/src/Avalonia.Base/Rendering/SceneGraph/OpacityMaskNode.cs b/src/Avalonia.Base/Rendering/SceneGraph/OpacityMaskNode.cs index b0584038a8..1c79a67944 100644 --- a/src/Avalonia.Base/Rendering/SceneGraph/OpacityMaskNode.cs +++ b/src/Avalonia.Base/Rendering/SceneGraph/OpacityMaskNode.cs @@ -30,7 +30,7 @@ namespace Avalonia.Rendering.SceneGraph /// - public override bool HitTest(Point p) => false; + public override bool HitTestTransformed(Point p) => false; /// /// Determines if this draw operation equals another. diff --git a/src/Avalonia.Base/Rendering/SceneGraph/RectangleNode.cs b/src/Avalonia.Base/Rendering/SceneGraph/RectangleNode.cs index 94f61df47d..e85992be34 100644 --- a/src/Avalonia.Base/Rendering/SceneGraph/RectangleNode.cs +++ b/src/Avalonia.Base/Rendering/SceneGraph/RectangleNode.cs @@ -74,7 +74,7 @@ namespace Avalonia.Rendering.SceneGraph public override void Render(IDrawingContextImpl context) => context.DrawRectangle(Brush, Pen, Rect, BoxShadows); /// - public override bool HitTest(Point p) + public override bool HitTestTransformed(Point p) { if (Brush != null) { diff --git a/src/Avalonia.Base/StyledElement.cs b/src/Avalonia.Base/StyledElement.cs index b51093b40c..5881efce1e 100644 --- a/src/Avalonia.Base/StyledElement.cs +++ b/src/Avalonia.Base/StyledElement.cs @@ -78,7 +78,7 @@ namespace Avalonia private static readonly ControlTheme s_invalidTheme = new ControlTheme(); private int _initCount; private string? _name; - private readonly Classes _classes = new Classes(); + private Classes? _classes; private ILogicalRoot? _logicalRoot; private IAvaloniaList? _logicalChildren; private IResourceDictionary? _resources; @@ -183,21 +183,7 @@ namespace Avalonia /// collection. /// /// - public Classes Classes - { - get - { - return _classes; - } - - set - { - if (_classes != value) - { - _classes.Replace(value); - } - } - } + public Classes Classes => _classes ??= new(); /// /// Gets or sets the control's data context. @@ -891,7 +877,7 @@ namespace Avalonia for (var i = 0; i < logicalChildrenCount; i++) { - if (logicalChildren[i] is StyledElement child) + if (logicalChildren[i] is StyledElement child && child._logicalRoot != e.Root) // child may already have been attached within an event handler { child.OnAttachedToLogicalTreeCore(e); } diff --git a/src/Avalonia.Base/StyledProperty.cs b/src/Avalonia.Base/StyledProperty.cs index e1b88cde49..5cb330eda9 100644 --- a/src/Avalonia.Base/StyledProperty.cs +++ b/src/Avalonia.Base/StyledProperty.cs @@ -176,6 +176,11 @@ namespace Avalonia o.ClearValue(this); } + internal override void RouteCoerceDefaultValue(AvaloniaObject o) + { + o.GetValueStore().CoerceDefaultValue(this); + } + /// internal override object? RouteGetValue(AvaloniaObject o) { diff --git a/src/Avalonia.Base/Styling/ITemplate.cs b/src/Avalonia.Base/Styling/ITemplate.cs index 8a130cb3b4..d183d3ff74 100644 --- a/src/Avalonia.Base/Styling/ITemplate.cs +++ b/src/Avalonia.Base/Styling/ITemplate.cs @@ -2,6 +2,6 @@ { public interface ITemplate { - object Build(); + object? Build(); } } diff --git a/src/Avalonia.Base/Threading/AvaloniaSynchronizationContext.cs b/src/Avalonia.Base/Threading/AvaloniaSynchronizationContext.cs index 2cade55f32..e0563876bf 100644 --- a/src/Avalonia.Base/Threading/AvaloniaSynchronizationContext.cs +++ b/src/Avalonia.Base/Threading/AvaloniaSynchronizationContext.cs @@ -1,6 +1,7 @@ using System; using System.Runtime.ConstrainedExecution; using System.Threading; +using Avalonia.Utilities; namespace Avalonia.Threading { @@ -9,6 +10,28 @@ namespace Avalonia.Threading /// public class AvaloniaSynchronizationContext : SynchronizationContext { + internal readonly DispatcherPriority Priority; + private readonly NonPumpingLockHelper.IHelperImpl? _nonPumpingHelper = + AvaloniaLocator.Current.GetService(); + + public AvaloniaSynchronizationContext(): this(Thread.CurrentThread.GetApartmentState() == ApartmentState.STA) + { + + } + + // This constructor is here to enforce STA behavior for unit tests + internal AvaloniaSynchronizationContext(bool isStaThread) + { + if (_nonPumpingHelper != null + && isStaThread) + SetWaitNotificationRequired(); + } + + public AvaloniaSynchronizationContext(DispatcherPriority priority) + { + Priority = priority; + } + /// /// Controls if SynchronizationContext should be installed in InstallIfNeeded. Used by Designer. /// @@ -24,13 +47,13 @@ namespace Avalonia.Threading return; } - SetSynchronizationContext(new AvaloniaSynchronizationContext()); + SetSynchronizationContext(Dispatcher.UIThread.GetContextWithPriority(DispatcherPriority.Normal)); } /// public override void Post(SendOrPostCallback d, object? state) { - Dispatcher.UIThread.Post(d, state, DispatcherPriority.Background); + Dispatcher.UIThread.Post(d, state, Priority); } /// @@ -41,7 +64,50 @@ namespace Avalonia.Threading else Dispatcher.UIThread.InvokeAsync(() => d(state), DispatcherPriority.Send).GetAwaiter().GetResult(); } + +#if !NET6_0_OR_GREATER + [PrePrepareMethod] +#endif + public override int Wait(IntPtr[] waitHandles, bool waitAll, int millisecondsTimeout) + { + if ( + _nonPumpingHelper != null + && Dispatcher.UIThread.CheckAccess() + && Dispatcher.UIThread.DisabledProcessingCount > 0) + return _nonPumpingHelper.Wait(waitHandles, waitAll, millisecondsTimeout); + return base.Wait(waitHandles, waitAll, millisecondsTimeout); + } + public record struct RestoreContext : IDisposable + { + private readonly SynchronizationContext? _oldContext; + private bool _needRestore; + internal RestoreContext(SynchronizationContext? oldContext) + { + _oldContext = oldContext; + _needRestore = true; + } + + public void Dispose() + { + if (_needRestore) + { + SetSynchronizationContext(_oldContext); + _needRestore = false; + } + } + } + + public static RestoreContext Ensure(DispatcherPriority priority) + { + if (Current is AvaloniaSynchronizationContext avaloniaContext + && avaloniaContext.Priority == priority) + return default; + var oldContext = Current; + Dispatcher.UIThread.VerifyAccess(); + SetSynchronizationContext(Dispatcher.UIThread.GetContextWithPriority(priority)); + return new RestoreContext(oldContext); + } } } diff --git a/src/Avalonia.Base/Threading/Dispatcher.Invoke.cs b/src/Avalonia.Base/Threading/Dispatcher.Invoke.cs new file mode 100644 index 0000000000..6842e4a255 --- /dev/null +++ b/src/Avalonia.Base/Threading/Dispatcher.Invoke.cs @@ -0,0 +1,598 @@ +using System; +using System.ComponentModel; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; + +namespace Avalonia.Threading; + +public partial class Dispatcher +{ + /// + /// Executes the specified Action synchronously on the thread that + /// the Dispatcher was created on. + /// + /// + /// An Action delegate to invoke through the dispatcher. + /// + /// + /// Note that the default priority is DispatcherPriority.Send. + /// + public void Invoke(Action callback) + { + Invoke(callback, DispatcherPriority.Send, CancellationToken.None, TimeSpan.FromMilliseconds(-1)); + } + + /// + /// Executes the specified Action synchronously on the thread that + /// the Dispatcher was created on. + /// + /// + /// An Action delegate to invoke through the dispatcher. + /// + /// + /// The priority that determines in what order the specified + /// callback is invoked relative to the other pending operations + /// in the Dispatcher. + /// + public void Invoke(Action callback, DispatcherPriority priority) + { + Invoke(callback, priority, CancellationToken.None, TimeSpan.FromMilliseconds(-1)); + } + + /// + /// Executes the specified Action synchronously on the thread that + /// the Dispatcher was created on. + /// + /// + /// An Action delegate to invoke through the dispatcher. + /// + /// + /// The priority that determines in what order the specified + /// callback is invoked relative to the other pending operations + /// in the Dispatcher. + /// + /// + /// A cancellation token that can be used to cancel the operation. + /// If the operation has not started, it will be aborted when the + /// cancellation token is canceled. If the operation has started, + /// the operation can cooperate with the cancellation request. + /// + public void Invoke(Action callback, DispatcherPriority priority, CancellationToken cancellationToken) + { + Invoke(callback, priority, cancellationToken, TimeSpan.FromMilliseconds(-1)); + } + + /// + /// Executes the specified Action synchronously on the thread that + /// the Dispatcher was created on. + /// + /// + /// An Action delegate to invoke through the dispatcher. + /// + /// + /// The priority that determines in what order the specified + /// callback is invoked relative to the other pending operations + /// in the Dispatcher. + /// + /// + /// A cancellation token that can be used to cancel the operation. + /// If the operation has not started, it will be aborted when the + /// cancellation token is canceled. If the operation has started, + /// the operation can cooperate with the cancellation request. + /// + /// + /// The minimum amount of time to wait for the operation to start. + /// Once the operation has started, it will complete before this method + /// returns. + /// + public void Invoke(Action callback, DispatcherPriority priority, CancellationToken cancellationToken, + TimeSpan timeout) + { + if (callback == null) + { + throw new ArgumentNullException("callback"); + } + + DispatcherPriority.Validate(priority, "priority"); + + if (timeout.TotalMilliseconds < 0 && + timeout != TimeSpan.FromMilliseconds(-1)) + { + throw new ArgumentOutOfRangeException("timeout"); + } + + // Fast-Path: if on the same thread, and invoking at Send priority, + // and the cancellation token is not already canceled, then just + // call the callback directly. + if (!cancellationToken.IsCancellationRequested && priority == DispatcherPriority.Send && CheckAccess()) + { + using (AvaloniaSynchronizationContext.Ensure(priority)) + callback(); + return; + } + + // Slow-Path: go through the queue. + DispatcherOperation operation = new DispatcherOperation(this, priority, callback, false); + InvokeImpl(operation, cancellationToken, timeout); + } + + /// + /// Executes the specified Func synchronously on the + /// thread that the Dispatcher was created on. + /// + /// + /// A Func delegate to invoke through the dispatcher. + /// + /// + /// The return value from the delegate being invoked. + /// + /// + /// Note that the default priority is DispatcherPriority.Send. + /// + public TResult Invoke(Func callback) + { + return Invoke(callback, DispatcherPriority.Send, CancellationToken.None, TimeSpan.FromMilliseconds(-1)); + } + + /// + /// Executes the specified Func synchronously on the + /// thread that the Dispatcher was created on. + /// + /// + /// A Func delegate to invoke through the dispatcher. + /// + /// + /// The priority that determines in what order the specified + /// callback is invoked relative to the other pending operations + /// in the Dispatcher. + /// + /// + /// The return value from the delegate being invoked. + /// + public TResult Invoke(Func callback, DispatcherPriority priority) + { + return Invoke(callback, priority, CancellationToken.None, TimeSpan.FromMilliseconds(-1)); + } + + /// + /// Executes the specified Func synchronously on the + /// thread that the Dispatcher was created on. + /// + /// + /// A Func delegate to invoke through the dispatcher. + /// + /// + /// The priority that determines in what order the specified + /// callback is invoked relative to the other pending operations + /// in the Dispatcher. + /// + /// + /// A cancellation token that can be used to cancel the operation. + /// If the operation has not started, it will be aborted when the + /// cancellation token is canceled. If the operation has started, + /// the operation can cooperate with the cancellation request. + /// + /// + /// The return value from the delegate being invoked. + /// + public TResult Invoke(Func callback, DispatcherPriority priority, + CancellationToken cancellationToken) + { + return Invoke(callback, priority, cancellationToken, TimeSpan.FromMilliseconds(-1)); + } + + /// + /// Executes the specified Func synchronously on the + /// thread that the Dispatcher was created on. + /// + /// + /// A Func delegate to invoke through the dispatcher. + /// + /// + /// The priority that determines in what order the specified + /// callback is invoked relative to the other pending operations + /// in the Dispatcher. + /// + /// + /// A cancellation token that can be used to cancel the operation. + /// If the operation has not started, it will be aborted when the + /// cancellation token is canceled. If the operation has started, + /// the operation can cooperate with the cancellation request. + /// + /// + /// The minimum amount of time to wait for the operation to start. + /// Once the operation has started, it will complete before this method + /// returns. + /// + /// + /// The return value from the delegate being invoked. + /// + public TResult Invoke(Func callback, DispatcherPriority priority, + CancellationToken cancellationToken, TimeSpan timeout) + { + if (callback == null) + { + throw new ArgumentNullException("callback"); + } + + DispatcherPriority.Validate(priority, "priority"); + + if (timeout.TotalMilliseconds < 0 && + timeout != TimeSpan.FromMilliseconds(-1)) + { + throw new ArgumentOutOfRangeException("timeout"); + } + + // Fast-Path: if on the same thread, and invoking at Send priority, + // and the cancellation token is not already canceled, then just + // call the callback directly. + if (!cancellationToken.IsCancellationRequested && priority == DispatcherPriority.Send && CheckAccess()) + { + using (AvaloniaSynchronizationContext.Ensure(priority)) + return callback(); + } + + // Slow-Path: go through the queue. + DispatcherOperation operation = new DispatcherOperation(this, priority, callback); + return (TResult)InvokeImpl(operation, cancellationToken, timeout)!; + } + + /// + /// Executes the specified Action asynchronously on the thread + /// that the Dispatcher was created on. + /// + /// + /// An Action delegate to invoke through the dispatcher. + /// + /// + /// An operation representing the queued delegate to be invoked. + /// + /// + /// Note that the default priority is DispatcherPriority.Normal. + /// + public DispatcherOperation InvokeAsync(Action callback) + { + return InvokeAsync(callback, DispatcherPriority.Normal, CancellationToken.None); + } + + /// + /// Executes the specified Action asynchronously on the thread + /// that the Dispatcher was created on. + /// + /// + /// An Action delegate to invoke through the dispatcher. + /// + /// + /// The priority that determines in what order the specified + /// callback is invoked relative to the other pending operations + /// in the Dispatcher. + /// + /// + /// An operation representing the queued delegate to be invoked. + /// + /// + /// An operation representing the queued delegate to be invoked. + /// + public DispatcherOperation InvokeAsync(Action callback, DispatcherPriority priority) + { + return InvokeAsync(callback, priority, CancellationToken.None); + } + + /// + /// Executes the specified Action asynchronously on the thread + /// that the Dispatcher was created on. + /// + /// + /// An Action delegate to invoke through the dispatcher. + /// + /// + /// The priority that determines in what order the specified + /// callback is invoked relative to the other pending operations + /// in the Dispatcher. + /// + /// + /// A cancellation token that can be used to cancel the operation. + /// If the operation has not started, it will be aborted when the + /// cancellation token is canceled. If the operation has started, + /// the operation can cooperate with the cancellation request. + /// + /// + /// An operation representing the queued delegate to be invoked. + /// + public DispatcherOperation InvokeAsync(Action callback, DispatcherPriority priority, + CancellationToken cancellationToken) + { + if (callback == null) + { + throw new ArgumentNullException("callback"); + } + + DispatcherPriority.Validate(priority, "priority"); + + DispatcherOperation operation = new DispatcherOperation(this, priority, callback, false); + InvokeAsyncImpl(operation, cancellationToken); + + return operation; + } + + /// + /// Executes the specified Func asynchronously on the + /// thread that the Dispatcher was created on. + /// + /// + /// A Func delegate to invoke through the dispatcher. + /// + /// + /// An operation representing the queued delegate to be invoked. + /// + /// + /// Note that the default priority is DispatcherPriority.Normal. + /// + public DispatcherOperation InvokeAsync(Func callback) + { + return InvokeAsync(callback, DispatcherPriority.Normal, CancellationToken.None); + } + + /// + /// Executes the specified Func asynchronously on the + /// thread that the Dispatcher was created on. + /// + /// + /// A Func delegate to invoke through the dispatcher. + /// + /// + /// The priority that determines in what order the specified + /// callback is invoked relative to the other pending operations + /// in the Dispatcher. + /// + /// + /// An operation representing the queued delegate to be invoked. + /// + public DispatcherOperation InvokeAsync(Func callback, DispatcherPriority priority) + { + return InvokeAsync(callback, priority, CancellationToken.None); + } + + /// + /// Executes the specified Func asynchronously on the + /// thread that the Dispatcher was created on. + /// + /// + /// A Func delegate to invoke through the dispatcher. + /// + /// + /// The priority that determines in what order the specified + /// callback is invoked relative to the other pending operations + /// in the Dispatcher. + /// + /// + /// A cancellation token that can be used to cancel the operation. + /// If the operation has not started, it will be aborted when the + /// cancellation token is canceled. If the operation has started, + /// the operation can cooperate with the cancellation request. + /// + /// + /// An operation representing the queued delegate to be invoked. + /// + public DispatcherOperation InvokeAsync(Func callback, DispatcherPriority priority, + CancellationToken cancellationToken) + { + if (callback == null) + { + throw new ArgumentNullException("callback"); + } + + DispatcherPriority.Validate(priority, "priority"); + + DispatcherOperation operation = new DispatcherOperation(this, priority, callback); + InvokeAsyncImpl(operation, cancellationToken); + + return operation; + } + + internal void InvokeAsyncImpl(DispatcherOperation operation, CancellationToken cancellationToken) + { + bool succeeded = false; + + // Could be a non-dispatcher thread, lock to read + lock (InstanceLock) + { + if (!cancellationToken.IsCancellationRequested && + !_hasShutdownFinished && + !Environment.HasShutdownStarted) + { + // Add the operation to the work queue + _queue.Enqueue(operation.Priority, operation); + + // Make sure we will wake up to process this operation. + succeeded = RequestProcessing(); + + if (!succeeded) + { + // Dequeue the item since we failed to request + // processing for it. Note we will mark it aborted + // below. + _queue.RemoveItem(operation); + } + } + } + + if (succeeded == true) + { + // We have enqueued the operation. Register a callback + // with the cancellation token to abort the operation + // when cancellation is requested. + if (cancellationToken.CanBeCanceled) + { + CancellationTokenRegistration cancellationRegistration = + cancellationToken.Register(s => ((DispatcherOperation)s!).Abort(), operation); + + // Revoke the cancellation when the operation is done. + operation.Aborted += (s, e) => cancellationRegistration.Dispose(); + operation.Completed += (s, e) => cancellationRegistration.Dispose(); + } + } + else + { + // We failed to enqueue the operation, and the caller that + // created the operation does not expose it before we return, + // so it is safe to modify the operation outside of the lock. + // Just mark the operation as aborted, which we can safely + // return to the user. + operation.DoAbort(); + } + } + + + private object? InvokeImpl(DispatcherOperation operation, CancellationToken cancellationToken, TimeSpan timeout) + { + object? result = null; + + Debug.Assert(timeout.TotalMilliseconds >= 0 || timeout == TimeSpan.FromMilliseconds(-1)); + Debug.Assert(operation.Priority != DispatcherPriority.Send || !CheckAccess()); // should be handled by caller + + if (!cancellationToken.IsCancellationRequested) + { + // This operation must be queued since it was invoked either to + // another thread, or at a priority other than Send. + InvokeAsyncImpl(operation, cancellationToken); + + CancellationToken ctTimeout = CancellationToken.None; + CancellationTokenRegistration ctTimeoutRegistration = new CancellationTokenRegistration(); + CancellationTokenSource? ctsTimeout = null; + + if (timeout.TotalMilliseconds >= 0) + { + // Create a CancellationTokenSource that will abort the + // operation after the timeout. Note that this does not + // cancel the operation, just abort it if it is still pending. + ctsTimeout = new CancellationTokenSource(timeout); + ctTimeout = ctsTimeout.Token; + ctTimeoutRegistration = ctTimeout.Register(s => ((DispatcherOperation)s!).Abort(), operation); + } + + + // We have already registered with the cancellation tokens + // (both provided by the user, and one for the timeout) to + // abort the operation when they are canceled. If the + // operation has already started when the timeout expires, + // we still wait for it to complete. This is different + // than simply waiting on the operation with a timeout + // because we are the ones queueing the dispatcher + // operation, not the caller. We can't leave the operation + // in a state that it might execute if we return that it did not + // invoke. + try + { + operation.Wait(); + + Debug.Assert(operation.Status == DispatcherOperationStatus.Completed || + operation.Status == DispatcherOperationStatus.Aborted); + + // Old async semantics return from Wait without + // throwing an exception if the operation was aborted. + // There is no need to test the timout condition, since + // the old async semantics would just return the result, + // which would be null. + + // This should not block because either the operation + // is using the old async sematics, or the operation + // completed successfully. + result = operation.GetResult(); + } + catch (OperationCanceledException) + { + Debug.Assert(operation.Status == DispatcherOperationStatus.Aborted); + + // New async semantics will throw an exception if the + // operation was aborted. Here we convert that + // exception into a timeout exception if the timeout + // has expired (admittedly a weak relationship + // assuming causality). + if (ctTimeout.IsCancellationRequested) + { + // The operation was canceled because of the + // timeout, throw a TimeoutException instead. + throw new TimeoutException(); + } + else + { + // The operation was canceled from some other reason. + throw; + } + } + finally + { + ctTimeoutRegistration.Dispose(); + if (ctsTimeout != null) + { + ctsTimeout.Dispose(); + } + } + } + + return result; + } + + /// + public void Post(Action action, DispatcherPriority priority = default) + { + _ = action ?? throw new ArgumentNullException(nameof(action)); + InvokeAsyncImpl(new DispatcherOperation(this, priority, action, true), CancellationToken.None); + } + + /// + /// Executes the specified Func asynchronously on the + /// thread that the Dispatcher was created on + /// + /// + /// A Func delegate to invoke through the dispatcher. + /// + /// + /// The priority that determines in what order the specified + /// callback is invoked relative to the other pending operations + /// in the Dispatcher. + /// + /// + /// An task that completes after the task returned from callback finishes + /// + public Task InvokeAsync(Func callback, DispatcherPriority priority = default) + { + _ = callback ?? throw new ArgumentNullException(nameof(callback)); + return InvokeAsync(callback, priority).GetTask().Unwrap(); + } + + /// + /// Executes the specified Func> asynchronously on the + /// thread that the Dispatcher was created on + /// + /// + /// A Func> delegate to invoke through the dispatcher. + /// + /// + /// The priority that determines in what order the specified + /// callback is invoked relative to the other pending operations + /// in the Dispatcher. + /// + /// + /// An task that completes after the task returned from callback finishes + /// + public Task InvokeAsync(Func> action, DispatcherPriority priority = default) + { + _ = action ?? throw new ArgumentNullException(nameof(action)); + return InvokeAsync>(action, priority).GetTask().Unwrap(); + } + + /// + /// Posts an action that will be invoked on the dispatcher thread. + /// + /// The method. + /// The argument of method to call. + /// The priority with which to invoke the method. + public void Post(SendOrPostCallback action, object? arg, DispatcherPriority priority = default) + { + _ = action ?? throw new ArgumentNullException(nameof(action)); + InvokeAsyncImpl(new SendOrPostCallbackDispatcherOperation(this, priority, action, arg, true), CancellationToken.None); + } +} \ No newline at end of file diff --git a/src/Avalonia.Base/Threading/Dispatcher.MainLoop.cs b/src/Avalonia.Base/Threading/Dispatcher.MainLoop.cs new file mode 100644 index 0000000000..e1833fef2b --- /dev/null +++ b/src/Avalonia.Base/Threading/Dispatcher.MainLoop.cs @@ -0,0 +1,216 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using Avalonia.Utilities; + +namespace Avalonia.Threading; + +public partial class Dispatcher +{ + internal bool ExitAllFramesRequested { get; private set; } + internal bool HasShutdownStarted { get; private set; } + internal int DisabledProcessingCount { get; set; } + private bool _hasShutdownFinished; + private bool _startingShutdown; + + private Stack _frames = new(); + + + /// + /// Raised when the dispatcher is shutting down. + /// + public event EventHandler? ShutdownStarted; + + /// + /// Raised when the dispatcher is shut down. + /// + public event EventHandler? ShutdownFinished; + + /// + /// Push an execution frame. + /// + /// + /// The frame for the dispatcher to process. + /// + public void PushFrame(DispatcherFrame frame) + { + VerifyAccess(); + if (_controlledImpl == null) + throw new PlatformNotSupportedException(); + _ = frame ?? throw new ArgumentNullException(nameof(frame)); + + if(_hasShutdownFinished) // Dispatcher thread - no lock needed for read + throw new InvalidOperationException("Cannot perform requested operation because the Dispatcher shut down"); + + if (DisabledProcessingCount > 0) + throw new InvalidOperationException( + "Cannot perform this operation while dispatcher processing is suspended."); + + try + { + _frames.Push(frame); + using (AvaloniaSynchronizationContext.Ensure(DispatcherPriority.Normal)) + frame.Run(_controlledImpl); + } + finally + { + _frames.Pop(); + if (_frames.Count == 0) + { + if (HasShutdownStarted) + ShutdownImpl(); + else + ExitAllFramesRequested = false; + } + } + } + + /// + /// Runs the dispatcher's main loop. + /// + /// + /// A cancellation token used to exit the main loop. + /// + public void MainLoop(CancellationToken cancellationToken) + { + if (_controlledImpl == null) + throw new PlatformNotSupportedException(); + var frame = new DispatcherFrame(); + cancellationToken.Register(() => frame.Continue = false); + PushFrame(frame); + } + + /// + /// Requests that all nested frames exit. + /// + public void ExitAllFrames() + { + if (_frames.Count == 0) + return; + ExitAllFramesRequested = true; + foreach (var f in _frames) + f.MaybeExitOnDispatcherRequest(); + } + + /// + /// Begins the process of shutting down the dispatcher. + /// + public void BeginInvokeShutdown(DispatcherPriority priority) => Post(StartShutdownImpl, priority); + + + /// + /// Initiates the shutdown process of the Dispatcher synchronously. + /// + public void InvokeShutdown() => Invoke(StartShutdownImpl, DispatcherPriority.Send); + + private void StartShutdownImpl() + { + if (!_startingShutdown) + { + // We only need this to prevent reentrancy if the ShutdownStarted event + // tries to shut down again. + _startingShutdown = true; + + // Call the ShutdownStarted event before we actually mark ourselves + // as shutting down. This is so the handlers can actually do work + // when they get this event without throwing exceptions. + ShutdownStarted?.Invoke(this, EventArgs.Empty); + + HasShutdownStarted = true; + + if (_frames.Count > 0) + ExitAllFrames(); + else ShutdownImpl(); + } + } + + + private void ShutdownImpl() + { + DispatcherOperation? operation = null; + _impl.Timer -= PromoteTimers; + _impl.Signaled -= Signaled; + do + { + lock (InstanceLock) + { + if (_queue.MaxPriority != DispatcherPriority.Invalid) + { + operation = _queue.Peek(); + } + else + { + operation = null; + } + } + + if (operation != null) + { + operation.Abort(); + } + } while (operation != null); + + _impl.UpdateTimer(null); + _hasShutdownFinished = true; + ShutdownFinished?.Invoke(this, EventArgs.Empty); + } + + public record struct DispatcherProcessingDisabled : IDisposable + { + private readonly SynchronizationContext? _oldContext; + + private readonly bool _restoreContext; + private Dispatcher? _dispatcher; + + internal DispatcherProcessingDisabled(Dispatcher dispatcher) + { + _dispatcher = dispatcher; + } + + internal DispatcherProcessingDisabled(Dispatcher dispatcher, SynchronizationContext? oldContext) : this( + dispatcher) + { + _oldContext = oldContext; + _restoreContext = true; + } + + public void Dispose() + { + if(_dispatcher==null) + return; + _dispatcher.DisabledProcessingCount--; + _dispatcher = null; + if (_restoreContext) + SynchronizationContext.SetSynchronizationContext(_oldContext); + } + } + + /// + /// Disable the event processing of the dispatcher. + /// + /// + /// This is an advanced method intended to eliminate the chance of + /// unrelated reentrancy. The effect of disabling processing is: + /// 1) CLR locks will not pump messages internally. + /// 2) No one is allowed to push a frame. + /// 3) No message processing is permitted. + /// + public DispatcherProcessingDisabled DisableProcessing() + { + VerifyAccess(); + + // Turn off processing. + DisabledProcessingCount++; + var oldContext = SynchronizationContext.Current; + if (oldContext is AvaloniaSynchronizationContext or NonPumpingSyncContext) + return new DispatcherProcessingDisabled(this); + + var helper = AvaloniaLocator.Current.GetService(); + if (helper == null) + return new DispatcherProcessingDisabled(this); + + SynchronizationContext.SetSynchronizationContext(new NonPumpingSyncContext(helper, oldContext)); + return new DispatcherProcessingDisabled(this, oldContext); + + } +} \ No newline at end of file diff --git a/src/Avalonia.Base/Threading/Dispatcher.Queue.cs b/src/Avalonia.Base/Threading/Dispatcher.Queue.cs new file mode 100644 index 0000000000..78a0740e56 --- /dev/null +++ b/src/Avalonia.Base/Threading/Dispatcher.Queue.cs @@ -0,0 +1,248 @@ +using System; +using System.Diagnostics; +using System.Threading; + +namespace Avalonia.Threading; + +public partial class Dispatcher +{ + private readonly DispatcherPriorityQueue _queue = new(); + private bool _signaled; + private bool _explicitBackgroundProcessingRequested; + private const int MaximumTimeProcessingBackgroundJobs = 50; + + void RequestBackgroundProcessing() + { + lock (InstanceLock) + { + if (_backgroundProcessingImpl != null) + { + if(_explicitBackgroundProcessingRequested) + return; + _explicitBackgroundProcessingRequested = true; + _backgroundProcessingImpl.RequestBackgroundProcessing(); + } + else if (_dueTimeForBackgroundProcessing == null) + { + _dueTimeForBackgroundProcessing = Now + 1; + UpdateOSTimer(); + } + } + } + + private void OnReadyForExplicitBackgroundProcessing() + { + lock (InstanceLock) + { + _explicitBackgroundProcessingRequested = false; + ExecuteJobsCore(); + } + } + + /// + /// Force-runs all dispatcher operations ignoring any pending OS events, use with caution + /// + public void RunJobs(DispatcherPriority? priority = null) + { + RunJobs(priority, CancellationToken.None); + } + + internal void RunJobs(DispatcherPriority? priority, CancellationToken cancellationToken) + { + if (DisabledProcessingCount > 0) + throw new InvalidOperationException( + "Cannot perform this operation while dispatcher processing is suspended."); + + priority ??= DispatcherPriority.MinimumActiveValue; + if (priority < DispatcherPriority.MinimumActiveValue) + priority = DispatcherPriority.MinimumActiveValue; + while (!cancellationToken.IsCancellationRequested) + { + DispatcherOperation? job; + lock (InstanceLock) + job = _queue.Peek(); + if (job == null) + return; + if (priority != null && job.Priority < priority.Value) + return; + ExecuteJob(job); + } + } + + class DummyShuttingDownUnitTestDispatcherImpl : IDispatcherImpl + { + public bool CurrentThreadIsLoopThread => true; + public void Signal() + { + } + + public event Action? Signaled; + public event Action? Timer; + public long Now => 0; + public void UpdateTimer(long? dueTimeInMs) + { + } + } + + internal static void ResetForUnitTests() + { + if (s_uiThread == null) + return; + var st = Stopwatch.StartNew(); + while (true) + { + s_uiThread._pendingInputImpl = s_uiThread._controlledImpl = null; + s_uiThread._impl = new DummyShuttingDownUnitTestDispatcherImpl(); + if (st.Elapsed.TotalSeconds > 5) + throw new InvalidProgramException("You've caused dispatcher loop"); + + DispatcherOperation? job; + lock (s_uiThread.InstanceLock) + job = s_uiThread._queue.Peek(); + if (job == null || job.Priority <= DispatcherPriority.Inactive) + { + s_uiThread = null; + return; + } + + s_uiThread.ExecuteJob(job); + } + + } + + private void ExecuteJob(DispatcherOperation job) + { + lock (InstanceLock) + _queue.RemoveItem(job); + job.Execute(); + // The backend might be firing timers with a low priority, + // so we manually check if our high priority timers are due for execution + PromoteTimers(); + } + + private void Signaled() + { + lock (InstanceLock) + _signaled = false; + + ExecuteJobsCore(); + } + + void ExecuteJobsCore() + { + long? backgroundJobExecutionStartedAt = null; + while (true) + { + DispatcherOperation? job; + + lock (InstanceLock) + job = _queue.Peek(); + + if (job == null || job.Priority < DispatcherPriority.MinimumActiveValue) + return; + + + // We don't stop for executing jobs queued with >Input priority + if (job.Priority > DispatcherPriority.Input) + { + ExecuteJob(job); + backgroundJobExecutionStartedAt = null; + } + // If platform supports pending input query, ask the platform if we can continue running low priority jobs + else if (_pendingInputImpl?.CanQueryPendingInput == true) + { + if (!_pendingInputImpl.HasPendingInput) + ExecuteJob(job); + else + { + RequestBackgroundProcessing(); + return; + } + } + // We can't check if there is pending input, but still need to enforce interactivity + // so we stop processing background jobs after some timeout and start a timer to continue later + else + { + if (backgroundJobExecutionStartedAt == null) + backgroundJobExecutionStartedAt = Now; + + if (Now - backgroundJobExecutionStartedAt.Value > MaximumTimeProcessingBackgroundJobs) + { + _signaled = true; + RequestBackgroundProcessing(); + return; + } + else + ExecuteJob(job); + } + } + } + + internal bool RequestProcessing() + { + lock (InstanceLock) + { + if (!CheckAccess()) + { + RequestForegroundProcessing(); + return true; + } + + if (_queue.MaxPriority <= DispatcherPriority.Input) + { + if (_pendingInputImpl is { CanQueryPendingInput: true, HasPendingInput: false }) + RequestForegroundProcessing(); + else + RequestBackgroundProcessing(); + } + else + RequestForegroundProcessing(); + } + return true; + } + + private void RequestForegroundProcessing() + { + if (!_signaled) + { + _signaled = true; + _impl.Signal(); + } + } + + internal void Abort(DispatcherOperation operation) + { + lock (InstanceLock) + _queue.RemoveItem(operation); + operation.DoAbort(); + } + + // Returns whether or not the priority was set. + internal bool SetPriority(DispatcherOperation operation, DispatcherPriority priority) // NOTE: should be Priority + { + bool notify = false; + + lock(InstanceLock) + { + if(operation.IsQueued) + { + _queue.ChangeItemPriority(operation, priority); + notify = true; + + if(notify) + { + // Make sure we will wake up to process this operation. + RequestProcessing(); + + } + } + } + return notify; + } + + public bool HasJobsWithPriority(DispatcherPriority priority) + { + lock (InstanceLock) + return _queue.MaxPriority >= priority; + } +} diff --git a/src/Avalonia.Base/Threading/Dispatcher.Timers.cs b/src/Avalonia.Base/Threading/Dispatcher.Timers.cs new file mode 100644 index 0000000000..bb252b7f55 --- /dev/null +++ b/src/Avalonia.Base/Threading/Dispatcher.Timers.cs @@ -0,0 +1,207 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Avalonia.Threading; + +public partial class Dispatcher +{ + private List _timers = new(); + private long _timersVersion; + private bool _dueTimeFound; + private long _dueTimeInMs; + + private long? _dueTimeForTimers; + private long? _dueTimeForBackgroundProcessing; + private long? _osTimerSetTo; + + internal long Now => _impl.Now; + + private void UpdateOSTimer() + { + VerifyAccess(); + var nextDueTime = + (_dueTimeForTimers.HasValue && _dueTimeForBackgroundProcessing.HasValue) ? + Math.Min(_dueTimeForTimers.Value, _dueTimeForBackgroundProcessing.Value) : + _dueTimeForTimers ?? _dueTimeForBackgroundProcessing; + if (_osTimerSetTo == nextDueTime) + return; + _impl.UpdateTimer(_osTimerSetTo = nextDueTime); + } + + internal void RescheduleTimers() + { + if (!CheckAccess()) + { + Post(RescheduleTimers, DispatcherPriority.Send); + return; + } + + lock (InstanceLock) + { + if (!_hasShutdownFinished) // Dispatcher thread, does not technically need the lock to read + { + bool oldDueTimeFound = _dueTimeFound; + long oldDueTimeInTicks = _dueTimeInMs; + _dueTimeFound = false; + _dueTimeInMs = 0; + + if (_timers.Count > 0) + { + // We could do better if we sorted the list of timers. + for (int i = 0; i < _timers.Count; i++) + { + var timer = _timers[i]; + + if (!_dueTimeFound || timer.DueTimeInMs - _dueTimeInMs < 0) + { + _dueTimeFound = true; + _dueTimeInMs = timer.DueTimeInMs; + } + } + } + + if (_dueTimeFound) + { + if (_dueTimeForTimers == null || !oldDueTimeFound || (oldDueTimeInTicks != _dueTimeInMs)) + { + _dueTimeForTimers = _dueTimeInMs; + UpdateOSTimer(); + } + } + else if (oldDueTimeFound) + { + _dueTimeForTimers = null; + UpdateOSTimer(); + } + } + } + } + + internal void AddTimer(DispatcherTimer timer) + { + lock (InstanceLock) + { + if (!_hasShutdownFinished) // Could be a non-dispatcher thread, lock to read + { + _timers.Add(timer); + _timersVersion++; + } + } + + RescheduleTimers(); + } + + internal void RemoveTimer(DispatcherTimer timer) + { + lock (InstanceLock) + { + if (!_hasShutdownFinished) // Could be a non-dispatcher thread, lock to read + { + _timers.Remove(timer); + _timersVersion++; + } + } + + RescheduleTimers(); + } + + private void OnOSTimer() + { + _impl.UpdateTimer(null); + _osTimerSetTo = null; + bool needToPromoteTimers = false; + bool needToProcessQueue = false; + lock (InstanceLock) + { + _impl.UpdateTimer(_osTimerSetTo = null); + needToPromoteTimers = _dueTimeForTimers.HasValue && _dueTimeForTimers.Value <= Now; + if (needToPromoteTimers) + _dueTimeForTimers = null; + needToProcessQueue = _dueTimeForBackgroundProcessing.HasValue && + _dueTimeForBackgroundProcessing.Value <= Now; + if (needToProcessQueue) + _dueTimeForBackgroundProcessing = null; + } + + if (needToPromoteTimers) + PromoteTimers(); + if (needToProcessQueue) + ExecuteJobsCore(); + UpdateOSTimer(); + } + + internal void PromoteTimers() + { + long currentTimeInTicks = Now; + try + { + List? timers = null; + long timersVersion = 0; + + lock (InstanceLock) + { + if (!_hasShutdownFinished) // Could be a non-dispatcher thread, lock to read + { + if (_dueTimeFound && _dueTimeInMs - currentTimeInTicks <= 0) + { + timers = _timers; + timersVersion = _timersVersion; + } + } + } + + if (timers != null) + { + DispatcherTimer? timer = null; + int iTimer = 0; + + do + { + lock (InstanceLock) + { + timer = null; + + // If the timers collection changed while we are in the middle of + // looking for timers, start over. + if (timersVersion != _timersVersion) + { + timersVersion = _timersVersion; + iTimer = 0; + } + + while (iTimer < _timers.Count) + { + // WARNING: this is vulnerable to wrapping + if (timers[iTimer].DueTimeInMs - currentTimeInTicks <= 0) + { + // Remove this timer from our list. + // Do not increment the index. + timer = timers[iTimer]; + timers.RemoveAt(iTimer); + break; + } + else + { + iTimer++; + } + } + } + + // Now that we are outside of the lock, promote the timer. + if (timer != null) + { + timer.Promote(); + } + } while (timer != null); + } + } + finally + { + RescheduleTimers(); + } + } + + internal static List SnapshotTimersForUnitTests() => + s_uiThread!._timers.ToList(); +} diff --git a/src/Avalonia.Base/Threading/Dispatcher.cs b/src/Avalonia.Base/Threading/Dispatcher.cs index 571f782813..f257072dc8 100644 --- a/src/Avalonia.Base/Threading/Dispatcher.cs +++ b/src/Avalonia.Base/Threading/Dispatcher.cs @@ -1,159 +1,88 @@ using System; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; using System.Threading; -using System.Threading.Tasks; using Avalonia.Platform; -namespace Avalonia.Threading -{ - /// - /// Provides services for managing work items on a thread. - /// - /// - /// In Avalonia, there is usually only a single in the application - - /// the one for the UI thread, retrieved via the property. - /// - public class Dispatcher : IDispatcher - { - private readonly JobRunner _jobRunner; - private IPlatformThreadingInterface? _platform; - - public static Dispatcher UIThread { get; } = - new Dispatcher(AvaloniaLocator.Current.GetService()); - - public Dispatcher(IPlatformThreadingInterface? platform) - { - _platform = platform; - _jobRunner = new JobRunner(platform); - - if (_platform != null) - { - _platform.Signaled += _jobRunner.RunJobs; - } - } - - /// - /// Checks that the current thread is the UI thread. - /// - public bool CheckAccess() => _platform?.CurrentThreadIsLoopThread ?? true; - - /// - /// Checks that the current thread is the UI thread and throws if not. - /// - /// - /// The current thread is not the UI thread. - /// - public void VerifyAccess() - { - if (!CheckAccess()) - throw new InvalidOperationException("Call from invalid thread"); - } - - /// - /// Runs the dispatcher's main loop. - /// - /// - /// A cancellation token used to exit the main loop. - /// - public void MainLoop(CancellationToken cancellationToken) - { - var platform = AvaloniaLocator.Current.GetRequiredService(); - cancellationToken.Register(() => platform.Signal(DispatcherPriority.Send)); - platform.RunLoop(cancellationToken); - } - - /// - /// Runs continuations pushed on the loop. - /// - public void RunJobs() - { - _jobRunner.RunJobs(null); - } - - /// - /// Use this method to ensure that more prioritized tasks are executed - /// - /// - public void RunJobs(DispatcherPriority minimumPriority) => _jobRunner.RunJobs(minimumPriority); - - /// - /// Use this method to check if there are more prioritized tasks - /// - /// - public bool HasJobsWithPriority(DispatcherPriority minimumPriority) => - _jobRunner.HasJobsWithPriority(minimumPriority); - - /// - public Task InvokeAsync(Action action, DispatcherPriority priority = default) - { - _ = action ?? throw new ArgumentNullException(nameof(action)); - return _jobRunner.InvokeAsync(action, priority); - } +namespace Avalonia.Threading; - /// - public Task InvokeAsync(Func function, DispatcherPriority priority = default) - { - _ = function ?? throw new ArgumentNullException(nameof(function)); - return _jobRunner.InvokeAsync(function, priority); - } +/// +/// Provides services for managing work items on a thread. +/// +/// +/// In Avalonia, there is usually only a single in the application - +/// the one for the UI thread, retrieved via the property. +/// +public partial class Dispatcher : IDispatcher +{ + private IDispatcherImpl _impl; + internal object InstanceLock { get; } = new(); + private IControlledDispatcherImpl? _controlledImpl; + private static Dispatcher? s_uiThread; + private IDispatcherImplWithPendingInput? _pendingInputImpl; + private readonly IDispatcherImplWithExplicitBackgroundProcessing? _backgroundProcessingImpl; - /// - public Task InvokeAsync(Func function, DispatcherPriority priority = default) - { - _ = function ?? throw new ArgumentNullException(nameof(function)); - return _jobRunner.InvokeAsync(function, priority).Unwrap(); - } + private readonly AvaloniaSynchronizationContext?[] _priorityContexts = + new AvaloniaSynchronizationContext?[DispatcherPriority.MaxValue - DispatcherPriority.MinValue + 1]; - /// - public Task InvokeAsync(Func> function, DispatcherPriority priority = default) - { - _ = function ?? throw new ArgumentNullException(nameof(function)); - return _jobRunner.InvokeAsync(function, priority).Unwrap(); - } + internal Dispatcher(IDispatcherImpl impl) + { + _impl = impl; + impl.Timer += OnOSTimer; + impl.Signaled += Signaled; + _controlledImpl = _impl as IControlledDispatcherImpl; + _pendingInputImpl = _impl as IDispatcherImplWithPendingInput; + _backgroundProcessingImpl = _impl as IDispatcherImplWithExplicitBackgroundProcessing; + if (_backgroundProcessingImpl != null) + _backgroundProcessingImpl.ReadyForBackgroundProcessing += OnReadyForExplicitBackgroundProcessing; + } + + public static Dispatcher UIThread => s_uiThread ??= CreateUIThreadDispatcher(); + public bool SupportsRunLoops => _controlledImpl != null; - /// - public void Post(Action action, DispatcherPriority priority = default) + private static Dispatcher CreateUIThreadDispatcher() + { + var impl = AvaloniaLocator.Current.GetService(); + if (impl == null) { - _ = action ?? throw new ArgumentNullException(nameof(action)); - _jobRunner.Post(action, priority); + var platformThreading = AvaloniaLocator.Current.GetService(); + if (platformThreading != null) + impl = new LegacyDispatcherImpl(platformThreading); + else + impl = new NullDispatcherImpl(); } + return new Dispatcher(impl); + } - /// - public void Post(SendOrPostCallback action, object? arg, DispatcherPriority priority = default) - { - _ = action ?? throw new ArgumentNullException(nameof(action)); - _jobRunner.Post(action, arg, priority); - } + /// + /// Checks that the current thread is the UI thread. + /// + public bool CheckAccess() => _impl?.CurrentThreadIsLoopThread ?? true; - /// - /// This is needed for platform backends that don't have internal priority system (e. g. win32) - /// To ensure that there are no jobs with higher priority - /// - /// - internal void EnsurePriority(DispatcherPriority currentPriority) + /// + /// Checks that the current thread is the UI thread and throws if not. + /// + /// + /// The current thread is not the UI thread. + /// + public void VerifyAccess() + { + if (!CheckAccess()) { - if (currentPriority == DispatcherPriority.MaxValue) - return; - currentPriority += 1; - _jobRunner.RunJobs(currentPriority); + // Used to inline VerifyAccess. + [DoesNotReturn] + [MethodImpl(MethodImplOptions.NoInlining)] + static void ThrowVerifyAccess() + => throw new InvalidOperationException("Call from invalid thread"); + ThrowVerifyAccess(); } + } - /// - /// Allows unit tests to change the platform threading interface. - /// - internal void UpdateServices() - { - if (_platform != null) - { - _platform.Signaled -= _jobRunner.RunJobs; - } - - _platform = AvaloniaLocator.Current.GetService(); - _jobRunner.UpdateServices(); - - if (_platform != null) - { - _platform.Signaled += _jobRunner.RunJobs; - } - } + internal AvaloniaSynchronizationContext GetContextWithPriority(DispatcherPriority priority) + { + DispatcherPriority.Validate(priority, nameof(priority)); + var index = priority - DispatcherPriority.MinValue; + return _priorityContexts[index] ??= new(priority); } } diff --git a/src/Avalonia.Base/Threading/DispatcherFrame.cs b/src/Avalonia.Base/Threading/DispatcherFrame.cs new file mode 100644 index 0000000000..1f8974dfa3 --- /dev/null +++ b/src/Avalonia.Base/Threading/DispatcherFrame.cs @@ -0,0 +1,125 @@ +using System; +using System.Threading; + +namespace Avalonia.Threading; + +/// +/// Representation of Dispatcher frame. +/// +public class DispatcherFrame +{ + private bool _exitWhenRequested; + private bool _continue; + private bool _isRunning; + private CancellationTokenSource? _cancellationTokenSource; + + /// + /// Constructs a new instance of the DispatcherFrame class. + /// + public DispatcherFrame() : this(true) + { + } + + public Dispatcher Dispatcher { get; } + + /// + /// Constructs a new instance of the DispatcherFrame class. + /// + /// + /// Indicates whether or not this frame will exit when all frames + /// are requested to exit. + ///

+ /// Dispatcher frames typically break down into two categories: + /// 1) Long running, general purpose frames, that exit only when + /// told to. These frames should exit when requested. + /// 2) Short running, very specific frames that exit themselves + /// when an important criteria is met. These frames may + /// consider not exiting when requested in favor of waiting + /// for their important criteria to be met. These frames + /// should have a timeout associated with them. + /// + public DispatcherFrame(bool exitWhenRequested) + { + Dispatcher = Dispatcher.UIThread; + Dispatcher.VerifyAccess(); + _exitWhenRequested = exitWhenRequested; + _continue = true; + } + + ///

+ /// Indicates that this dispatcher frame should exit. + /// + public bool Continue + { + get + { + // This method is free-threaded. + + // First check if this frame wants to continue. + bool shouldContinue = _continue; + if (shouldContinue) + { + // This frame wants to continue, so next check if it will + // respect the "exit requests" from the dispatcher. + if (_exitWhenRequested) + { + Dispatcher dispatcher = Dispatcher; + + // This frame is willing to respect the "exit requests" of + // the dispatcher, so check them. + if (dispatcher.ExitAllFramesRequested || dispatcher.HasShutdownStarted) + { + shouldContinue = false; + } + } + } + + return shouldContinue; + } + + set + { + // This method is free-threaded. + lock (Dispatcher.InstanceLock) + { + _continue = value; + if (!_continue) + _cancellationTokenSource?.Cancel(); + } + } + } + + internal void Run(IControlledDispatcherImpl impl) + { + // Since the actual platform run loop is controlled by a Cancellation token, we are restarting + // it if frame still needs to run + while (Continue) + RunCore(impl); + } + + private void RunCore(IControlledDispatcherImpl impl) + { + if (_isRunning) + throw new InvalidOperationException("This frame is already running"); + _isRunning = true; + try + { + _cancellationTokenSource = new CancellationTokenSource(); + // Wake up the dispatcher in case it has pending jobs + Dispatcher.RequestProcessing(); + impl.RunLoop(_cancellationTokenSource.Token); + } + finally + { + _isRunning = false; + _cancellationTokenSource?.Cancel(); + _cancellationTokenSource = null; + } + } + + internal void MaybeExitOnDispatcherRequest() + { + if (_exitWhenRequested) + _cancellationTokenSource?.Cancel(); + } +} \ No newline at end of file diff --git a/src/Avalonia.Base/Threading/DispatcherOperation.cs b/src/Avalonia.Base/Threading/DispatcherOperation.cs new file mode 100644 index 0000000000..809c41ff02 --- /dev/null +++ b/src/Avalonia.Base/Threading/DispatcherOperation.cs @@ -0,0 +1,419 @@ +using System; +using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; + +namespace Avalonia.Threading; + +public class DispatcherOperation +{ + protected readonly bool ThrowOnUiThread; + public DispatcherOperationStatus Status { get; protected set; } + public Dispatcher Dispatcher { get; } + + public DispatcherPriority Priority + { + get => _priority; + set + { + _priority = value; + // Dispatcher is null in ctor + // ReSharper disable once ConditionalAccessQualifierIsNonNullableAccordingToAPIContract + Dispatcher?.SetPriority(this, value); + } + } + + protected object? Callback; + protected object? TaskSource; + + internal DispatcherOperation? SequentialPrev { get; set; } + internal DispatcherOperation? SequentialNext { get; set; } + internal DispatcherOperation? PriorityPrev { get; set; } + internal DispatcherOperation? PriorityNext { get; set; } + internal PriorityChain? Chain { get; set; } + + internal bool IsQueued => Chain != null; + + private EventHandler? _aborted; + private EventHandler? _completed; + private DispatcherPriority _priority; + + internal DispatcherOperation(Dispatcher dispatcher, DispatcherPriority priority, Action callback, bool throwOnUiThread) : + this(dispatcher, priority, throwOnUiThread) + { + Callback = callback; + } + + private protected DispatcherOperation(Dispatcher dispatcher, DispatcherPriority priority, bool throwOnUiThread) + { + ThrowOnUiThread = throwOnUiThread; + Priority = priority; + Dispatcher = dispatcher; + } + + /// + /// An event that is raised when the operation is aborted or canceled. + /// + public event EventHandler Aborted + { + add + { + lock (Dispatcher.InstanceLock) + { + _aborted += value; + } + } + + remove + { + lock(Dispatcher.InstanceLock) + { + _aborted -= value; + } + } + } + + /// + /// An event that is raised when the operation completes. + /// + /// + /// Completed indicates that the operation was invoked and has + /// either completed successfully or faulted. Note that a canceled + /// or aborted operation is never is never considered completed. + /// + public event EventHandler Completed + { + add + { + lock (Dispatcher.InstanceLock) + { + _completed += value; + } + } + + remove + { + lock(Dispatcher.InstanceLock) + { + _completed -= value; + } + } + } + + public bool Abort() + { + lock (Dispatcher.InstanceLock) + { + if (Status != DispatcherOperationStatus.Pending) + return false; + Dispatcher.Abort(this); + return true; + } + } + + /// + /// Waits for this operation to complete. + /// + /// + /// The status of the operation. To obtain the return value + /// of the invoked delegate, use the the Result property. + /// + public void Wait() => Wait(TimeSpan.FromMilliseconds(-1)); + + /// + /// Waits for this operation to complete. + /// + /// + /// The maximum amount of time to wait. + /// + public void Wait(TimeSpan timeout) + { + if ((Status == DispatcherOperationStatus.Pending || Status == DispatcherOperationStatus.Executing) && + timeout.TotalMilliseconds != 0) + { + if (Dispatcher.CheckAccess()) + { + if (Status == DispatcherOperationStatus.Executing) + { + // We are the dispatching thread, and the current operation state is + // executing, which means that the operation is in the middle of + // executing (on this thread) and is trying to wait for the execution + // to complete. Unfortunately, the thread will now deadlock, so + // we throw an exception instead. + throw new InvalidOperationException("A thread cannot wait on operations already running on the same thread."); + } + + var cts = new CancellationTokenSource(); + EventHandler finishedHandler = delegate + { + cts.Cancel(); + }; + Completed += finishedHandler; + Aborted += finishedHandler; + try + { + while (Status == DispatcherOperationStatus.Pending) + { + if (Dispatcher.SupportsRunLoops) + { + if (Priority >= DispatcherPriority.MinimumForegroundPriority) + Dispatcher.RunJobs(Priority, cts.Token); + else + Dispatcher.PushFrame(new DispatcherOperationFrame(this, timeout)); + } + else + Dispatcher.RunJobs(DispatcherPriority.MinimumActiveValue, cts.Token); + } + } + finally + { + Completed -= finishedHandler; + Aborted -= finishedHandler; + } + } + } + GetTask().GetAwaiter().GetResult(); + } + + private class DispatcherOperationFrame : DispatcherFrame + { + // Note: we pass "exitWhenRequested=false" to the base + // DispatcherFrame construsctor because we do not want to exit + // this frame if the dispatcher is shutting down. This is + // because we may need to invoke operations during the shutdown process. + public DispatcherOperationFrame(DispatcherOperation op, TimeSpan timeout) : base(false) + { + _operation = op; + + // We will exit this frame once the operation is completed or aborted. + _operation.Aborted += OnCompletedOrAborted; + _operation.Completed += OnCompletedOrAborted; + + // We will exit the frame if the operation is not completed within + // the requested timeout. + if (timeout.TotalMilliseconds > 0) + { + _waitTimer = new Timer(_ => Exit(), + null, + timeout, + TimeSpan.FromMilliseconds(-1)); + } + + // Some other thread could have aborted the operation while we were + // setting up the handlers. We check the state again and mark the + // frame as "should not continue" if this happened. + if (_operation.Status != DispatcherOperationStatus.Pending) + { + Exit(); + } + } + + private void Exit() + { + Continue = false; + + if (_waitTimer != null) + { + _waitTimer.Dispose(); + } + + _operation.Aborted -= OnCompletedOrAborted; + _operation.Completed -= OnCompletedOrAborted; + } + + private void OnCompletedOrAborted(object? sender, EventArgs e) => Exit(); + + private DispatcherOperation _operation; + private Timer? _waitTimer; + } + + public Task GetTask() => GetTaskCore(); + + /// + /// Returns an awaiter for awaiting the completion of the operation. + /// + /// + /// This method is intended to be used by compilers. + /// + [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)] + public TaskAwaiter GetAwaiter() + { + return GetTask().GetAwaiter(); + } + + internal void DoAbort() + { + Status = DispatcherOperationStatus.Aborted; + AbortTask(); + _aborted?.Invoke(this, EventArgs.Empty); + } + + internal void Execute() + { + lock (Dispatcher.InstanceLock) + { + Status = DispatcherOperationStatus.Executing; + } + + try + { + using (AvaloniaSynchronizationContext.Ensure(Priority)) + InvokeCore(); + } + finally + { + _completed?.Invoke(this, EventArgs.Empty); + } + } + + protected virtual void InvokeCore() + { + try + { + ((Action)Callback!)(); + lock (Dispatcher.InstanceLock) + { + Status = DispatcherOperationStatus.Completed; + if (TaskSource is TaskCompletionSource tcs) + tcs.SetResult(null); + } + } + catch (Exception e) + { + lock (Dispatcher.InstanceLock) + { + Status = DispatcherOperationStatus.Completed; + if (TaskSource is TaskCompletionSource tcs) + tcs.SetException(e); + } + + if (ThrowOnUiThread) + throw; + } + } + + internal virtual object? GetResult() => null; + + protected virtual void AbortTask() => (TaskSource as TaskCompletionSource)?.SetCanceled(); + + private static CancellationToken CreateCancelledToken() + { + var cts = new CancellationTokenSource(); + cts.Cancel(); + return cts.Token; + } + + private static readonly Task s_abortedTask = Task.FromCanceled(CreateCancelledToken()); + + protected virtual Task GetTaskCore() + { + lock (Dispatcher.InstanceLock) + { + if (Status == DispatcherOperationStatus.Aborted) + return s_abortedTask; + if (Status == DispatcherOperationStatus.Completed) + return Task.CompletedTask; + if (TaskSource is not TaskCompletionSource tcs) + TaskSource = tcs = new(TaskCreationOptions.RunContinuationsAsynchronously); + return tcs.Task; + } + } +} + +public class DispatcherOperation : DispatcherOperation +{ + public DispatcherOperation(Dispatcher dispatcher, DispatcherPriority priority, Func callback) : base(dispatcher, priority, false) + { + TaskSource = new TaskCompletionSource(); + Callback = callback; + } + + private TaskCompletionSource TaskCompletionSource => (TaskCompletionSource)TaskSource!; + + public new Task GetTask() => TaskCompletionSource!.Task; + + protected override Task GetTaskCore() => GetTask(); + + protected override void AbortTask() => TaskCompletionSource.SetCanceled(); + + internal override object? GetResult() => GetTask().Result; + + protected override void InvokeCore() + { + try + { + var result = ((Func)Callback!)(); + lock (Dispatcher.InstanceLock) + { + Status = DispatcherOperationStatus.Completed; + TaskCompletionSource.SetResult(result); + } + } + catch (Exception e) + { + lock (Dispatcher.InstanceLock) + { + Status = DispatcherOperationStatus.Completed; + TaskCompletionSource.SetException(e); + } + } + } + + public T Result + { + get + { + if (TaskCompletionSource.Task.IsCompleted || !Dispatcher.CheckAccess()) + return TaskCompletionSource.Task.GetAwaiter().GetResult(); + throw new InvalidOperationException("Synchronous wait is only supported on non-UI threads"); + } + } +} + +internal class SendOrPostCallbackDispatcherOperation : DispatcherOperation +{ + private readonly object? _arg; + + internal SendOrPostCallbackDispatcherOperation(Dispatcher dispatcher, DispatcherPriority priority, + SendOrPostCallback callback, object? arg, bool throwOnUiThread) + : base(dispatcher, priority, throwOnUiThread) + { + Callback = callback; + _arg = arg; + } + + protected override void InvokeCore() + { + try + { + ((SendOrPostCallback)Callback!)(_arg); + lock (Dispatcher.InstanceLock) + { + Status = DispatcherOperationStatus.Completed; + if (TaskSource is TaskCompletionSource tcs) + tcs.SetResult(null); + } + } + catch (Exception e) + { + lock (Dispatcher.InstanceLock) + { + Status = DispatcherOperationStatus.Completed; + if (TaskSource is TaskCompletionSource tcs) + tcs.SetException(e); + } + + if (ThrowOnUiThread) + throw; + } + } +} + +public enum DispatcherOperationStatus +{ + Pending = 0, + Aborted = 1, + Completed = 2, + Executing = 3, +} diff --git a/src/Avalonia.Base/Threading/DispatcherPriority.cs b/src/Avalonia.Base/Threading/DispatcherPriority.cs index b3194e249b..3017b45dc7 100644 --- a/src/Avalonia.Base/Threading/DispatcherPriority.cs +++ b/src/Avalonia.Base/Threading/DispatcherPriority.cs @@ -18,44 +18,64 @@ namespace Avalonia.Threading } /// - /// Minimum possible priority + /// The lowest foreground dispatcher priority /// - public static readonly DispatcherPriority MinValue = new(0); + public static readonly DispatcherPriority Default = new(0); + internal static readonly DispatcherPriority MinimumForegroundPriority = Default; + /// - /// The job will be processed when the system is idle. + /// The job will be processed with the same priority as input. /// - [Obsolete("WPF compatibility")] public static readonly DispatcherPriority SystemIdle = MinValue; - + public static readonly DispatcherPriority Input = new(Default - 1); + /// - /// The job will be processed when the application is idle. + /// The job will be processed after other non-idle operations have completed. /// - [Obsolete("WPF compatibility")] public static readonly DispatcherPriority ApplicationIdle = MinValue; - + public static readonly DispatcherPriority Background = new(Input - 1); + /// /// The job will be processed after background operations have completed. /// - [Obsolete("WPF compatibility")] public static readonly DispatcherPriority ContextIdle = MinValue; - + public static readonly DispatcherPriority ContextIdle = new(Background - 1); + + /// - /// The job will be processed with normal priority. + /// The job will be processed when the application is idle. /// - public static readonly DispatcherPriority Normal = MinValue; - + public static readonly DispatcherPriority ApplicationIdle = new (ContextIdle - 1); + /// - /// The job will be processed after other non-idle operations have completed. + /// The job will be processed when the system is idle. /// - public static readonly DispatcherPriority Background = new(MinValue + 1); + public static readonly DispatcherPriority SystemIdle = new(ApplicationIdle - 1); /// - /// The job will be processed with the same priority as input. + /// Minimum possible priority that's actually dispatched, default value /// - public static readonly DispatcherPriority Input = new(Background + 1); + internal static readonly DispatcherPriority MinimumActiveValue = new(SystemIdle); + + /// + /// A dispatcher priority for jobs that shouldn't be executed yet + /// + public static readonly DispatcherPriority Inactive = new(MinimumActiveValue - 1); + + /// + /// Minimum valid priority + /// + internal static readonly DispatcherPriority MinValue = new(Inactive); + + /// + /// Used internally in dispatcher code + /// + public static readonly DispatcherPriority Invalid = new(MinimumActiveValue - 2); + + /// /// The job will be processed after layout and render but before input. /// - public static readonly DispatcherPriority Loaded = new(Input + 1); + public static readonly DispatcherPriority Loaded = new(Default + 1); /// /// The job will be processed with the same priority as render. @@ -80,12 +100,19 @@ namespace Avalonia.Threading /// /// The job will be processed with the same priority as data binding. /// - [Obsolete("WPF compatibility")] public static readonly DispatcherPriority DataBind = MinValue; + [Obsolete("WPF compatibility")] public static readonly DispatcherPriority DataBind = new(Layout); + + /// + /// The job will be processed with normal priority. + /// +#pragma warning disable CS0618 + public static readonly DispatcherPriority Normal = new(DataBind + 1); +#pragma warning restore CS0618 /// /// The job will be processed before other asynchronous operations. /// - public static readonly DispatcherPriority Send = new(Layout + 1); + public static readonly DispatcherPriority Send = new(Normal + 1); /// /// Maximum possible priority @@ -127,5 +154,48 @@ namespace Avalonia.Threading /// public int CompareTo(DispatcherPriority other) => Value.CompareTo(other.Value); + + public static void Validate(DispatcherPriority priority, string parameterName) + { + if (priority < Inactive || priority > MaxValue) + throw new ArgumentException("Invalid DispatcherPriority value", parameterName); + } + +#pragma warning disable CS0618 + public override string ToString() + { + if (this == Invalid) + return nameof(Invalid); + if (this == Inactive) + return nameof(Inactive); + if (this == SystemIdle) + return nameof(SystemIdle); + if (this == ContextIdle) + return nameof(ContextIdle); + if (this == ApplicationIdle) + return nameof(ApplicationIdle); + if (this == Background) + return nameof(Background); + if (this == Input) + return nameof(Input); + if (this == Default) + return nameof(Default); + if (this == Loaded) + return nameof(Loaded); + if (this == Render) + return nameof(Render); + if (this == Composition) + return nameof(Composition); + if (this == PreComposition) + return nameof(PreComposition); + if (this == DataBind) + return nameof(DataBind); + if (this == Normal) + return nameof(Normal); + if (this == Send) + return nameof(Send); + return Value.ToString(); + } +#pragma warning restore CS0618 } } diff --git a/src/Avalonia.Base/Threading/DispatcherPriorityQueue.cs b/src/Avalonia.Base/Threading/DispatcherPriorityQueue.cs new file mode 100644 index 0000000000..524b4fab8d --- /dev/null +++ b/src/Avalonia.Base/Threading/DispatcherPriorityQueue.cs @@ -0,0 +1,418 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using Avalonia.Threading; + +namespace Avalonia.Threading; + + +internal class DispatcherPriorityQueue +{ + // Priority chains... + private readonly SortedList _priorityChains; // NOTE: should be Priority + private readonly Stack _cacheReusableChains; + + // Sequential chain... + private DispatcherOperation? _head; + private DispatcherOperation? _tail; + + public DispatcherPriorityQueue() + { + // Build the collection of priority chains. + _priorityChains = new SortedList(); // NOTE: should be Priority + _cacheReusableChains = new Stack(10); + + _head = _tail = null; + } + + // NOTE: not used + // public int Count {get{return _count;}} + + public DispatcherPriority MaxPriority // NOTE: should be Priority + { + get + { + int count = _priorityChains.Count; + + if (count > 0) + { + return _priorityChains.Keys[count - 1]; + } + else + { + return DispatcherPriority.Invalid; // NOTE: should be Priority.Invalid; + } + } + } + + public DispatcherOperation Enqueue(DispatcherPriority priority, DispatcherOperation item) // NOTE: should be Priority + { + // Find the existing chain for this priority, or create a new one + // if one does not exist. + PriorityChain chain = GetChain(priority); + + // Step 1: Append this to the end of the "sequential" linked list. + InsertItemInSequentialChain(item, _tail); + + // Step 2: Append the item into the priority chain. + InsertItemInPriorityChain(item, chain, chain.Tail); + + return item; + } + + public DispatcherOperation Dequeue() + { + // Get the max-priority chain. + int count = _priorityChains.Count; + if (count > 0) + { + PriorityChain chain = _priorityChains.Values[count - 1]; + Debug.Assert(chain != null, "PriorityQueue.Dequeue: a chain should exist."); + + DispatcherOperation? item = chain.Head; + Debug.Assert(item != null, "PriorityQueue.Dequeue: a priority item should exist."); + + RemoveItem(item); + + return item; + } + else + { + throw new InvalidOperationException(); + } + } + + public DispatcherOperation? Peek() + { + // Get the max-priority chain. + int count = _priorityChains.Count; + if (count > 0) + { + PriorityChain chain = _priorityChains.Values[count - 1]; + Debug.Assert(chain != null, "PriorityQueue.Peek: a chain should exist."); + + DispatcherOperation? item = chain.Head; + Debug.Assert(item != null, "PriorityQueue.Peek: a priority item should exist."); + + return item; + } + + return null; + } + + public void RemoveItem(DispatcherOperation item) + { + Debug.Assert(item != null, "PriorityQueue.RemoveItem: invalid item."); + Debug.Assert(item.Chain != null, "PriorityQueue.RemoveItem: a chain should exist."); + + // Step 1: Remove the item from its priority chain. + RemoveItemFromPriorityChain(item); + + // Step 2: Remove the item from the sequential chain. + RemoveItemFromSequentialChain(item); + } + + public void ChangeItemPriority(DispatcherOperation item, DispatcherPriority priority) // NOTE: should be Priority + { + // Remove the item from its current priority and insert it into + // the new priority chain. Note that this does not change the + // sequential ordering. + + // Step 1: Remove the item from the priority chain. + RemoveItemFromPriorityChain(item); + + // Step 2: Insert the item into the new priority chain. + // Find the existing chain for this priority, or create a new one + // if one does not exist. + PriorityChain chain = GetChain(priority); + InsertItemInPriorityChain(item, chain); + } + + private PriorityChain GetChain(DispatcherPriority priority) // NOTE: should be Priority + { + PriorityChain? chain = null; + + int count = _priorityChains.Count; + if (count > 0) + { + if (priority == _priorityChains.Keys[0]) + { + chain = _priorityChains.Values[0]; + } + else if (priority == _priorityChains.Keys[count - 1]) + { + chain = _priorityChains.Values[count - 1]; + } + else if ((priority > _priorityChains.Keys[0]) && + (priority < _priorityChains.Keys[count - 1])) + { + _priorityChains.TryGetValue(priority, out chain); + } + } + + if (chain == null) + { + if (_cacheReusableChains.Count > 0) + { + chain = _cacheReusableChains.Pop(); + chain.Priority = priority; + } + else + { + chain = new PriorityChain(priority); + } + + _priorityChains.Add(priority, chain); + } + + return chain; + } + + private void InsertItemInPriorityChain(DispatcherOperation item, PriorityChain chain) + { + // Scan along the sequential chain, in the previous direction, + // looking for an item that is already in the new chain. We will + // insert ourselves after the item we found. We can short-circuit + // this search if the new chain is empty. + if (chain.Head == null) + { + Debug.Assert(chain.Tail == null, + "PriorityQueue.InsertItemInPriorityChain: both the head and the tail should be null."); + InsertItemInPriorityChain(item, chain, null); + } + else + { + Debug.Assert(chain.Tail != null, + "PriorityQueue.InsertItemInPriorityChain: both the head and the tail should not be null."); + + DispatcherOperation? after; + + // Search backwards along the sequential chain looking for an + // item already in this list. + for (after = item.SequentialPrev; after != null; after = after.SequentialPrev) + { + if (after.Chain == chain) + { + break; + } + } + + InsertItemInPriorityChain(item, chain, after); + } + } + + internal void InsertItemInPriorityChain(DispatcherOperation item, PriorityChain chain, DispatcherOperation? after) + { + Debug.Assert(chain != null, "PriorityQueue.InsertItemInPriorityChain: a chain must be provided."); + Debug.Assert(item.Chain == null && item.PriorityPrev == null && item.PriorityNext == null, + "PriorityQueue.InsertItemInPriorityChain: item must not already be in a priority chain."); + + item.Chain = chain; + + if (after == null) + { + // Note: passing null for after means insert at the head. + + if (chain.Head != null) + { + Debug.Assert(chain.Tail != null, + "PriorityQueue.InsertItemInPriorityChain: both the head and the tail should not be null."); + + chain.Head.PriorityPrev = item; + item.PriorityNext = chain.Head; + chain.Head = item; + } + else + { + Debug.Assert(chain.Tail == null, + "PriorityQueue.InsertItemInPriorityChain: both the head and the tail should be null."); + + chain.Head = chain.Tail = item; + } + } + else + { + item.PriorityPrev = after; + + if (after.PriorityNext != null) + { + item.PriorityNext = after.PriorityNext; + after.PriorityNext.PriorityPrev = item; + after.PriorityNext = item; + } + else + { + Debug.Assert(item.Chain.Tail == after, + "PriorityQueue.InsertItemInPriorityChain: the chain's tail should be the item we are inserting after."); + after.PriorityNext = item; + chain.Tail = item; + } + } + + chain.Count++; + } + + private void RemoveItemFromPriorityChain(DispatcherOperation item) + { + Debug.Assert(item != null, "PriorityQueue.RemoveItemFromPriorityChain: invalid item."); + Debug.Assert(item.Chain != null, "PriorityQueue.RemoveItemFromPriorityChain: a chain should exist."); + + // Step 1: Fix up the previous link + if (item.PriorityPrev != null) + { + Debug.Assert(item.Chain.Head != item, + "PriorityQueue.RemoveItemFromPriorityChain: the head should not point to this item."); + + item.PriorityPrev.PriorityNext = item.PriorityNext; + } + else + { + Debug.Assert(item.Chain.Head == item, + "PriorityQueue.RemoveItemFromPriorityChain: the head should point to this item."); + + item.Chain.Head = item.PriorityNext; + } + + // Step 2: Fix up the next link + if (item.PriorityNext != null) + { + Debug.Assert(item.Chain.Tail != item, + "PriorityQueue.RemoveItemFromPriorityChain: the tail should not point to this item."); + + item.PriorityNext.PriorityPrev = item.PriorityPrev; + } + else + { + Debug.Assert(item.Chain.Tail == item, + "PriorityQueue.RemoveItemFromPriorityChain: the tail should point to this item."); + + item.Chain.Tail = item.PriorityPrev; + } + + // Step 3: cleanup + item.PriorityPrev = item.PriorityNext = null; + item.Chain.Count--; + if (item.Chain.Count == 0) + { + if (item.Chain.Priority == _priorityChains.Keys[_priorityChains.Count - 1]) + { + _priorityChains.RemoveAt(_priorityChains.Count - 1); + } + else + { + _priorityChains.Remove(item.Chain.Priority); + } + + if (_cacheReusableChains.Count < 10) + { + item.Chain.Priority = DispatcherPriority.Invalid; + _cacheReusableChains.Push(item.Chain); + } + } + + item.Chain = null; + } + + internal void InsertItemInSequentialChain(DispatcherOperation item, DispatcherOperation? after) + { + Debug.Assert(item.SequentialPrev == null && item.SequentialNext == null, + "PriorityQueue.InsertItemInSequentialChain: item must not already be in the sequential chain."); + + if (after == null) + { + // Note: passing null for after means insert at the head. + + if (_head != null) + { + Debug.Assert(_tail != null, + "PriorityQueue.InsertItemInSequentialChain: both the head and the tail should not be null."); + + _head.SequentialPrev = item; + item.SequentialNext = _head; + _head = item; + } + else + { + Debug.Assert(_tail == null, + "PriorityQueue.InsertItemInSequentialChain: both the head and the tail should be null."); + + _head = _tail = item; + } + } + else + { + item.SequentialPrev = after; + + if (after.SequentialNext != null) + { + item.SequentialNext = after.SequentialNext; + after.SequentialNext.SequentialPrev = item; + after.SequentialNext = item; + } + else + { + Debug.Assert(_tail == after, + "PriorityQueue.InsertItemInSequentialChain: the tail should be the item we are inserting after."); + after.SequentialNext = item; + _tail = item; + } + } + } + + private void RemoveItemFromSequentialChain(DispatcherOperation item) + { + Debug.Assert(item != null, "PriorityQueue.RemoveItemFromSequentialChain: invalid item."); + + // Step 1: Fix up the previous link + if (item.SequentialPrev != null) + { + Debug.Assert(_head != item, + "PriorityQueue.RemoveItemFromSequentialChain: the head should not point to this item."); + + item.SequentialPrev.SequentialNext = item.SequentialNext; + } + else + { + Debug.Assert(_head == item, + "PriorityQueue.RemoveItemFromSequentialChain: the head should point to this item."); + + _head = item.SequentialNext; + } + + // Step 2: Fix up the next link + if (item.SequentialNext != null) + { + Debug.Assert(_tail != item, + "PriorityQueue.RemoveItemFromSequentialChain: the tail should not point to this item."); + + item.SequentialNext.SequentialPrev = item.SequentialPrev; + } + else + { + Debug.Assert(_tail == item, + "PriorityQueue.RemoveItemFromSequentialChain: the tail should point to this item."); + + _tail = item.SequentialPrev; + } + + // Step 3: cleanup + item.SequentialPrev = item.SequentialNext = null; + } +} + + +internal class PriorityChain +{ + public PriorityChain(DispatcherPriority priority) // NOTE: should be Priority + { + Priority = priority; + } + + public DispatcherPriority Priority { get; set; } // NOTE: should be Priority + + public int Count { get; set; } + + public DispatcherOperation? Head { get; set; } + + public DispatcherOperation? Tail { get; set; } +} \ No newline at end of file diff --git a/src/Avalonia.Base/Threading/DispatcherTimer.cs b/src/Avalonia.Base/Threading/DispatcherTimer.cs index f81229eb48..879d9d8a5f 100644 --- a/src/Avalonia.Base/Threading/DispatcherTimer.cs +++ b/src/Avalonia.Base/Threading/DispatcherTimer.cs @@ -1,207 +1,352 @@ using System; using Avalonia.Reactive; -using Avalonia.Platform; -namespace Avalonia.Threading +namespace Avalonia.Threading; + +/// +/// A timer that is integrated into the Dispatcher queues, and will +/// be processed after a given amount of time at a specified priority. +/// +public partial class DispatcherTimer { /// - /// A timer that uses a to fire at a specified interval. + /// Creates a timer that uses theUI thread's Dispatcher2 to + /// process the timer event at background priority. /// - public class DispatcherTimer + public DispatcherTimer() : this(DispatcherPriority.Background) { - private IDisposable? _timer; + } - private readonly DispatcherPriority _priority; + /// + /// Creates a timer that uses the UI thread's Dispatcher2 to + /// process the timer event at the specified priority. + /// + /// + /// The priority to process the timer at. + /// + public DispatcherTimer(DispatcherPriority priority) : this(Threading.Dispatcher.UIThread, priority, + TimeSpan.FromMilliseconds(0)) + { + } - private TimeSpan _interval; + /// + /// Creates a timer that uses the specified Dispatcher2 to + /// process the timer event at the specified priority. + /// + /// + /// The priority to process the timer at. + /// + /// + /// The dispatcher to use to process the timer. + /// + internal DispatcherTimer(DispatcherPriority priority, Dispatcher dispatcher) : this(dispatcher, priority, + TimeSpan.FromMilliseconds(0)) + { + } - /// - /// Initializes a new instance of the class. - /// - public DispatcherTimer() : this(DispatcherPriority.Background) + /// + /// Creates a timer that uses the UI thread's Dispatcher2 to + /// process the timer event at the specified priority after the specified timeout. + /// + /// + /// The interval to tick the timer after. + /// + /// + /// The priority to process the timer at. + /// + /// + /// The callback to call when the timer ticks. + /// + public DispatcherTimer(TimeSpan interval, DispatcherPriority priority, EventHandler callback) + : this(Threading.Dispatcher.UIThread, priority, interval) + { + if (callback == null) { + throw new ArgumentNullException("callback"); } - /// - /// Initializes a new instance of the class. - /// - /// The priority to use. - public DispatcherTimer(DispatcherPriority priority) - { - _priority = priority; - } + Tick += callback; + Start(); + } - /// - /// Initializes a new instance of the class. - /// - /// The interval at which to tick. - /// The priority to use. - /// The event to call when the timer ticks. - public DispatcherTimer(TimeSpan interval, DispatcherPriority priority, EventHandler callback) : this(priority) - { - _priority = priority; - Interval = interval; - Tick += callback; - } + /// + /// Gets the dispatcher this timer is associated with. + /// + public Dispatcher Dispatcher + { + get { return _dispatcher; } + } - /// - /// Finalizes an instance of the class. - /// - ~DispatcherTimer() + /// + /// Gets or sets whether the timer is running. + /// + public bool IsEnabled + { + get { return _isEnabled; } + + set { - if (_timer != null) + lock (_instanceLock) { - Stop(); + if (!value && _isEnabled) + { + Stop(); + } + else if (value && !_isEnabled) + { + Start(); + } } } + } - /// - /// Raised when the timer ticks. - /// - public event EventHandler? Tick; + /// + /// Gets or sets the time between timer ticks. + /// + public TimeSpan Interval + { + get { return _interval; } - /// - /// Gets or sets the interval at which the timer ticks. - /// - public TimeSpan Interval + set { - get + bool updateOSTimer = false; + + if (value.TotalMilliseconds < 0) + throw new ArgumentOutOfRangeException("value", + "TimeSpan period must be greater than or equal to zero."); + + if (value.TotalMilliseconds > Int32.MaxValue) + throw new ArgumentOutOfRangeException("value", + "TimeSpan period must be less than or equal to Int32.MaxValue."); + + lock (_instanceLock) { - return _interval; + _interval = value; + + if (_isEnabled) + { + DueTimeInMs = _dispatcher.Now + (long)_interval.TotalMilliseconds; + updateOSTimer = true; + } } - set + if (updateOSTimer) { - bool enabled = IsEnabled; - Stop(); - _interval = value; - IsEnabled = enabled; + _dispatcher.RescheduleTimers(); } } + } - /// - /// Gets or sets a value indicating whether the timer is running. - /// - public bool IsEnabled + /// + /// Starts the timer. + /// + public void Start() + { + lock (_instanceLock) { - get + if (!_isEnabled) { - return _timer != null; + _isEnabled = true; + + Restart(); } + } + } - set + /// + /// Stops the timer. + /// + public void Stop() + { + bool updateOSTimer = false; + + lock (_instanceLock) + { + if (_isEnabled) { - if (IsEnabled != value) + _isEnabled = false; + updateOSTimer = true; + + // If the operation is in the queue, abort it. + if (_operation != null) { - if (value) - { - Start(); - } - else - { - Stop(); - } + _operation.Abort(); + _operation = null; } } } - /// - /// Gets or sets user-defined data associated with the timer. - /// - public object? Tag + if (updateOSTimer) { - get; - set; + _dispatcher.RemoveTimer(this); } + } + + /// + /// Starts a new timer. + /// + /// + /// The method to call on timer tick. If the method returns false, the timer will stop. + /// + /// The interval at which to tick. + /// The priority to use. + /// An used to cancel the timer. + public static IDisposable Run(Func action, TimeSpan interval, DispatcherPriority priority = default) + { + var timer = new DispatcherTimer(priority) { Interval = interval }; - /// - /// Starts a new timer. - /// - /// - /// The method to call on timer tick. If the method returns false, the timer will stop. - /// - /// The interval at which to tick. - /// The priority to use. - /// An used to cancel the timer. - public static IDisposable Run(Func action, TimeSpan interval, DispatcherPriority priority = default) + timer.Tick += (s, e) => { - var timer = new DispatcherTimer(priority) { Interval = interval }; - - timer.Tick += (s, e) => + if (!action()) { - if (!action()) - { - timer.Stop(); - } - }; + timer.Stop(); + } + }; - timer.Start(); + timer.Start(); - return Disposable.Create(() => timer.Stop()); - } + return Disposable.Create(() => timer.Stop()); + } + + /// + /// Runs a method once, after the specified interval. + /// + /// + /// The method to call after the interval has elapsed. + /// + /// The interval after which to call the method. + /// The priority to use. + /// An used to cancel the timer. + public static IDisposable RunOnce( + Action action, + TimeSpan interval, + DispatcherPriority priority = default) + { + interval = (interval != TimeSpan.Zero) ? interval : TimeSpan.FromTicks(1); + + var timer = new DispatcherTimer(priority) { Interval = interval }; - /// - /// Runs a method once, after the specified interval. - /// - /// - /// The method to call after the interval has elapsed. - /// - /// The interval after which to call the method. - /// The priority to use. - /// An used to cancel the timer. - public static IDisposable RunOnce( - Action action, - TimeSpan interval, - DispatcherPriority priority = default) + timer.Tick += (s, e) => { - interval = (interval != TimeSpan.Zero) ? interval : TimeSpan.FromTicks(1); - - var timer = new DispatcherTimer(priority) { Interval = interval }; + action(); + timer.Stop(); + }; + + timer.Start(); + + return Disposable.Create(() => timer.Stop()); + } + + /// + /// Occurs when the specified timer interval has elapsed and the + /// timer is enabled. + /// + public event EventHandler? Tick; + + /// + /// Any data that the caller wants to pass along with the timer. + /// + public object? Tag { get; set; } - timer.Tick += (s, e) => - { - action(); - timer.Stop(); - }; - timer.Start(); + internal DispatcherTimer(Dispatcher dispatcher, DispatcherPriority priority, TimeSpan interval) + { + if (dispatcher == null) + { + throw new ArgumentNullException("dispatcher"); + } - return Disposable.Create(() => timer.Stop()); + DispatcherPriority.Validate(priority, "priority"); + if (priority == DispatcherPriority.Inactive) + { + throw new ArgumentException("Specified priority is not valid.", "priority"); } - /// - /// Starts the timer. - /// - public void Start() + if (interval.TotalMilliseconds < 0) + throw new ArgumentOutOfRangeException("interval", "TimeSpan period must be greater than or equal to zero."); + + if (interval.TotalMilliseconds > Int32.MaxValue) + throw new ArgumentOutOfRangeException("interval", + "TimeSpan period must be less than or equal to Int32.MaxValue."); + + + _dispatcher = dispatcher; + _priority = priority; + _interval = interval; + } + + private void Restart() + { + lock (_instanceLock) { - if (!IsEnabled) + if (_operation != null) { - var threading = AvaloniaLocator.Current.GetRequiredService(); - _timer = threading.StartTimer(_priority, Interval, InternalTick); + // Timer has already been restarted, e.g. Start was called form the Tick handler. + return; + } + + // BeginInvoke a new operation. + _operation = _dispatcher.InvokeAsync(FireTick, DispatcherPriority.Inactive); + + DueTimeInMs = _dispatcher.Now + (long)_interval.TotalMilliseconds; + + if (_interval.TotalMilliseconds == 0 && _dispatcher.CheckAccess()) + { + // shortcut - just promote the item now + Promote(); + } + else + { + _dispatcher.AddTimer(this); } } + } - /// - /// Stops the timer. - /// - public void Stop() + internal void Promote() // called from Dispatcher + { + lock (_instanceLock) { - if (IsEnabled) + // Simply promote the operation to it's desired priority. + if (_operation != null) { - _timer!.Dispose(); - _timer = null; + _operation.Priority = _priority; } } + } + private void FireTick() + { + // The operation has been invoked, so forget about it. + _operation = null; + // The dispatcher thread is calling us because item's priority + // was changed from inactive to something else. + if (Tick != null) + { + Tick(this, EventArgs.Empty); + } - /// - /// Raises the event on the dispatcher thread. - /// - private void InternalTick() + // If we are still enabled, start the timer again. + if (_isEnabled) { - Dispatcher.UIThread.EnsurePriority(_priority); - Tick?.Invoke(this, EventArgs.Empty); + Restart(); } } -} + + // This is the object we use to synchronize access. + private object _instanceLock = new object(); + + // Note: We cannot BE a dispatcher-affinity object because we can be + // created by a worker thread. We are still associated with a + // dispatcher (where we post the item) but we can be accessed + // by any thread. + private Dispatcher _dispatcher; + + private DispatcherPriority _priority; // NOTE: should be Priority + private TimeSpan _interval; + private DispatcherOperation? _operation; + private bool _isEnabled; + + // used by Dispatcher + internal long DueTimeInMs { get; private set; } +} \ No newline at end of file diff --git a/src/Avalonia.Base/Threading/IDispatcher.cs b/src/Avalonia.Base/Threading/IDispatcher.cs index 1c700132b7..e2a3115f9c 100644 --- a/src/Avalonia.Base/Threading/IDispatcher.cs +++ b/src/Avalonia.Base/Threading/IDispatcher.cs @@ -26,30 +26,5 @@ namespace Avalonia.Threading /// The method. /// The priority with which to invoke the method. void Post(Action action, DispatcherPriority priority = default); - - /// - /// Posts an action that will be invoked on the dispatcher thread. - /// - /// The method. - /// The argument of method to call. - /// The priority with which to invoke the method. - void Post(SendOrPostCallback action, object? arg, DispatcherPriority priority = default); - - /// - /// Invokes a action on the dispatcher thread. - /// - /// The method. - /// The priority with which to invoke the method. - /// A task that can be used to track the method's execution. - Task InvokeAsync(Action action, DispatcherPriority priority = default); - - /// - /// Queues the specified work to run on the dispatcher thread and returns a proxy for the - /// task returned by . - /// - /// The work to execute asynchronously. - /// The priority with which to invoke the method. - /// A task that represents a proxy for the task returned by . - Task InvokeAsync(Func function, DispatcherPriority priority = default); } } diff --git a/src/Avalonia.Base/Threading/IDispatcherImpl.cs b/src/Avalonia.Base/Threading/IDispatcherImpl.cs new file mode 100644 index 0000000000..4c30e2eb2c --- /dev/null +++ b/src/Avalonia.Base/Threading/IDispatcherImpl.cs @@ -0,0 +1,103 @@ +using System; +using System.Diagnostics; +using System.Threading; +using Avalonia.Metadata; +using Avalonia.Platform; + +namespace Avalonia.Threading; + +[Unstable] +public interface IDispatcherImpl +{ + bool CurrentThreadIsLoopThread { get; } + + // Asynchronously triggers Signaled callback + void Signal(); + event Action Signaled; + event Action Timer; + long Now { get; } + void UpdateTimer(long? dueTimeInMs); +} + +[Unstable] +public interface IDispatcherImplWithPendingInput : IDispatcherImpl +{ + // Checks if dispatcher implementation can + bool CanQueryPendingInput { get; } + // Checks if there is pending user input + bool HasPendingInput { get; } +} + +[Unstable] +public interface IDispatcherImplWithExplicitBackgroundProcessing : IDispatcherImpl +{ + event Action ReadyForBackgroundProcessing; + void RequestBackgroundProcessing(); +} + +[Unstable] +public interface IControlledDispatcherImpl : IDispatcherImplWithPendingInput +{ + // Runs the event loop + void RunLoop(CancellationToken token); +} + +internal class LegacyDispatcherImpl : IDispatcherImpl +{ + private readonly IPlatformThreadingInterface _platformThreading; + private IDisposable? _timer; + private Stopwatch _clock = Stopwatch.StartNew(); + + public LegacyDispatcherImpl(IPlatformThreadingInterface platformThreading) + { + _platformThreading = platformThreading; + _platformThreading.Signaled += delegate { Signaled?.Invoke(); }; + } + + public bool CurrentThreadIsLoopThread => _platformThreading.CurrentThreadIsLoopThread; + public void Signal() => _platformThreading.Signal(DispatcherPriority.Send); + + public event Action? Signaled; + public event Action? Timer; + public long Now => _clock.ElapsedMilliseconds; + public void UpdateTimer(long? dueTimeInMs) + { + _timer?.Dispose(); + _timer = null; + + if (dueTimeInMs.HasValue) + { + var interval = Math.Max(1, dueTimeInMs.Value - _clock.ElapsedMilliseconds); + _timer = _platformThreading.StartTimer(DispatcherPriority.Send, + TimeSpan.FromMilliseconds(interval), + OnTick); + } + } + + private void OnTick() + { + _timer?.Dispose(); + _timer = null; + Timer?.Invoke(); + } +} + +class NullDispatcherImpl : IDispatcherImpl +{ + public bool CurrentThreadIsLoopThread => true; + + public void Signal() + { + + } + + public event Action? Signaled; + public event Action? Timer; + + public long Now => 0; + + public void UpdateTimer(long? dueTimeInMs) + { + + } +} diff --git a/src/Avalonia.Base/Threading/JobRunner.cs b/src/Avalonia.Base/Threading/JobRunner.cs deleted file mode 100644 index ced3423c27..0000000000 --- a/src/Avalonia.Base/Threading/JobRunner.cs +++ /dev/null @@ -1,300 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Avalonia.Platform; - -namespace Avalonia.Threading -{ - /// - /// A main loop in a . - /// - internal class JobRunner - { - private IPlatformThreadingInterface? _platform; - - private readonly Queue[] _queues = Enumerable.Range(0, (int)DispatcherPriority.MaxValue + 1) - .Select(_ => new Queue()).ToArray(); - - public JobRunner(IPlatformThreadingInterface? platform) - { - _platform = platform; - } - - /// - /// Runs continuations pushed on the loop. - /// - /// Priority to execute jobs for. Pass null if platform doesn't have internal priority system - public void RunJobs(DispatcherPriority? priority) - { - var minimumPriority = priority ?? DispatcherPriority.MinValue; - while (true) - { - var job = GetNextJob(minimumPriority); - if (job == null) - return; - - job.Run(); - } - } - - /// - /// Invokes a method on the main loop. - /// - /// The method. - /// The priority with which to invoke the method. - /// A task that can be used to track the method's execution. - public Task InvokeAsync(Action action, DispatcherPriority priority) - { - var job = new Job(action, priority, false); - AddJob(job); - return job.Task!; - } - - /// - /// Invokes a method on the main loop. - /// - /// The method. - /// The priority with which to invoke the method. - /// A task that can be used to track the method's execution. - public Task InvokeAsync(Func function, DispatcherPriority priority) - { - var job = new JobWithResult(function, priority); - AddJob(job); - return job.Task; - } - - /// - /// Post action that will be invoked on main thread - /// - /// The method. - /// - /// The priority with which to invoke the method. - internal void Post(Action action, DispatcherPriority priority) - { - AddJob(new Job(action, priority, true)); - } - - /// - /// Post action that will be invoked on main thread - /// - /// The method to call. - /// The parameter of method to call. - /// The priority with which to invoke the method. - internal void Post(SendOrPostCallback action, object? parameter, DispatcherPriority priority) - { - AddJob(new JobWithArg(action, parameter, priority, true)); - } - - /// - /// Allows unit tests to change the platform threading interface. - /// - internal void UpdateServices() - { - _platform = AvaloniaLocator.Current.GetService(); - } - - private void AddJob(IJob job) - { - bool needWake; - var queue = _queues[(int)job.Priority]; - lock (queue) - { - needWake = queue.Count == 0; - queue.Enqueue(job); - } - if (needWake) - _platform?.Signal(job.Priority); - } - - private IJob? GetNextJob(DispatcherPriority minimumPriority) - { - for (int c = (int)DispatcherPriority.MaxValue; c >= (int)minimumPriority; c--) - { - var q = _queues[c]; - lock (q) - { - if (q.Count > 0) - return q.Dequeue(); - } - } - return null; - } - - public bool HasJobsWithPriority(DispatcherPriority minimumPriority) - { - for (int c = (int)minimumPriority; c < (int)DispatcherPriority.MaxValue; c++) - { - var q = _queues[c]; - lock (q) - { - if (q.Count > 0) - return true; - } - } - - return false; - } - - private interface IJob - { - /// - /// Gets the job priority. - /// - DispatcherPriority Priority { get; } - - /// - /// Runs the job. - /// - void Run(); - } - - /// - /// A job to run. - /// - private sealed class Job : IJob - { - /// - /// The method to call. - /// - private readonly Action _action; - /// - /// The task completion source. - /// - private readonly TaskCompletionSource? _taskCompletionSource; - - /// - /// Initializes a new instance of the class. - /// - /// The method to call. - /// The job priority. - /// Do not wrap exception in TaskCompletionSource - public Job(Action action, DispatcherPriority priority, bool throwOnUiThread) - { - _action = action; - Priority = priority; - _taskCompletionSource = throwOnUiThread ? null : new TaskCompletionSource(); - } - - /// - public DispatcherPriority Priority { get; } - - /// - /// The task. - /// - public Task? Task => _taskCompletionSource?.Task; - - /// - void IJob.Run() - { - if (_taskCompletionSource == null) - { - _action(); - return; - } - try - { - _action(); - _taskCompletionSource.SetResult(null); - } - catch (Exception e) - { - _taskCompletionSource.SetException(e); - } - } - } - - /// - /// A typed job to run. - /// - private sealed class JobWithArg : IJob - { - private readonly SendOrPostCallback _action; - private readonly object? _parameter; - private readonly TaskCompletionSource? _taskCompletionSource; - - /// - /// Initializes a new instance of the class. - /// - /// The method to call. - /// The parameter of method to call. - /// The job priority. - /// Do not wrap exception in TaskCompletionSource - - public JobWithArg(SendOrPostCallback action, object? parameter, DispatcherPriority priority, bool throwOnUiThread) - { - _action = action; - _parameter = parameter; - Priority = priority; - _taskCompletionSource = throwOnUiThread ? null : new TaskCompletionSource(); - } - - /// - public DispatcherPriority Priority { get; } - - /// - void IJob.Run() - { - if (_taskCompletionSource == null) - { - _action(_parameter); - return; - } - try - { - _action(_parameter); - _taskCompletionSource.SetResult(default); - } - catch (Exception e) - { - _taskCompletionSource.SetException(e); - } - } - } - - /// - /// A job to run thath return value. - /// - /// Type of job result - private sealed class JobWithResult : IJob - { - private readonly Func _function; - private readonly TaskCompletionSource _taskCompletionSource; - - /// - /// Initializes a new instance of the class. - /// - /// The method to call. - /// The job priority. - public JobWithResult(Func function, DispatcherPriority priority) - { - _function = function; - Priority = priority; - _taskCompletionSource = new TaskCompletionSource(); - } - - /// - public DispatcherPriority Priority { get; } - - /// - /// The task. - /// - public Task Task => _taskCompletionSource.Task; - - /// - void IJob.Run() - { - try - { - var result = _function(); - _taskCompletionSource.SetResult(result); - } - catch (Exception e) - { - _taskCompletionSource.SetException(e); - } - } - } - } -} diff --git a/src/Windows/Avalonia.Win32/NonPumpingSyncContext.cs b/src/Avalonia.Base/Threading/NonPumpingSyncContext.cs similarity index 72% rename from src/Windows/Avalonia.Win32/NonPumpingSyncContext.cs rename to src/Avalonia.Base/Threading/NonPumpingSyncContext.cs index 2c4d2c9468..03fc0cc76c 100644 --- a/src/Windows/Avalonia.Win32/NonPumpingSyncContext.cs +++ b/src/Avalonia.Base/Threading/NonPumpingSyncContext.cs @@ -2,16 +2,17 @@ using System; using System.Runtime.ConstrainedExecution; using System.Threading; using Avalonia.Utilities; -using Avalonia.Win32.Interop; -namespace Avalonia.Win32 +namespace Avalonia.Threading { internal class NonPumpingSyncContext : SynchronizationContext, IDisposable { + private readonly NonPumpingLockHelper.IHelperImpl _impl; private readonly SynchronizationContext? _inner; - private NonPumpingSyncContext(SynchronizationContext? inner) + public NonPumpingSyncContext(NonPumpingLockHelper.IHelperImpl impl, SynchronizationContext? inner) { + _impl = impl; _inner = inner; SetWaitNotificationRequired(); SetSynchronizationContext(this); @@ -48,15 +49,12 @@ namespace Avalonia.Win32 #if !NET6_0_OR_GREATER [PrePrepareMethod] #endif - public override int Wait(IntPtr[] waitHandles, bool waitAll, int millisecondsTimeout) - { - return UnmanagedMethods.WaitForMultipleObjectsEx(waitHandles.Length, waitHandles, waitAll, - millisecondsTimeout, false); - } + public override int Wait(IntPtr[] waitHandles, bool waitAll, int millisecondsTimeout) => + _impl.Wait(waitHandles, waitAll, millisecondsTimeout); public void Dispose() => SetSynchronizationContext(_inner); - public static IDisposable? Use() + internal static IDisposable? Use(NonPumpingLockHelper.IHelperImpl impl) { var current = Current; if (current == null) @@ -67,12 +65,8 @@ namespace Avalonia.Win32 if (current is NonPumpingSyncContext) return null; - return new NonPumpingSyncContext(current); - } - - internal class HelperImpl : NonPumpingLockHelper.IHelperImpl - { - IDisposable? NonPumpingLockHelper.IHelperImpl.Use() => NonPumpingSyncContext.Use(); + return new NonPumpingSyncContext(impl, current); } + } } diff --git a/src/Avalonia.Base/Utilities/NonPumpingLockHelper.cs b/src/Avalonia.Base/Utilities/NonPumpingLockHelper.cs index 4cc1ba5190..55fd9a7957 100644 --- a/src/Avalonia.Base/Utilities/NonPumpingLockHelper.cs +++ b/src/Avalonia.Base/Utilities/NonPumpingLockHelper.cs @@ -1,4 +1,5 @@ using System; +using Avalonia.Threading; namespace Avalonia.Utilities { @@ -6,9 +7,15 @@ namespace Avalonia.Utilities { public interface IHelperImpl { - IDisposable? Use(); + int Wait(IntPtr[] waitHandles, bool waitAll, int millisecondsTimeout); } - public static IDisposable? Use() => AvaloniaLocator.Current.GetService()?.Use(); + public static IDisposable? Use() + { + var impl = AvaloniaLocator.Current.GetService(); + if (impl == null) + return null; + return NonPumpingSyncContext.Use(impl); + } } } diff --git a/src/Avalonia.Base/Visual.cs b/src/Avalonia.Base/Visual.cs index df0c5b100f..79cc760fc6 100644 --- a/src/Avalonia.Base/Visual.cs +++ b/src/Avalonia.Base/Visual.cs @@ -48,9 +48,9 @@ namespace Avalonia /// public static readonly StyledProperty ClipProperty = AvaloniaProperty.Register(nameof(Clip)); - + /// - /// Defines the property. + /// Defines the property. /// public static readonly StyledProperty IsVisibleProperty = AvaloniaProperty.Register(nameof(IsVisible), true); @@ -66,6 +66,12 @@ namespace Avalonia /// public static readonly StyledProperty OpacityMaskProperty = AvaloniaProperty.Register(nameof(OpacityMask)); + + /// + /// Defines the property. + /// + public static readonly StyledProperty EffectProperty = + AvaloniaProperty.Register(nameof(Effect)); /// /// Defines the property. @@ -127,6 +133,8 @@ namespace Avalonia ClipToBoundsProperty, IsVisibleProperty, OpacityProperty, + OpacityMaskProperty, + EffectProperty, HasMirrorTransformProperty); RenderTransformProperty.Changed.Subscribe(RenderTransformChanged); ZIndexProperty.Changed.Subscribe(ZIndexChanged); @@ -233,6 +241,16 @@ namespace Avalonia get { return GetValue(OpacityMaskProperty); } set { SetValue(OpacityMaskProperty, value); } } + + /// + /// Gets or sets the effect of the control. + /// + public IEffect? Effect + { + get => GetValue(EffectProperty); + set => SetValue(EffectProperty, value); + } + /// /// Gets or sets a value indicating whether to apply mirror transform on this control. @@ -487,7 +505,7 @@ namespace Avalonia for (var i = 0; i < visualChildrenCount; i++) { - if (visualChildren[i] is { } child) + if (visualChildren[i] is { } child && child._visualRoot != e.Root) // child may already have been attached within an event handler { child.OnAttachedToVisualTreeCore(e); } diff --git a/src/Avalonia.Base/composition-schema.xml b/src/Avalonia.Base/composition-schema.xml index 31722974ee..91d718dfd8 100644 --- a/src/Avalonia.Base/composition-schema.xml +++ b/src/Avalonia.Base/composition-schema.xml @@ -6,7 +6,8 @@ Avalonia.Rendering.Composition.Animations - + + @@ -27,7 +28,8 @@ - + + diff --git a/src/Avalonia.Controls.ColorPicker/AlphaComponentPosition.cs b/src/Avalonia.Controls.ColorPicker/AlphaComponentPosition.cs new file mode 100644 index 0000000000..4f3ae46a24 --- /dev/null +++ b/src/Avalonia.Controls.ColorPicker/AlphaComponentPosition.cs @@ -0,0 +1,26 @@ +namespace Avalonia.Controls +{ + /// + /// Defines the position of a color's alpha component relative to all other components. + /// + public enum AlphaComponentPosition + { + /// + /// The alpha component occurs before all other components. + /// + /// + /// For example, this may indicate the #AARRGGBB or ARGB format which + /// is the default format for XAML itself and the Color struct. + /// + Leading, + + /// + /// The alpha component occurs after all other components. + /// + /// + /// For example, this may indicate the #RRGGBBAA or RGBA format which + /// is the default format for CSS. + /// + Trailing, + } +} diff --git a/src/Avalonia.Controls.ColorPicker/ColorPicker/ColorPicker.cs b/src/Avalonia.Controls.ColorPicker/ColorPicker/ColorPicker.cs index 01cb745ba7..92d8535272 100644 --- a/src/Avalonia.Controls.ColorPicker/ColorPicker/ColorPicker.cs +++ b/src/Avalonia.Controls.ColorPicker/ColorPicker/ColorPicker.cs @@ -1,6 +1,4 @@ -using Avalonia.Controls.Primitives; - -namespace Avalonia.Controls +namespace Avalonia.Controls { /// /// Presents a color for user editing using a spectrum, palette and component sliders within a drop down. diff --git a/src/Avalonia.Controls.ColorPicker/ColorPreviewer/ColorPreviewer.cs b/src/Avalonia.Controls.ColorPicker/ColorPreviewer/ColorPreviewer.cs index 6f49430505..58702ecb61 100644 --- a/src/Avalonia.Controls.ColorPicker/ColorPreviewer/ColorPreviewer.cs +++ b/src/Avalonia.Controls.ColorPicker/ColorPreviewer/ColorPreviewer.cs @@ -124,7 +124,7 @@ namespace Avalonia.Controls.Primitives if (accentStep != 0) { // ColorChanged will be invoked in OnPropertyChanged if the value is different - HsvColor = AccentColorConverter.GetAccent(hsvColor, accentStep); + SetCurrentValue(HsvColorProperty, AccentColorConverter.GetAccent(hsvColor, accentStep)); } } } diff --git a/src/Avalonia.Controls.ColorPicker/ColorSlider/ColorSlider.cs b/src/Avalonia.Controls.ColorPicker/ColorSlider/ColorSlider.cs index dd5e7d5b01..ce47a797ec 100644 --- a/src/Avalonia.Controls.ColorPicker/ColorSlider/ColorSlider.cs +++ b/src/Avalonia.Controls.ColorPicker/ColorSlider/ColorSlider.cs @@ -96,8 +96,22 @@ namespace Avalonia.Controls.Primitives // independent pixels of controls. var scale = LayoutHelper.GetLayoutScale(this); - var pixelWidth = Convert.ToInt32(Bounds.Width * scale); - var pixelHeight = Convert.ToInt32(Bounds.Height * scale); + int pixelWidth; + int pixelHeight; + + if (base._track != null) + { + pixelWidth = Convert.ToInt32(base._track.Bounds.Width * scale); + pixelHeight = Convert.ToInt32(base._track.Bounds.Height * scale); + } + else + { + // As a fallback, attempt to calculate using the overall control size + // This shouldn't happen as a track is a required template part of a slider + // However, if it does, the spectrum will still be shown + pixelWidth = Convert.ToInt32(Bounds.Width * scale); + pixelHeight = Convert.ToInt32(Bounds.Height * scale); + } if (pixelWidth != 0 && pixelHeight != 0) { @@ -373,7 +387,7 @@ namespace Avalonia.Controls.Primitives ignorePropertyChanged = true; // Always keep the two color properties in sync - HsvColor = Color.ToHsv(); + SetCurrentValue(HsvColorProperty, Color.ToHsv()); SetColorToSliderValues(); UpdateBackground(); @@ -403,7 +417,7 @@ namespace Avalonia.Controls.Primitives ignorePropertyChanged = true; // Always keep the two color properties in sync - Color = HsvColor.ToRgb(); + SetCurrentValue(ColorProperty, HsvColor.ToRgb()); SetColorToSliderValues(); UpdateBackground(); @@ -440,13 +454,13 @@ namespace Avalonia.Controls.Primitives if (ColorModel == ColorModel.Hsva) { - HsvColor = hsvColor; - Color = hsvColor.ToRgb(); + SetCurrentValue(HsvColorProperty, hsvColor); + SetCurrentValue(ColorProperty, hsvColor.ToRgb()); } else { - Color = color; - HsvColor = color.ToHsv(); + SetCurrentValue(ColorProperty, color); + SetCurrentValue(HsvColorProperty, color.ToHsv()); } UpdatePseudoClasses(); diff --git a/src/Avalonia.Controls.ColorPicker/ColorSpectrum/ColorSpectrum.Properties.cs b/src/Avalonia.Controls.ColorPicker/ColorSpectrum/ColorSpectrum.Properties.cs index 5c7de2459b..2245eb8022 100644 --- a/src/Avalonia.Controls.ColorPicker/ColorSpectrum/ColorSpectrum.Properties.cs +++ b/src/Avalonia.Controls.ColorPicker/ColorSpectrum/ColorSpectrum.Properties.cs @@ -96,10 +96,10 @@ namespace Avalonia.Controls.Primitives /// /// Defines the property. /// - public static readonly StyledProperty ThirdComponentProperty = - AvaloniaProperty.Register( + public static readonly DirectProperty ThirdComponentProperty = + AvaloniaProperty.RegisterDirect( nameof(ThirdComponent), - ColorComponent.Component3); // Value + o => o.ThirdComponent); /// /// Gets or sets the currently selected color in the RGB color model. @@ -239,8 +239,8 @@ namespace Avalonia.Controls.Primitives /// public ColorComponent ThirdComponent { - get => GetValue(ThirdComponentProperty); - protected set => SetValue(ThirdComponentProperty, value); + get => _thirdComponent; + private set => SetAndRaise(ThirdComponentProperty, ref _thirdComponent, value); } } } diff --git a/src/Avalonia.Controls.ColorPicker/ColorSpectrum/ColorSpectrum.cs b/src/Avalonia.Controls.ColorPicker/ColorSpectrum/ColorSpectrum.cs index 6f4c0003a8..9198a2f237 100644 --- a/src/Avalonia.Controls.ColorPicker/ColorSpectrum/ColorSpectrum.cs +++ b/src/Avalonia.Controls.ColorPicker/ColorSpectrum/ColorSpectrum.cs @@ -13,9 +13,9 @@ using Avalonia.Interactivity; using Avalonia.Layout; using Avalonia.Media; using Avalonia.Media.Imaging; +using Avalonia.Reactive; using Avalonia.Threading; using Avalonia.Utilities; -using Avalonia.Reactive; namespace Avalonia.Controls.Primitives { @@ -48,6 +48,7 @@ namespace Avalonia.Controls.Primitives private bool _isPointerPressed = false; private bool _shouldShowLargeSelection = false; private List _hsvValues = new List(); + private ColorComponent _thirdComponent = ColorComponent.Component3; // HsvComponent.Value private IDisposable? _layoutRootDisposable; private IDisposable? _selectionEllipsePanelDisposable; @@ -403,7 +404,7 @@ namespace Avalonia.Controls.Primitives _updatingHsvColor = true; Hsv newHsv = (new Rgb(color)).ToHsv(); - HsvColor = newHsv.ToHsvColor(color.A / 255.0); + SetCurrentValue(HsvColorProperty, newHsv.ToHsvColor(color.A / 255.0)); _updatingHsvColor = false; UpdateEllipse(); @@ -534,7 +535,7 @@ namespace Avalonia.Controls.Primitives _updatingColor = true; Rgb newRgb = (new Hsv(hsvColor)).ToRgb(); - Color = newRgb.ToColor(hsvColor.A); + SetCurrentValue(ColorProperty, newRgb.ToColor(hsvColor.A)); _updatingColor = false; @@ -608,8 +609,8 @@ namespace Avalonia.Controls.Primitives Rgb newRgb = newHsv.ToRgb(); double alpha = HsvColor.A; - Color = newRgb.ToColor(alpha); - HsvColor = newHsv.ToHsvColor(alpha); + SetCurrentValue(ColorProperty, newRgb.ToColor(alpha)); + SetCurrentValue(HsvColorProperty, newHsv.ToHsvColor(alpha)); UpdateEllipse(); UpdatePseudoClasses(); diff --git a/src/Avalonia.Controls.ColorPicker/ColorView/ColorView.Properties.cs b/src/Avalonia.Controls.ColorPicker/ColorView/ColorView.Properties.cs index b76059037b..532e87a9fc 100644 --- a/src/Avalonia.Controls.ColorPicker/ColorView/ColorView.Properties.cs +++ b/src/Avalonia.Controls.ColorPicker/ColorView/ColorView.Properties.cs @@ -42,6 +42,14 @@ namespace Avalonia.Controls nameof(ColorSpectrumShape), ColorSpectrumShape.Box); + /// + /// Defines the property. + /// + public static readonly StyledProperty HexInputAlphaPositionProperty = + AvaloniaProperty.Register( + nameof(HexInputAlphaPosition), + AlphaComponentPosition.Leading); // By default match XAML and the WinUI control + /// /// Defines the property. /// @@ -260,6 +268,16 @@ namespace Avalonia.Controls set => SetValue(ColorSpectrumShapeProperty, value); } + /// + /// Gets or sets the position of the alpha component in the hexadecimal input box relative to + /// all other color components. + /// + public AlphaComponentPosition HexInputAlphaPosition + { + get => GetValue(HexInputAlphaPositionProperty); + set => SetValue(HexInputAlphaPositionProperty, value); + } + /// public HsvColor HsvColor { diff --git a/src/Avalonia.Controls.ColorPicker/ColorView/ColorView.cs b/src/Avalonia.Controls.ColorPicker/ColorView/ColorView.cs index 977b1f5c84..7674b74b6a 100644 --- a/src/Avalonia.Controls.ColorPicker/ColorView/ColorView.cs +++ b/src/Avalonia.Controls.ColorPicker/ColorView/ColorView.cs @@ -1,14 +1,10 @@ using System; using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Globalization; using Avalonia.Controls.Converters; using Avalonia.Controls.Metadata; -using Avalonia.Controls.Presenters; using Avalonia.Controls.Primitives; using Avalonia.Media; using Avalonia.Threading; -using Avalonia.VisualTree; namespace Avalonia.Controls { @@ -28,8 +24,7 @@ namespace Avalonia.Controls private TextBox? _hexTextBox; private TabControl? _tabControl; - private ColorToHexConverter colorToHexConverter = new ColorToHexConverter(); - protected bool ignorePropertyChanged = false; + protected bool _ignorePropertyChanged = false; /// /// Initializes a new instance of the class. @@ -46,11 +41,11 @@ namespace Avalonia.Controls { if (_hexTextBox != null) { - var convertedColor = colorToHexConverter.ConvertBack(_hexTextBox.Text, typeof(Color), null, CultureInfo.CurrentCulture); + var convertedColor = ColorToHexConverter.ParseHexString(_hexTextBox.Text ?? string.Empty, HexInputAlphaPosition); if (convertedColor is Color color) { - Color = color; + SetCurrentValue(ColorProperty, color); } // Re-apply the hex value @@ -66,7 +61,11 @@ namespace Avalonia.Controls { if (_hexTextBox != null) { - _hexTextBox.Text = colorToHexConverter.Convert(Color, typeof(string), null, CultureInfo.CurrentCulture) as string; + _hexTextBox.Text = ColorToHexConverter.ToHexString( + Color, + HexInputAlphaPosition, + includeAlpha: (IsAlphaEnabled && IsAlphaVisible), + includeSymbol: false); } } @@ -167,7 +166,7 @@ namespace Avalonia.Controls // The work-around for this is done here where SelectedIndex is forcefully // synchronized with whatever the TabControl property value is. This is // possible since selection validation is already done by this method. - SelectedIndex = _tabControl.SelectedIndex; + SetCurrentValue(SelectedIndexProperty, _tabControl.SelectedIndex); } return; @@ -200,7 +199,7 @@ namespace Avalonia.Controls /// protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { - if (ignorePropertyChanged) + if (_ignorePropertyChanged) { base.OnPropertyChanged(change); return; @@ -209,29 +208,29 @@ namespace Avalonia.Controls // Always keep the two color properties in sync if (change.Property == ColorProperty) { - ignorePropertyChanged = true; + _ignorePropertyChanged = true; - HsvColor = Color.ToHsv(); + SetCurrentValue(HsvColorProperty, Color.ToHsv()); SetColorToHexTextBox(); OnColorChanged(new ColorChangedEventArgs( change.GetOldValue(), change.GetNewValue())); - ignorePropertyChanged = false; + _ignorePropertyChanged = false; } else if (change.Property == HsvColorProperty) { - ignorePropertyChanged = true; + _ignorePropertyChanged = true; - Color = HsvColor.ToRgb(); + SetCurrentValue(ColorProperty, HsvColor.ToRgb()); SetColorToHexTextBox(); OnColorChanged(new ColorChangedEventArgs( change.GetOldValue().ToRgb(), change.GetNewValue().ToRgb())); - ignorePropertyChanged = false; + _ignorePropertyChanged = false; } else if (change.Property == PaletteProperty) { @@ -241,7 +240,7 @@ namespace Avalonia.Controls // bound properties controlling the palette grid if (palette != null) { - PaletteColumnCount = palette.ColorCount; + SetCurrentValue(PaletteColumnCountProperty, palette.ColorCount); List newPaletteColors = new List(); for (int shadeIndex = 0; shadeIndex < palette.ShadeCount; shadeIndex++) @@ -252,14 +251,14 @@ namespace Avalonia.Controls } } - PaletteColors = newPaletteColors; + SetCurrentValue(PaletteColorsProperty, newPaletteColors); } } else if (change.Property == IsAlphaEnabledProperty) { // Manually coerce the HsvColor value // (Color will be coerced automatically if HsvColor changes) - HsvColor = OnCoerceHsvColor(HsvColor); + SetCurrentValue(HsvColorProperty, OnCoerceHsvColor(HsvColor)); } else if (change.Property == IsColorComponentsVisibleProperty || change.Property == IsColorPaletteVisibleProperty || diff --git a/src/Avalonia.Controls.ColorPicker/Converters/ColorToHexConverter.cs b/src/Avalonia.Controls.ColorPicker/Converters/ColorToHexConverter.cs index 8d5f2332be..8257499d70 100644 --- a/src/Avalonia.Controls.ColorPicker/Converters/ColorToHexConverter.cs +++ b/src/Avalonia.Controls.ColorPicker/Converters/ColorToHexConverter.cs @@ -2,6 +2,7 @@ using System.Globalization; using Avalonia.Data.Converters; using Avalonia.Media; +using Avalonia.Utilities; namespace Avalonia.Controls.Converters { @@ -10,6 +11,23 @@ namespace Avalonia.Controls.Converters /// public class ColorToHexConverter : IValueConverter { + /// + /// Gets or sets a value indicating whether the alpha component is visible in the Hex formatted text. + /// + /// + /// When hidden the existing alpha component value is maintained. Also when hidden the user is still + /// able to input an 8-digit number with alpha. Alpha will be processed but then removed when displayed. + /// + /// Because this property only controls whether alpha is displayed (and it is still processed regardless) + /// it is termed 'Visible' instead of 'Enabled'. + /// + public bool IsAlphaVisible { get; set; } = true; + + /// + /// Gets or sets the position of a color's alpha component relative to all other components. + /// + public AlphaComponentPosition AlphaPosition { get; set; } = AlphaComponentPosition.Leading; + /// public object? Convert( object? value, @@ -42,16 +60,7 @@ namespace Avalonia.Controls.Converters return AvaloniaProperty.UnsetValue; } - string hexColor = color.ToUint32().ToString("x8", CultureInfo.InvariantCulture).ToUpperInvariant(); - - if (includeSymbol == false) - { - // TODO: When .net standard 2.0 is dropped, replace the below line - //hexColor = hexColor.Replace("#", string.Empty, StringComparison.Ordinal); - hexColor = hexColor.Replace("#", string.Empty); - } - - return hexColor; + return ToHexString(color, AlphaPosition, IsAlphaVisible, includeSymbol); } /// @@ -62,21 +71,173 @@ namespace Avalonia.Controls.Converters CultureInfo culture) { string hexValue = value?.ToString() ?? string.Empty; + return ParseHexString(hexValue, AlphaPosition) ?? AvaloniaProperty.UnsetValue; + } + + /// + /// Converts the given color to its hex color value string representation. + /// + /// The color to represent as a hex value string. + /// The output position of the alpha component. + /// Whether the alpha component will be included in the hex string. + /// Whether the hex symbol '#' will be added. + /// The input color converted to its hex value string. + public static string ToHexString( + Color color, + AlphaComponentPosition alphaPosition, + bool includeAlpha = true, + bool includeSymbol = false) + { + uint intColor; + string hexColor; - if (Color.TryParse(hexValue, out Color color)) + if (includeAlpha) + { + if (alphaPosition == AlphaComponentPosition.Trailing) + { + intColor = ((uint)color.R << 24) | ((uint)color.G << 16) | ((uint)color.B << 8) | (uint)color.A; + } + else + { + // Default is Leading alpha (same as XAML) + intColor = ((uint)color.A << 24) | ((uint)color.R << 16) | ((uint)color.G << 8) | (uint)color.B; + } + + hexColor = intColor.ToString("x8", CultureInfo.InvariantCulture).ToUpperInvariant(); + } + else + { + // In this case the alpha position no longer matters + // Both cases are calculated the same + intColor = ((uint)color.R << 16) | ((uint)color.G << 8) | (uint)color.B; + hexColor = intColor.ToString("x6", CultureInfo.InvariantCulture).ToUpperInvariant(); + } + + if (includeSymbol) + { + hexColor = '#' + hexColor; + } + + return hexColor; + } + + /// + /// Parses a hex color value string into a new . + /// + /// The hex color string to parse. + /// The input position of the alpha component. + /// The parsed ; otherwise, null. + public static Color? ParseHexString( + string hexColor, + AlphaComponentPosition alphaPosition) + { + hexColor = hexColor.Trim(); + + if (!hexColor.StartsWith("#", StringComparison.Ordinal)) + { + hexColor = "#" + hexColor; + } + + if (TryParseHexFormat(hexColor.AsSpan(), alphaPosition, out Color color)) { return color; } - else if (hexValue.StartsWith("#", StringComparison.Ordinal) == false && - Color.TryParse("#" + hexValue, out Color color2)) + + return null; + } + + /// + /// Parses the given span of characters representing a hex color value into a new . + /// + /// + /// This is based on the Color.TryParseHexFormat() method. + /// It is copied because it needs to be extended to handle alpha position. + /// However, the alpha position enum is only available in the controls namespace with the ColorPicker control. + /// + private static bool TryParseHexFormat( + ReadOnlySpan s, + AlphaComponentPosition alphaPosition, + out Color color) + { + static bool TryParseCore(ReadOnlySpan input, AlphaComponentPosition alphaPosition, ref Color color) { - return color2; + var alphaComponent = 0u; + + if (input.Length == 6) + { + if (alphaPosition == AlphaComponentPosition.Trailing) + { + alphaComponent = 0x000000FF; + } + else + { + alphaComponent = 0xFF000000; + } + } + else if (input.Length != 8) + { + return false; + } + + if (!input.TryParseUInt(NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var parsed)) + { + return false; + } + + if (alphaComponent != 0) + { + if (alphaPosition == AlphaComponentPosition.Trailing) + { + parsed = (parsed << 8) | alphaComponent; + } + else + { + parsed = parsed | alphaComponent; + } + } + + if (alphaPosition == AlphaComponentPosition.Trailing) + { + // #RRGGBBAA + color = new Color( + a: (byte)(parsed & 0xFF), + r: (byte)((parsed >> 24) & 0xFF), + g: (byte)((parsed >> 16) & 0xFF), + b: (byte)((parsed >> 8) & 0xFF)); + } + else + { + // #AARRGGBB + color = new Color( + a: (byte)((parsed >> 24) & 0xFF), + r: (byte)((parsed >> 16) & 0xFF), + g: (byte)((parsed >> 8) & 0xFF), + b: (byte)(parsed & 0xFF)); + } + + return true; } - else + + color = default; + + ReadOnlySpan input = s.Slice(1); + + // Handle shorthand cases like #FFF (RGB) or #FFFF (ARGB). + if (input.Length == 3 || input.Length == 4) { - // Invalid hex color value provided - return AvaloniaProperty.UnsetValue; + var extendedLength = 2 * input.Length; + Span extended = stackalloc char[extendedLength]; + + for (int i = 0; i < input.Length; i++) + { + extended[2 * i + 0] = input[i]; + extended[2 * i + 1] = input[i]; + } + + return TryParseCore(extended, alphaPosition, ref color); } + + return TryParseCore(input, alphaPosition, ref color); } } } diff --git a/src/Avalonia.Controls.ColorPicker/Helpers/ColorHelper.cs b/src/Avalonia.Controls.ColorPicker/Helpers/ColorHelper.cs index c1a03b1b77..c9801c432b 100644 --- a/src/Avalonia.Controls.ColorPicker/Helpers/ColorHelper.cs +++ b/src/Avalonia.Controls.ColorPicker/Helpers/ColorHelper.cs @@ -1,6 +1,6 @@ using System; -using System.Globalization; using System.Collections.Generic; +using System.Globalization; using Avalonia.Media; using Avalonia.Utilities; @@ -11,8 +11,11 @@ namespace Avalonia.Controls.Primitives /// public static class ColorHelper { - private static readonly Dictionary cachedDisplayNames = new Dictionary(); - private static readonly object cacheMutex = new object(); + private static readonly Dictionary _cachedDisplayNames = new Dictionary(); + private static readonly Dictionary _cachedKnownColorNames = new Dictionary(); + private static readonly object _displayNameCacheMutex = new object(); + private static readonly object _knownColorCacheMutex = new object(); + private static readonly KnownColor[] _knownColors = (KnownColor[])Enum.GetValues(typeof(KnownColor)); /// /// Gets the relative (perceptual) luminance/brightness of the given color. @@ -59,7 +62,36 @@ namespace Avalonia.Controls.Primitives /// The approximate color display name. public static string ToDisplayName(Color color) { - // Without rounding, there are 16,777,216 possible RGB colors (without alpha). + var hsvColor = color.ToHsv(); + + // Handle extremes that are outside the below algorithm + if (color.A == 0x00) + { + return GetDisplayName(KnownColor.Transparent); + } + + // HSV ---------------------------------------------------------------------- + // + // There are far too many possible HSV colors to cache and search through + // for performance reasons. Therefore, the HSV color is rounded. + // Rounding is tolerable in this algorithm because it is perception based. + // Hue is the most important for user perception so is rounded the least. + // Then there is a lot of loss in rounding the saturation and value components + // which are not as closely related to perceived color. + // + // Hue : Round to nearest int (0..360) + // Saturation : Round to the nearest 1/10 (0..1) + // Value : Round to the nearest 1/10 (0..1) + // Alpha : Is ignored in this algorithm + // + // Rounding results in ~36_000 values to cache in the worse case. + // + // RGB ---------------------------------------------------------------------- + // + // The original algorithm worked in RGB color space. + // If this code is every adjusted to work in RGB again note the following: + // + // Without rounding, there are 16_777_216 possible RGB colors (without alpha). // This is too many to cache and search through for performance reasons. // It is also needlessly large as there are only ~140 known/named colors. // Therefore, rounding of the input color's component values is done to @@ -68,42 +100,67 @@ namespace Avalonia.Controls.Primitives // The rounding value of 5 is specially chosen. // It is a factor of 255 and therefore evenly divisible which improves // the quality of the calculations. - double rounding = 5; - var roundedColor = new Color( - 0xFF, - Convert.ToByte(Math.Round(color.R / rounding) * rounding), - Convert.ToByte(Math.Round(color.G / rounding) * rounding), - Convert.ToByte(Math.Round(color.B / rounding) * rounding)); + var roundedHsvColor = new HsvColor( + 1.0, + Math.Round(hsvColor.H, 0), + Math.Round(hsvColor.S, 1), + Math.Round(hsvColor.V, 1)); // Attempt to use a previously cached display name - lock (cacheMutex) + lock (_displayNameCacheMutex) { - if (cachedDisplayNames.TryGetValue(roundedColor, out var displayName)) + if (_cachedDisplayNames.TryGetValue(roundedHsvColor, out var displayName)) { return displayName; } } + // Build the KnownColor name cache if it doesn't already exist + lock (_knownColorCacheMutex) + { + if (_cachedKnownColorNames.Count == 0) + { + for (int i = 1; i < _knownColors.Length; i++) // Skip 'None' so start at 1 + { + KnownColor knownColor = _knownColors[i]; + + // Some known colors have the same numerical value. For example: + // - Aqua = 0xff00ffff + // - Cyan = 0xff00ffff + // + // This is not possible to represent in a dictionary which requires + // unique values. Therefore, only the first value is used. + + if (!_cachedKnownColorNames.ContainsKey(knownColor)) + { + _cachedKnownColorNames.Add(knownColor, GetDisplayName(knownColor)); + } + } + } + } + // Find the closest known color by measuring 3D Euclidean distance (ignore alpha) + // This is done in HSV color space to most closely match user-perception var closestKnownColor = KnownColor.None; var closestKnownColorDistance = double.PositiveInfinity; - var knownColors = (KnownColor[])Enum.GetValues(typeof(KnownColor)); - for (int i = 1; i < knownColors.Length; i++) // Skip 'None' + for (int i = 1; i < _knownColors.Length; i++) // Skip 'None' so start at 1 { + KnownColor knownColor = _knownColors[i]; + // Transparent is skipped since alpha is ignored making it equivalent to White - if (knownColors[i] != KnownColor.Transparent) + if (knownColor != KnownColor.Transparent) { - Color knownColor = KnownColors.ToColor(knownColors[i]); + HsvColor knownHsvColor = KnownColors.ToColor(knownColor).ToHsv(); double distance = Math.Sqrt( - Math.Pow((double)(roundedColor.R - knownColor.R), 2.0) + - Math.Pow((double)(roundedColor.G - knownColor.G), 2.0) + - Math.Pow((double)(roundedColor.B - knownColor.B), 2.0)); + Math.Pow((roundedHsvColor.H - knownHsvColor.H), 2.0) + + Math.Pow((roundedHsvColor.S - knownHsvColor.S), 2.0) + + Math.Pow((roundedHsvColor.V - knownHsvColor.V), 2.0)); if (distance < closestKnownColorDistance) { - closestKnownColor = knownColors[i]; + closestKnownColor = knownColor; closestKnownColorDistance = distance; } } @@ -113,26 +170,19 @@ namespace Avalonia.Controls.Primitives // Cache results for next time as well if (closestKnownColor != KnownColor.None) { - var sb = StringBuilderCache.Acquire(); - string name = closestKnownColor.ToString(); + string? displayName; - // Add spaces converting PascalCase to human-readable names - for (int i = 0; i < name.Length; i++) + lock (_knownColorCacheMutex) { - if (i != 0 && - char.IsUpper(name[i])) + if (!_cachedKnownColorNames.TryGetValue(closestKnownColor, out displayName)) { - sb.Append(' '); + displayName = GetDisplayName(closestKnownColor); } - - sb.Append(name[i]); } - string displayName = StringBuilderCache.GetStringAndRelease(sb); - - lock (cacheMutex) + lock (_displayNameCacheMutex) { - cachedDisplayNames.Add(roundedColor, displayName); + _cachedDisplayNames.Add(roundedHsvColor, displayName); } return displayName; @@ -142,5 +192,35 @@ namespace Avalonia.Controls.Primitives return string.Empty; } } + + /// + /// Gets the human-readable display name for the given . + /// + /// + /// This currently uses the enum value's C# name directly + /// which limits it to the EN language only. In the future this should be localized + /// to other cultures. + /// + /// The to get the display name for. + /// The human-readable display name for the given . + private static string GetDisplayName(KnownColor knownColor) + { + var sb = StringBuilderCache.Acquire(); + string name = knownColor.ToString(); + + // Add spaces converting PascalCase to human-readable names + for (int i = 0; i < name.Length; i++) + { + if (i != 0 && + char.IsUpper(name[i])) + { + sb.Append(' '); + } + + sb.Append(name[i]); + } + + return StringBuilderCache.GetStringAndRelease(sb); + } } } diff --git a/src/Avalonia.Controls.ColorPicker/Helpers/ColorPickerHelpers.cs b/src/Avalonia.Controls.ColorPicker/Helpers/ColorPickerHelpers.cs index 819d745772..dbd92d4ac5 100644 --- a/src/Avalonia.Controls.ColorPicker/Helpers/ColorPickerHelpers.cs +++ b/src/Avalonia.Controls.ColorPicker/Helpers/ColorPickerHelpers.cs @@ -4,8 +4,6 @@ // Licensed to The Avalonia Project under the MIT License. using System; -using System.Collections.Generic; -using System.Linq; using System.Runtime.InteropServices; using System.Threading.Tasks; using Avalonia.Layout; diff --git a/src/Avalonia.Controls.ColorPicker/Themes/Fluent/ColorPicker.xaml b/src/Avalonia.Controls.ColorPicker/Themes/Fluent/ColorPicker.xaml index 807e4de0b1..b3c7cd9f9c 100644 --- a/src/Avalonia.Controls.ColorPicker/Themes/Fluent/ColorPicker.xaml +++ b/src/Avalonia.Controls.ColorPicker/Themes/Fluent/ColorPicker.xaml @@ -6,6 +6,8 @@ + + @@ -42,7 +44,8 @@ - + + 20 + 20 + 10 + 10 + - + @@ -25,27 +32,28 @@ + diff --git a/src/Avalonia.Themes.Fluent/Controls/EmbeddableControlRoot.xaml b/src/Avalonia.Themes.Fluent/Controls/EmbeddableControlRoot.xaml index 6a2651e3f5..f60424a2dc 100644 --- a/src/Avalonia.Themes.Fluent/Controls/EmbeddableControlRoot.xaml +++ b/src/Avalonia.Themes.Fluent/Controls/EmbeddableControlRoot.xaml @@ -3,6 +3,7 @@ + diff --git a/src/Avalonia.Themes.Fluent/Controls/Menu.xaml b/src/Avalonia.Themes.Fluent/Controls/Menu.xaml index c234cfd68e..e6bbbde632 100644 --- a/src/Avalonia.Themes.Fluent/Controls/Menu.xaml +++ b/src/Avalonia.Themes.Fluent/Controls/Menu.xaml @@ -28,6 +28,7 @@ diff --git a/src/Avalonia.Themes.Fluent/Controls/MenuScrollViewer.xaml b/src/Avalonia.Themes.Fluent/Controls/MenuScrollViewer.xaml index e8508935d2..adca099a10 100644 --- a/src/Avalonia.Themes.Fluent/Controls/MenuScrollViewer.xaml +++ b/src/Avalonia.Themes.Fluent/Controls/MenuScrollViewer.xaml @@ -75,17 +75,10 @@ Height="20" /> - + - + diff --git a/src/Avalonia.Themes.Fluent/Controls/ScrollViewer.xaml b/src/Avalonia.Themes.Fluent/Controls/ScrollViewer.xaml index 7a8c15bf60..1d9815713c 100644 --- a/src/Avalonia.Themes.Fluent/Controls/ScrollViewer.xaml +++ b/src/Avalonia.Themes.Fluent/Controls/ScrollViewer.xaml @@ -29,46 +29,23 @@ Grid.RowSpan="2" Grid.ColumnSpan="2" Background="{TemplateBinding Background}" - CanHorizontallyScroll="{TemplateBinding CanHorizontallyScroll}" - CanVerticallyScroll="{TemplateBinding CanVerticallyScroll}" - Content="{TemplateBinding Content}" - Extent="{TemplateBinding Extent, Mode=TwoWay}" - Padding="{TemplateBinding Padding}" HorizontalSnapPointsType="{TemplateBinding HorizontalSnapPointsType}" VerticalSnapPointsType="{TemplateBinding VerticalSnapPointsType}" HorizontalSnapPointsAlignment="{TemplateBinding HorizontalSnapPointsAlignment}" VerticalSnapPointsAlignment="{TemplateBinding VerticalSnapPointsAlignment}" - Offset="{TemplateBinding Offset, Mode=TwoWay}" - Viewport="{TemplateBinding Viewport, Mode=TwoWay}" - IsScrollChainingEnabled="{TemplateBinding IsScrollChainingEnabled}"> + Padding="{TemplateBinding Padding}"> - + + Grid.Row="1" /> + Grid.Column="1" /> + diff --git a/src/Avalonia.Themes.Simple/Controls/CaptionButtons.xaml b/src/Avalonia.Themes.Simple/Controls/CaptionButtons.xaml index 4522ffac87..040de9ddbd 100644 --- a/src/Avalonia.Themes.Simple/Controls/CaptionButtons.xaml +++ b/src/Avalonia.Themes.Simple/Controls/CaptionButtons.xaml @@ -54,7 +54,8 @@