diff --git a/src/Avalonia.Base/Data/Converters/StringFormatValueConverter.cs b/src/Avalonia.Base/Data/Converters/StringFormatValueConverter.cs new file mode 100644 index 0000000000..fc695762b8 --- /dev/null +++ b/src/Avalonia.Base/Data/Converters/StringFormatValueConverter.cs @@ -0,0 +1,49 @@ +using System; +using System.Globalization; + +namespace Avalonia.Data.Converters +{ + /// + /// A value converter which calls + /// + public class StringFormatValueConverter : IValueConverter + { + /// + /// Initializes a new instance of the class. + /// + /// The format string. + /// + /// An optional inner converter to be called before the format takes place. + /// + public StringFormatValueConverter(string format, IValueConverter inner) + { + Contract.Requires(format != null); + + Format = format; + Inner = inner; + } + + /// + /// Gets an inner value converter which will be called before the string format takes place. + /// + public IValueConverter Inner { get; } + + /// + /// Gets the format string. + /// + public string Format { get; } + + /// + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + value = Inner?.Convert(value, targetType, parameter, culture) ?? value; + return string.Format(culture, Format, value); + } + + /// + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotSupportedException("Two way bindings are not supported with a string format"); + } + } +} diff --git a/src/Avalonia.Controls/Primitives/PopupRoot.cs b/src/Avalonia.Controls/Primitives/PopupRoot.cs index 457a7bd4b4..0ae4be5550 100644 --- a/src/Avalonia.Controls/Primitives/PopupRoot.cs +++ b/src/Avalonia.Controls/Primitives/PopupRoot.cs @@ -83,21 +83,22 @@ namespace Avalonia.Controls.Primitives /// public void SnapInsideScreenEdges() { - var window = this.GetSelfAndLogicalAncestors().OfType().First(); - - var screen = window.Screens.ScreenFromPoint(Position); + var screen = Application.Current.MainWindow?.Screens.ScreenFromPoint(Position); - var screenX = Position.X + Bounds.Width - screen.Bounds.X; - var screenY = Position.Y + Bounds.Height - screen.Bounds.Y; - - if (screenX > screen.Bounds.Width) + if (screen != null) { - Position = Position.WithX(Position.X - (screenX - screen.Bounds.Width)); - } + var screenX = Position.X + Bounds.Width - screen.Bounds.X; + var screenY = Position.Y + Bounds.Height - screen.Bounds.Y; - if (screenY > screen.Bounds.Height) - { - Position = Position.WithY(Position.Y - (screenY - screen.Bounds.Height)); + if (screenX > screen.Bounds.Width) + { + Position = Position.WithX(Position.X - (screenX - screen.Bounds.Width)); + } + + if (screenY > screen.Bounds.Height) + { + Position = Position.WithY(Position.Y - (screenY - screen.Bounds.Height)); + } } } diff --git a/src/Avalonia.DotNetCoreRuntime/Avalonia.DotNetCoreRuntime.csproj b/src/Avalonia.DotNetCoreRuntime/Avalonia.DotNetCoreRuntime.csproj index f80462e958..0aed0a9717 100644 --- a/src/Avalonia.DotNetCoreRuntime/Avalonia.DotNetCoreRuntime.csproj +++ b/src/Avalonia.DotNetCoreRuntime/Avalonia.DotNetCoreRuntime.csproj @@ -10,6 +10,7 @@ + diff --git a/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/BindingExtension.cs b/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/BindingExtension.cs index 08ba26573f..1ceef5c824 100644 --- a/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/BindingExtension.cs +++ b/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/BindingExtension.cs @@ -43,8 +43,9 @@ namespace Avalonia.Markup.Xaml.MarkupExtensions Path = Path, Priority = Priority, Source = Source, + StringFormat = StringFormat, RelativeSource = RelativeSource, - DefaultAnchor = new WeakReference(GetDefaultAnchor((ITypeDescriptorContext)serviceProvider)) + DefaultAnchor = new WeakReference(GetDefaultAnchor(descriptorContext)) }; } @@ -79,6 +80,8 @@ namespace Avalonia.Markup.Xaml.MarkupExtensions public object Source { get; set; } + public string StringFormat { get; set; } + public RelativeSource RelativeSource { get; set; } } } diff --git a/src/Markup/Avalonia.Markup/Data/Binding.cs b/src/Markup/Avalonia.Markup/Data/Binding.cs index 03678c3b5a..4f18c682b4 100644 --- a/src/Markup/Avalonia.Markup/Data/Binding.cs +++ b/src/Markup/Avalonia.Markup/Data/Binding.cs @@ -84,6 +84,11 @@ namespace Avalonia.Data /// public object Source { get; set; } + /// + /// Gets or sets the string format. + /// + public string StringFormat { get; set; } + public WeakReference DefaultAnchor { get; set; } /// @@ -181,11 +186,23 @@ namespace Avalonia.Data fallback = null; } + var converter = Converter; + var targetType = targetProperty?.PropertyType ?? typeof(object); + + // We only respect `StringFormat` if the type of the property we're assigning to will + // accept a string. Note that this is slightly different to WPF in that WPF only applies + // `StringFormat` for target type `string` (not `object`). + if (!string.IsNullOrWhiteSpace(StringFormat) && + (targetType == typeof(string) || targetType == typeof(object))) + { + converter = new StringFormatValueConverter(StringFormat, converter); + } + var subject = new BindingExpression( observer, - targetProperty?.PropertyType ?? typeof(object), + targetType, fallback, - Converter ?? DefaultValueConverter.Instance, + converter ?? DefaultValueConverter.Instance, ConverterParameter, Priority); diff --git a/src/OSX/Avalonia.MonoMac/PopupImpl.cs b/src/OSX/Avalonia.MonoMac/PopupImpl.cs index ba4b7f0eac..267e47a6a0 100644 --- a/src/OSX/Avalonia.MonoMac/PopupImpl.cs +++ b/src/OSX/Avalonia.MonoMac/PopupImpl.cs @@ -8,11 +8,23 @@ namespace Avalonia.MonoMac public PopupImpl() { UpdateStyle(); + Window.Level = NSWindowLevel.PopUpMenu; } protected override NSWindowStyle GetStyle() { return NSWindowStyle.Borderless; } + + protected override CustomWindow CreateCustomWindow() => new CustomPopupWindow(this); + + private class CustomPopupWindow : CustomWindow + { + public CustomPopupWindow(WindowBaseImpl impl) + : base(impl) + { } + + public override bool WorksWhenModal() => true; + } } -} \ No newline at end of file +} diff --git a/src/OSX/Avalonia.MonoMac/WindowBaseImpl.cs b/src/OSX/Avalonia.MonoMac/WindowBaseImpl.cs index 89cef59b53..d3053a6af1 100644 --- a/src/OSX/Avalonia.MonoMac/WindowBaseImpl.cs +++ b/src/OSX/Avalonia.MonoMac/WindowBaseImpl.cs @@ -14,17 +14,18 @@ namespace Avalonia.MonoMac private readonly ManagedWindowResizeDragHelper _managedDrag; public CustomWindow Window { get; private set; } + private bool _closed; + public WindowBaseImpl() { _managedDrag = new ManagedWindowResizeDragHelper(this, _ => { }, ResizeForManagedDrag); - Window = new CustomWindow(this) - { - StyleMask = NSWindowStyle.Titled, - BackingType = NSBackingStore.Buffered, - ContentView = View, - // ReSharper disable once VirtualMemberCallInConstructor - Delegate = CreateWindowDelegate() - }; + // ReSharper disable once VirtualMemberCallInConstructor + Window = CreateCustomWindow(); + Window.StyleMask = NSWindowStyle.Titled; + Window.BackingType = NSBackingStore.Buffered; + Window.ContentView = View; + // ReSharper disable once VirtualMemberCallInConstructor + Window.Delegate = CreateWindowDelegate(); } public class CustomWindow : NSWindow @@ -55,6 +56,7 @@ namespace Avalonia.MonoMac public void SetCanBecomeKeyAndMain() => _canBecomeKeyAndMain = true; } + protected virtual CustomWindow CreateCustomWindow() => new CustomWindow(this); protected virtual NSWindowDelegate CreateWindowDelegate() => new WindowBaseDelegate(this); public class WindowBaseDelegate : NSWindowDelegate @@ -78,9 +80,8 @@ namespace Avalonia.MonoMac public override void WillClose(global::MonoMac.Foundation.NSNotification notification) { - _impl.Window.Dispose(); - _impl.Window = null; - _impl.Dispose(); + _impl._closed = true; + _impl.Closed?.Invoke(); } public override CGRect WillUseStandardFrame(NSWindow window, CGRect newFrame) @@ -94,8 +95,19 @@ namespace Avalonia.MonoMac { return true; } + + public override void DidResize(NSNotification notification) + { + _impl.OnResized(); + } } + /// + /// As you can't combine NSWindowDelegate overrides and events this is a workaround + /// + protected virtual void OnResized() + { + } public Point Position { @@ -185,12 +197,11 @@ namespace Avalonia.MonoMac return cocoaScreenPoint.ConvertPointY().ToAvaloniaPoint(); } - - public override void Dispose() { - Window?.Close(); - Window?.Dispose(); + if (!_closed) + Window.Close(); + Window.Dispose(); base.Dispose(); } } diff --git a/src/OSX/Avalonia.MonoMac/WindowImpl.cs b/src/OSX/Avalonia.MonoMac/WindowImpl.cs index 3900af8709..89b5550688 100644 --- a/src/OSX/Avalonia.MonoMac/WindowImpl.cs +++ b/src/OSX/Avalonia.MonoMac/WindowImpl.cs @@ -26,18 +26,19 @@ namespace Avalonia.MonoMac }); Window.SetCanBecomeKeyAndMain(); - - Window.DidResize += delegate - { - var windowState = Window.IsMiniaturized ? WindowState.Minimized - : (IsZoomed ? WindowState.Maximized : WindowState.Normal); + } - if (windowState != _lastWindowState) - { - _lastWindowState = windowState; - WindowStateChanged?.Invoke(windowState); - } - }; + + protected override void OnResized() + { + var windowState = Window.IsMiniaturized ? WindowState.Minimized + : (IsZoomed ? WindowState.Maximized : WindowState.Normal); + + if (windowState != _lastWindowState) + { + _lastWindowState = windowState; + WindowStateChanged?.Invoke(windowState); + } } public WindowState WindowState @@ -129,24 +130,47 @@ namespace Avalonia.MonoMac class ModalDisposable : IDisposable { readonly WindowImpl _impl; + readonly IntPtr _modalSession; + bool disposed; - public ModalDisposable(WindowImpl impl) + public ModalDisposable(WindowImpl impl, IntPtr modalSession) { _impl = impl; + _modalSession = modalSession; + } + + public void Continue() + { + if (disposed) + return; + + var response = (NSRunResponse)NSApplication.SharedApplication.RunModalSession(_modalSession); + if (response == NSRunResponse.Continues) + { + Dispatcher.UIThread.Post(Continue, DispatcherPriority.ContextIdle); + } + else + { + Logging.Logger.Log(Logging.LogEventLevel.Debug, "MonoMac", this, "Modal session ended"); + } } public void Dispose() { + Logging.Logger.Log(Logging.LogEventLevel.Debug, "MonoMac", this, "ModalDisposable disposed"); _impl.Window.OrderOut(_impl.Window); + NSApplication.SharedApplication.EndModalSession(_modalSession); + disposed = true; } } public IDisposable ShowDialog() { - //TODO: Investigate how to return immediately. - // May be add some magic to our run loop or something - NSApplication.SharedApplication.RunModalForWindow(Window); - return new ModalDisposable(this); + var session = NSApplication.SharedApplication.BeginModalSession(Window); + var disposable = new ModalDisposable(this, session); + Dispatcher.UIThread.Post(disposable.Continue, DispatcherPriority.ContextIdle); + + return disposable; } } -} \ No newline at end of file +} diff --git a/tests/Avalonia.Controls.UnitTests/ContextMenuTests.cs b/tests/Avalonia.Controls.UnitTests/ContextMenuTests.cs index 94d5caa720..66f0cc3a40 100644 --- a/tests/Avalonia.Controls.UnitTests/ContextMenuTests.cs +++ b/tests/Avalonia.Controls.UnitTests/ContextMenuTests.cs @@ -64,7 +64,10 @@ namespace Avalonia.Controls.UnitTests { ContextMenu = sut }; - new Window { Content = target }; + + var window = new Window { Content = target }; + + Avalonia.Application.Current.MainWindow = window; target.RaiseEvent(new PointerReleasedEventArgs { diff --git a/tests/Avalonia.Markup.UnitTests/Data/BindingTests_Converters.cs b/tests/Avalonia.Markup.UnitTests/Data/BindingTests_Converters.cs new file mode 100644 index 0000000000..123aadfda5 --- /dev/null +++ b/tests/Avalonia.Markup.UnitTests/Data/BindingTests_Converters.cs @@ -0,0 +1,146 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using System; +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Data.Converters; +using Avalonia.Data.Core; +using Xunit; + +namespace Avalonia.Markup.UnitTests.Data +{ + public class BindingTests_Converters + { + [Fact] + public void Converter_Should_Be_Used() + { + var textBlock = new TextBlock + { + DataContext = new Class1(), + }; + + var target = new Binding(nameof(Class1.Foo)) + { + Converter = StringConverters.NullOrEmpty, + }; + + var expressionObserver = (BindingExpression)target.Initiate( + textBlock, + TextBlock.TextProperty).Observable; + + Assert.Same(StringConverters.NullOrEmpty, expressionObserver.Converter); + } + + public class When_Binding_To_String + { + [Fact] + public void StringFormatConverter_Should_Be_Used_When_Binding_Has_StringFormat() + { + var textBlock = new TextBlock + { + DataContext = new Class1(), + }; + + var target = new Binding(nameof(Class1.Foo)) + { + StringFormat = "Hello {0}", + }; + + var expressionObserver = (BindingExpression)target.Initiate( + textBlock, + TextBlock.TextProperty).Observable; + + Assert.IsType(expressionObserver.Converter); + } + } + + public class When_Binding_To_Object + { + [Fact] + public void StringFormatConverter_Should_Be_Used_When_Binding_Has_StringFormat() + { + var textBlock = new TextBlock + { + DataContext = new Class1(), + }; + + var target = new Binding(nameof(Class1.Foo)) + { + StringFormat = "Hello {0}", + }; + + var expressionObserver = (BindingExpression)target.Initiate( + textBlock, + TextBlock.TagProperty).Observable; + + Assert.IsType(expressionObserver.Converter); + } + } + + public class When_Binding_To_Non_String_Or_Object + { + [Fact] + public void StringFormatConverter_Should_Not_Be_Used_When_Binding_Has_StringFormat() + { + var textBlock = new TextBlock + { + DataContext = new Class1(), + }; + + var target = new Binding(nameof(Class1.Foo)) + { + StringFormat = "Hello {0}", + }; + + var expressionObserver = (BindingExpression)target.Initiate( + textBlock, + TextBlock.MarginProperty).Observable; + + Assert.Same(DefaultValueConverter.Instance, expressionObserver.Converter); + } + } + + [Fact] + public void StringFormat_Should_Be_Applied() + { + var textBlock = new TextBlock + { + DataContext = new Class1(), + }; + + var target = new Binding(nameof(Class1.Foo)) + { + StringFormat = "Hello {0}", + }; + + textBlock.Bind(TextBlock.TextProperty, target); + + Assert.Equal("Hello foo", textBlock.Text); + } + + [Fact] + public void StringFormat_Should_Be_Applied_After_Converter() + { + var textBlock = new TextBlock + { + DataContext = new Class1(), + }; + + var target = new Binding(nameof(Class1.Foo)) + { + Converter = StringConverters.NotNullOrEmpty, + StringFormat = "Hello {0}", + }; + + textBlock.Bind(TextBlock.TextProperty, target); + + Assert.Equal("Hello True", textBlock.Text); + } + + private class Class1 + { + public string Foo { get; set; } = "foo"; + } + } +} diff --git a/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/BindingTests.cs b/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/BindingTests.cs index f327e9ccf2..fef9dfb675 100644 --- a/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/BindingTests.cs +++ b/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/BindingTests.cs @@ -308,5 +308,27 @@ namespace Avalonia.Markup.Xaml.UnitTests.Xaml Assert.Equal(5.6, AttachedPropertyOwner.GetDouble(textBlock)); } } + + [Fact] + public void Binding_To_TextBlock_Text_With_StringConverter_Works() + { + using (UnitTestApplication.Start(TestServices.StyledWindow)) + { + var xaml = @" + + +"; + var loader = new AvaloniaXamlLoader(); + var window = (Window)loader.Load(xaml); + var textBlock = window.FindControl("textBlock"); + + textBlock.DataContext = new { Foo = "world" }; + window.ApplyTemplate(); + + Assert.Equal("Hello world", textBlock.Text); + } + } } }