[Mouse Jump] - screenshot performance (#24607) (#24630)

* [Mouse Jump] - screenshot performance (#24607)

* [Mouse Jump] - add words to spellchecker (#24607)

* [Mouse Jump] - progressive activation (#24607)

* [Mouse Jump] - fixing build (#24607)

* [Mouse Jump] - fixing jump location, add unit tests, refactor (#24607)

* [Mouse Jump] - removed testing code (#24607)

* [Mouse Jump] added failing tests for

* [Mouse Jump] - fix problem with some monitor layouts (#24607)

* [Mouse Jump] - cleaning up some comments and namepsaces

* [Mouse Jump] - added another screen layout test (#24607)
This commit is contained in:
Michael Clayton
2023-03-17 18:12:27 +00:00
committed by GitHub
parent 5cbe9dd911
commit e6767c8e8b
36 changed files with 2121 additions and 951 deletions

View File

@@ -49,6 +49,7 @@ angularsen
Animatable
ansicolor
ANull
ANDSCANS
AOC
aocfnapldcnfbofgmbbllojgocaelgdd
APARTMENTTHREADED
@@ -139,7 +140,9 @@ BITMAPINFOHEADER
bitmask
BITSPIXEL
bla
BLACKONWHITE
Blockquotes
Blt
blogs
BLUEGRAY
Bluetooth
@@ -185,6 +188,7 @@ CALG
callbackptr
Cangjie
CANRENAME
CAPTUREBLT
CAPTURECHANGED
CAtl
cch
@@ -258,6 +262,7 @@ colorformat
colorhistory
colorhistorylimit
COLORKEY
COLORONCOLOR
colorpicker
COLORREF
comctl
@@ -305,6 +310,7 @@ CProj
CREATESCHEDULEDTASK
CREATESTRUCT
CREATEWINDOWFAILED
createcompatibledc
critsec
Crossdevice
CRSEL
@@ -368,6 +374,7 @@ dcomp
dcompi
DComposition
DCR
DCs
DDevice
ddf
DDxgi
@@ -388,6 +395,7 @@ DEFERERASE
DEFPUSHBUTTON
deinitialization
DELA
DELETESCANS
deletethis
Delimarsky
dend
@@ -442,6 +450,7 @@ drawingcolor
dreamsofameaningfullife
drivedetectionwarning
dshow
DSTINVERT
dutil
DVASPECT
DVASPECTINFO
@@ -1035,6 +1044,8 @@ Melman
MENUITEMINFO
MENUITEMINFOW
menurc
MERGECOPY
MERGEPAINT
Metadatas
metafile
mfapi
@@ -1193,6 +1204,7 @@ NOINHERITLAYOUT
NOINTERFACE
NOLINKINFO
NOMINMAX
NOMIRRORBITMAP
NOMOVE
NONAME
nonclient
@@ -1224,6 +1236,8 @@ notmatch
Noto
NOTOPMOST
NOTRACK
NOTSRCCOPY
NOTSRCERASE
NOUPDATE
NOZORDER
NPH
@@ -1273,6 +1287,7 @@ openxmlformats
OPTIMIZEFORINVOKE
ORAW
ORPHANEDDIALOGTITLE
ORSCANS
oss
ostr
OSVERSIONINFOEX
@@ -1304,8 +1319,11 @@ PArgb
parray
PARTIALCONFIRMATIONDIALOGTITLE
pasteplain
PATCOPY
pathcch
Pathto
PATINVERT
PATPAINT
PAUDIO
pbc
Pbgra
@@ -1547,6 +1565,7 @@ Roamable
robmensching
Roboto
rooler
rop
roslyn
Rothera
roundf
@@ -1716,8 +1735,12 @@ spsi
spsia
spsrm
spsv
SRCAND
SRCCOPY
SRCERASE
Srch
SRCINVERT
SRCPAINT
sre
Srednekolymsk
SResize
@@ -2039,6 +2062,7 @@ website
wekyb
Wevtapi
wgpocpl
WHITEONBLACK
whitespaces
WIC
wifi
@@ -2073,6 +2097,7 @@ winevt
winexe
winforms
winfx
wingdi
winget
wingetcreate
Winhook

View File

@@ -0,0 +1,141 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using MouseJumpUI.Drawing.Models;
namespace MouseJumpUI.UnitTests.Drawing;
[TestClass]
public static class RectangleInfoTests
{
[TestClass]
public class CenterTests
{
public class TestCase
{
public TestCase(RectangleInfo rectangle, PointInfo point, RectangleInfo expectedResult)
{
this.Rectangle = rectangle;
this.Point = point;
this.ExpectedResult = expectedResult;
}
public RectangleInfo Rectangle { get; set; }
public PointInfo Point { get; set; }
public RectangleInfo ExpectedResult { get; set; }
}
public static IEnumerable<object[]> GetTestCases()
{
// zero-sized
yield return new[] { new TestCase(new(0, 0, 0, 0), new(0, 0), new(0, 0, 0, 0)), };
// zero-origin
yield return new[] { new TestCase(new(0, 0, 200, 200), new(100, 100), new(0, 0, 200, 200)), };
yield return new[] { new TestCase(new(0, 0, 200, 200), new(500, 500), new(400, 400, 200, 200)), };
yield return new[] { new TestCase(new(0, 0, 800, 600), new(1000, 1000), new(600, 700, 800, 600)), };
// non-zero origin
yield return new[] { new TestCase(new(1000, 2000, 200, 200), new(100, 100), new(0, 0, 200, 200)), };
yield return new[] { new TestCase(new(1000, 2000, 200, 200), new(500, 500), new(400, 400, 200, 200)), };
yield return new[] { new TestCase(new(1000, 2000, 800, 600), new(1000, 1000), new(600, 700, 800, 600)), };
// negative result
yield return new[] { new TestCase(new(0, 0, 1000, 1200), new(300, 300), new(-200, -300, 1000, 1200)), };
}
[TestMethod]
[DynamicData(nameof(GetTestCases), DynamicDataSourceType.Method)]
public void RunTestCases(TestCase data)
{
var actual = data.Rectangle.Center(data.Point);
var expected = data.ExpectedResult;
Assert.AreEqual(expected.X, actual.X);
Assert.AreEqual(expected.Y, actual.Y);
Assert.AreEqual(expected.Width, actual.Width);
Assert.AreEqual(expected.Height, actual.Height);
}
}
[TestClass]
public class ClampTests
{
public class TestCase
{
public TestCase(RectangleInfo inner, RectangleInfo outer, RectangleInfo expectedResult)
{
this.Inner = inner;
this.Outer = outer;
this.ExpectedResult = expectedResult;
}
public RectangleInfo Inner { get; set; }
public RectangleInfo Outer { get; set; }
public RectangleInfo ExpectedResult { get; set; }
}
public static IEnumerable<object[]> GetTestCases()
{
// already inside - obj fills bounds exactly
yield return new[]
{
new TestCase(new(0, 0, 100, 100), new(0, 0, 100, 100), new(0, 0, 100, 100)),
};
// already inside - obj exactly in each corner
yield return new[]
{
new TestCase(new(0, 0, 100, 100), new(0, 0, 200, 200), new(0, 0, 100, 100)),
};
yield return new[]
{
new TestCase(new(100, 0, 100, 100), new(0, 0, 200, 200), new(100, 0, 100, 100)),
};
yield return new[]
{
new TestCase(new(0, 100, 100, 100), new(0, 0, 200, 200), new(0, 100, 100, 100)),
};
yield return new[]
{
new TestCase(new(100, 100, 100, 100), new(0, 0, 200, 200), new(100, 100, 100, 100)),
};
// move inside - obj outside each corner
yield return new[]
{
new TestCase(new(-50, -50, 100, 100), new(0, 0, 200, 200), new(0, 0, 100, 100)),
};
yield return new[]
{
new TestCase(new(250, -50, 100, 100), new(0, 0, 200, 200), new(100, 0, 100, 100)),
};
yield return new[]
{
new TestCase(new(-50, 250, 100, 100), new(0, 0, 200, 200), new(0, 100, 100, 100)),
};
yield return new[]
{
new TestCase(new(150, 150, 100, 100), new(0, 0, 200, 200), new(100, 100, 100, 100)),
};
}
[TestMethod]
[DynamicData(nameof(GetTestCases), DynamicDataSourceType.Method)]
public void RunTestCases(TestCase data)
{
var actual = data.Inner.Clamp(data.Outer);
var expected = data.ExpectedResult;
Assert.AreEqual(expected.X, actual.X);
Assert.AreEqual(expected.Y, actual.Y);
Assert.AreEqual(expected.Width, actual.Width);
Assert.AreEqual(expected.Height, actual.Height);
}
}
}

View File

@@ -0,0 +1,105 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using MouseJumpUI.Drawing.Models;
namespace MouseJumpUI.UnitTests.Drawing;
public sealed class SizeInfoTests
{
[TestClass]
public class ScaleToFitTests
{
public class TestCase
{
public TestCase(SizeInfo obj, SizeInfo bounds, SizeInfo expectedResult)
{
this.Obj = obj;
this.Bounds = bounds;
this.ExpectedResult = expectedResult;
}
public SizeInfo Obj { get; set; }
public SizeInfo Bounds { get; set; }
public SizeInfo ExpectedResult { get; set; }
}
public static IEnumerable<object[]> GetTestCases()
{
// identity tests
yield return new[] { new TestCase(new(512, 384), new(512, 384), new(512, 384)), };
yield return new[] { new TestCase(new(1024, 768), new(1024, 768), new(1024, 768)), };
// general tests
yield return new[] { new TestCase(new(512, 384), new(2048, 1536), new(2048, 1536)), };
yield return new[] { new TestCase(new(2048, 1536), new(1024, 768), new(1024, 768)), };
// scale to fit width
yield return new[] { new TestCase(new(512, 384), new(2048, 3072), new(2048, 1536)), };
// scale to fit height
yield return new[] { new TestCase(new(512, 384), new(4096, 1536), new(2048, 1536)), };
}
[TestMethod]
[DynamicData(nameof(GetTestCases), DynamicDataSourceType.Method)]
public void RunTestCases(TestCase data)
{
var actual = data.Obj.ScaleToFit(data.Bounds);
var expected = data.ExpectedResult;
Assert.AreEqual(expected.Width, actual.Width);
Assert.AreEqual(expected.Height, actual.Height);
}
}
[TestClass]
public class ScaleToFitRatioTests
{
public class TestCase
{
public TestCase(SizeInfo obj, SizeInfo bounds, decimal expectedResult)
{
this.Obj = obj;
this.Bounds = bounds;
this.ExpectedResult = expectedResult;
}
public SizeInfo Obj { get; set; }
public SizeInfo Bounds { get; set; }
public decimal ExpectedResult { get; set; }
}
public static IEnumerable<object[]> GetTestCases()
{
// identity tests
yield return new[] { new TestCase(new(512, 384), new(512, 384), 1), };
yield return new[] { new TestCase(new(1024, 768), new(1024, 768), 1), };
// general tests
yield return new[] { new TestCase(new(512, 384), new(2048, 1536), 4), };
yield return new[] { new TestCase(new(2048, 1536), new(1024, 768), 0.5M), };
// scale to fit width
yield return new[] { new TestCase(new(512, 384), new(2048, 3072), 4), };
// scale to fit height
yield return new[] { new TestCase(new(512, 384), new(4096, 1536), 4), };
}
[TestMethod]
[DynamicData(nameof(GetTestCases), DynamicDataSourceType.Method)]
public void RunTestCases(TestCase data)
{
var actual = data.Obj.ScaleToFitRatio(data.Bounds);
var expected = data.ExpectedResult;
Assert.AreEqual(expected, actual);
}
}
}

View File

@@ -0,0 +1,223 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
using System.Drawing;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using MouseJumpUI.Drawing.Models;
using MouseJumpUI.Helpers;
namespace MouseJumpUI.UnitTests.Helpers;
[TestClass]
public static class DrawingHelperTests
{
[TestClass]
public class CalculateLayoutInfoTests
{
public class TestCase
{
public TestCase(LayoutConfig layoutConfig, LayoutInfo expectedResult)
{
this.LayoutConfig = layoutConfig;
this.ExpectedResult = expectedResult;
}
public LayoutConfig LayoutConfig { get; set; }
public LayoutInfo ExpectedResult { get; set; }
}
public static IEnumerable<object[]> GetTestCases()
{
// happy path - check the preview form is shown
// at the correct size and position on a single screen
//
// +----------------+
// | |
// | 0 |
// | |
// +----------------+
var layoutConfig = new LayoutConfig(
virtualScreen: new(0, 0, 5120, 1440),
screenBounds: new List<Rectangle>
{
new(0, 0, 5120, 1440),
},
activatedLocation: new(5120 / 2, 1440 / 2),
activatedScreen: 0,
maximumFormSize: new(1600, 1200),
formPadding: new(5, 5, 5, 5),
previewPadding: new(0, 0, 0, 0));
var layoutInfo = new LayoutInfo(
layoutConfig: layoutConfig,
formBounds: new(1760, 491.40625M, 1600, 457.1875M),
previewBounds: new(0, 0, 1590, 447.1875M),
screenBounds: new List<RectangleInfo>
{
new(0, 0, 1590, 447.1875M),
},
activatedScreen: new(0, 0, 5120, 1440));
yield return new[] { new TestCase(layoutConfig, layoutInfo) };
// primary monitor not topmost / leftmost - if there are screens
// that are further left or higher than the primary monitor
// they'll have negative coordinates which has caused some
// issues with calculations in the past. this test will make
// sure we handle negative coordinates gracefully
//
// +-------+
// | 0 +----------------+
// +-------+ |
// | 1 |
// | |
// +----------------+
layoutConfig = new LayoutConfig(
virtualScreen: new(-1920, -472, 7040, 1912),
screenBounds: new List<Rectangle>
{
new(-1920, -472, 1920, 1080),
new(0, 0, 5120, 1440),
},
activatedLocation: new(-960, -236),
activatedScreen: 0,
maximumFormSize: new(1600, 1200),
formPadding: new(5, 5, 5, 5),
previewPadding: new(0, 0, 0, 0));
layoutInfo = new LayoutInfo(
layoutConfig: layoutConfig,
formBounds: new(
-1760,
-456.91477M, // -236 - (((decimal)(1600-10) / 7040 * 1912) + 10) / 2
1600,
441.829545M // ((decimal)(1600-10) / 7040 * 1912) + 10
),
previewBounds: new(0, 0, 1590, 431.829545M),
screenBounds: new List<RectangleInfo>
{
new(0, 0, 433.63636M, 243.92045M),
new(433.63636M, 106.602270M, 1156.36363M, 325.22727M),
},
activatedScreen: new(-1920, -472, 1920, 1080));
yield return new[] { new TestCase(layoutConfig, layoutInfo) };
// check we handle rounding errors in scaling the preview form
// that might make the form *larger* than the current screen -
// e.g. a desktop 7168 x 1440 scaled to a screen 1024 x 768
// with a 5px form padding border:
//
// ((decimal)1014 / 7168) * 7168 = 1014.0000000000000000000000002
//
// +----------------+
// | |
// | 1 +-------+
// | | 0 |
// +----------------+-------+
layoutConfig = new LayoutConfig(
virtualScreen: new(0, 0, 7168, 1440),
screenBounds: new List<Rectangle>
{
new(6144, 0, 1024, 768),
new(0, 0, 6144, 1440),
},
activatedLocation: new(6656, 384),
activatedScreen: 0,
maximumFormSize: new(1600, 1200),
formPadding: new(5, 5, 5, 5),
previewPadding: new(0, 0, 0, 0));
layoutInfo = new LayoutInfo(
layoutConfig: layoutConfig,
formBounds: new(6144, 277.14732M, 1024, 213.70535M),
previewBounds: new(0, 0, 1014, 203.70535M),
screenBounds: new List<RectangleInfo>
{
new(869.14285M, 0, 144.85714M, 108.642857M),
new(0, 0, 869.142857M, 203.705357M),
},
activatedScreen: new(6144, 0, 1024, 768));
yield return new[] { new TestCase(layoutConfig, layoutInfo) };
// check we handle rounding errors in scaling the preview form
// that might make the form a pixel *smaller* than the current screen -
// e.g. a desktop 7168 x 1440 scaled to a screen 1024 x 768
// with a 5px form padding border:
//
// ((decimal)1280 / 7424) * 7424 = 1279.9999999999999999999999999
//
// +----------------+
// | |
// | 1 +-------+
// | | 0 |
// +----------------+-------+
layoutConfig = new LayoutConfig(
virtualScreen: new(0, 0, 7424, 1440),
screenBounds: new List<Rectangle>
{
new(6144, 0, 1280, 768),
new(0, 0, 6144, 1440),
},
activatedLocation: new(6784, 384),
activatedScreen: 0,
maximumFormSize: new(1600, 1200),
formPadding: new(5, 5, 5, 5),
previewPadding: new(0, 0, 0, 0));
layoutInfo = new LayoutInfo(
layoutConfig: layoutConfig,
formBounds: new(
6144,
255.83189M, // (768 - (((decimal)(1280-10) / 7424 * 1440) + 10)) / 2
1280,
256.33620M // ((decimal)(1280 - 10) / 7424 * 1440) + 10
),
previewBounds: new(0, 0, 1270, 246.33620M),
screenBounds: new List<RectangleInfo>
{
new(1051.03448M, 0, 218.96551M, 131.37931M),
new(0, 0M, 1051.03448M, 246.33620M),
},
activatedScreen: new(6144, 0, 1280, 768));
yield return new[] { new TestCase(layoutConfig, layoutInfo) };
}
[TestMethod]
[DynamicData(nameof(GetTestCases), DynamicDataSourceType.Method)]
public void RunTestCases(TestCase data)
{
// note - even if values are within 0.0001M of each other they could
// still round to different values - e.g.
// (int)1279.999999999999 -> 1279
// vs
// (int)1280.000000000000 -> 1280
// so we'll compare the raw values, *and* convert to an int-based
// Rectangle to compare rounded values
var actual = DrawingHelper.CalculateLayoutInfo(data.LayoutConfig);
var expected = data.ExpectedResult;
Assert.AreEqual(expected.FormBounds.X, actual.FormBounds.X, 0.00001M, "FormBounds.X");
Assert.AreEqual(expected.FormBounds.Y, actual.FormBounds.Y, 0.00001M, "FormBounds.Y");
Assert.AreEqual(expected.FormBounds.Width, actual.FormBounds.Width, 0.00001M, "FormBounds.Width");
Assert.AreEqual(expected.FormBounds.Height, actual.FormBounds.Height, 0.00001M, "FormBounds.Height");
Assert.AreEqual(expected.FormBounds.ToRectangle(), actual.FormBounds.ToRectangle(), "FormBounds.ToRectangle");
Assert.AreEqual(expected.PreviewBounds.X, actual.PreviewBounds.X, 0.00001M, "PreviewBounds.X");
Assert.AreEqual(expected.PreviewBounds.Y, actual.PreviewBounds.Y, 0.00001M, "PreviewBounds.Y");
Assert.AreEqual(expected.PreviewBounds.Width, actual.PreviewBounds.Width, 0.00001M, "PreviewBounds.Width");
Assert.AreEqual(expected.PreviewBounds.Height, actual.PreviewBounds.Height, 0.00001M, "PreviewBounds.Height");
Assert.AreEqual(expected.PreviewBounds.ToRectangle(), actual.PreviewBounds.ToRectangle(), "PreviewBounds.ToRectangle");
Assert.AreEqual(expected.ScreenBounds.Count, actual.ScreenBounds.Count, "ScreenBounds.Count");
for (var i = 0; i < expected.ScreenBounds.Count; i++)
{
Assert.AreEqual(expected.ScreenBounds[i].X, actual.ScreenBounds[i].X, 0.00001M, $"ScreenBounds[{i}].X");
Assert.AreEqual(expected.ScreenBounds[i].Y, actual.ScreenBounds[i].Y, 0.00001M, $"ScreenBounds[{i}].Y");
Assert.AreEqual(expected.ScreenBounds[i].Width, actual.ScreenBounds[i].Width, 0.00001M, $"ScreenBounds[{i}].Width");
Assert.AreEqual(expected.ScreenBounds[i].Height, actual.ScreenBounds[i].Height, 0.00001M, $"ScreenBounds[{i}].Height");
Assert.AreEqual(expected.ScreenBounds[i].ToRectangle(), actual.ScreenBounds[i].ToRectangle(), "ActivatedScreen.ToRectangle");
}
Assert.AreEqual(expected.ActivatedScreen.X, actual.ActivatedScreen.X, "ActivatedScreen.X");
Assert.AreEqual(expected.ActivatedScreen.Y, actual.ActivatedScreen.Y, "ActivatedScreen.Y");
Assert.AreEqual(expected.ActivatedScreen.Width, actual.ActivatedScreen.Width, "ActivatedScreen.Width");
Assert.AreEqual(expected.ActivatedScreen.Height, actual.ActivatedScreen.Height, "ActivatedScreen.Height");
Assert.AreEqual(expected.ActivatedScreen.ToRectangle(), actual.ActivatedScreen.ToRectangle(), "ActivatedScreen.ToRectangle");
}
}
}

View File

@@ -1,514 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
using System.Drawing;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace MouseJumpUI.Helpers.Tests;
[TestClass]
public static class LayoutHelperTests
{
[TestClass]
public class CenterObjectTests
{
public class TestCase
{
public TestCase(Size obj, Point midpoint, Point expectedResult)
{
this.Obj = obj;
this.Midpoint = midpoint;
this.ExpectedResult = expectedResult;
}
public Size Obj { get; set; }
public Point Midpoint { get; set; }
public Point ExpectedResult { get; set; }
}
public static IEnumerable<object[]> GetTestCases()
{
// zero-sized object should center exactly on the midpoint
yield return new[] { new TestCase(new(0, 0), new(0, 0), new(0, 0)), };
// odd-sized objects should center above/left of the midpoint
yield return new[] { new TestCase(new(1, 1), new(1, 1), new(0, 0)), };
yield return new[] { new TestCase(new(1, 1), new(5, 5), new(4, 4)), };
// even-sized objects should center exactly on the midpoint
yield return new[] { new TestCase(new(2, 2), new(1, 1), new(0, 0)), };
yield return new[] { new TestCase(new(2, 2), new(5, 5), new(4, 4)), };
yield return new[] { new TestCase(new(800, 600), new(1000, 1000), new(600, 700)), };
// negative result position
yield return new[] { new TestCase(new(1000, 1200), new(300, 300), new(-200, -300)), };
}
[TestMethod]
[DynamicData(nameof(GetTestCases), DynamicDataSourceType.Method)]
public void RunTestCases(TestCase data)
{
var actual = LayoutHelper.CenterObject(data.Obj, data.Midpoint);
var expected = data.ExpectedResult;
Assert.AreEqual(expected, actual);
}
}
[TestClass]
public class CombineRegionsTests
{
public class TestCase
{
public TestCase(List<Rectangle> bounds, Rectangle expectedResult)
{
this.Bounds = bounds;
this.ExpectedResult = expectedResult;
}
public List<Rectangle> Bounds { get; set; }
public Rectangle ExpectedResult { get; set; }
}
public static IEnumerable<object[]> GetTestCases()
{
// empty list
yield return new[]
{
new TestCase(
new(),
Rectangle.Empty),
};
// empty bounds
yield return new[]
{
new TestCase(
new()
{
Rectangle.Empty,
},
Rectangle.Empty),
};
// single region
//
// +---+
// | 0 |
// +---+
yield return new[]
{
new TestCase(
new()
{
new(100, 100, 100, 100),
},
new(100, 100, 100, 100)),
};
// multi-monitor desktop
//
// +----------------+
// | |
// | 1 +-------+
// | | 0 |
// +----------------+-------+
yield return new[]
{
new TestCase(
new()
{
new(5120, 0, 1920, 1080),
new(0, 0, 5120, 1440),
},
new(0, 0, 7040, 1440)),
};
// multi-monitor desktop
//
// note - windows puts the *primary* monitor at the origin (0,0),
// so screens positioned *above* or *left* will have negative coordinates
//
// +-------+
// | 0 |
// +-------+--------+
// | |
// | 1 |
// | |
// +----------------+
yield return new[]
{
new TestCase(
new()
{
new(0, -1000, 1920, 1080),
new(0, 0, 5120, 1440),
},
new(0, -1000, 5120, 2440)),
};
// multi-monitor desktop
//
// note - windows puts the *primary* monitor at the origin (0,0),
// so screens positioned *above* or *left* will have negative coordinates
//
// +-------+----------------+
// | 0 | |
// +-------+ 1 |
// | |
// +----------------+
yield return new[]
{
new TestCase(
new()
{
new(-1920, 0, 1920, 1080),
new(0, 0, 5120, 1440),
},
new(-1920, 0, 7040, 1440)),
};
// non-contiguous regions
//
// +---+
// | 0 | +-------+
// +---+ | |
// | 1 |
// | |
// +-------+
yield return new[]
{
new TestCase(
new()
{
new(0, 0, 100, 100),
new(200, 150, 200, 200),
},
new(0, 0, 400, 350)),
};
}
[TestMethod]
[DynamicData(nameof(GetTestCases), DynamicDataSourceType.Method)]
public void RunTestCases(TestCase data)
{
var actual = LayoutHelper.CombineRegions(data.Bounds);
var expected = data.ExpectedResult;
Assert.AreEqual(expected, actual);
}
}
[TestClass]
public class GetMidpointTests
{
}
[TestClass]
public class MoveInsideTests
{
public class TestCase
{
public TestCase(Rectangle obj, Rectangle bounds, Rectangle expectedResult)
{
this.Obj = obj;
this.Bounds = bounds;
this.ExpectedResult = expectedResult;
}
public Rectangle Obj { get; set; }
public Rectangle Bounds { get; set; }
public Rectangle ExpectedResult { get; set; }
}
public static IEnumerable<object[]> GetTestCases()
{
// already inside - obj fills bounds exactly
yield return new[]
{
new TestCase(new(0, 0, 100, 100), new(0, 0, 100, 100), new(0, 0, 100, 100)),
};
// already inside - obj exactly in each corner
yield return new[]
{
new TestCase(new(0, 0, 100, 100), new(0, 0, 200, 200), new(0, 0, 100, 100)),
};
yield return new[]
{
new TestCase(new(100, 0, 100, 100), new(0, 0, 200, 200), new(100, 0, 100, 100)),
};
yield return new[]
{
new TestCase(new(0, 100, 100, 100), new(0, 0, 200, 200), new(0, 100, 100, 100)),
};
yield return new[]
{
new TestCase(new(100, 100, 100, 100), new(0, 0, 200, 200), new(100, 100, 100, 100)),
};
// move inside - obj outside each corner
yield return new[]
{
new TestCase(new(-50, -50, 100, 100), new(0, 0, 200, 200), new(0, 0, 100, 100)),
};
yield return new[]
{
new TestCase(new(250, -50, 100, 100), new(0, 0, 200, 200), new(100, 0, 100, 100)),
};
yield return new[]
{
new TestCase(new(-50, 250, 100, 100), new(0, 0, 200, 200), new(0, 100, 100, 100)),
};
yield return new[]
{
new TestCase(new(150, 150, 100, 100), new(0, 0, 200, 200), new(100, 100, 100, 100)),
};
}
[TestMethod]
[DynamicData(nameof(GetTestCases), DynamicDataSourceType.Method)]
public void RunTestCases(TestCase data)
{
var actual = LayoutHelper.MoveInside(data.Obj, data.Bounds);
var expected = data.ExpectedResult;
Assert.AreEqual(expected, actual);
}
}
[TestClass]
public class ScaleLocationTests
{
}
[TestClass]
public class ScaleToFitTests
{
public class TestCase
{
public TestCase(Size obj, Size bounds, Size expectedResult)
{
this.Obj = obj;
this.Bounds = bounds;
this.ExpectedResult = expectedResult;
}
public Size Obj { get; set; }
public Size Bounds { get; set; }
public Size ExpectedResult { get; set; }
}
public static IEnumerable<object[]> GetTestCases()
{
// identity tests
yield return new[]
{
new TestCase(new(0, 0), new(0, 0), new(0, 0)),
};
yield return new[]
{
new TestCase(new(512, 384), new(512, 384), new(512, 384)),
};
yield return new[]
{
new TestCase(new(1024, 768), new(1024, 768), new(1024, 768)),
};
// integer scaling factor tests
yield return new[]
{
new TestCase(new(512, 384), new(2048, 1536), new(2048, 1536)),
};
yield return new[]
{
new TestCase(new(2048, 1536), new(1024, 768), new(1024, 768)),
};
// scale to fit width
yield return new[]
{
new TestCase(new(512, 384), new(2048, 3072), new(2048, 1536)),
};
// scale to fit height
yield return new[]
{
new TestCase(new(512, 384), new(4096, 1536), new(2048, 1536)),
};
}
[TestMethod]
[DynamicData(nameof(GetTestCases), DynamicDataSourceType.Method)]
public void RunTestCases(TestCase data)
{
var actual = LayoutHelper.ScaleToFit(data.Obj, data.Bounds);
var expected = data.ExpectedResult;
Assert.AreEqual(expected, actual);
}
}
[TestClass]
public class GetPreviewFormBoundsTests
{
public class TestCase
{
public TestCase(
Rectangle desktopBounds,
Point cursorPosition,
Rectangle currentMonitorBounds,
Size maximumPreviewImageSize,
Size previewImagePadding,
Rectangle expectedResult)
{
this.DesktopBounds = desktopBounds;
this.CursorPosition = cursorPosition;
this.CurrentMonitorBounds = currentMonitorBounds;
this.MaximumPreviewImageSize = maximumPreviewImageSize;
this.PreviewImagePadding = previewImagePadding;
this.ExpectedResult = expectedResult;
}
public Rectangle DesktopBounds { get; set; }
public Point CursorPosition { get; set; }
public Rectangle CurrentMonitorBounds { get; set; }
public Size MaximumPreviewImageSize { get; set; }
public Size PreviewImagePadding { get; set; }
public Rectangle ExpectedResult { get; set; }
}
public static IEnumerable<object[]> GetTestCases()
{
// multi-monitor desktop
//
// +----------------+
// | |
// | 1 +-------+
// | | 0 |
// +----------------+-------+
//
// clicked near top left corner so that the
// preview box overhangs the top and left
//
// +----------------+
// | * |
// | 1 +-------+
// | | 0 |
// +----------------+-------+
//
// form is centered on mouse cursor and then
// nudged back into the top left corner
//
// +-----+----------+
// | * | |
// +-----+ 1 +-------+
// | | 0 |
// +----------------+-------+
yield return new[]
{
new TestCase(
desktopBounds: new(-5120, -359, 7040, 1440),
cursorPosition: new(-5020, -259),
currentMonitorBounds: new(-5120, -359, 5120, 1440),
maximumPreviewImageSize: new(1600, 1200),
previewImagePadding: new(10, 10),
expectedResult: new(-5120, -359, 1610, 337)),
};
// multi-monitor desktop
//
// +----------------+
// | |
// | 1 +-------+
// | | 0 |
// +----------------+-------+
//
// clicked in the center of the second monitor
//
// +----------------+
// | |
// | * +-------+
// | | 0 |
// +----------------+-------+
//
// form is centered on the mouse cursor
//
// +----------------+
// | +-----+ |
// | | * | +-------+
// | +-----+ | 0 |
// +----------------+-------+
yield return new[]
{
new TestCase(
desktopBounds: new(-5120, -359, 7040, 1440),
cursorPosition: new(-2560, 361),
currentMonitorBounds: new(-5120, -359, 5120, 1440),
maximumPreviewImageSize: new(1600, 1200),
previewImagePadding: new(10, 10),
expectedResult: new(-3365, 192, 1610, 337)),
};
// multi-monitor desktop
//
// +----------------+
// | |
// | 1 +-------+
// | | 0 |
// +----------------+-------+
//
// clicked in the center of the monitor
//
// +----------------+
// | |
// | * +-------+
// | | 0 |
// +----------------+-------+
//
// max preview is larger than monitor,
// form is scaled to monitor size, with
// consideration for image padding
//
// *----------------*
// |+--------------+|
// || * |+-------+
// |+--------------+| 0 |
// +----------------+-------+
yield return new[]
{
new TestCase(
desktopBounds: new(-5120, -359, 7040, 1440),
cursorPosition: new(-2560, 361),
currentMonitorBounds: new(-5120, -359, 5120, 1440),
maximumPreviewImageSize: new(160000, 120000),
previewImagePadding: new(10, 10),
expectedResult: new(-5120, -166, 5120, 1055)),
};
}
[TestMethod]
[DynamicData(nameof(GetTestCases), DynamicDataSourceType.Method)]
public void RunTestCases(TestCase data)
{
var actual = LayoutHelper.GetPreviewFormBounds(
desktopBounds: data.DesktopBounds,
activatedPosition: data.CursorPosition,
activatedMonitorBounds: data.CurrentMonitorBounds,
maximumThumbnailImageSize: data.MaximumPreviewImageSize,
thumbnailImagePadding: data.PreviewImagePadding);
var expected = data.ExpectedResult;
Assert.AreEqual(expected, actual);
}
}
}

View File

@@ -0,0 +1,74 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using MouseJumpUI.Drawing.Models;
using MouseJumpUI.Helpers;
namespace MouseJumpUI.UnitTests.Helpers;
[TestClass]
public static class MouseHelperTests
{
[TestClass]
public class GetJumpLocationTests
{
public class TestCase
{
public TestCase(PointInfo previewLocation, SizeInfo previewSize, RectangleInfo desktopBounds, PointInfo expectedResult)
{
this.PreviewLocation = previewLocation;
this.PreviewSize = previewSize;
this.DesktopBounds = desktopBounds;
this.ExpectedResult = expectedResult;
}
public PointInfo PreviewLocation { get; set; }
public SizeInfo PreviewSize { get; set; }
public RectangleInfo DesktopBounds { get; set; }
public PointInfo ExpectedResult { get; set; }
}
public static IEnumerable<object[]> GetTestCases()
{
// screen corners and midpoint with a zero origin
yield return new[] { new TestCase(new(0, 0), new(160, 120), new(0, 0, 1600, 1200), new(0, 0)) };
yield return new[] { new TestCase(new(160, 0), new(160, 120), new(0, 0, 1600, 1200), new(1600, 0)) };
yield return new[] { new TestCase(new(0, 120), new(160, 120), new(0, 0, 1600, 1200), new(0, 1200)) };
yield return new[] { new TestCase(new(160, 120), new(160, 120), new(0, 0, 1600, 1200), new(1600, 1200)) };
yield return new[] { new TestCase(new(80, 60), new(160, 120), new(0, 0, 1600, 1200), new(800, 600)) };
// screen corners and midpoint with a positive origin
yield return new[] { new TestCase(new(0, 0), new(160, 120), new(1000, 1000, 1600, 1200), new(1000, 1000)) };
yield return new[] { new TestCase(new(160, 0), new(160, 120), new(1000, 1000, 1600, 1200), new(2600, 1000)) };
yield return new[] { new TestCase(new(0, 120), new(160, 120), new(1000, 1000, 1600, 1200), new(1000, 2200)) };
yield return new[] { new TestCase(new(160, 120), new(160, 120), new(1000, 1000, 1600, 1200), new(2600, 2200)) };
yield return new[] { new TestCase(new(80, 60), new(160, 120), new(1000, 1000, 1600, 1200), new(1800, 1600)) };
// screen corners and midpoint with a negative origin
yield return new[] { new TestCase(new(0, 0), new(160, 120), new(-1000, -1000, 1600, 1200), new(-1000, -1000)) };
yield return new[] { new TestCase(new(160, 0), new(160, 120), new(-1000, -1000, 1600, 1200), new(600, -1000)) };
yield return new[] { new TestCase(new(0, 120), new(160, 120), new(-1000, -1000, 1600, 1200), new(-1000, 200)) };
yield return new[] { new TestCase(new(160, 120), new(160, 120), new(-1000, -1000, 1600, 1200), new(600, 200)) };
yield return new[] { new TestCase(new(80, 60), new(160, 120), new(-1000, -1000, 1600, 1200), new(-200, -400)) };
}
[TestMethod]
[DynamicData(nameof(GetTestCases), DynamicDataSourceType.Method)]
public void RunTestCases(TestCase data)
{
var actual = MouseHelper.GetJumpLocation(
data.PreviewLocation,
data.PreviewSize,
data.DesktopBounds);
var expected = data.ExpectedResult;
Assert.AreEqual(expected.X, actual.X);
Assert.AreEqual(expected.Y, actual.Y);
}
}
}

View File

@@ -0,0 +1,109 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Drawing;
using System.Linq;
using System.Windows.Forms;
namespace MouseJumpUI.Drawing.Models;
/// <summary>
/// Represents a collection of values needed for calculating the MainForm layout.
/// </summary>
public sealed class LayoutConfig
{
public LayoutConfig(
Rectangle virtualScreen,
IEnumerable<Rectangle> screenBounds,
Point activatedLocation,
int activatedScreen,
Size maximumFormSize,
Padding formPadding,
Padding previewPadding)
{
// make sure the virtual screen entirely contains all of the individual screen bounds
ArgumentNullException.ThrowIfNull(screenBounds);
if (screenBounds.Any(screen => !virtualScreen.Contains(screen)))
{
throw new ArgumentException($"'{nameof(virtualScreen)}' must contain all of the screens in '{nameof(screenBounds)}'", nameof(virtualScreen));
}
this.VirtualScreen = new RectangleInfo(virtualScreen);
this.ScreenBounds = new(
screenBounds.Select(screen => new RectangleInfo(screen)).ToList());
this.ActivatedLocation = new(activatedLocation);
this.ActivatedScreen = activatedScreen;
this.MaximumFormSize = new(maximumFormSize);
this.FormPadding = new(formPadding);
this.PreviewPadding = new(previewPadding);
}
/// <summary>
/// Gets the coordinates of the entire virtual screen.
/// </summary>
/// <remarks>
/// The Virtual Screen is the bounding rectangle of all the monitors.
/// https://learn.microsoft.com/en-us/windows/win32/gdi/the-virtual-screen
/// </remarks>
public RectangleInfo VirtualScreen
{
get;
}
/// <summary>
/// Gets the bounds of all of the screens connected to the system.
/// </summary>
public ReadOnlyCollection<RectangleInfo> ScreenBounds
{
get;
}
/// <summary>
/// Gets the point where the cursor was located when the form was activated.
/// </summary>
/// <summary>
/// The preview form will be centered on this location unless there are any
/// constraints such as the being too close to edge of a screen, in which case
/// the form will be displayed as close as possible to this location.
/// </summary>
public PointInfo ActivatedLocation
{
get;
}
/// <summary>
/// Gets the index of the screen the cursor was on when the form was activated.
/// </summary>
public int ActivatedScreen
{
get;
}
/// <summary>
/// Gets the maximum size of the screen preview form.
/// </summary>
public SizeInfo MaximumFormSize
{
get;
}
/// <summary>
/// Gets the padding border around the screen preview form.
/// </summary>
public PaddingInfo FormPadding
{
get;
}
/// <summary>
/// Gets the padding border inside the screen preview image.
/// </summary>
public PaddingInfo PreviewPadding
{
get;
}
}

View File

@@ -0,0 +1,111 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
namespace MouseJumpUI.Drawing.Models;
public sealed class LayoutInfo
{
public sealed class Builder
{
public Builder()
{
this.ScreenBounds = new();
}
public LayoutConfig? LayoutConfig
{
get;
set;
}
public RectangleInfo? FormBounds
{
get;
set;
}
public RectangleInfo? PreviewBounds
{
get;
set;
}
public List<RectangleInfo> ScreenBounds
{
get;
set;
}
public RectangleInfo? ActivatedScreen
{
get;
set;
}
public LayoutInfo Build()
{
return new LayoutInfo(
layoutConfig: this.LayoutConfig ?? throw new InvalidOperationException(),
formBounds: this.FormBounds ?? throw new InvalidOperationException(),
previewBounds: this.PreviewBounds ?? throw new InvalidOperationException(),
screenBounds: this.ScreenBounds ?? throw new InvalidOperationException(),
activatedScreen: this.ActivatedScreen ?? throw new InvalidOperationException());
}
}
public LayoutInfo(
LayoutConfig layoutConfig,
RectangleInfo formBounds,
RectangleInfo previewBounds,
IEnumerable<RectangleInfo> screenBounds,
RectangleInfo activatedScreen)
{
this.LayoutConfig = layoutConfig ?? throw new ArgumentNullException(nameof(layoutConfig));
this.FormBounds = formBounds ?? throw new ArgumentNullException(nameof(formBounds));
this.PreviewBounds = previewBounds ?? throw new ArgumentNullException(nameof(previewBounds));
this.ScreenBounds = new(
(screenBounds ?? throw new ArgumentNullException(nameof(screenBounds)))
.ToList());
this.ActivatedScreen = activatedScreen ?? throw new ArgumentNullException(nameof(activatedScreen));
}
/// <summary>
/// Gets the original LayoutConfig settings used to calculate coordinates.
/// </summary>
public LayoutConfig LayoutConfig
{
get;
}
/// <summary>
/// Gets the size and location of the preview form.
/// </summary>
public RectangleInfo FormBounds
{
get;
}
/// <summary>
/// Gets the size and location of the preview image.
/// </summary>
public RectangleInfo PreviewBounds
{
get;
}
public ReadOnlyCollection<RectangleInfo> ScreenBounds
{
get;
}
public RectangleInfo ActivatedScreen
{
get;
}
}

View File

@@ -0,0 +1,65 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Windows.Forms;
namespace MouseJumpUI.Drawing.Models;
/// <summary>
/// Immutable version of a System.Windows.Forms.Padding object with some extra utility methods.
/// </summary>
public sealed class PaddingInfo
{
public PaddingInfo(decimal all)
: this(all, all, all, all)
{
}
public PaddingInfo(Padding padding)
: this(padding.Left, padding.Top, padding.Right, padding.Bottom)
{
}
public PaddingInfo(decimal left, decimal top, decimal right, decimal bottom)
{
this.Left = left;
this.Top = top;
this.Right = right;
this.Bottom = bottom;
}
public decimal Left
{
get;
}
public decimal Top
{
get;
}
public decimal Right
{
get;
}
public decimal Bottom
{
get;
}
public decimal Horizontal => this.Left + this.Right;
public decimal Vertical => this.Top + this.Bottom;
public override string ToString()
{
return "{" +
$"{nameof(this.Left)}={this.Left}," +
$"{nameof(this.Top)}={this.Top}," +
$"{nameof(this.Right)}={this.Right}," +
$"{nameof(this.Bottom)}={this.Bottom}" +
"}";
}
}

View File

@@ -0,0 +1,50 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Drawing;
namespace MouseJumpUI.Drawing.Models;
/// <summary>
/// Immutable version of a System.Drawing.Point object with some extra utility methods.
/// </summary>
public sealed class PointInfo
{
public PointInfo(decimal x, decimal y)
{
this.X = x;
this.Y = y;
}
public PointInfo(Point point)
: this(point.X, point.Y)
{
}
public decimal X
{
get;
}
public decimal Y
{
get;
}
public SizeInfo Size => new((int)this.X, (int)this.Y);
public PointInfo Scale(decimal scalingFactor) => new(this.X * scalingFactor, this.Y * scalingFactor);
public PointInfo Offset(PointInfo amount) => new(this.X + amount.X, this.Y + amount.Y);
public Point ToPoint() => new((int)this.X, (int)this.Y);
public override string ToString()
{
return "{" +
$"{nameof(this.X)}={this.X}," +
$"{nameof(this.Y)}={this.Y}" +
"}";
}
}

View File

@@ -0,0 +1,123 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Drawing;
namespace MouseJumpUI.Drawing.Models;
/// <summary>
/// Immutable version of a System.Drawing.Rectangle object with some extra utility methods.
/// </summary>
public sealed class RectangleInfo
{
public RectangleInfo(decimal x, decimal y, decimal width, decimal height)
{
this.X = x;
this.Y = y;
this.Width = width;
this.Height = height;
}
public RectangleInfo(Rectangle rectangle)
: this(rectangle.X, rectangle.Y, rectangle.Width, rectangle.Height)
{
}
public RectangleInfo(Point location, SizeInfo size)
: this(location.X, location.Y, size.Width, size.Height)
{
}
public RectangleInfo(SizeInfo size)
: this(0, 0, size.Width, size.Height)
{
}
public decimal X
{
get;
}
public decimal Y
{
get;
}
public decimal Width
{
get;
}
public decimal Height
{
get;
}
public decimal Left => this.X;
public decimal Top => this.Y;
public decimal Right => this.X + this.Width;
public decimal Bottom => this.Y + this.Height;
public SizeInfo Size => new(this.Width, this.Height);
public PointInfo Location => new(this.X, this.Y);
public decimal Area => this.Width * this.Height;
public RectangleInfo Enlarge(PaddingInfo padding) => new(
this.X + padding.Left,
this.Y + padding.Top,
this.Width + padding.Horizontal,
this.Height + padding.Vertical);
public RectangleInfo Offset(SizeInfo amount) => this.Offset(amount.Width, amount.Height);
public RectangleInfo Offset(decimal dx, decimal dy) => new(this.X + dx, this.Y + dy, this.Width, this.Height);
public RectangleInfo Scale(decimal scalingFactor) => new(
this.X * scalingFactor,
this.Y * scalingFactor,
this.Width * scalingFactor,
this.Height * scalingFactor);
public RectangleInfo Center(PointInfo point) => new(
x: point.X - (this.Width / 2),
y: point.Y - (this.Height / 2),
width: this.Width,
height: this.Height);
public PointInfo Midpoint => new(
x: this.X + (this.Width / 2),
y: this.Y + (this.Height / 2));
public RectangleInfo Clamp(RectangleInfo outer)
{
if ((this.Width > outer.Width) || (this.Height > outer.Height))
{
throw new ArgumentException($"Value cannot be larger than {nameof(outer)}.");
}
return new(
x: Math.Clamp(this.X, outer.X, outer.Right - this.Width),
y: Math.Clamp(this.Y, outer.Y, outer.Bottom - this.Height),
width: this.Width,
height: this.Height);
}
public Rectangle ToRectangle() => new((int)this.X, (int)this.Y, (int)this.Width, (int)this.Height);
public override string ToString()
{
return "{" +
$"{nameof(this.Left)}={this.Left}," +
$"{nameof(this.Top)}={this.Top}," +
$"{nameof(this.Width)}={this.Width}," +
$"{nameof(this.Height)}={this.Height}" +
"}";
}
}

View File

@@ -0,0 +1,87 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Drawing;
namespace MouseJumpUI.Drawing.Models;
/// <summary>
/// Immutable version of a System.Drawing.Size object with some extra utility methods.
/// </summary>
public sealed class SizeInfo
{
public SizeInfo(decimal width, decimal height)
{
this.Width = width;
this.Height = height;
}
public SizeInfo(Size size)
: this(size.Width, size.Height)
{
}
public decimal Width
{
get;
}
public decimal Height
{
get;
}
public SizeInfo Negate() => new(-this.Width, -this.Height);
public SizeInfo Shrink(PaddingInfo padding) => new(this.Width - padding.Horizontal, this.Height - padding.Vertical);
public SizeInfo Intersect(SizeInfo size) => new(
Math.Min(this.Width, size.Width),
Math.Min(this.Height, size.Height));
public RectangleInfo PlaceAt(decimal x, decimal y) => new(x, y, this.Width, this.Height);
public SizeInfo ScaleToFit(SizeInfo bounds)
{
var widthRatio = bounds.Width / this.Width;
var heightRatio = bounds.Height / this.Height;
return widthRatio.CompareTo(heightRatio) switch
{
< 0 => new(bounds.Width, this.Height * widthRatio),
0 => bounds,
> 0 => new(this.Width * heightRatio, bounds.Height),
};
}
/// <summary>
/// Get the scaling ratio to scale obj by so that it fits inside the specified bounds
/// without distorting the aspect ratio.
/// </summary>
public decimal ScaleToFitRatio(SizeInfo bounds)
{
if (bounds.Width == 0 || bounds.Height == 0)
{
throw new ArgumentException($"{nameof(bounds.Width)} or {nameof(bounds.Height)} cannot be zero", nameof(bounds));
}
var widthRatio = bounds.Width / this.Width;
var heightRatio = bounds.Height / this.Height;
var scalingRatio = Math.Min(widthRatio, heightRatio);
return scalingRatio;
}
public Size ToSize() => new((int)this.Width, (int)this.Height);
public Point ToPoint() => new((int)this.Width, (int)this.Height);
public override string ToString()
{
return "{" +
$"{nameof(this.Width)}={this.Width}," +
$"{nameof(this.Height)}={this.Height}" +
"}";
}
}

View File

@@ -0,0 +1,237 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Linq;
using System.Windows.Forms;
using MouseJumpUI.Drawing.Models;
using MouseJumpUI.NativeMethods.Core;
using MouseJumpUI.NativeWrappers;
namespace MouseJumpUI.Helpers;
internal static class DrawingHelper
{
public static LayoutInfo CalculateLayoutInfo(
LayoutConfig layoutConfig)
{
if (layoutConfig is null)
{
throw new ArgumentNullException(nameof(layoutConfig));
}
var builder = new LayoutInfo.Builder
{
LayoutConfig = layoutConfig,
};
builder.ActivatedScreen = layoutConfig.ScreenBounds[layoutConfig.ActivatedScreen];
// work out the maximum *constrained* form size
// * can't be bigger than the activated screen
// * can't be bigger than the max form size
var maxFormSize = builder.ActivatedScreen.Size
.Intersect(layoutConfig.MaximumFormSize);
// the drawing area for screen images is inside the
// form border and inside the preview border
var maxDrawingSize = maxFormSize
.Shrink(layoutConfig.FormPadding)
.Shrink(layoutConfig.PreviewPadding);
// scale the virtual screen to fit inside the drawing bounds
var scalingRatio = layoutConfig.VirtualScreen.Size
.ScaleToFitRatio(maxDrawingSize);
// position the drawing bounds inside the preview border
var drawingBounds = layoutConfig.VirtualScreen.Size
.ScaleToFit(maxDrawingSize)
.PlaceAt(layoutConfig.PreviewPadding.Left, layoutConfig.PreviewPadding.Top);
// now we know the size of the drawing area we can work out the preview size
builder.PreviewBounds = drawingBounds.Enlarge(layoutConfig.PreviewPadding);
// ... and the form size
// * center the form to the activated position, but nudge it back
// inside the visible area of the activated screen if it falls outside
builder.FormBounds = builder.PreviewBounds.Size
.PlaceAt(0, 0)
.Enlarge(layoutConfig.FormPadding)
.Center(layoutConfig.ActivatedLocation)
.Clamp(builder.ActivatedScreen);
// now calculate the positions of each of the screen images on the preview
builder.ScreenBounds = layoutConfig.ScreenBounds
.Select(
screen => screen
.Offset(layoutConfig.VirtualScreen.Location.Size.Negate())
.Scale(scalingRatio)
.Offset(layoutConfig.PreviewPadding.Left, layoutConfig.PreviewPadding.Top))
.ToList();
return builder.Build();
}
/// <summary>
/// Resize and position the specified form.
/// </summary>
public static void PositionForm(
Form form, RectangleInfo formBounds)
{
// note - do this in two steps rather than "this.Bounds = formBounds" as there
// appears to be an issue in WinForms with dpi scaling even when using PerMonitorV2,
// where the form scaling uses either the *primary* screen scaling or the *previous*
// screen's scaling when the form is moved to a different screen. i've got no idea
// *why*, but the exact sequence of calls below seems to be a workaround...
// see https://github.com/mikeclayton/FancyMouse/issues/2
var bounds = formBounds.ToRectangle();
form.Location = bounds.Location;
_ = form.PointToScreen(Point.Empty);
form.Size = bounds.Size;
}
/// <summary>
/// Draw the preview background.
/// </summary>
public static void DrawPreviewBackground(
Graphics previewGraphics, RectangleInfo previewBounds, IEnumerable<RectangleInfo> screenBounds)
{
using var backgroundBrush = new LinearGradientBrush(
previewBounds.Location.ToPoint(),
previewBounds.Size.ToPoint(),
Color.FromArgb(13, 87, 210), // light blue
Color.FromArgb(3, 68, 192)); // darker blue
// it's faster to build a region with the screen areas excluded
// and fill that than it is to fill the entire bounding rectangle
var backgroundRegion = new Region(previewBounds.ToRectangle());
foreach (var screen in screenBounds)
{
backgroundRegion.Exclude(screen.ToRectangle());
}
previewGraphics.FillRegion(backgroundBrush, backgroundRegion);
}
public static void EnsureDesktopDeviceContext(ref HWND desktopHwnd, ref HDC desktopHdc)
{
if (desktopHwnd.IsNull)
{
desktopHwnd = User32.GetDesktopWindow();
}
if (desktopHdc.IsNull)
{
desktopHdc = User32.GetWindowDC(desktopHwnd);
}
}
public static void FreeDesktopDeviceContext(ref HWND desktopHwnd, ref HDC desktopHdc)
{
if (!desktopHwnd.IsNull && !desktopHdc.IsNull)
{
_ = User32.ReleaseDC(desktopHwnd, desktopHdc);
}
desktopHwnd = HWND.Null;
desktopHdc = HDC.Null;
}
/// <summary>
/// Checks if the device context handle exists, and creates a new one from the
/// Graphics object if not.
/// </summary>
public static void EnsurePreviewDeviceContext(Graphics previewGraphics, ref HDC previewHdc)
{
if (previewHdc.IsNull)
{
previewHdc = new HDC(previewGraphics.GetHdc());
_ = Gdi32.SetStretchBltMode(previewHdc, MouseJumpUI.NativeMethods.Gdi32.STRETCH_BLT_MODE.STRETCH_HALFTONE);
}
}
/// <summary>
/// Free the specified device context handle if it exists.
/// </summary>
public static void FreePreviewDeviceContext(Graphics previewGraphics, ref HDC previewHdc)
{
if ((previewGraphics is not null) && !previewHdc.IsNull)
{
previewGraphics.ReleaseHdc(previewHdc.Value);
previewHdc = HDC.Null;
}
}
/// <summary>
/// Draw placeholder images for any non-activated screens on the preview.
/// Will release the specified device context handle if it needs to draw anything.
/// </summary>
public static void DrawPreviewPlaceholders(
Graphics previewGraphics, IEnumerable<RectangleInfo> screenBounds)
{
// we can exclude the activated screen because we've already draw
// the screen capture image for that one on the preview
if (screenBounds.Any())
{
var brush = Brushes.Black;
previewGraphics.FillRectangles(brush, screenBounds.Select(screen => screen.ToRectangle()).ToArray());
}
}
/// <summary>
/// Draws screen captures from the specified desktop handle onto the target device context.
/// </summary>
public static void DrawPreviewScreen(
HDC sourceHdc,
HDC targetHdc,
RectangleInfo sourceBounds,
RectangleInfo targetBounds)
{
var source = sourceBounds.ToRectangle();
var target = targetBounds.ToRectangle();
_ = Gdi32.StretchBlt(
targetHdc,
target.X,
target.Y,
target.Width,
target.Height,
sourceHdc,
source.X,
source.Y,
source.Width,
source.Height,
MouseJumpUI.NativeMethods.Gdi32.ROP_CODE.SRCCOPY);
}
/// <summary>
/// Draws screen captures from the specified desktop handle onto the target device context.
/// </summary>
public static void DrawPreviewScreens(
HDC sourceHdc,
HDC targetHdc,
IList<RectangleInfo> sourceBounds,
IList<RectangleInfo> targetBounds)
{
for (var i = 0; i < sourceBounds.Count; i++)
{
var source = sourceBounds[i].ToRectangle();
var target = targetBounds[i].ToRectangle();
_ = Gdi32.StretchBlt(
targetHdc,
target.X,
target.Y,
target.Width,
target.Height,
sourceHdc,
source.X,
source.Y,
source.Width,
source.Height,
MouseJumpUI.NativeMethods.Gdi32.ROP_CODE.SRCCOPY);
}
}
}

View File

@@ -1,191 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
namespace MouseJumpUI.Helpers;
internal static class LayoutHelper
{
/// <summary>
/// Center an object on the given origin.
/// </summary>
public static Point CenterObject(Size obj, Point origin)
{
return new Point(
x: (int)(origin.X - ((float)obj.Width / 2)),
y: (int)(origin.Y - ((float)obj.Height / 2)));
}
/// <summary>
/// Combines the specified regions and returns the smallest rectangle that contains them.
/// </summary>
/// <param name="regions">The regions to combine.</param>
/// <returns>
/// Returns the smallest rectangle that contains all the specified regions.
/// </returns>
public static Rectangle CombineRegions(List<Rectangle> regions)
{
if (regions == null)
{
throw new ArgumentNullException(nameof(regions));
}
if (regions.Count == 0)
{
return Rectangle.Empty;
}
var combined = regions.Aggregate(
seed: regions[0],
func: Rectangle.Union);
return combined;
}
/// <summary>
/// Returns the midpoint of the given region.
/// </summary>
public static Point GetMidpoint(Rectangle region)
{
return new Point(
(region.Left + region.Right) / 2,
(region.Top + region.Bottom) / 2);
}
/// <summary>
/// Returns the largest Size object that can fit inside
/// all of the given sizes. (Equivalent to a Size
/// object with the smallest Width and smallest Height from
/// all of the specified sizes).
/// </summary>
public static Size IntersectSizes(params Size[] sizes)
{
return new Size(
sizes.Min(s => s.Width),
sizes.Min(s => s.Height));
}
/// <summary>
/// Returns the location to move the inner rectangle so that it sits entirely inside
/// the outer rectangle. Returns the inner rectangle's current position if it is
/// already inside the outer rectangle.
/// </summary>
public static Rectangle MoveInside(Rectangle inner, Rectangle outer)
{
if ((inner.Width > outer.Width) || (inner.Height > outer.Height))
{
throw new ArgumentException($"{nameof(inner)} cannot be larger than {nameof(outer)}.");
}
return inner with
{
X = Math.Clamp(inner.X, outer.X, outer.Right - inner.Width),
Y = Math.Clamp(inner.Y, outer.Y, outer.Bottom - inner.Height),
};
}
/// <summary>
/// Scales a location within a reference region onto a new region
/// so that it's proportionally in the same position in the new region.
/// </summary>
public static Point ScaleLocation(Rectangle originalBounds, Point originalLocation, Rectangle scaledBounds)
{
return new Point(
(int)(originalLocation.X / (double)originalBounds.Width * scaledBounds.Width) + scaledBounds.Left,
(int)(originalLocation.Y / (double)originalBounds.Height * scaledBounds.Height) + scaledBounds.Top);
}
/// <summary>
/// Scale an object to fit inside the specified bounds while maintaining aspect ratio.
/// </summary>
public static Size ScaleToFit(Size obj, Size bounds)
{
if (bounds.Width == 0 || bounds.Height == 0)
{
return Size.Empty;
}
var widthRatio = (double)obj.Width / bounds.Width;
var heightRatio = (double)obj.Height / bounds.Height;
var scaledSize = (widthRatio > heightRatio)
? bounds with
{
Height = (int)(obj.Height / widthRatio),
}
: bounds with
{
Width = (int)(obj.Width / heightRatio),
};
return scaledSize;
}
/// <summary>
/// Calculates the position to show the preview form based on a number of factors.
/// </summary>
/// <param name="desktopBounds">
/// The bounds of the entire desktop / virtual screen. Might start at a negative
/// x, y if a non-primary screen is located left of or above the primary screen.
/// </param>
/// <param name="activatedPosition">
/// The current position of the cursor on the virtual desktop.
/// </param>
/// <param name="activatedMonitorBounds">
/// The bounds of the screen the cursor is currently on. Might start at a negative
/// x, y if a non-primary screen is located left of or above the primary screen.
/// </param>
/// <param name="maximumThumbnailImageSize">
/// The largest allowable size of the preview image. This is literally the just
/// image itself, not including padding around the image.
/// </param>
/// <param name="thumbnailImagePadding">
/// The total width and height of padding around the preview image.
/// </param>
/// <returns>
/// The size and location to use when showing the preview image form.
/// </returns>
public static Rectangle GetPreviewFormBounds(
Rectangle desktopBounds,
Point activatedPosition,
Rectangle activatedMonitorBounds,
Size maximumThumbnailImageSize,
Size thumbnailImagePadding)
{
// see https://learn.microsoft.com/en-gb/windows/win32/gdi/the-virtual-screen
// calculate the maximum size the form is allowed to be
var maxFormSize = LayoutHelper.IntersectSizes(
new[]
{
// can't be bigger than the current screen
activatedMonitorBounds.Size,
// can't be bigger than the max preview image
// *plus* the padding around the preview image
// (max thumbnail image size doesn't include the padding)
maximumThumbnailImageSize + thumbnailImagePadding,
});
// calculate the actual form size by scaling the entire
// desktop bounds into the max thumbnail size while accounting
// for the size of the padding around the preview
var thumbnailImageSize = LayoutHelper.ScaleToFit(
obj: desktopBounds.Size,
bounds: maxFormSize - thumbnailImagePadding);
var formSize = thumbnailImageSize + thumbnailImagePadding;
// center the form to the activated position, but nudge it back
// inside the visible area of the screen if it falls outside
var formBounds = LayoutHelper.MoveInside(
inner: new Rectangle(
LayoutHelper.CenterObject(
obj: formSize,
origin: activatedPosition),
formSize),
outer: activatedMonitorBounds);
return formBounds;
}
}

View File

@@ -67,10 +67,9 @@ namespace MouseJumpUI.Helpers
private static string GetCallerInfo()
{
StackTrace stackTrace = new StackTrace();
var stackTrace = new StackTrace();
var methodName = stackTrace.GetFrame(3)?.GetMethod();
var className = methodName?.DeclaringType.Name;
var className = methodName?.DeclaringType?.Name;
return "[Method]: " + methodName?.Name + " [Class]: " + className;
}
}

View File

@@ -0,0 +1,87 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Drawing;
using System.Windows.Forms;
using MouseJumpUI.Drawing.Models;
namespace MouseJumpUI.Helpers;
internal static class MouseHelper
{
/// <summary>
/// Calculates where to move the cursor to by projecting a point from
/// the preview image onto the desktop and using that as the target location.
/// </summary>
/// <remarks>
/// The preview image origin is (0, 0) but the desktop origin may be non-zero,
/// or even negative if the primary monitor is not the at the top-left of the
/// entire desktop rectangle, so results may contain negative coordinates.
/// </remarks>
public static PointInfo GetJumpLocation(PointInfo previewLocation, SizeInfo previewSize, RectangleInfo desktopBounds)
{
return previewLocation
.Scale(previewSize.ScaleToFitRatio(desktopBounds.Size))
.Offset(desktopBounds.Location);
}
/// <summary>
/// Moves the cursor to the specified location.
/// </summary>
/// <remarks>
/// See https://github.com/mikeclayton/FancyMouse/pull/3
/// </remarks>
public static void JumpCursor(PointInfo location)
{
// set the new cursor position *twice* - the cursor sometimes end up in
// the wrong place if we try to cross the dead space between non-aligned
// monitors - e.g. when trying to move the cursor from (a) to (b) we can
// *sometimes* - for no clear reason - end up at (c) instead.
//
// +----------------+
// |(c) (b) |
// | |
// | |
// | |
// +---------+ |
// | (a) | |
// +---------+----------------+
//
// setting the position a second time seems to fix this and moves the
// cursor to the expected location (b)
var point = location.ToPoint();
Cursor.Position = point;
Cursor.Position = point;
}
/// <summary>
/// Sends an input simulating an absolute mouse move to the new location.
/// </summary>
/// <remarks>
/// See https://github.com/microsoft/PowerToys/issues/24523
/// https://github.com/microsoft/PowerToys/pull/24527
/// </remarks>
public static void SimulateMouseMovementEvent(Point location)
{
var mouseMoveInput = new NativeMethods.INPUT
{
type = NativeMethods.INPUTTYPE.INPUT_MOUSE,
data = new NativeMethods.InputUnion
{
mi = new NativeMethods.MOUSEINPUT
{
dx = NativeMethods.CalculateAbsoluteCoordinateX(location.X),
dy = NativeMethods.CalculateAbsoluteCoordinateY(location.Y),
mouseData = 0,
dwFlags = (uint)NativeMethods.MOUSE_INPUT_FLAGS.MOUSEEVENTF_MOVE
| (uint)NativeMethods.MOUSE_INPUT_FLAGS.MOUSEEVENTF_ABSOLUTE,
time = 0,
dwExtraInfo = 0,
},
},
};
var inputs = new NativeMethods.INPUT[] { mouseMoveInput };
_ = NativeMethods.SendInput(1, inputs, NativeMethods.INPUT.Size);
}
}

View File

@@ -5,10 +5,10 @@
using System;
using System.Runtime.InteropServices;
namespace MouseJumpUI.Helpers
{
namespace MouseJumpUI.Helpers;
// Win32 functions required for temporary workaround for issue #1273
internal class NativeMethods
internal static class NativeMethods
{
[DllImport("user32.dll")]
internal static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize);
@@ -109,4 +109,3 @@ namespace MouseJumpUI.Helpers
return (y * 65536) / GetSystemMetrics(SystemMetric.SM_CYSCREEN);
}
}
}

View File

@@ -60,7 +60,7 @@ partial class MainForm
Thumbnail.Location = new System.Drawing.Point(5, 5);
Thumbnail.Name = "Thumbnail";
Thumbnail.Size = new System.Drawing.Size(790, 440);
Thumbnail.SizeMode = PictureBoxSizeMode.StretchImage;
Thumbnail.SizeMode = PictureBoxSizeMode.Normal;
Thumbnail.TabIndex = 1;
Thumbnail.TabStop = false;
Thumbnail.Click += Thumbnail_Click;

View File

@@ -3,11 +3,14 @@
// See the LICENSE file in the project root for more information.
using System;
using System.Diagnostics;
using System.Drawing;
using System.Drawing.Imaging;
using System.Linq;
using System.Windows.Forms;
using MouseJumpUI.Drawing.Models;
using MouseJumpUI.Helpers;
using MouseJumpUI.NativeMethods.Core;
namespace MouseJumpUI;
@@ -33,172 +36,170 @@ internal partial class MainForm : Form
private void MainForm_Deactivate(object sender, EventArgs e)
{
// dispose the existing image if there is one
if (Thumbnail.Image != null)
{
Thumbnail.Image.Dispose();
Thumbnail.Image = null;
}
this.Close();
}
// Sends an input simulating an absolute mouse move to the new location.
private void SimulateMouseMovementEvent(Point location)
{
NativeMethods.INPUT mouseMoveInput = new NativeMethods.INPUT
{
type = NativeMethods.INPUTTYPE.INPUT_MOUSE,
data = new NativeMethods.InputUnion
{
mi = new NativeMethods.MOUSEINPUT
{
dx = NativeMethods.CalculateAbsoluteCoordinateX(location.X),
dy = NativeMethods.CalculateAbsoluteCoordinateY(location.Y),
mouseData = 0,
dwFlags = (uint)NativeMethods.MOUSE_INPUT_FLAGS.MOUSEEVENTF_MOVE | (uint)NativeMethods.MOUSE_INPUT_FLAGS.MOUSEEVENTF_ABSOLUTE,
time = 0,
dwExtraInfo = 0,
},
},
};
NativeMethods.INPUT[] inputs = new NativeMethods.INPUT[] { mouseMoveInput };
_ = NativeMethods.SendInput(1, inputs, NativeMethods.INPUT.Size);
}
private void Thumbnail_Click(object sender, EventArgs e)
{
var mouseEventArgs = (MouseEventArgs)e;
Logger.LogInfo($"Reporting mouse event args \n\tbutton = {mouseEventArgs.Button}\n\tlocation = {mouseEventArgs.Location} ");
if (mouseEventArgs.Button == MouseButtons.Left)
{
// plain click - move mouse pointer
var desktopBounds = LayoutHelper.CombineRegions(
Screen.AllScreens.Select(
screen => screen.Bounds).ToList());
Logger.LogInfo($"desktop bounds = {desktopBounds}");
var mouseEvent = (MouseEventArgs)e;
var scaledLocation = LayoutHelper.ScaleLocation(
originalBounds: Thumbnail.Bounds,
originalLocation: new Point(mouseEvent.X, mouseEvent.Y),
scaledBounds: desktopBounds);
Logger.LogInfo($"scaled location = {scaledLocation}");
// set the new cursor position *twice* - the cursor sometimes end up in
// the wrong place if we try to cross the dead space between non-aligned
// monitors - e.g. when trying to move the cursor from (a) to (b) we can
// *sometimes* - for no clear reason - end up at (c) instead.
//
// +----------------+
// |(c) (b) |
// | |
// | |
// | |
// +---------+ |
// | (a) | |
// +---------+----------------+
//
// setting the position a second time seems to fix this and moves the
// cursor to the expected location (b) - for more details see
// https://github.com/mikeclayton/FancyMouse/pull/3
Cursor.Position = scaledLocation;
Cursor.Position = scaledLocation;
// Simulate mouse input for handlers that won't just catch the Cursor change
SimulateMouseMovementEvent(scaledLocation);
Microsoft.PowerToys.Telemetry.PowerToysTelemetry.Log.WriteEvent(new Telemetry.MouseJumpTeleportCursorEvent());
}
this.Close();
}
public void ShowThumbnail()
{
if (this.Thumbnail.Image != null)
if (this.Thumbnail.Image is not null)
{
var tmp = this.Thumbnail.Image;
this.Thumbnail.Image = null;
tmp.Dispose();
}
}
private void Thumbnail_Click(object sender, EventArgs e)
{
var mouseEventArgs = (MouseEventArgs)e;
Logger.LogInfo(string.Join(
'\n',
$"Reporting mouse event args",
$"\tbutton = {mouseEventArgs.Button}",
$"\tlocation = {mouseEventArgs.Location}"));
if (mouseEventArgs.Button == MouseButtons.Left)
{
// plain click - move mouse pointer
var scaledLocation = MouseHelper.GetJumpLocation(
new PointInfo(mouseEventArgs.X, mouseEventArgs.Y),
new SizeInfo(this.Thumbnail.Size),
new RectangleInfo(SystemInformation.VirtualScreen));
Logger.LogInfo($"scaled location = {scaledLocation}");
MouseHelper.JumpCursor(scaledLocation);
// Simulate mouse input for handlers that won't just catch the Cursor change
MouseHelper.SimulateMouseMovementEvent(scaledLocation.ToPoint());
Microsoft.PowerToys.Telemetry.PowerToysTelemetry.Log.WriteEvent(new Telemetry.MouseJumpTeleportCursorEvent());
}
this.OnDeactivate(EventArgs.Empty);
}
public void ShowThumbnail()
{
var screens = Screen.AllScreens;
foreach (var i in Enumerable.Range(0, screens.Length))
{
var screen = screens[i];
Logger.LogInfo($"screen[{i}] = \"{screen.DeviceName}\"\n\tprimary = {screen.Primary}\n\tbounds = {screen.Bounds}\n\tworking area = {screen.WorkingArea}");
Logger.LogInfo(string.Join(
'\n',
$"screen[{i}] = \"{screen.DeviceName}\"",
$"\tprimary = {screen.Primary}",
$"\tbounds = {screen.Bounds}",
$"\tworking area = {screen.WorkingArea}"));
}
var desktopBounds = LayoutHelper.CombineRegions(
screens.Select(screen => screen.Bounds).ToList());
Logger.LogInfo(
$"desktop bounds = {desktopBounds}");
// collect together some values that we need for calculating layout
var activatedLocation = Cursor.Position;
var layoutConfig = new LayoutConfig(
virtualScreen: SystemInformation.VirtualScreen,
screenBounds: Screen.AllScreens.Select(screen => screen.Bounds),
activatedLocation: activatedLocation,
activatedScreen: Array.IndexOf(Screen.AllScreens, Screen.FromPoint(activatedLocation)),
maximumFormSize: new Size(1600, 1200),
formPadding: this.panel1.Padding,
previewPadding: new Padding(0));
Logger.LogInfo(string.Join(
'\n',
$"Layout config",
$"-------------",
$"virtual screen = {layoutConfig.VirtualScreen}",
$"activated location = {layoutConfig.ActivatedLocation}",
$"activated screen = {layoutConfig.ActivatedScreen}",
$"maximum form size = {layoutConfig.MaximumFormSize}",
$"form padding = {layoutConfig.FormPadding}",
$"preview padding = {layoutConfig.PreviewPadding}"));
var activatedPosition = Cursor.Position;
Logger.LogInfo(
$"activated position = {activatedPosition}");
// calculate the layout coordinates for everything
var layoutInfo = DrawingHelper.CalculateLayoutInfo(layoutConfig);
Logger.LogInfo(string.Join(
'\n',
$"Layout info",
$"-----------",
$"form bounds = {layoutInfo.FormBounds}",
$"preview bounds = {layoutInfo.PreviewBounds}",
$"activated screen = {layoutInfo.ActivatedScreen}"));
var previewImagePadding = new Size(
panel1.Padding.Left + panel1.Padding.Right,
panel1.Padding.Top + panel1.Padding.Bottom);
Logger.LogInfo(
$"image padding = {previewImagePadding}");
DrawingHelper.PositionForm(this, layoutInfo.FormBounds);
var maxThumbnailSize = new Size(1600, 1200);
var formBounds = LayoutHelper.GetPreviewFormBounds(
desktopBounds: desktopBounds,
activatedPosition: activatedPosition,
activatedMonitorBounds: Screen.FromPoint(activatedPosition).Bounds,
maximumThumbnailImageSize: maxThumbnailSize,
thumbnailImagePadding: previewImagePadding);
Logger.LogInfo(
$"form bounds = {formBounds}");
// initialize the preview image
var preview = new Bitmap(
(int)layoutInfo.PreviewBounds.Width,
(int)layoutInfo.PreviewBounds.Height,
PixelFormat.Format32bppArgb);
this.Thumbnail.Image = preview;
// take a screenshot of the entire desktop
// see https://learn.microsoft.com/en-gb/windows/win32/gdi/the-virtual-screen
var screenshot = new Bitmap(desktopBounds.Width, desktopBounds.Height, PixelFormat.Format32bppArgb);
using (var graphics = Graphics.FromImage(screenshot))
using var previewGraphics = Graphics.FromImage(preview);
DrawingHelper.DrawPreviewBackground(previewGraphics, layoutInfo.PreviewBounds, layoutInfo.ScreenBounds);
var desktopHwnd = HWND.Null;
var desktopHdc = HDC.Null;
var previewHdc = HDC.Null;
try
{
// note - it *might* be faster to capture each monitor individually and assemble them into
// a single image ourselves as we *may* not have to transfer all of the blank pixels
// that are outside the desktop bounds - e.g. the *** in the ascii art below
//
// +----------------+********
// | |********
// | 1 +-------+
// | | |
// +----------------+ 0 |
// *****************| |
// *****************+-------+
//
// for very irregular monitor layouts this *might* be a big percentage of the rectangle
// containing the desktop bounds.
//
// then again, it might not make much difference at all - we'd need to do some perf tests
graphics.CopyFromScreen(desktopBounds.Left, desktopBounds.Top, 0, 0, desktopBounds.Size);
DrawingHelper.EnsureDesktopDeviceContext(ref desktopHwnd, ref desktopHdc);
// we have to capture the screen where we're going to show the form first
// as the form will obscure the screen as soon as it's visible
var activatedStopwatch = Stopwatch.StartNew();
DrawingHelper.EnsurePreviewDeviceContext(previewGraphics, ref previewHdc);
DrawingHelper.DrawPreviewScreen(
desktopHdc,
previewHdc,
layoutConfig.ScreenBounds[layoutConfig.ActivatedScreen],
layoutInfo.ScreenBounds[layoutConfig.ActivatedScreen]);
activatedStopwatch.Stop();
// show the placeholder images if it looks like it might take a while
// to capture the remaining screenshot images
if (activatedStopwatch.ElapsedMilliseconds > 250)
{
var activatedArea = layoutConfig.ScreenBounds[layoutConfig.ActivatedScreen].Area;
var totalArea = layoutConfig.ScreenBounds.Sum(screen => screen.Area);
if ((activatedArea / totalArea) < 0.5M)
{
// we need to release the device context handle before we can draw the placeholders
// using the Graphics object otherwise we'll get an error from GDI saying
// "Object is currently in use elsewhere"
DrawingHelper.FreePreviewDeviceContext(previewGraphics, ref previewHdc);
DrawingHelper.DrawPreviewPlaceholders(
previewGraphics,
layoutInfo.ScreenBounds.Where((_, idx) => idx != layoutConfig.ActivatedScreen));
MainForm.ShowPreview(this);
}
}
// resize and position the form
// note - do this in two steps rather than "this.Bounds = formBounds" as there
// appears to be an issue in WinForms with dpi scaling even when using PerMonitorV2,
// where the form scaling uses either the *primary* screen scaling or the *previous*
// screen's scaling when the form is moved to a different screen. i've got no idea
// *why*, but the exact sequence of calls below seems to be a workaround...
// see https://github.com/mikeclayton/FancyMouse/issues/2
this.Location = formBounds.Location;
_ = this.PointToScreen(Point.Empty);
this.Size = formBounds.Size;
// update the preview image
this.Thumbnail.Image = screenshot;
this.Show();
Microsoft.PowerToys.Telemetry.PowerToysTelemetry.Log.WriteEvent(new Telemetry.MouseJumpTeleportCursorEvent());
// draw the remaining screen captures (if any) on the preview image
var sourceScreens = layoutConfig.ScreenBounds.Where((_, idx) => idx != layoutConfig.ActivatedScreen).ToList();
if (sourceScreens.Any())
{
DrawingHelper.EnsurePreviewDeviceContext(previewGraphics, ref previewHdc);
DrawingHelper.DrawPreviewScreens(
desktopHdc,
previewHdc,
sourceScreens,
layoutInfo.ScreenBounds.Where((_, idx) => idx != layoutConfig.ActivatedScreen).ToList());
MainForm.ShowPreview(this);
}
}
finally
{
DrawingHelper.FreeDesktopDeviceContext(ref desktopHwnd, ref desktopHdc);
DrawingHelper.FreePreviewDeviceContext(previewGraphics, ref previewHdc);
}
// we have to activate the form to make sure the deactivate event fires
MainForm.ShowPreview(this);
Microsoft.PowerToys.Telemetry.PowerToysTelemetry.Log.WriteEvent(new Telemetry.MouseJumpTeleportCursorEvent());
this.Activate();
}
private static void ShowPreview(MainForm form)
{
if (!form.Visible)
{
form.Show();
}
form.Thumbnail.Refresh();
}
}

View File

@@ -12,6 +12,7 @@
<StartupObject>MouseJumpUI.Program</StartupObject>
<SelfContained>true</SelfContained>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<Nullable>enable</Nullable>
</PropertyGroup>
<!-- SelfContained=true requires RuntimeIdentifier to be set -->

View File

@@ -0,0 +1,36 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace MouseJumpUI.NativeMethods.Core;
/// <summary>
/// A Boolean variable (should be TRUE or FALSE).
/// This type is declared in WinDef.h as follows:
/// typedef int BOOL;
/// </summary>
/// <remarks>
/// See https://learn.microsoft.com/en-us/windows/win32/winprog/windows-data-types
/// </remarks>
internal readonly struct BOOL
{
public readonly int Value;
public BOOL(int value)
{
this.Value = value;
}
public BOOL(bool value)
{
this.Value = value ? 1 : 0;
}
public static implicit operator bool(BOOL value) => value.Value != 0;
public static implicit operator BOOL(bool value) => new(value);
public static implicit operator int(BOOL value) => value.Value;
public static implicit operator BOOL(int value) => new(value);
}

View File

@@ -0,0 +1,29 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
namespace MouseJumpUI.NativeMethods.Core;
/// <summary>
/// A handle to a device context (DC).
/// This type is declared in WinDef.h as follows:
/// typedef HANDLE HDC;
/// </summary>
/// <remarks>
/// See https://learn.microsoft.com/en-us/windows/win32/winprog/windows-data-types
/// </remarks>
internal readonly struct HDC
{
public static readonly HDC Null = new(IntPtr.Zero);
public readonly IntPtr Value;
public HDC(IntPtr value)
{
this.Value = value;
}
public bool IsNull => this.Value == HDC.Null.Value;
}

View File

@@ -0,0 +1,29 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
namespace MouseJumpUI.NativeMethods.Core;
/// <summary>
/// A handle to a window.
/// This type is declared in WinDef.h as follows:
/// typedef HANDLE HWND;
/// </summary>
/// <remarks>
/// See https://learn.microsoft.com/en-us/windows/win32/winprog/windows-data-types
/// </remarks>
internal readonly struct HWND
{
public static readonly HWND Null = new(IntPtr.Zero);
public readonly IntPtr Value;
public HWND(IntPtr value)
{
this.Value = value;
}
public bool IsNull => this.Value == HWND.Null.Value;
}

View File

@@ -0,0 +1,37 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace MouseJumpUI.NativeMethods;
internal static partial class Gdi32
{
/// <summary>
/// A raster-operation code. These codes define how the color data for the source
/// rectangle is to be combined with the color data for the destination rectangle
/// to achieve the final color.
/// </summary>
/// <remarks>
/// See https://learn.microsoft.com/en-us/windows/win32/api/wingdi/nf-wingdi-bitblt
/// </remarks>
public enum ROP_CODE : uint
{
BLACKNESS = 0x00000042,
CAPTUREBLT = 0x40000000,
DSTINVERT = 0x00550009,
MERGECOPY = 0x00C000CA,
MERGEPAINT = 0x00BB0226,
NOMIRRORBITMAP = 0x80000000,
NOTSRCCOPY = 0x00330008,
NOTSRCERASE = 0x001100A6,
PATCOPY = 0x00F00021,
PATINVERT = 0x005A0049,
PATPAINT = 0x00FB0A09,
SRCAND = 0x008800C6,
SRCCOPY = 0x00CC0020,
SRCERASE = 0x00440328,
SRCINVERT = 0x00660046,
SRCPAINT = 0x00EE0086,
WHITENESS = 0x00FF0062,
}
}

View File

@@ -0,0 +1,26 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace MouseJumpUI.NativeMethods;
internal static partial class Gdi32
{
/// <summary>
/// The stretching mode.
/// </summary>
/// <remarks>
/// See https://learn.microsoft.com/en-us/windows/win32/api/wingdi/nf-wingdi-setstretchbltmode
/// </remarks>
public enum STRETCH_BLT_MODE : int
{
BLACKONWHITE = 1,
COLORONCOLOR = 3,
HALFTONE = 4,
WHITEONBLACK = 2,
STRETCH_ANDSCANS = STRETCH_BLT_MODE.BLACKONWHITE,
STRETCH_DELETESCANS = STRETCH_BLT_MODE.COLORONCOLOR,
STRETCH_HALFTONE = STRETCH_BLT_MODE.HALFTONE,
STRETCH_ORSCANS = STRETCH_BLT_MODE.WHITEONBLACK,
}
}

View File

@@ -0,0 +1,27 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Runtime.InteropServices;
using MouseJumpUI.NativeMethods.Core;
namespace MouseJumpUI.NativeMethods;
internal static partial class Gdi32
{
/// <summary>
/// The SetStretchBltMode function sets the bitmap stretching mode in the specified device context.
/// </summary>
/// <returns>
/// If the function succeeds, the return value is the previous stretching mode.
/// If the function fails, the return value is zero.
/// This function can return the following value.
/// </returns>
/// <remarks>
/// See https://learn.microsoft.com/en-us/windows/win32/api/wingdi/nf-wingdi-setstretchbltmode
/// </remarks>
[LibraryImport(Libraries.Gdi32)]
public static partial int SetStretchBltMode(
HDC hdc,
STRETCH_BLT_MODE mode);
}

View File

@@ -0,0 +1,38 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Runtime.InteropServices;
using MouseJumpUI.NativeMethods.Core;
namespace MouseJumpUI.NativeMethods;
internal static partial class Gdi32
{
/// <summary>
/// The StretchBlt function copies a bitmap from a source rectangle into a destination
/// rectangle, stretching or compressing the bitmap to fit the dimensions of the
/// destination rectangle, if necessary. The system stretches or compresses the bitmap
/// according to the stretching mode currently set in the destination device context.
/// </summary>
/// <returns>
/// If the function succeeds, the return value is nonzero.
/// If the function fails, the return value is zero.
/// </returns>
/// <remarks>
/// See https://learn.microsoft.com/en-us/windows/win32/api/wingdi/nf-wingdi-stretchblt
/// </remarks>
[LibraryImport(Libraries.Gdi32)]
public static partial BOOL StretchBlt(
HDC hdcDest,
int xDest,
int yDest,
int wDest,
int hDest,
HDC hdcSrc,
int xSrc,
int ySrc,
int wSrc,
int hSrc,
ROP_CODE rop);
}

View File

@@ -0,0 +1,11 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace MouseJumpUI.NativeMethods;
internal static class Libraries
{
public const string Gdi32 = "gdi32";
public const string User32 = "user32";
}

View File

@@ -0,0 +1,24 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Runtime.InteropServices;
using MouseJumpUI.NativeMethods.Core;
namespace MouseJumpUI.NativeMethods;
internal static partial class User32
{
/// <summary>
/// Retrieves a handle to the desktop window. The desktop window covers the entire
/// screen. The desktop window is the area on top of which other windows are painted.
/// </summary>
/// <returns>
/// The return value is a handle to the desktop window.
/// </returns>
/// <remarks>
/// See https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getdesktopwindow
/// </remarks>
[LibraryImport(Libraries.User32)]
public static partial HWND GetDesktopWindow();
}

View File

@@ -0,0 +1,31 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Runtime.InteropServices;
using MouseJumpUI.NativeMethods.Core;
namespace MouseJumpUI.NativeMethods;
internal static partial class User32
{
/// <summary>
/// The GetWindowDC function retrieves the device context (DC) for the entire window,
/// including title bar, menus, and scroll bars. A window device context permits painting
/// anywhere in a window, because the origin of the device context is the upper-left
/// corner of the window instead of the client area.
///
/// GetWindowDC assigns default attributes to the window device context each time it
/// retrieves the device context. Previous attributes are lost.
/// </summary>
/// <returns>
/// If the function succeeds, the return value is a handle to a device context for the specified window.
/// If the function fails, the return value is NULL, indicating an error or an invalid hWnd parameter.
/// </returns>
/// <remarks>
/// See https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getwindowdc
/// </remarks>
[LibraryImport(Libraries.User32)]
public static partial HDC GetWindowDC(
HWND hWnd);
}

View File

@@ -0,0 +1,28 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Runtime.InteropServices;
using MouseJumpUI.NativeMethods.Core;
namespace MouseJumpUI.NativeMethods;
internal static partial class User32
{
/// <summary>
/// The ReleaseDC function releases a device context (DC), freeing it for use by other
/// applications. The effect of the ReleaseDC function depends on the type of DC. It
/// frees only common and window DCs. It has no effect on class or private DCs.
/// </summary>
/// <returns>
/// The return value indicates whether the DC was released. If the DC was released, the return value is 1.
/// If the DC was not released, the return value is zero.
/// </returns>
/// <remarks>
/// See https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-releasedc
/// </remarks>
[LibraryImport(Libraries.User32)]
public static partial int ReleaseDC(
HWND hWnd,
HDC hDC);
}

View File

@@ -0,0 +1,21 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using MouseJumpUI.NativeMethods.Core;
namespace MouseJumpUI.NativeWrappers;
internal static partial class Gdi32
{
public static int SetStretchBltMode(HDC hdc, NativeMethods.Gdi32.STRETCH_BLT_MODE mode)
{
var result = NativeMethods.Gdi32.SetStretchBltMode(hdc, mode);
return result == 0
? throw new InvalidOperationException(
$"{nameof(Gdi32.SetStretchBltMode)} returned {result}")
: result;
}
}

View File

@@ -0,0 +1,43 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using MouseJumpUI.NativeMethods.Core;
namespace MouseJumpUI.NativeWrappers;
internal static partial class Gdi32
{
public static BOOL StretchBlt(
HDC hdcDest,
int xDest,
int yDest,
int wDest,
int hDest,
HDC hdcSrc,
int xSrc,
int ySrc,
int wSrc,
int hSrc,
NativeMethods.Gdi32.ROP_CODE rop)
{
var result = NativeMethods.Gdi32.StretchBlt(
hdcDest,
xDest,
yDest,
wDest,
hDest,
hdcSrc,
xSrc,
ySrc,
wSrc,
hSrc,
rop);
return result
? result
: throw new InvalidOperationException(
$"{nameof(Gdi32.StretchBlt)} returned {result.Value}");
}
}

View File

@@ -0,0 +1,16 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using MouseJumpUI.NativeMethods.Core;
namespace MouseJumpUI.NativeWrappers;
internal static partial class User32
{
public static HWND GetDesktopWindow()
{
return NativeMethods.User32.GetDesktopWindow();
}
}

View File

@@ -0,0 +1,21 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using MouseJumpUI.NativeMethods.Core;
namespace MouseJumpUI.NativeWrappers;
internal static partial class User32
{
public static HDC GetWindowDC(HWND hWnd)
{
var hdc = NativeMethods.User32.GetWindowDC(hWnd);
return hdc.IsNull
? throw new InvalidOperationException(
$"{nameof(User32.GetWindowDC)} returned null")
: hdc;
}
}

View File

@@ -0,0 +1,21 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using MouseJumpUI.NativeMethods.Core;
namespace MouseJumpUI.NativeWrappers;
internal static partial class User32
{
public static int ReleaseDC(HWND hWnd, HDC hDC)
{
var result = NativeMethods.User32.ReleaseDC(hWnd, hDC);
return result == 0
? throw new InvalidOperationException(
$"{nameof(User32.ReleaseDC)} returned {result}")
: result;
}
}