diff --git a/README.md b/README.md index 9ca49fec..6b94f1d7 100644 --- a/README.md +++ b/README.md @@ -83,3 +83,6 @@ available! - The theme came from https://github.com/AngryCarrot789/WPFDarkTheme. - Some icons came from https://github.com/microsoft/fluentui-system-icons (search engine is at https://fluenticons.co/). +- The original grab cursor came from + https://www.svgrepo.com/svg/372329/cursor-hand-grab. The one I'm using is + modified to better support high DPI. diff --git a/Tql.App/Images.cs b/Tql.App/Images.cs index 2d44384e..a60412eb 100644 --- a/Tql.App/Images.cs +++ b/Tql.App/Images.cs @@ -55,4 +55,5 @@ private static DrawingImage LoadImage(string resourceName, Color? color) public static readonly DrawingImage PinOff = GetImage("Pin Off.svg"); public static readonly DrawingImage NuGet = GetImage("NuGet.svg"); public static readonly DrawingImage CheckmarkCircle = GetImage("Checkmark Circle.svg"); + public static readonly DrawingImage Grab = GetImage("Grab.svg"); } diff --git a/Tql.App/MainWindow.WinForms.xaml.cs b/Tql.App/MainWindow.WinForms.xaml.cs index 76a33fd4..2b7644d2 100644 --- a/Tql.App/MainWindow.WinForms.xaml.cs +++ b/Tql.App/MainWindow.WinForms.xaml.cs @@ -61,9 +61,24 @@ private void RepositionScreen() Left = (screen.WorkingArea.Left / scaleX) + ((screen.WorkingArea.Width / scaleX) - Width) / 2; - // The window is roughly 100 units in height. Most of the time though, it'll have - // content. Position it slightly above center so that the quick start window + + // The window without content is roughly 100 units in height. Most of the time though, + // it'll have content. Position it slightly above center so that the quick start window // has enough space to point at the configuration icon. - Top = (screen.WorkingArea.Top / scaleY) + ((screen.WorkingArea.Height / scaleY) - 140) / 2; + var top = + (screen.WorkingArea.Top / scaleY) + ((screen.WorkingArea.Height / scaleY) - 140) / 2; + + // The window height with content is 416 units. Move the window up somewhat if it + // would not fit in the work area. + var maxTop = (screen.WorkingArea.Bottom / scaleY) - (416 + 10); + if (top > maxTop) + top = maxTop; + + // And ensure it doesn't go out of the screen at the top. + var minTop = (screen.WorkingArea.Top / scaleY) + 10; + if (top < minTop) + top = minTop; + + Top = top; } } diff --git a/Tql.App/QuickStart/QuickStartAdorner.cs b/Tql.App/QuickStart/QuickStartAdorner.cs new file mode 100644 index 00000000..6a8a094c --- /dev/null +++ b/Tql.App/QuickStart/QuickStartAdorner.cs @@ -0,0 +1,43 @@ +namespace Tql.App.QuickStart; + +internal class QuickStartAdorner : Adorner +{ + public const int Distance = 6; + + private readonly Border _border; + + protected override int VisualChildrenCount => 1; + + public QuickStartAdorner(UIElement adornedElement) + : base(adornedElement) + { + _border = new Border + { + BorderBrush = new SolidColorBrush(Color.FromRgb(87, 157, 255)), + BorderThickness = new Thickness(2), + CornerRadius = new CornerRadius(4), + Margin = new Thickness(-Distance) + }; + + AddVisualChild(_border); + } + + protected override Size MeasureOverride(Size constraint) + { + _border.Measure(constraint); + + return base.MeasureOverride(constraint); + } + + protected override Size ArrangeOverride(Size finalSize) + { + _border.Arrange(new Rect(finalSize)); + + return base.ArrangeOverride(finalSize); + } + + protected override Visual GetVisualChild(int index) + { + return _border; + } +} diff --git a/Tql.App/QuickStart/QuickStartManager.cs b/Tql.App/QuickStart/QuickStartManager.cs index c87b3486..f8c6a5e3 100644 --- a/Tql.App/QuickStart/QuickStartManager.cs +++ b/Tql.App/QuickStart/QuickStartManager.cs @@ -13,6 +13,7 @@ internal class QuickStartManager private readonly UI _ui; private QuickStartDto _state; private QuickStartWindow? _window; + private bool _repositioning; public QuickStartDto State { @@ -51,6 +52,15 @@ public IDisposable Show( Window.GetWindow(owner) ?? throw new InvalidOperationException("Cannot resolve window"); var ownerIsControl = ownerWindow != owner; + var adorner = default(Adorner); + + if (ownerIsControl) + { + adorner = new QuickStartAdorner(owner); + + AdornerLayer.GetAdornerLayer(owner)!.Add(adorner); + } + _window = new QuickStartWindow(_ui) { DataContext = popup, @@ -92,12 +102,15 @@ public IDisposable Show( _window.Owner = ownerWindow; - _window.SourceInitialized += (_, _) => UpdateWindowLocation(owner, popup, mode); - ownerWindow.LocationChanged += (_, _) => UpdateWindowLocation(owner, popup, mode); - ownerWindow.SizeChanged += (_, _) => UpdateWindowLocation(owner, popup, mode); + _window.SourceInitialized += (_, _) => UpdateWindowLocation(owner, popup, mode, true); + ownerWindow.LocationChanged += (_, _) => UpdateWindowLocation(owner, popup, mode, false); + ownerWindow.SizeChanged += (_, _) => UpdateWindowLocation(owner, popup, mode, false); _window.Closed += (s, _) => { + if (adorner != null) + AdornerLayer.GetAdornerLayer(owner)!.Remove(adorner); + if (_window == (Window?)s) _window = null; }; @@ -125,12 +138,32 @@ public IDisposable Show( private void UpdateWindowLocation( FrameworkElement owner, QuickStartPopup popup, - QuickStartPopupMode mode = QuickStartPopupMode.None + QuickStartPopupMode mode, + bool initialUpdate ) { - if (_window == null || !ReferenceEquals(_window.DataContext, popup)) + if (_repositioning || _window == null || !ReferenceEquals(_window.DataContext, popup)) return; + _repositioning = true; + + try + { + DoUpdateWindowLocation(owner, popup, mode, initialUpdate); + } + finally + { + _repositioning = false; + } + } + + private void DoUpdateWindowLocation( + FrameworkElement owner, + QuickStartPopup popup, + QuickStartPopupMode mode, + bool initialUpdate + ) + { var ownerWindow = Window.GetWindow(owner) ?? throw new InvalidOperationException("Cannot resolve window"); var ownerIsControl = ownerWindow != owner; @@ -143,7 +176,8 @@ private void UpdateWindowLocation( var scaleY = source.CompositionTarget!.TransformToDevice.M22; var ownerBounds = ScaleBounds(GetOwnerBounds()); - var windowSize = ScaleSize(new Size(_window.ActualWidth, _window.ActualHeight)); + var ownerWindowBounds = GetOwnerWindowBounds(); + var windowSize = ScaleSize(new Size(_window!.ActualWidth, _window.ActualHeight)); var showOnScreen = ShowOnScreenManager.Create(_settings.ShowOnScreen); var screen = showOnScreen.GetScreen(); @@ -169,11 +203,26 @@ private void UpdateWindowLocation( // Ensure the quick start window is always this amount of pixels from // the owner window and the screen border. var edgeDistance = 10 * scaleX; + var repositionEdge = Edge.None; if (ownerIsControl) { x -= windowSize.Width / 2; - y = ownerBounds.Bottom + edgeDistance; + y = ownerBounds.Bottom + (QuickStartAdorner.Distance - 1) * scaleY; + + // Ensure that the quick start window is visible. + var bottom = y + windowSize.Height + edgeDistance; + var overhang = bottom - screen.WorkingArea.Bottom; + if (overhang > 0) + { + var availableSpace = + ownerWindowBounds.Top - (screen.WorkingArea.Top + edgeDistance); + if (overhang > availableSpace) + overhang = availableSpace; + + y -= overhang; + ownerWindow.Top -= overhang / scaleY; + } } else if (mode.HasFlag(QuickStartPopupMode.Modal)) { @@ -183,27 +232,37 @@ private void UpdateWindowLocation( } else { + var maxEdge = 4; + + // Prevent the quick start window from showing at the top of + // bottom if it wouldn't fit. + var requiredSpace = (windowSize.Height + ownerWindowBounds.Height + edgeDistance * 2); + if (requiredSpace > screen.WorkingArea.Height) + maxEdge = 2; + // Put the quick start window to the side, at a random side. - switch (random.Next(0, 4)) + repositionEdge = (Edge)random.Next(0, maxEdge); + + switch (repositionEdge) { - case 0: // North - x += xOffset; - y = ownerBounds.Top - (windowSize.Height + edgeDistance); + case Edge.Left: + x = ownerBounds.Left - (windowSize.Width + edgeDistance); + y += yOffset; break; - case 1: // East + case Edge.Right: x = ownerBounds.Right + edgeDistance; y += yOffset; break; - case 2: // South + case Edge.Top: x += xOffset; - y = ownerBounds.Bottom + edgeDistance; + y = ownerBounds.Top - (windowSize.Height + edgeDistance); break; - case 3: // West - x = ownerBounds.Left - (windowSize.Width + edgeDistance); - y += yOffset; + case Edge.Bottom: + x += xOffset; + y = ownerBounds.Bottom + edgeDistance; break; default: @@ -214,14 +273,63 @@ private void UpdateWindowLocation( if (!ownerIsControl) { // Make sure the quick start window is visible on the screen. - if (x < screen.Bounds.Left + edgeDistance) - x = screen.Bounds.Left + edgeDistance; - else if (x + windowSize.Width > screen.Bounds.Right - edgeDistance) - x = screen.Bounds.Right - edgeDistance - windowSize.Width; - if (y < screen.Bounds.Top + edgeDistance) - y = screen.Bounds.Top + edgeDistance; - else if (y + windowSize.Height > screen.Bounds.Bottom - edgeDistance) - y = screen.Bounds.Bottom - edgeDistance - windowSize.Height; + if (x < screen.WorkingArea.Left + edgeDistance) + x = screen.WorkingArea.Left + edgeDistance; + else if (x + windowSize.Width > screen.WorkingArea.Right - edgeDistance) + x = screen.WorkingArea.Right - edgeDistance - windowSize.Width; + if (y < screen.WorkingArea.Top + edgeDistance) + y = screen.WorkingArea.Top + edgeDistance; + else if (y + windowSize.Height > screen.WorkingArea.Bottom - edgeDistance) + y = screen.WorkingArea.Bottom - edgeDistance - windowSize.Height; + } + + // Minimize overlap with the owner window. + if (initialUpdate) + { + switch (repositionEdge) + { + case Edge.Left: + var right = x + windowSize.Width; + var overhang = right - ownerWindowBounds.Left; + if (overhang > 0) + { + var availableSpace = + screen.WorkingArea.Right - ownerWindowBounds.Right - edgeDistance; + ownerWindow.Left += Math.Min(overhang, availableSpace); + } + break; + + case Edge.Right: + overhang = ownerWindowBounds.Right - x; + if (overhang > 0) + { + var availableSpace = + ownerWindowBounds.Left - screen.WorkingArea.Left - edgeDistance; + ownerWindow.Left -= Math.Min(overhang, availableSpace); + } + break; + + case Edge.Top: + var bottom = y + windowSize.Height; + overhang = bottom - ownerWindowBounds.Top; + if (overhang > 0) + { + var availableSpace = + screen.WorkingArea.Bottom - ownerWindowBounds.Bottom - edgeDistance; + ownerWindow.Top += Math.Min(overhang, availableSpace); + } + break; + + case Edge.Bottom: + overhang = ownerWindowBounds.Bottom - y; + if (overhang > 0) + { + var availableSpace = + ownerWindowBounds.Top - screen.WorkingArea.Top - edgeDistance; + ownerWindow.Top -= Math.Min(overhang, availableSpace); + } + break; + } } _window.Left = x / scaleX; @@ -229,29 +337,30 @@ private void UpdateWindowLocation( Rect GetOwnerBounds() { - if (ownerIsControl) - { - var location = owner.TransformToAncestor(ownerWindow).Transform(new Point()); - - var clientRect = WindowInterop.GetClientRect( - new WindowInteropHelper(ownerWindow).Handle - ); - - return new Rect( - ownerWindow.Left + (clientRect.Left / scaleX) + location.X, - ownerWindow.Top + (clientRect.Top / scaleY) + location.Y, - owner.ActualWidth, - owner.ActualHeight - ); - } + if (!ownerIsControl) + return GetOwnerWindowBounds(); + + var location = owner.TransformToAncestor(ownerWindow).Transform(new Point()); + + var clientRect = WindowInterop.GetClientRect( + new WindowInteropHelper(ownerWindow).Handle + ); return new Rect( + ownerWindow.Left + (clientRect.Left / scaleX) + location.X, + ownerWindow.Top + (clientRect.Top / scaleY) + location.Y, + owner.ActualWidth, + owner.ActualHeight + ); + } + + Rect GetOwnerWindowBounds() => + new( ownerWindow.Left, ownerWindow.Top, ownerWindow.ActualWidth, ownerWindow.ActualHeight ); - } Rect ScaleBounds(Rect bounds) => new( @@ -276,4 +385,13 @@ public void Dispose() window.Close(); } } + + private enum Edge + { + None = -1, + Left = 0, + Right, + Top, + Bottom + } } diff --git a/Tql.App/QuickStart/QuickStartWindow.xaml b/Tql.App/QuickStart/QuickStartWindow.xaml index cce146dc..a74ede7c 100644 --- a/Tql.App/QuickStart/QuickStartWindow.xaml +++ b/Tql.App/QuickStart/QuickStartWindow.xaml @@ -17,7 +17,10 @@ d:DataContext="{d:DesignInstance local:QuickStartPopup}" d:DesignWidth="450" d:DesignHeight="300" - SourceInitialized="BaseWindow_SourceInitialized"> + SourceInitialized="BaseWindow_SourceInitialized" + MouseDown="BaseWindow_MouseDown" + MouseMove="BaseWindow_MouseMove" + MouseUp="BaseWindow_MouseUp"> + + diff --git a/Tql.App/Support/CursorUtils.cs b/Tql.App/Support/CursorUtils.cs new file mode 100644 index 00000000..12d5765a --- /dev/null +++ b/Tql.App/Support/CursorUtils.cs @@ -0,0 +1,69 @@ +using System.IO; + +namespace Tql.App.Support; + +internal static class CursorUtils +{ + // Taken from https://stackoverflow.com/questions/35296030. + public static Cursor Create(DrawingImage image, Point hotspot, Size cursorSize, DpiScale scale) + { + var scaledSize = new Size( + cursorSize.Width * scale.DpiScaleX, + cursorSize.Height * scale.DpiScaleY + ); + var scaledHotspot = new Point(hotspot.X * scale.DpiScaleX, hotspot.Y * scale.DpiScaleY); + + var vis = new DrawingVisual(); + using (var dc = vis.RenderOpen()) + { + dc.DrawImage(image, new Rect(0, 0, scaledSize.Width, scaledSize.Height)); + dc.Close(); + } + var rtb = new RenderTargetBitmap( + (int)scaledSize.Width, + (int)scaledSize.Height, + 96, + 96, + PixelFormats.Pbgra32 + ); + rtb.Render(vis); + + using (var ms1 = new MemoryStream()) + { + var penc = new PngBitmapEncoder(); + penc.Frames.Add(BitmapFrame.Create(rtb)); + penc.Save(ms1); + + var pngBytes = ms1.ToArray(); + var size = pngBytes.GetLength(0); + + //.cur format spec http://en.wikipedia.org/wiki/ICO_(file_format) + using (var ms = new MemoryStream()) + { + { //ICONDIR Structure + ms.Write(BitConverter.GetBytes((Int16)0), 0, 2); //Reserved must be zero; 2 bytes + ms.Write(BitConverter.GetBytes((Int16)2), 0, 2); //image type 1 = ico 2 = cur; 2 bytes + ms.Write(BitConverter.GetBytes((Int16)1), 0, 2); //number of images; 2 bytes + } + + { //ICONDIRENTRY structure + ms.WriteByte((byte)(scaledSize.Width)); //image width in pixels + ms.WriteByte((byte)(scaledSize.Height)); //image height in pixels + + ms.WriteByte(0); //Number of Colors in the color palette. Should be 0 if the image doesn't use a color palette + ms.WriteByte(0); //reserved must be 0 + + ms.Write(BitConverter.GetBytes((Int16)(scaledHotspot.X / 2.0)), 0, 2); //2 bytes. In CUR format: Specifies the horizontal coordinates of the hotspot in number of pixels from the left. + ms.Write(BitConverter.GetBytes((Int16)(scaledHotspot.Y / 2.0)), 0, 2); //2 bytes. In CUR format: Specifies the vertical coordinates of the hotspot in number of pixels from the top. + + ms.Write(BitConverter.GetBytes(size), 0, 4); //Specifies the size of the image's data in bytes + ms.Write(BitConverter.GetBytes((Int32)22), 0, 4); //Specifies the offset of BMP or PNG data from the beginning of the ICO/CUR file + } + + ms.Write(pngBytes, 0, size); //write the png data. + ms.Seek(0, SeekOrigin.Begin); + return new Cursor(ms); + } + } + } +} diff --git a/Tql.App/Support/FrameworkElementUtils.cs b/Tql.App/Support/FrameworkElementUtils.cs index d8059064..65e0770e 100644 --- a/Tql.App/Support/FrameworkElementUtils.cs +++ b/Tql.App/Support/FrameworkElementUtils.cs @@ -14,18 +14,25 @@ Action action { self.Cursor = Cursors.Hand; - self.MouseDown += (_, _) => self.CaptureMouse(); + self.MouseDown += (_, e) => + { + e.Handled = true; + + self.CaptureMouse(); + }; self.MouseUp += (s, e) => { + e.Handled = true; + if (e.ChangedButton != MouseButton.Left) return; - if (self.IsMouseOver) - action(s, e); - self.ReleaseMouseCapture(); + if (self.IsMouseDirectlyOver) + action(s, e); + e.Handled = true; }; }