[v3-Windows] New DIP system for Enhanced High DPI Monitor Support (#3665)

* [v3-Windows] New DIP system for Enhanced High DPI Monitor Support

* Update changelog

* Remove asset middleware

* Remove SetThreadDpiAwarenessContext()

* Fix macOS build.

* Fill missing screens fields (linux, darwin)

* Skip DPI transformation on unsupported platforms

* Simplify distanceFromRectSquared()

* Update v3/pkg/application/screenmanager.go

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: Lea Anthony <lea.anthony@gmail.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
Mohamed Gharib 2024-09-22 01:38:30 +03:00 committed by GitHub
commit efe0c8d534
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 3318 additions and 498 deletions

View file

@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## v3.0.0-alpha.7 - 2024-09-18
### Added
- [windows] New DIP system for Enhanced High DPI Monitor Support by [mmghv](https://github.com/mmghv) in [#3665](https://github.com/wailsapp/wails/pull/3665)
- [windows] Window class name option by [windom](https://github.com/windom/) in [#3682](https://github.com/wailsapp/wails/pull/3682)
- Services have been expanded to provide plugin functionality. By [atterpac](https://github.com/atterpac) and [leaanthony](https://github.com/leaanthony) in [#3570](https://github.com/wailsapp/wails/pull/3570)

View file

@ -0,0 +1,206 @@
window.examples = [
[
// Normal examples (demonstrate real life scenarios)
{
name: "Single 4k monitor",
screens: [
{id: 1, w: 3840, h: 2160, s: 163.0 / 96, name: `27" 4K UHD 163DPI`},
]
},
{
name: "Two monitors",
screens: [
{id: 1, w: 3840, h: 2160, s: 163.0 / 96, name: `27" 4K UHD 163DPI`},
{id: 2, w: 1920, h: 1080, s: 1, parent: {id: 1, align: "r", offset: 0}, name: `23" FHD 96DPI`},
]
},
{
name: "Two monitors (2)",
screens: [
{id: 1, w: 1920, h: 1080, s: 1, name: `23" FHD 96DPI`},
{id: 2, w: 1920, h: 1080, s: 1.25, parent: {id: 1, align: "r", offset: 0}, name: `23" FHD 96DPI (125%)`},
]
},
{
name: "Three monitors",
screens: [
{id: 1, w: 3840, h: 2160, s: 163.0 / 96, name: `27" 4K UHD 163DPI`},
{id: 2, w: 1920, h: 1080, s: 1, parent: {id: 1, align: "r", offset: 0}, name: `23" FHD 96DPI`},
{id: 3, w: 1920, h: 1080, s: 1.25, parent: {id: 1, align: "l", offset: 0}, name: `23" FHD 96DPI (125%)`},
]
},
{
name: "Four monitors",
screens: [
{id: 1, w: 3840, h: 2160, s: 163.0 / 96, name: `27" 4K UHD 163DPI`},
{id: 2, w: 1920, h: 1080, s: 1, parent: {id: 1, align: "r", offset: 0}, name: `23" FHD 96DPI`},
{id: 3, w: 1920, h: 1080, s: 1.25, parent: {id: 2, align: "b", offset: 0}, name: `23" FHD 96DPI (125%)`},
{id: 4, w: 1080, h: 1920, s: 1, parent: {id: 1, align: "l", offset: 0}, name: `23" FHD (90deg)`},
]
},
],
[
// Test cases examples (demonstrate the algorithm basics)
{
name: "Child scaled, Start offset",
screens: [
{id: 1, w: 1200, h: 1200, s: 1, name: "Parent"},
{id: 2, w: 1200, h: 1200, s: 1.5, parent: {id: 1, align: "r", offset: 600}, name: "Child"},
]
},
{
name: "Child scaled, End offset",
screens: [
{id: 1, w: 1200, h: 1200, s: 1, name: "Parent"},
{id: 2, w: 1200, h: 1200, s: 1.5, parent: {id: 1, align: "r", offset: -600}, name: "Child"},
]
},
{
name: "Parent scaled, Start offset percent",
screens: [
{id: 1, w: 1200, h: 1200, s: 1.5, name: "Parent"},
{id: 2, w: 1200, h: 1200, s: 1, parent: {id: 1, align: "r", offset: 600}, name: "Child"},
]
},
{
name: "Parent scaled, End offset percent",
screens: [
{id: 1, w: 1200, h: 1200, s: 1.5, name: "Parent"},
{id: 2, w: 1200, h: 1200, s: 1, parent: {id: 1, align: "r", offset: -600}, name: "Child"},
]
},
{
name: "Parent scaled, Start align",
screens: [
{id: 1, w: 1200, h: 1200, s: 1.5, name: "Parent"},
{id: 2, w: 1200, h: 1100, s: 1, parent: {id: 1, align: "r", offset: 0}, name: "Child"},
]
},
{
name: "Parent scaled, End align",
screens: [
{id: 1, w: 1200, h: 1200, s: 1.5, name: "Parent"},
{id: 2, w: 1200, h: 1200, s: 1, parent: {id: 1, align: "r", offset: 0}, name: "Child"},
]
},
{
name: "Parent scaled, in-between",
screens: [
{id: 1, w: 1200, h: 1200, s: 1.5, name: "Parent"},
{id: 2, w: 1200, h: 1500, s: 1, parent: {id: 1, align: "r", offset: -200}, name: "Child"},
]
},
],
[
// Edge cases examples
{
name: "Parent order (5 is parent of 4)",
screens: [
{id: 1, w: 1920, h: 1080, s: 1},
{id: 2, w: 1024, h: 600, s: 1.25, parent: {id: 1, align: "r", offset: -200}},
{id: 3, w: 800, h: 800, s: 1.25, parent: {id: 2, align: "b", offset: 0}},
{id: 4, w: 800, h: 1080, s: 1.5, parent: {id: 2, align: "re", offset: 100}},
{id: 5, w: 600, h: 600, s: 1, parent: {id: 3, align: "r", offset: 100}},
]
},
{
name: "de-intersection reparent",
screens: [
{id: 1, w: 1920, h: 1080, s: 1},
{id: 2, w: 1680, h: 1050, s: 1.25, parent: {id: 1, align: "r", offset: 10}},
{id: 3, w: 1440, h: 900, s: 1.5, parent: {id: 1, align: "le", offset: 150}},
{id: 4, w: 1024, h: 768, s: 1, parent: {id: 3, align: "bc", offset: -200}},
{id: 5, w: 1024, h: 768, s: 1.25, parent: {id: 4, align: "r", offset: 400}},
]
},
{
name: "de-intersection (unattached child)",
screens: [
{id: 1, w: 1920, h: 1080, s: 1},
{id: 2, w: 1024, h: 768, s: 1.5, parent: {id: 1, align: "le", offset: 10}},
{id: 3, w: 1024, h: 768, s: 1.25, parent: {id: 2, align: "b", offset: 100}},
{id: 4, w: 1024, h: 768, s: 1, parent: {id: 3, align: "r", offset: 500}},
]
},
{
name: "Multiple de-intersection",
screens: [
{id: 1, w: 1920, h: 1080, s: 1},
{id: 2, w: 1024, h: 768, s: 1, parent: {id: 1, align: "be", offset: 0}},
{id: 3, w: 1024, h: 768, s: 1, parent: {id: 2, align: "b", offset: 300}},
{id: 4, w: 1024, h: 768, s: 1.5, parent: {id: 2, align: "le", offset: 100}},
{id: 5, w: 1024, h: 768, s: 1, parent: {id: 4, align: "be", offset: 100}},
]
},
{
name: "Multiple de-intersection (left-side)",
screens: [
{id: 1, w: 1920, h: 1080, s: 1},
{id: 2, w: 1024, h: 768, s: 1, parent: {id: 1, align: "le", offset: 0}},
{id: 3, w: 1024, h: 768, s: 1, parent: {id: 2, align: "b", offset: 300}},
{id: 4, w: 1024, h: 768, s: 1.5, parent: {id: 2, align: "le", offset: 100}},
{id: 5, w: 1024, h: 768, s: 1, parent: {id: 4, align: "be", offset: 100}},
]
},
{
name: "Parent de-intersection child offset",
screens: [
{id: 1, w: 1600, h: 1600, s: 1.5},
{id: 2, w: 800, h: 800, s: 1, parent: {id: 1, align: "r", offset: 0}},
{id: 3, w: 800, h: 800, s: 1, parent: {id: 1, align: "r", offset: 800}},
{id: 4, w: 800, h: 1600, s: 1, parent: {id: 2, align: "r", offset: 0}},
]
},
],
].map(sections => sections.map(layout => {
return parseLayout(layout)
}))
function parseLayout(layout) {
const screens = []
for (const screen of layout.screens) {
let x = 0, y = 0
const {w, h} = screen
if (screen.parent) {
const parent = screens.find(s => s.ID == screen.parent.id).Bounds
const offset = screen.parent.offset
let align = screen.parent.align
let align2 = ""
if (align.length == 2) {
align2 = align.charAt(1)
align = align.charAt(0)
}
x = parent.X
y = parent.Y
// t: top, b: bottom, l: left, r: right, e: edge, c: corner
if (align == "t" || align == "b") {
x += offset + (align2 == "e" || align2 == "c" ? parent.Width : 0) - (align2 == "e" ? w : 0)
y += (align == "t" ? -h : parent.Height)
} else {
y += offset + (align2 == "e" || align2 == "c" ? parent.Height : 0) - (align2 == "e" ? h : 0)
x += (align == "l" ? -w : parent.Width)
}
}
screens.push({
ID: `${screen.id}`,
Name: screen.name ?? `Display${screen.id}`,
ScaleFactor: Math.round(screen.s * 100) / 100,
X: x,
Y: y,
Size: {Width: w, Height: h},
Bounds: {X: x, Y: y, Width: w, Height: h},
PhysicalBounds: {X: x, Y: y, Width: w, Height: h},
WorkArea: {X: x, Y: y, Width: w, Height: h-Math.round(40*screen.s)},
PhysicalWorkArea: {X: x, Y: y, Width: w, Height: h-Math.round(40*screen.s)},
IsPrimary: screen.id == 1,
Rotation: 0
})
}
return {name: layout.name, screens}
}

View file

@ -4,65 +4,138 @@
<meta charset="UTF-8">
<title>Screens Demo</title>
<style>
* {
box-sizing: border-box;
}
body {
padding-top: 75px;
margin: 0 auto;
margin: 0;
width: 100vw;
height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
background-color: white;
}
#container {
display: flex;
justify-content: space-around;
flex-wrap: wrap;
text-align: -webkit-center;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif
flex-direction: column;
width: 100%;
height: 100%;
padding: 10px;
}
article {
padding: 1em;
svg {
user-select: none;
cursor: crosshair;
}
.monitor {
width: 300px;
height: 150px;
overflow-y: scroll;
border: solid 1em #333;
border-radius: .5em;
background: lightblue;
.controls > div {
padding-bottom: 10px;
}
.monitor::-webkit-scrollbar {
width: 15px;
.controls > div > span:first-child {
display: inline-block;
width: 6em;
}
.monitor::-webkit-scrollbar-thumb {
background: #666;
.controls .radio-btn {
display: inline-block;
padding: 0 3px 2px;
border-radius: 5px;
min-width: 1.5em;
text-align: center;
background-color: #ecf3f7;
box-shadow: 1px 1px 3px 0px #00000070;
border: solid 1px #9a9a9a;
cursor: default;
user-select: none;
}
::-webkit-scrollbar-track {
background-color: #888;
.controls .radio-btn.active {
background-color: #d0d8dc;
box-shadow: inset 1px 1px 3px 0px #00000070;
}
.center {
text-align: -webkit-center;
#probe-buttons:not(.active) button:last-child {
display: none;
}
#probe-buttons.active button:not(:last-child) {
display: none;
}
.advanced {
display: none;
}
</style>
</head>
<body>
<div id="container">
<!-- Remove the underscore "_" from the class name to show test buttons in advanced move -->
<div class="advanced_" style="display: none;">
<button onclick="document.location.reload()">Reload</button>&nbsp;&nbsp;
<button onclick="test1()">Test1</button>
<button onclick="test2()">Test2</button>
<hr>
</div>
<div class="advanced">
<label>
&#10021;
<input id="step" type="number" value="50" style="width: 55px" title="DIP Step, Arrow Move" onkeydown="arrowMove(event)">
</label>
&nbsp;X:
<button onclick="updateDipRect(-step.value)">&lt;</button>
<button onclick="updateDipRect(+step.value)">&gt;</button>
&nbsp;Width:
<button onclick="updateDipRect(0, 0, -step.value)">-</button>
<button onclick="updateDipRect(0, 0, +step.value)">+</button>
&nbsp;
<input id="x" type="number" value="100" style="width: 55px" title="X">
<input id="y" type="number" value="100" style="width: 55px" title="Y">
&nbsp;
<input id="w" type="number" value="800" style="width: 55px" title="Width">
<input id="h" type="number" value="600" style="width: 55px" title="Height">
&nbsp;
<button onclick="drawRect({X: +x.value, Y: +y.value, Width: +w.value, Height: +h.value})">Set rect</button>
&nbsp;
Layers: <input id="slider" type="range" min="1" max="5" value="3" style="width: 70px" oninput="drawRect()">
<hr>
</div>
<div class="controls" onmousedown="radioBtnClick(event)">
<div id="layout-selector" class="radio-btn-group">
<span>Screens</span>:&nbsp;
<span class="radio-btn" data-value="0">System</span>
<span class="advanced">
<span>&nbsp;-&nbsp;Examples </span>
<select id="examples-type" onchange="setExamplesType(event.target.value)">
<option value="0" selected>Normal</option>
<option value="1">Test cases</option>
<option value="2">Edge cases</option>
</select> :
<span id="examples-list"></span>&nbsp;
<small id="example-name"></small>
</span>
</div>
<div id="coordinate-selector" class="radio-btn-group">
<span>Coordinates</span>:&nbsp;
<span class="radio-btn active" data-value="0">Physical (PX)</span>
<span class="radio-btn" data-value="1">Logical (DIP)</span>
<label><input type="checkbox" id="retain-viewbox" onchange="draw()" checked> Retain viewbox</label>
<button onclick="showAdvanced(event)" style="margin-left: 1em">Show advanced</button>
&nbsp;
<span id="probe-buttons" class="advanced">
<button onclick="probeLayout()">Probe layout</button>
<button onclick="probeAllExamples()">Probe all examples</button>
<button onclick="window.cancelProbing = true">Cancel Probing</button>
</span>
</div>
</div>
<svg
id="svg"
viewBox="0 0 500 500"
fill="none"
stroke="none"
preserveAspectRatio="xMinYMin"
>
<defs>
<svg id="point1" preserveAspectRatio="xMinYMin" viewBox="0 0 100 100"><path d="M9 1H2.4L7.1 5.7 5.7 7.1 1 2.4V9H-1V2.3L-5.7 7-7.1 5.6-2.6 1H-9V-1H-2.4L-7.1-5.7-6-7-1-2.4V-9H1V-2.6L5.6-7.2 7-5.7 2.3-1H9V1Z" /></svg>
<path id="point2" d="M9 1H2.4L7.1 5.7 5.7 7.1 1 2.4V9H-1V2.3L-5.7 7-7.1 5.6-2.6 1H-9V-1H-2.4L-7.1-5.7-6-7-1-2.4V-9H1V-2.6L5.6-7.2 7-5.7 2.3-1H9V1Z" />
</defs>
</svg>
</div>
<script type="module">
import * as wails from "/wails/runtime.js";
let monitors = wails.Screens.GetAll();
monitors.then((result) => {
console.log({result})
let html = "";
for (let i = 0; i < result.length; i++) {
html += "<article class='center'><div class='monitor'><div style='padding:5px;'>";
let hidpi = result[i].Scale === 2 ? " (HiDPI)" : "";
html += "<h3 class='center'>"+result[i].Name+"</h3>";
html += "<h4 class='center'>"+hidpi+"</h4>";
html += "<h4 class='center'>"+result[i].Size.Width+"x"+result[i].Size.Height+"</h4>";
html += "</div></div></article>";
}
document.body.innerHTML = html;
})
</script>
<script src="/wails/runtime.js" type="module"></script>
<script id="script" src="./examples.js"></script>
<script id="script" src="./main.js" defer></script>
</body>
</html>

View file

@ -0,0 +1,406 @@
setExamplesType(document.getElementById('examples-type').value, 0)
function setExamplesType(type, autoSelectLayout = 1) {
window.examples_type = parseInt(type)
document.getElementById('examples-list').innerHTML = examples[examples_type].map((layout, i) => {
return `<span class="radio-btn" data-value="${i + 1}" title="${layout.name}">${i + 1}</span>`
}).join("\n")
if (autoSelectLayout != null) setLayout(autoSelectLayout)
}
async function setLayout(indexOrLayout, physicalCoordinate = true) {
if (typeof indexOrLayout == 'number') {
await radioBtnClick(null, `#layout-selector [data-value="${indexOrLayout}"]`)
} else {
document.querySelectorAll('#layout-selector .active').forEach(el => el.classList.remove('active'))
window.layout = indexOrLayout
window.point = null
window.rect = null
await processLayout()
await draw()
}
const physical = !parseInt(document.querySelector('#coordinate-selector .active').dataset.value)
if (physical != physicalCoordinate) {
await setCoordinateType(physicalCoordinate)
}
}
async function setCoordinateType(physicalCoordinate = true) {
await radioBtnClick(null, `#coordinate-selector [data-value="${physicalCoordinate ? 0 : 1}"]`)
}
async function radioBtnClick(e, selector) {
if (e == null) {
e = new Event("mousedown")
document.querySelector(selector).dispatchEvent(e)
}
if (!e.target.classList.contains('radio-btn')) return
const btnGroup = e.target.closest('.radio-btn-group')
btnGroup.querySelectorAll('.radio-btn.active').forEach(el => el.classList.remove('active'))
e.target.classList.add('active')
if (btnGroup.id == 'layout-selector') {
window.point = null
window.rect = null
await processLayout()
}
await draw()
}
async function processLayout() {
const layoutBtn = document.querySelector('#layout-selector .active')
const i = layoutBtn ? parseInt(layoutBtn.dataset.value) : -1
if (i == 0) {
// system screens
window.layout = {
name: '',
screens: await callBinding('main.ScreenService.GetSystemScreens'),
}
} else {
if (i > 0) {
// example layouts
window.layout = structuredClone(examples[examples_type][i - 1])
}
layout.screens = await callBinding('main.ScreenService.ProcessExampleScreens', layout.screens)
}
document.getElementById('example-name').textContent = layout.name
}
async function draw() {
console.log(layout)
let minX = 0, minY = 0, maxX = 0, maxY = 0;
let html = '';
const physical = !parseInt(document.querySelector('#coordinate-selector .active').dataset.value)
const retainViewbox = document.querySelector('#retain-viewbox').checked
layout.screens.forEach(screen => {
const b = physical ? screen.PhysicalBounds : screen.Bounds
const wa = physical ? screen.PhysicalWorkArea : screen.WorkArea
const vbBounds = retainViewbox ? [screen.Bounds, screen.PhysicalBounds] : [b]
minX = Math.min(minX, ...vbBounds.map(b => b.X))
minY = Math.min(minY, ...vbBounds.map(b => b.Y))
maxX = Math.max(maxX, ...vbBounds.map(b => b.X + b.Width))
maxY = Math.max(maxY, ...vbBounds.map(b => b.Y + b.Height))
html += `
<rect x="${b.X}" y="${b.Y}" width="${b.Width}" height="${b.Height}" fill="#00ceff" />
<rect x="${wa.X}" y="${wa.Y}" width="${wa.Width}" height="${wa.Height}" fill="#def9ff" />
<rect x="${b.X + 1}" y="${b.Y + 1}" width="${b.Width - 2}" height="${b.Height - 2}" stroke="black" stroke-width="2" />
<g transform="translate(${b.X}, ${b.Y})" fill="black">
<text x="10" y="10" text-anchor="start" dominant-baseline="hanging" font-size="50">(${b.X}, ${b.Y})</text>
<g transform="translate(${b.Width / 2}, ${b.Height / 2})" text-anchor="middle" font-size="100">
<text x="0" y="-1.5em" fill="${screen.IsPrimary ? '#006bff' : '#5881ba'}" font-weight="bold">${screen.Name}</text>
<text x="0" y="0">${b.Width} x ${b.Height}</text>
<text x="0" y="1.2em">Scale factor: ${screen.ScaleFactor}</text>
</g>
</g>
`
})
const svg = document.getElementById('svg')
svg.innerHTML = `
${svg.querySelector('& > defs').outerHTML}
<rect x="${minX}" y="${minY}" width="${maxX - minX}" height="${maxY - minY}" fill="antiquewhite" />
${html}
<g id="rects"></g>
<g id="points"></g>
`
svg.setAttribute('viewBox', `${minX} ${minY} ${maxX - minX} ${maxY - minY}`)
if (window.point) await probePoint()
if (window.rect) await drawRect()
svg.onmousedown = async function(e) {
let pt = new DOMPoint(e.clientX, e.clientY)
pt = pt.matrixTransform(svg.getScreenCTM().inverse())
pt.x = parseInt(pt.x)
pt.y = parseInt(pt.y)
if (e.buttons == 1) {
await probePoint({X: pt.x, Y: pt.y})
} else if (e.buttons == 2) {
if (e.ctrlKey) {
if (!window.rect) {
window.rect = {X: pt.x, Y: pt.y, Width: 0, Height: 0}
}
if (!window.rectCursor) {
window.rectAnchor = {x: window.rect.X, y: window.rect.Y}
window.rectCursor = {x: window.rectAnchor.x + window.rect.Width, y: window.rectAnchor.y + window.rect.Height}
}
window.rectCursorOffset = {
x: pt.x - window.rectCursor.x,
y: pt.y - window.rectCursor.y,
}
} else {
window.rectAnchor = pt
window.rectCursorOffset = {x: 0, y: 0}
window.probing = true
drawRect({X: pt.x, Y: pt.y, Width: 0, Height: 0})
window.probing = false
}
} else if (e.buttons == 4) {
drawRect({X: pt.x, Y: pt.y, Width: 50, Height: 50})
}
}
svg.onmousemove = async function(e) {
if (window.probing) return
window.probing = true
if (e.buttons == 1) {
await svg.onmousedown(e)
} else if (e.buttons == 2) {
let pt = new DOMPoint(e.clientX, e.clientY)
pt = pt.matrixTransform(svg.getScreenCTM().inverse())
if (e.ctrlKey) {
window.rectAnchor.x += pt.x - rectCursor.x - window.rectCursorOffset.x
window.rectAnchor.y += pt.y - rectCursor.y - window.rectCursorOffset.y
}
window.rectCursor = {
x: pt.x - window.rectCursorOffset.x,
y: pt.y - window.rectCursorOffset.y,
}
await drawRect({
X: parseInt(Math.min(window.rectAnchor.x, window.rectCursor.x)),
Y: parseInt(Math.min(window.rectAnchor.y, window.rectCursor.y)),
Width: parseInt(Math.abs(window.rectCursor.x - window.rectAnchor.x)),
Height: parseInt(Math.abs(window.rectCursor.y - window.rectAnchor.y)),
})
}
window.probing = false
}
svg.oncontextmenu = function(e) {
e.preventDefault()
}
}
async function probePoint(p = null) {
const svg = document.getElementById('svg');
const physical = !parseInt(document.querySelector('#coordinate-selector .active').dataset.value)
if (p == null) {
if (window.pointIsPhysical == physical) {
p = window.point
} else {
p = (await callBinding('main.ScreenService.TransformPoint', window.point, window.pointIsPhysical))[0]
}
}
window.point = p
window.pointIsPhysical = physical
const [ptTransformed, ptDblTransformed] = await callBinding('main.ScreenService.TransformPoint', p, physical)
svg.getElementById('points').innerHTML = `
<circle cx="${p.X}" cy="${p.Y}" r="15" fill="red" />
<circle cx="${ptTransformed.X}" cy="${ptTransformed.Y}" r="5" fill="green" />
<circle cx="${ptTransformed.X}" cy="${ptTransformed.Y}" r="35" stroke="green" stroke-width="4" />
<circle cx="${ptDblTransformed.X}" cy="${ptDblTransformed.Y}" r="25" stroke="red" stroke-width="4" />
`
// await new Promise((resolve) => setTimeout(resolve, 200)) // delay
return ptDblTransformed
}
async function drawRect(r = null) {
const svg = document.getElementById('svg');
const physical = !parseInt(document.querySelector('#coordinate-selector .active').dataset.value)
if (r == null) {
if (window.rectIsPhysical == physical) {
r = window.rect
} else {
r = await callBinding('main.ScreenService.TransformRect', window.rect, window.rectIsPhysical)
}
}
if (!window.probing) {
window.rectAnchor = null
window.rectCursor = null
}
document.getElementById('x').value = r.X
document.getElementById('y').value = r.Y
document.getElementById('w').value = r.Width
document.getElementById('h').value = r.Height
window.rect = r
window.rectIsPhysical = physical
window.rTransformed = await callBinding('main.ScreenService.TransformRect', r, physical)
window.rDblTransformed = await callBinding('main.ScreenService.TransformRect', rTransformed, !physical)
window.rTransformed = rTransformed
await rectLayers()
return rDblTransformed
}
async function rectLayers() {
const s = document.getElementById('slider').value
if (window.rect == null) await test1()
const r = await callBinding('main.ScreenService.TransformRect', rectIsPhysical ? rect : rTransformed, true)
const rShifted = {...r, X: r.X+50}
const rShiftedPhysical = await callBinding('main.ScreenService.TransformRect', rShifted, false)
svg.getElementById('rects').innerHTML = [
[window.rect, 'rgb(255 255 255 / 100%)'], // w
[window.rTransformed, 'rgb(0 255 0 / 25%)'], // g
[window.rDblTransformed, 'none'], // none
[rShifted, 'rgb(255 0 0 / 15%)'], // r
[rShiftedPhysical, 'rgb(0 0 255 / 15%)'], // b
].filter((_,i) => i<s).map(([r, color], i) => {
let lines = ''
if (i == 0) {
const center = {X: r.X + (r.Width-1)/2, Y: r.Y + (r.Height-1)/2}
lines += `
<line x1="${center.X}" x2="${center.X}" y1="${r.Y}" y2="${r.Y + r.Height-1}" stroke="gray" stroke-width="1" />
<line x1="${r.X}" x2="${r.X + r.Width-1}" y1="${center.Y}" y2="${center.Y}" stroke="gray" stroke-width="1" />
`
}
return `<rect x="${r.X}" y="${r.Y}" width="${r.Width}" height="${r.Height}" fill="${color}" stroke="${color == 'none' ? 'red' : 'black'}" stroke-width="${color == 'none' ? 5 : 1}" stroke-dasharray="${color == 'none' ? 5 : 'none'}" />${lines}`
}).join('/n')
}
async function updateDipRect(x, y=0, w=0, h=0) {
if (rect == null) {
await drawRect({
X: +document.getElementById('x').value,
Y: +document.getElementById('y').value,
Width: +document.getElementById('w').value,
Height: +document.getElementById('h').value,
})
}
// Simulate real window by first retrieving the physical bounds then transforming it to dip
// then updating the bounds and transforming it back to physical
let rPhysical = rectIsPhysical ? rect : rTransformed
const r = await callBinding('main.ScreenService.TransformRect', rPhysical, true)
r.X += x
r.Y += y
r.Width += w
r.Height += h
rPhysical = await callBinding('main.ScreenService.TransformRect', r, false)
drawRect(rectIsPhysical ? rPhysical : r)
}
function arrowMove(e) {
let x = 0, y = 0
if (e.key == 'ArrowLeft') x = -step.value
if (e.key == 'ArrowRight') x = +step.value
if (e.key == 'ArrowUp') y = -step.value
if (e.key == 'ArrowDown') y = +step.value
if (!(x || y)) return
e.preventDefault()
updateDipRect(x, y)
}
async function test1() {
// Edge case 1: invalid dip rect: no physical rect can produce it
await setLayout(parseLayout({screens: [
{id: 1, w: 1200, h: 1200, s: 1},
{id: 2, w: 1200, h: 1100, s: 1.5, parent: {id: 1, align: "r", offset: 0}},
]}), false)
await drawRect({X: 1050, Y: 700, Width: 400, Height: 300})
}
async function test2() {
// Edge case 2: physical rect that changes when double transformed (2 physical rects produce the same dip rect)
await setLayout(parseLayout({screens: [
{id: 1, w: 1200, h: 1200, s: 1.5},
{id: 2, w: 1200, h: 900, s: 1, parent: {id: 1, align: "r", offset: 0}},
]}), true)
await drawRect({X: 1050, Y: 890, Width: 400, Height: 300})
}
async function probeLayout(finishup = true) {
const probeButtons = document.getElementById('probe-buttons')
const svg = document.getElementById('svg')
const threshold = 1
const physical = !parseInt(document.querySelector('#coordinate-selector .active').dataset.value)
window.cancelProbing = false
probeButtons.classList.add('active')
const steps = 3
let failed = false
for (const screen of layout.screens) {
if (window.cancelProbing) break
const b = physical ? screen.PhysicalBounds : screen.Bounds
const xStep = parseInt(b.Width / steps) || 1
const yStep = parseInt(b.Height / steps) || 1
let x = b.X, y = b.Y
let xDone = false, yDone = false
while (!(yDone || window.cancelProbing)) {
if (y >= b.Y + b.Height - 1) {
y = b.Y + b.Height - 1
yDone = true
}
x = b.X
xDone = false
while (!(xDone || window.cancelProbing)) {
if (x >= b.X + b.Width - 1) {
x = b.X + b.Width - 1
xDone = true
}
const pt = {X: x, Y: y}
let ptDblTransformed, err
try {
ptDblTransformed = await probePoint(pt)
} catch (e) {
err = e
}
if (err || Math.abs(pt.X - ptDblTransformed.X) > threshold || Math.abs(pt.Y - ptDblTransformed.Y) > threshold) {
failed = true
console.log(pt, ptDblTransformed)
window.cancelProbing = true
setTimeout(() => {
alert(err ?? `**FAILED**\nProbing failed at point: {X: ${pt.X}, Y: ${pt.Y}}\nDouble transformed point: {X: ${ptDblTransformed.X}, Y: ${ptDblTransformed.Y}}\n(Exceeded threshold of ${threshold} pixels)`)
}, 50)
}
x += xStep
}
y += yStep
}
}
if (finishup || window.cancelProbing) probeButtons.classList.remove('active')
if (!(failed || window.cancelProbing)) {
window.point = null
if (finishup) {
setTimeout(() => {
svg.getElementById('points').innerHTML = ''
alert(`Successfully probed all points!, All within threshold of ${threshold} pixels.`)
}, 50)
}
return true
}
}
async function probeAllExamples() {
console.time('probeAllExamples')
loop1:
for (let typeI = 0; typeI < examples.length; typeI++) {
document.getElementById('examples-type').value = typeI
setExamplesType(typeI, null)
for (let layoutI = (typeI ? 0 : -1); layoutI < examples[typeI].length; layoutI++) {
await radioBtnClick(null, `#layout-selector [data-value="${layoutI + 1}"]`)
for (let i = 0; i < 2; i++) {
const lastLayout = (typeI == examples.length - 1 && layoutI == examples[typeI].length - 1 && i == 1)
if (!await probeLayout(lastLayout)) break loop1
if (i == 0) await setCoordinateType(!pointIsPhysical)
}
}
}
console.timeEnd('probeAllExamples')
}
async function callBinding(name, ...params) {
return wails.Call.ByName(name, ...params)
}
function showAdvanced(e) {
e.target.style.display = 'none'
document.querySelectorAll('.advanced').forEach(el => el.style.display = 'initial')
}

View file

@ -2,8 +2,12 @@ package main
import (
"embed"
_ "embed"
"log"
"log/slog"
"net/http"
"os"
"path/filepath"
"runtime"
"github.com/wailsapp/wails/v3/pkg/application"
)
@ -25,8 +29,33 @@ func main() {
WebviewUserDataPath: "",
WebviewBrowserPath: "",
},
Services: []application.Service{
application.NewService(&ScreenService{}),
},
LogLevel: slog.LevelError,
Assets: application.AssetOptions{
Handler: application.BundledAssetFileServer(assets),
Middleware: func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Disable caching
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
_, filename, _, _ := runtime.Caller(0)
dir := filepath.Dir(filename)
url := r.URL.Path
path := dir + "/assets" + url
if _, err := os.Stat(path); err == nil {
// Serve file from disk to make testing easy
http.ServeFile(w, r, path)
} else {
// Passthrough to the default asset handler if file not found on disk
next.ServeHTTP(w, r)
}
})
},
},
})

View file

@ -0,0 +1,129 @@
package main
import (
"runtime"
"github.com/wailsapp/wails/v3/pkg/application"
)
type ScreenService struct {
screenManager application.ScreenManager
isExampleLayout bool
}
func (s *ScreenService) GetSystemScreens() []*application.Screen {
s.isExampleLayout = false
screens, _ := application.Get().GetScreens()
return screens
}
func (s *ScreenService) ProcessExampleScreens(rawScreens []interface{}) []*application.Screen {
s.isExampleLayout = true
parseRect := func(m map[string]interface{}) application.Rect {
return application.Rect{
X: int(m["X"].(float64)),
Y: int(m["Y"].(float64)),
Width: int(m["Width"].(float64)),
Height: int(m["Height"].(float64)),
}
}
screens := []*application.Screen{}
for _, s := range rawScreens {
s := s.(map[string]interface{})
bounds := parseRect(s["Bounds"].(map[string]interface{}))
screens = append(screens, &application.Screen{
ID: s["ID"].(string),
Name: s["Name"].(string),
X: bounds.X,
Y: bounds.Y,
Size: application.Size{Width: bounds.Width, Height: bounds.Height},
Bounds: bounds,
PhysicalBounds: parseRect(s["PhysicalBounds"].(map[string]interface{})),
WorkArea: parseRect(s["WorkArea"].(map[string]interface{})),
PhysicalWorkArea: parseRect(s["PhysicalWorkArea"].(map[string]interface{})),
IsPrimary: s["IsPrimary"].(bool),
ScaleFactor: float32(s["ScaleFactor"].(float64)),
Rotation: 0,
})
}
s.screenManager.LayoutScreens(screens)
return s.screenManager.Screens()
}
func (s *ScreenService) transformPoint(point application.Point, toDIP bool) application.Point {
if s.isExampleLayout {
if toDIP {
return s.screenManager.PhysicalToDipPoint(point)
} else {
return s.screenManager.DipToPhysicalPoint(point)
}
} else {
// =======================
// TODO: remove this block when DPI is implemented in Linux & Mac
if runtime.GOOS != "windows" {
println("DPI not implemented yet!")
return point
}
// =======================
if toDIP {
return application.PhysicalToDipPoint(point)
} else {
return application.DipToPhysicalPoint(point)
}
}
}
func (s *ScreenService) TransformPoint(point map[string]interface{}, toDIP bool) (points [2]application.Point) {
pt := application.Point{
X: int(point["X"].(float64)),
Y: int(point["Y"].(float64)),
}
ptTransformed := s.transformPoint(pt, toDIP)
ptDblTransformed := s.transformPoint(ptTransformed, !toDIP)
// double-transform multiple times to catch any double-rounding issues
for i := 0; i < 10; i++ {
ptTransformed = s.transformPoint(ptDblTransformed, toDIP)
ptDblTransformed = s.transformPoint(ptTransformed, !toDIP)
}
points[0] = ptTransformed
points[1] = ptDblTransformed
return points
}
func (s *ScreenService) TransformRect(rect map[string]interface{}, toDIP bool) application.Rect {
r := application.Rect{
X: int(rect["X"].(float64)),
Y: int(rect["Y"].(float64)),
Width: int(rect["Width"].(float64)),
Height: int(rect["Height"].(float64)),
}
if s.isExampleLayout {
if toDIP {
return s.screenManager.PhysicalToDipRect(r)
} else {
return s.screenManager.DipToPhysicalRect(r)
}
} else {
// =======================
// TODO: remove this block when DPI is implemented in Linux & Mac
if runtime.GOOS != "windows" {
println("DPI not implemented yet!")
return r
}
// =======================
if toDIP {
return application.PhysicalToDipRect(r)
} else {
return application.DipToPhysicalRect(r)
}
}
}

View file

@ -23,7 +23,7 @@ func main() {
_ = app.NewWebviewWindowWithOptions(application.WebviewWindowOptions{
Width: 500,
Height: 800,
Height: 500,
Name: "Systray Demo Window",
Frameless: true,
AlwaysOnTop: true,

View file

@ -0,0 +1,90 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Window Demo</title>
<style>
body {
color: white;
}
</style>
<script src="/wails/runtime.js" type="module"></script>
<script>
async function callBinding(name, ...params) {
return wails.Call.ByName(name, ...params)
}
</script>
</head>
<body>
<div style="background-color: aquamarine; height: 50px;" onclick="showInfo = !showInfo">
<div style="background-color: rgb(57, 109, 92); width: 50%; height: 100%; position: relative;">
<span id="text" style="float: left; font-size: 1.5em;"></span>
<div style="background-color: rgb(237, 157, 66); width: 40px; height: 100%; opacity: 0.5; position: absolute; right: -20px;"></div>
</div>
</div>
<!-- ===================================================== -->
<hr>
<label>
&#10021;
<input id="step" type="number" value="50" style="width: 50px" title="Step, Arrow Move" onkeydown="arrowMove(event)">
</label>
&nbsp;X:
<button onclick="setPos(true, -step.value)">&lt;</button>
<button onclick="setPos(true, +step.value)">&gt;</button>
&nbsp;Width:
<button onclick="setSize(true, -step.value)">-</button>
<button onclick="setSize(true, +step.value)">+</button>
&nbsp;
<button onclick="getBounds()">getBounds</button>
<button onclick="getBounds().then(b => {x.value = b.x; y.value = b.y; w.value = b.width; h.value = b.height})"></button>
<div style="height: 10px"></div>
<input id="x" type="number" value="100" style="width: 50px" title="X">
<input id="y" type="number" value="100" style="width: 50px" title="Y">
<button onclick="setPos(false, +x.value, +y.value)">setPos</button>
&nbsp;
<input id="w" type="number" value="800" style="width: 50px" title="Width">
<input id="h" type="number" value="600" style="width: 50px" title="Height">
<button onclick="setSize(false, +w.value, +h.value)">setSize</button>
&nbsp;
<button onclick="setBounds(+x.value, +y.value, +w.value, +h.value)">setBounds</button>
<hr>
<!-- ===================================================== -->
<script>
function arrowMove(e) {
let x = 0, y = 0
if (e.key == 'ArrowLeft') x = -step.value
if (e.key == 'ArrowRight') x = +step.value
if (e.key == 'ArrowUp') y = -step.value
if (e.key == 'ArrowDown') y = +step.value
if (!(x || y)) return
e.preventDefault()
setPos(true, x, y)
}
function setPos(relative, x, y=0) {
callBinding('main.WindowService.SetPos', relative, x, y)
}
function setSize(relative, w, h=0) {
callBinding('main.WindowService.SetSize', relative, w, h)
}
function setBounds(x, y, w, h) {
callBinding('main.WindowService.SetBounds', x, y, w, h)
}
async function getBounds() {
const b = await callBinding('main.WindowService.GetBounds')
return {x: b.X, y: b.Y, width: b.Width, height: b.Height}
}
let showInfo = false
setInterval(async () => {
if (!showInfo) {
text.textContent = ''
return
}
const b = await getBounds()
text.textContent = `${b.x}, ${b.y} - ${b.width}, ${b.height}`
}, 100);
</script>
</body>
</html>

View file

@ -1,7 +1,7 @@
package main
import (
_ "embed"
"embed"
"fmt"
"log"
"math/rand"
@ -20,14 +20,69 @@ var getExStyle = func() int {
return 0
}
//go:embed assets/*
var assets embed.FS
type WindowService struct{}
// ==============================================
func (s *WindowService) SetPos(relative bool, x, y float64) {
win := application.Get().CurrentWindow()
initX, initY := win.Position()
if relative {
x += float64(initX)
y += float64(initY)
}
win.SetPosition(int(x), int(y))
currentX, currentY := win.Position()
fmt.Printf("SetPos: %d, %d => %d, %d\n", initX, initY, currentX, currentY)
}
func (s *WindowService) SetSize(relative bool, wdt, hgt float64) {
win := application.Get().CurrentWindow()
initW, initH := win.Size()
if relative {
wdt += float64(initW)
hgt += float64(initH)
}
win.SetSize(int(wdt), int(hgt))
currentW, currentH := win.Size()
fmt.Printf("SetSize: %d, %d => %d, %d\n", initW, initH, currentW, currentH)
}
func (s *WindowService) SetBounds(x, y, w, h float64) {
win := application.Get().CurrentWindow()
initR := win.Bounds()
win.SetBounds(application.Rect{
X: int(x),
Y: int(y),
Width: int(w),
Height: int(h),
})
currentR := win.Bounds()
fmt.Printf("SetBounds: %+v => %+v\n", initR, currentR)
}
func (s *WindowService) GetBounds() application.Rect {
win := application.Get().CurrentWindow()
r := win.Bounds()
mid := r.X + (r.Width-1)/2
fmt.Printf("GetBounds: %+v: mid: %d\n", r, mid)
return r
}
// ==============================================
func main() {
app := application.New(application.Options{
Name: "WebviewWindow Demo",
Description: "A demo of the WebviewWindow API",
Assets: application.AlphaAssets,
Assets: application.AssetOptions{
Handler: application.BundledAssetFileServer(assets),
},
Mac: application.MacOptions{
ApplicationShouldTerminateAfterLastWindowClosed: false,
},
Services: []application.Service{
application.NewService(&WindowService{}),
},
})
app.OnApplicationEvent(events.Common.ApplicationStarted, func(event *application.ApplicationEvent) {
log.Println("ApplicationDidFinishLaunching")
@ -403,6 +458,12 @@ func main() {
w.SetRelativePosition(0, 0)
})
})
positionMenu.Add("Set Relative Position (Corner)").OnClick(func(ctx *application.Context) {
currentWindow(func(w *application.WebviewWindow) {
screen, _ := w.GetScreen()
w.SetRelativePosition(screen.WorkArea.Width-w.Width(), screen.WorkArea.Height-w.Height())
})
})
positionMenu.Add("Set Relative Position (Random)").OnClick(func(ctx *application.Context) {
currentWindow(func(w *application.WebviewWindow) {
w.SetRelativePosition(rand.Intn(1000), rand.Intn(800))
@ -585,6 +646,7 @@ func main() {
})
app.NewWebviewWindowWithOptions(application.WebviewWindowOptions{
Title: "Window Demo",
BackgroundColour: application.NewRGB(33, 37, 41),
Mac: application.MacWindow{
DisableShadow: true,

File diff suppressed because one or more lines are too long

View file

@ -1,6 +1,6 @@
{
"name": "@wailsio/runtime",
"version": "3.0.0-alpha.26",
"version": "3.0.0-alpha.27",
"description": "Wails Runtime",
"main": "src/index.js",
"types": "types/index.d.ts",

View file

@ -10,23 +10,17 @@ The electron alternative for Go
/* jshint esversion: 9 */
/**
* @typedef {Object} Position
* @property {number} X - The X coordinate.
* @property {number} Y - The Y coordinate.
*/
/**
* @typedef {Object} Size
* @property {number} X - The width.
* @property {number} Y - The height.
* @property {number} Width - The width.
* @property {number} Height - The height.
*/
/**
* @typedef {Object} Rect
* @property {number} X - The X coordinate of the top-left corner.
* @property {number} Y - The Y coordinate of the top-left corner.
* @property {number} X - The X coordinate of the origin.
* @property {number} Y - The Y coordinate of the origin.
* @property {number} Width - The width of the rectangle.
* @property {number} Height - The height of the rectangle.
*/
@ -36,11 +30,14 @@ The electron alternative for Go
* @typedef {Object} Screen
* @property {string} Id - Unique identifier for the screen.
* @property {string} Name - Human readable name of the screen.
* @property {number} Scale - The resolution scale of the screen. 1 = standard resolution, 2 = high (Retina), etc.
* @property {Position} Position - Contains the X and Y coordinates of the screen's position.
* @property {number} ScaleFactor - The scale factor of the screen (DPI/96). 1 = standard DPI, 2 = HiDPI (Retina), etc.
* @property {number} X - The X coordinate of the screen.
* @property {number} Y - The Y coordinate of the screen.
* @property {Size} Size - Contains the width and height of the screen.
* @property {Rect} Bounds - Contains the bounds of the screen in terms of X, Y, Width, and Height.
* @property {Rect} PhysicalBounds - Contains the physical bounds of the screen in terms of X, Y, Width, and Height (before scaling).
* @property {Rect} WorkArea - Contains the area of the screen that is actually usable (excluding taskbar and other system UI).
* @property {Rect} PhysicalWorkArea - Contains the physical WorkArea of the screen (before scaling).
* @property {boolean} IsPrimary - True if this is the primary monitor selected by the user in the operating system.
* @property {number} Rotation - The rotation of the screen.
*/

View file

@ -14,33 +14,23 @@ export function GetPrimary(): Promise<Screen>;
* @returns {Promise<Screen>} A promise that resolves with the current active screen.
*/
export function GetCurrent(): Promise<Screen>;
export type Position = {
/**
* - The X coordinate.
*/
X: number;
/**
* - The Y coordinate.
*/
Y: number;
};
export type Size = {
/**
* - The width.
*/
X: number;
Width: number;
/**
* - The height.
*/
Y: number;
Height: number;
};
export type Rect = {
/**
* - The X coordinate of the top-left corner.
* - The X coordinate of the origin.
*/
X: number;
/**
* - The Y coordinate of the top-left corner.
* - The Y coordinate of the origin.
*/
Y: number;
/**
@ -62,13 +52,17 @@ export type Screen = {
*/
Name: string;
/**
* - The resolution scale of the screen. 1 = standard resolution, 2 = high (Retina), etc.
* - The scale factor of the screen (DPI/96). 1 = standard DPI, 2 = HiDPI (Retina), etc.
*/
Scale: number;
ScaleFactor: number;
/**
* - Contains the X and Y coordinates of the screen's position.
* - The X coordinate of the screen.
*/
Position: Position;
X: number;
/**
* - The Y coordinate of the screen.
*/
Y: number;
/**
* - Contains the width and height of the screen.
*/
@ -77,10 +71,18 @@ export type Screen = {
* - Contains the bounds of the screen in terms of X, Y, Width, and Height.
*/
Bounds: Rect;
/**
* - Contains the physical bounds of the screen in terms of X, Y, Width, and Height (before scaling).
*/
PhysicalBounds: Rect;
/**
* - Contains the area of the screen that is actually usable (excluding taskbar and other system UI).
*/
WorkArea: Rect;
/**
* - Contains the physical WorkArea of the screen (before scaling).
*/
PhysicalWorkArea: Rect;
/**
* - True if this is the primary monitor selected by the user in the operating system.
*/

View file

@ -276,6 +276,9 @@ type App struct {
applicationEventHooks map[uint][]*eventHook
applicationEventHooksLock sync.RWMutex
// Screens layout manager (handles DIP coordinate system)
screenManager ScreenManager
// Windows
windows map[uint]Window
windowsLock sync.RWMutex
@ -814,12 +817,18 @@ func SaveFileDialog() *SaveFileDialogStruct {
return newSaveFileDialog()
}
func (a *App) GetPrimaryScreen() (*Screen, error) {
return a.impl.getPrimaryScreen()
}
// NOTE: should use screenManager directly after DPI is implemented in all platforms
// (should also get rid of the error return)
func (a *App) GetScreens() ([]*Screen, error) {
return a.impl.getScreens()
// return a.screenManager.screens, nil
}
// NOTE: should use screenManager directly after DPI is implemented in all platforms
// (should also get rid of the error return)
func (a *App) GetPrimaryScreen() (*Screen, error) {
return a.impl.getPrimaryScreen()
// return a.screenManager.primaryScreen, nil
}
func (a *App) Clipboard() *Clipboard {

View file

@ -4,15 +4,12 @@ package application
import (
"fmt"
"os"
"strconv"
"sync"
"syscall"
"unsafe"
"github.com/wailsapp/go-webview2/webviewloader"
"github.com/wailsapp/wails/v3/internal/operatingsystem"
"golang.org/x/sys/windows"
"github.com/wailsapp/wails/v3/pkg/events"
"github.com/wailsapp/wails/v3/pkg/w32"
@ -71,54 +68,6 @@ func getNativeApplication() *windowsApp {
return globalApplication.impl.(*windowsApp)
}
func (m *windowsApp) getPrimaryScreen() (*Screen, error) {
screens, err := m.getScreens()
if err != nil {
return nil, err
}
for _, screen := range screens {
if screen.IsPrimary {
return screen, nil
}
}
return nil, fmt.Errorf("no primary screen found")
}
func (m *windowsApp) getScreens() ([]*Screen, error) {
allScreens, err := w32.GetAllScreens()
if err != nil {
return nil, err
}
// Convert result to []*Screen
screens := make([]*Screen, len(allScreens))
for id, screen := range allScreens {
x := int(screen.MONITORINFOEX.RcMonitor.Left)
y := int(screen.MONITORINFOEX.RcMonitor.Top)
right := int(screen.MONITORINFOEX.RcMonitor.Right)
bottom := int(screen.MONITORINFOEX.RcMonitor.Bottom)
width := right - x
height := bottom - y
screens[id] = &Screen{
ID: strconv.Itoa(id),
Name: windows.UTF16ToString(screen.MONITORINFOEX.SzDevice[:]),
X: x,
Y: y,
Size: Size{Width: width, Height: height},
Bounds: Rect{X: x, Y: y, Width: width, Height: height},
WorkArea: Rect{
X: int(screen.MONITORINFOEX.RcWork.Left),
Y: int(screen.MONITORINFOEX.RcWork.Top),
Width: int(screen.MONITORINFOEX.RcWork.Right - screen.MONITORINFOEX.RcWork.Left),
Height: int(screen.MONITORINFOEX.RcWork.Bottom - screen.MONITORINFOEX.RcWork.Top),
},
IsPrimary: screen.IsPrimary,
Scale: screen.Scale,
Rotation: 0,
}
}
return screens, nil
}
func (m *windowsApp) hide() {
// Get the current focussed window
m.focusedWindow = w32.GetForegroundWindow()
@ -236,6 +185,16 @@ func (m *windowsApp) wndProc(hwnd w32.HWND, msg uint32, wParam, lParam uintptr)
}
}
// Reprocess and cache screens when display settings change
if hwnd == m.mainThreadWindowHWND {
if msg == w32.WM_DISPLAYCHANGE || (msg == w32.WM_SETTINGCHANGE && wParam == w32.SPI_SETWORKAREA) {
err := m.processAndCacheScreens()
if err != nil {
m.parent.error(err.Error())
}
}
}
switch msg {
case w32.WM_SETTINGCHANGE:
settingChanged := w32.UTF16PtrToString((*uint16)(unsafe.Pointer(lParam)))
@ -313,11 +272,35 @@ func (m *windowsApp) unregisterWindow(w *windowsWebviewWindow) {
}
}
func setupDPIAwareness() error {
// https://learn.microsoft.com/en-us/windows/win32/hidpi/setting-the-default-dpi-awareness-for-a-process
// https://learn.microsoft.com/en-us/windows/win32/hidpi/high-dpi-desktop-application-development-on-windows
if w32.HasSetProcessDpiAwarenessContextFunc() {
// This is most recent version with the best results
// supported beginning with Windows 10, version 1703
return w32.SetProcessDpiAwarenessContext(w32.DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)
}
if w32.HasSetProcessDpiAwarenessFunc() {
// Supported beginning with Windows 8.1
return w32.SetProcessDpiAwareness(w32.PROCESS_PER_MONITOR_DPI_AWARE)
}
if w32.HasSetProcessDPIAwareFunc() {
// If none of the above is supported, fallback to SetProcessDPIAware
// which is supported beginning with Windows Vista
return w32.SetProcessDPIAware()
}
return fmt.Errorf("no DPI awareness method supported")
}
func newPlatformApp(app *App) *windowsApp {
err := w32.SetProcessDPIAware()
err := setupDPIAwareness()
if err != nil {
globalApplication.fatal("Fatal error in application initialisation: %s", err.Error())
os.Exit(1)
app.error(err.Error())
}
result := &windowsApp{
@ -327,6 +310,11 @@ func newPlatformApp(app *App) *windowsApp {
systrayMap: make(map[w32.HWND]*windowsSystemTray),
}
err = result.processAndCacheScreens()
if err != nil {
app.fatal(err.Error())
}
result.init()
result.initMainLoop()

View file

@ -145,7 +145,7 @@ typedef struct Screen {
int w_height;
int w_x;
int w_y;
float scale;
float scaleFactor;
double rotation;
bool isPrimary;
} Screen;
@ -744,12 +744,12 @@ func getScreenByIndex(display *C.struct__GdkDisplay, index int) *Screen {
}
name := C.gdk_monitor_get_model(monitor)
return &Screen{
ID: fmt.Sprintf("%d", index),
Name: C.GoString(name),
IsPrimary: primary,
Scale: float32(C.gdk_monitor_get_scale_factor(monitor)),
X: int(geometry.x),
Y: int(geometry.y),
ID: fmt.Sprintf("%d", index),
Name: C.GoString(name),
IsPrimary: primary,
ScaleFactor: float32(C.gdk_monitor_get_scale_factor(monitor)),
X: int(geometry.x),
Y: int(geometry.y),
Size: Size{
Height: int(geometry.height),
Width: int(geometry.width),
@ -760,6 +760,24 @@ func getScreenByIndex(display *C.struct__GdkDisplay, index int) *Screen {
Height: int(geometry.height),
Width: int(geometry.width),
},
PhysicalBounds: Rect{
X: int(geometry.x),
Y: int(geometry.y),
Height: int(geometry.height),
Width: int(geometry.width),
},
WorkArea: Rect{
X: int(geometry.x),
Y: int(geometry.y),
Height: int(geometry.height),
Width: int(geometry.width),
},
PhysicalWorkArea: Rect{
X: int(geometry.x),
Y: int(geometry.y),
Height: int(geometry.height),
Width: int(geometry.width),
},
}
}
@ -829,18 +847,18 @@ func getMousePosition() (int, int, *Screen) {
monitor := C.gdk_display_get_monitor_at_point(defaultDisplay, x, y)
geometry := C.GdkRectangle{}
C.gdk_monitor_get_geometry(monitor, &geometry)
scale := int(C.gdk_monitor_get_scale_factor(monitor))
scaleFactor := int(C.gdk_monitor_get_scale_factor(monitor))
return int(x), int(y), &Screen{
ID: fmt.Sprintf("%d", 0), // A unique identifier for the display
Name: C.GoString(C.gdk_monitor_get_model(monitor)), // The name of the display
Scale: float32(scale), // The scale factor of the display
X: int(geometry.x), // The x-coordinate of the top-left corner of the rectangle
Y: int(geometry.y), // The y-coordinate of the top-left corner of the rectangle
Size: Size{Width: int(geometry.width), Height: int(geometry.height)}, // The size of the display
Bounds: Rect{}, // The bounds of the display
WorkArea: Rect{}, // The work area of the display
IsPrimary: false, // Whether this is the primary display
Rotation: 0.0, // The rotation of the display
ID: fmt.Sprintf("%d", 0), // A unique identifier for the display
Name: C.GoString(C.gdk_monitor_get_model(monitor)), // The name of the display
ScaleFactor: float32(scaleFactor), // The scale factor of the display
X: int(geometry.x), // The x-coordinate of the top-left corner of the rectangle
Y: int(geometry.y), // The y-coordinate of the top-left corner of the rectangle
Size: Size{Width: int(geometry.width), Height: int(geometry.height)}, // The size of the display
Bounds: Rect{}, // The bounds of the display
WorkArea: Rect{}, // The work area of the display
IsPrimary: false, // Whether this is the primary display
Rotation: 0.0, // The rotation of the display
}
}
@ -858,12 +876,12 @@ func (w *linuxWebviewWindow) destroy() {
func (w *linuxWebviewWindow) fullscreen() {
w.maximise()
//w.lastWidth, w.lastHeight = w.size()
x, y, width, height, scale := w.getCurrentMonitorGeometry()
x, y, width, height, scaleFactor := w.getCurrentMonitorGeometry()
if x == -1 && y == -1 && width == -1 && height == -1 {
return
}
w.setMinMaxSize(0, 0, width*scale, height*scale)
w.setSize(width*scale, height*scale)
w.setMinMaxSize(0, 0, width*scaleFactor, height*scaleFactor)
w.setSize(width*scaleFactor, height*scaleFactor)
C.gtk_window_fullscreen(w.gtkWindow())
w.setRelativePosition(0, 0)
}
@ -882,30 +900,30 @@ func (w *linuxWebviewWindow) getScreen() (*Screen, error) {
// Get the current screen for the window
monitor := w.getCurrentMonitor()
name := C.gdk_monitor_get_model(monitor)
mx, my, width, height, scale := w.getCurrentMonitorGeometry()
mx, my, width, height, scaleFactor := w.getCurrentMonitorGeometry()
return &Screen{
ID: fmt.Sprintf("%d", w.id), // A unique identifier for the display
Name: C.GoString(name), // The name of the display
Scale: float32(scale), // The scale factor of the display
X: mx, // The x-coordinate of the top-left corner of the rectangle
Y: my, // The y-coordinate of the top-left corner of the rectangle
Size: Size{Width: width, Height: height}, // The size of the display
Bounds: Rect{}, // The bounds of the display
WorkArea: Rect{}, // The work area of the display
IsPrimary: false, // Whether this is the primary display
Rotation: 0.0, // The rotation of the display
ID: fmt.Sprintf("%d", w.id), // A unique identifier for the display
Name: C.GoString(name), // The name of the display
ScaleFactor: float32(scaleFactor), // The scale factor of the display
X: mx, // The x-coordinate of the top-left corner of the rectangle
Y: my, // The y-coordinate of the top-left corner of the rectangle
Size: Size{Width: width, Height: height}, // The size of the display
Bounds: Rect{}, // The bounds of the display
WorkArea: Rect{}, // The work area of the display
IsPrimary: false, // Whether this is the primary display
Rotation: 0.0, // The rotation of the display
}, nil
}
func (w *linuxWebviewWindow) getCurrentMonitorGeometry() (x int, y int, width int, height int, scale int) {
func (w *linuxWebviewWindow) getCurrentMonitorGeometry() (x int, y int, width int, height int, scaleFactor int) {
monitor := w.getCurrentMonitor()
if monitor == nil {
return -1, -1, -1, -1, 1
}
var result C.GdkRectangle
C.gdk_monitor_get_geometry(monitor, &result)
scale = int(C.gdk_monitor_get_scale_factor(monitor))
return int(result.x), int(result.y), int(result.width), int(result.height), scale
scaleFactor = int(C.gdk_monitor_get_scale_factor(monitor))
return int(result.x), int(result.y), int(result.width), int(result.height), scaleFactor
}
func (w *linuxWebviewWindow) size() (int, int) {
@ -1101,7 +1119,7 @@ func getPrimaryScreen() (*Screen, error) {
monitor := C.gdk_display_get_primary_monitor(display)
geometry := C.GdkRectangle{}
C.gdk_monitor_get_geometry(monitor, &geometry)
scale := int(C.gdk_monitor_get_scale_factor(monitor))
scaleFactor := int(C.gdk_monitor_get_scale_factor(monitor))
// get the name for the screen
name := C.gdk_monitor_get_model(monitor)
return &Screen{
@ -1120,7 +1138,7 @@ func getPrimaryScreen() (*Screen, error) {
Height: int(geometry.height),
Width: int(geometry.width),
},
Scale: float32(scale),
ScaleFactor: float32(scaleFactor),
}, nil
}

View file

@ -621,10 +621,10 @@ func getScreenByIndex(display pointer, index int) *Screen {
}
return &Screen{
IsPrimary: primary,
Scale: 1.0,
X: int(geometry.x),
Y: int(geometry.y),
IsPrimary: primary,
ScaleFactor: 1.0,
X: int(geometry.x),
Y: int(geometry.y),
Size: Size{
Height: int(geometry.height),
Width: int(geometry.width),
@ -719,7 +719,7 @@ func windowGetCurrentMonitor(window pointer) pointer {
return gdkDisplayGetMonitorAtWindow(display, window)
}
func windowGetCurrentMonitorGeometry(window pointer) (x int, y int, width int, height int, scale int) {
func windowGetCurrentMonitorGeometry(window pointer) (x int, y int, width int, height int, scaleFactor int) {
monitor := windowGetCurrentMonitor(window)
if monitor == 0 {
return -1, -1, -1, -1, 1

View file

@ -1,26 +0,0 @@
package application
type Screen struct {
ID string // A unique identifier for the display
Name string // The name of the display
Scale float32 // The scale factor of the display
X int // The x-coordinate of the top-left corner of the rectangle
Y int // The y-coordinate of the top-left corner of the rectangle
Size Size // The size of the display
Bounds Rect // The bounds of the display
WorkArea Rect // The work area of the display
IsPrimary bool // Whether this is the primary display
Rotation float32 // The rotation of the display
}
type Rect struct {
X int
Y int
Width int
Height int
}
type Size struct {
Width int
Height int
}

View file

@ -24,7 +24,7 @@ typedef struct Screen {
int w_height;
int w_x;
int w_y;
float scale;
float scaleFactor;
double rotation;
bool isPrimary;
} Screen;
@ -36,7 +36,7 @@ int GetNumScreens(){
Screen processScreen(NSScreen* screen){
Screen returnScreen;
returnScreen.scale = screen.backingScaleFactor;
returnScreen.scaleFactor = screen.backingScaleFactor;
// screen bounds
returnScreen.height = screen.frame.size.height;
@ -137,17 +137,29 @@ func cScreenToScreen(screen C.Screen) *Screen {
Height: int(screen.height),
Width: int(screen.width),
},
PhysicalBounds: Rect{
X: int(screen.x),
Y: int(screen.y),
Height: int(screen.height),
Width: int(screen.width),
},
WorkArea: Rect{
X: int(screen.w_x),
Y: int(screen.w_y),
Height: int(screen.w_height),
Width: int(screen.w_width),
},
Scale: float32(screen.scale),
ID: C.GoString(screen.id),
Name: C.GoString(screen.name),
IsPrimary: bool(screen.isPrimary),
Rotation: float32(screen.rotation),
PhysicalWorkArea: Rect{
X: int(screen.w_x),
Y: int(screen.w_y),
Height: int(screen.w_height),
Width: int(screen.w_width),
},
ScaleFactor: float32(screen.scaleFactor),
ID: C.GoString(screen.id),
Name: C.GoString(screen.name),
IsPrimary: bool(screen.isPrimary),
Rotation: float32(screen.rotation),
}
}

View file

@ -0,0 +1,88 @@
//go:build windows
package application
import (
"fmt"
"strconv"
"github.com/wailsapp/wails/v3/pkg/w32"
"golang.org/x/sys/windows"
)
func (m *windowsApp) processAndCacheScreens() error {
allScreens, err := w32.GetAllScreens()
if err != nil {
return err
}
// Convert result to []*Screen
var screens []*Screen
for _, screen := range allScreens {
x := int(screen.MONITORINFOEX.RcMonitor.Left)
y := int(screen.MONITORINFOEX.RcMonitor.Top)
right := int(screen.MONITORINFOEX.RcMonitor.Right)
bottom := int(screen.MONITORINFOEX.RcMonitor.Bottom)
width := right - x
height := bottom - y
workArea := Rect{
X: int(screen.MONITORINFOEX.RcWork.Left),
Y: int(screen.MONITORINFOEX.RcWork.Top),
Width: int(screen.MONITORINFOEX.RcWork.Right - screen.MONITORINFOEX.RcWork.Left),
Height: int(screen.MONITORINFOEX.RcWork.Bottom - screen.MONITORINFOEX.RcWork.Top),
}
screens = append(screens, &Screen{
ID: hMonitorToScreenID(screen.HMonitor),
Name: windows.UTF16ToString(screen.MONITORINFOEX.SzDevice[:]),
X: x,
Y: y,
Size: Size{Width: width, Height: height},
Bounds: Rect{X: x, Y: y, Width: width, Height: height},
PhysicalBounds: Rect{X: x, Y: y, Width: width, Height: height},
WorkArea: workArea,
PhysicalWorkArea: workArea,
IsPrimary: screen.IsPrimary,
ScaleFactor: screen.ScaleFactor,
Rotation: 0,
})
}
err = m.parent.screenManager.LayoutScreens(screens)
if err != nil {
return err
}
return nil
}
// NOTE: should be moved to *App after DPI is implemented in all platforms
func (m *windowsApp) getScreens() ([]*Screen, error) {
return m.parent.screenManager.screens, nil
}
// NOTE: should be moved to *App after DPI is implemented in all platforms
func (m *windowsApp) getPrimaryScreen() (*Screen, error) {
return m.parent.screenManager.primaryScreen, nil
}
func getScreenForWindow(window *windowsWebviewWindow) (*Screen, error) {
return ScreenNearestPhysicalRect(window.physicalBounds()), nil
}
func getScreenForWindowHwnd(hwnd w32.HWND) (*Screen, error) {
hMonitor := w32.MonitorFromWindow(hwnd, w32.MONITOR_DEFAULTTONEAREST)
screenID := hMonitorToScreenID(hMonitor)
for _, screen := range globalApplication.screenManager.screens {
if screen.ID == screenID {
return screen, nil
}
}
return nil, fmt.Errorf("screen not found for window")
}
func hMonitorToScreenID(hMonitor uintptr) string {
return strconv.Itoa(int(hMonitor))
}

View file

@ -0,0 +1,868 @@
package application
import (
"fmt"
"math"
"sort"
)
// Heavily inspired by the Chromium project (Copyright 2015 The Chromium Authors)
// Chromium License: https://chromium.googlesource.com/chromium/src/+/HEAD/LICENSE
type ScreenManager struct {
screens []*Screen
primaryScreen *Screen
}
type Screen struct {
ID string // A unique identifier for the display
Name string // The name of the display
ScaleFactor float32 // The scale factor of the display (DPI/96)
X int // The x-coordinate of the top-left corner of the rectangle
Y int // The y-coordinate of the top-left corner of the rectangle
Size Size // The size of the display
Bounds Rect // The bounds of the display
PhysicalBounds Rect // The physical bounds of the display (before scaling)
WorkArea Rect // The work area of the display
PhysicalWorkArea Rect // The physical work area of the display (before scaling)
IsPrimary bool // Whether this is the primary display
Rotation float32 // The rotation of the display
}
type Rect struct {
X int
Y int
Width int
Height int
}
type Point struct {
X int
Y int
}
type Size struct {
Width int
Height int
}
type Alignment int
type OffsetReference int
const (
TOP Alignment = iota
RIGHT
BOTTOM
LEFT
)
const (
BEGIN OffsetReference = iota // TOP or LEFT
END // BOTTOM or RIGHT
)
// ScreenPlacement specifies where the screen (S) is placed relative to
// parent (P) screen. In the following example, (S) is RIGHT aligned to (P)
// with a positive offset and a BEGIN (top) offset reference.
//
// . +------------+ +
// . | | | offset
// . | P | v
// . | +--------+
// . | | |
// . +------------+ S |
// . | |
// . +--------+
type ScreenPlacement struct {
screen *Screen
parent *Screen
alignment Alignment
offset int
offsetReference OffsetReference
}
func (r Rect) Origin() Point {
return Point{
X: r.X,
Y: r.Y,
}
}
func (s Screen) Origin() Point {
return Point{
X: s.X,
Y: s.Y,
}
}
func (r Rect) Corner() Point {
return Point{
X: r.right(),
Y: r.bottom(),
}
}
func (r Rect) InsideCorner() Point {
return Point{
X: r.right() - 1,
Y: r.bottom() - 1,
}
}
func (r Rect) right() int {
return r.X + r.Width
}
func (r Rect) bottom() int {
return r.Y + r.Height
}
func (s Screen) right() int {
return s.Bounds.right()
}
func (s Screen) bottom() int {
return s.Bounds.bottom()
}
func (s Screen) scale(value int, toDip bool) int {
// Round up when scaling down and round down when scaling up.
// This mix rounding strategy prevents drift over time when applying multiple scaling back and forth.
// In addition, It has been shown that using this approach minimized rounding issues and improved overall
// precision when converting between DIP and physical coordinates.
if toDip {
return int(math.Ceil(float64(value) / float64(s.ScaleFactor)))
} else {
return int(math.Floor(float64(value) * float64(s.ScaleFactor)))
}
}
func (r Rect) Size() Size {
return Size{
Width: r.Width,
Height: r.Height,
}
}
func (r Rect) IsEmpty() bool {
return r.Width <= 0 || r.Height <= 0
}
func (r Rect) Contains(pt Point) bool {
return pt.X >= r.X && pt.X < r.X+r.Width && pt.Y >= r.Y && pt.Y < r.Y+r.Height
}
// Get intersection with another rect
func (r Rect) Intersect(otherRect Rect) Rect {
if r.IsEmpty() || otherRect.IsEmpty() {
return Rect{}
}
maxLeft := max(r.X, otherRect.X)
maxTop := max(r.Y, otherRect.Y)
minRight := min(r.right(), otherRect.right())
minBottom := min(r.bottom(), otherRect.bottom())
if minRight > maxLeft && minBottom > maxTop {
return Rect{
X: maxLeft,
Y: maxTop,
Width: minRight - maxLeft,
Height: minBottom - maxTop,
}
}
return Rect{}
}
// Check if screens intersects another screen
func (s *Screen) intersects(otherScreen *Screen) bool {
maxLeft := max(s.X, otherScreen.X)
maxTop := max(s.Y, otherScreen.Y)
minRight := min(s.right(), otherScreen.right())
minBottom := min(s.bottom(), otherScreen.bottom())
return minRight > maxLeft && minBottom > maxTop
}
// Get distance from another rect (squared)
func (r Rect) distanceFromRectSquared(otherRect Rect) int {
// If they intersect, return negative area of intersection
intersection := r.Intersect(otherRect)
if !intersection.IsEmpty() {
return -(intersection.Width * intersection.Height)
}
dX := max(0, max(r.X-otherRect.right(), otherRect.X-r.right()))
dY := max(0, max(r.Y-otherRect.bottom(), otherRect.Y-r.bottom()))
// Distance squared
return dX*dX + dY*dY
}
// Apply screen placement
func (p ScreenPlacement) apply() {
parentBounds := p.parent.Bounds
screenBounds := p.screen.Bounds
newX := parentBounds.X
newY := parentBounds.Y
offset := p.offset
if p.alignment == TOP || p.alignment == BOTTOM {
if p.offsetReference == END {
offset = parentBounds.Width - offset - screenBounds.Width
}
offset = min(offset, parentBounds.Width)
offset = max(offset, -screenBounds.Width)
newX += offset
if p.alignment == TOP {
newY -= screenBounds.Height
} else {
newY += parentBounds.Height
}
} else {
if p.offsetReference == END {
offset = parentBounds.Height - offset - screenBounds.Height
}
offset = min(offset, parentBounds.Height)
offset = max(offset, -screenBounds.Height)
newY += offset
if p.alignment == LEFT {
newX -= screenBounds.Width
} else {
newX += parentBounds.Width
}
}
p.screen.move(newX, newY)
}
func (s *Screen) absoluteToRelativeDipPoint(dipPoint Point) Point {
return Point{
X: dipPoint.X - s.Bounds.X,
Y: dipPoint.Y - s.Bounds.Y,
}
}
func (s *Screen) relativeToAbsoluteDipPoint(dipPoint Point) Point {
return Point{
X: dipPoint.X + s.Bounds.X,
Y: dipPoint.Y + s.Bounds.Y,
}
}
func (s *Screen) absoluteToRelativePhysicalPoint(physicalPoint Point) Point {
return Point{
X: physicalPoint.X - s.PhysicalBounds.X,
Y: physicalPoint.Y - s.PhysicalBounds.Y,
}
}
func (s *Screen) relativeToAbsolutePhysicalPoint(physicalPoint Point) Point {
return Point{
X: physicalPoint.X + s.PhysicalBounds.X,
Y: physicalPoint.Y + s.PhysicalBounds.Y,
}
}
func (s *Screen) move(newX, newY int) {
workAreaOffsetX := s.WorkArea.X - s.X
workAreaOffsetY := s.WorkArea.Y - s.Y
s.X = newX
s.Y = newY
s.Bounds.X = newX
s.Bounds.Y = newY
s.WorkArea.X = newX + workAreaOffsetX
s.WorkArea.Y = newY + workAreaOffsetY
}
func (s *Screen) applyDPIScaling() {
if s.ScaleFactor == 1 {
return
}
workAreaOffsetX := s.WorkArea.X - s.Bounds.X
workAreaOffsetY := s.WorkArea.Y - s.Bounds.Y
s.WorkArea.X = s.Bounds.X + s.scale(workAreaOffsetX, true)
s.WorkArea.Y = s.Bounds.Y + s.scale(workAreaOffsetY, true)
s.Bounds.Width = s.scale(s.PhysicalBounds.Width, true)
s.Bounds.Height = s.scale(s.PhysicalBounds.Height, true)
s.WorkArea.Width = s.scale(s.PhysicalWorkArea.Width, true)
s.WorkArea.Height = s.scale(s.PhysicalWorkArea.Height, true)
s.Size.Width = s.Bounds.Width
s.Size.Height = s.Bounds.Height
}
func (s *Screen) dipToPhysicalPoint(dipPoint Point, isCorner bool) Point {
relativePoint := s.absoluteToRelativeDipPoint(dipPoint)
scaledRelativePoint := Point{
X: s.scale(relativePoint.X, false),
Y: s.scale(relativePoint.Y, false),
}
// Align edge points (fixes rounding issues)
edgeOffset := 1
if isCorner {
edgeOffset = 0
}
if relativePoint.X == s.Bounds.Width-edgeOffset {
scaledRelativePoint.X = s.PhysicalBounds.Width - edgeOffset
}
if relativePoint.Y == s.Bounds.Height-edgeOffset {
scaledRelativePoint.Y = s.PhysicalBounds.Height - edgeOffset
}
return s.relativeToAbsolutePhysicalPoint(scaledRelativePoint)
}
func (s *Screen) physicalToDipPoint(physicalPoint Point, isCorner bool) Point {
relativePoint := s.absoluteToRelativePhysicalPoint(physicalPoint)
scaledRelativePoint := Point{
X: s.scale(relativePoint.X, true),
Y: s.scale(relativePoint.Y, true),
}
// Align edge points (fixes rounding issues)
edgeOffset := 1
if isCorner {
edgeOffset = 0
}
if relativePoint.X == s.PhysicalBounds.Width-edgeOffset {
scaledRelativePoint.X = s.Bounds.Width - edgeOffset
}
if relativePoint.Y == s.PhysicalBounds.Height-edgeOffset {
scaledRelativePoint.Y = s.Bounds.Height - edgeOffset
}
return s.relativeToAbsoluteDipPoint(scaledRelativePoint)
}
func (s *Screen) dipToPhysicalRect(dipRect Rect) Rect {
origin := s.dipToPhysicalPoint(dipRect.Origin(), false)
corner := s.dipToPhysicalPoint(dipRect.Corner(), true)
return Rect{
X: origin.X,
Y: origin.Y,
Width: corner.X - origin.X,
Height: corner.Y - origin.Y,
}
}
func (s *Screen) physicalToDipRect(physicalRect Rect) Rect {
origin := s.physicalToDipPoint(physicalRect.Origin(), false)
corner := s.physicalToDipPoint(physicalRect.Corner(), true)
return Rect{
X: origin.X,
Y: origin.Y,
Width: corner.X - origin.X,
Height: corner.Y - origin.Y,
}
}
// Layout screens in the virtual space with DIP calculations and cache the screens
// for future coordinate transformation between the physical and logical (DIP) space
func (m *ScreenManager) LayoutScreens(screens []*Screen) error {
if screens == nil || len(screens) == 0 {
return fmt.Errorf("screens parameter is nil or empty")
}
m.screens = screens
err := m.calculateScreensDipCoordinates()
if err != nil {
return err
}
return nil
}
}
func (m *ScreenManager) Screens() []*Screen {
return m.screens
}
func (m *ScreenManager) PrimaryScreen() *Screen {
return m.primaryScreen
}
// Reference: https://source.chromium.org/chromium/chromium/src/+/main:ui/display/win/screen_win.cc;l=317
func (m *ScreenManager) calculateScreensDipCoordinates() error {
remainingScreens := []*Screen{}
// Find the primary screen
m.primaryScreen = nil
for _, screen := range m.screens {
if screen.IsPrimary {
m.primaryScreen = screen
} else {
remainingScreens = append(remainingScreens, screen)
}
}
if m.primaryScreen == nil {
return fmt.Errorf("no primary screen found")
} else if len(remainingScreens) != len(m.screens)-1 {
return fmt.Errorf("invalid primary screen found")
}
// Build screens tree using the primary screen as root
screensPlacements := []ScreenPlacement{}
availableParents := []*Screen{m.primaryScreen}
for len(availableParents) > 0 {
// Pop a parent
end := len(availableParents) - 1
parent := availableParents[end]
availableParents = availableParents[:end]
// Find touching screens
for _, child := range m.findAndRemoveTouchingScreens(parent, &remainingScreens) {
screenPlacement := m.calculateScreenPlacement(child, parent)
screensPlacements = append(screensPlacements, screenPlacement)
availableParents = append(availableParents, child)
}
}
// Apply screens DPI scaling and placement starting with
// the primary screen and then dependent screens
m.primaryScreen.applyDPIScaling()
for _, placement := range screensPlacements {
placement.screen.applyDPIScaling()
placement.apply()
}
// Now that all the placements have been applied,
// we must detect and fix any overlapping screens.
m.deIntersectScreens(screensPlacements)
return nil
}
// Returns a ScreenPlacement for |screen| relative to |parent|.
// Note that ScreenPlacement's are always in DIPs, so this also performs the
// required scaling.
// References:
// - https://github.com/chromium/chromium/blob/main/ui/display/win/scaling_util.h#L25
// - https://github.com/chromium/chromium/blob/main/ui/display/win/scaling_util.cc#L142
func (m *ScreenManager) calculateScreenPlacement(screen, parent *Screen) ScreenPlacement {
// Examples (The offset is indicated by the arrow.):
// Scaled and Unscaled Coordinates
// +--------------+ + Since both screens are of the same scale
// | | | factor, relative positions remain the same.
// | Parent | V
// | 1x +----------+
// | | |
// +--------------+ Screen |
// | 1x |
// +----------+
//
// Unscaled Coordinates
// +--------------+ The 2x screen is offset to maintain a
// | | similar neighboring relationship with the 1x
// | Parent | parent. Screen's position is based off of the
// | 1x +----------+ percentage position along its parent. This
// | | | percentage position is preserved in the scaled
// +--------------+ Screen | coordinates.
// | 2x |
// +----------+
// Scaled Coordinates
// +--------------+ +
// | | |
// | Parent | V
// | 1x +-----+
// | | S 2x|
// +--------------+-----+
//
//
// Unscaled Coordinates
// +--------------+ The parent screen has a 2x scale factor.
// | | The offset is adjusted to maintain the
// | | relative positioning of the 1x screen in
// | Parent +----------+ the scaled coordinate space. Screen's
// | 2x | | position is based off of the percentage
// | | Screen | position along its parent. This percentage
// | | 1x | position is preserved in the scaled
// +--------------+ | coordinates.
// | |
// +----------+
// Scaled Coordinates
// +-------+ +
// | | V
// | Parent+----------+
// | 2x | |
// +-------+ Screen |
// | 1x |
// | |
// | |
// +----------+
//
// Unscaled Coordinates
// +----------+ In this case, parent lies between the top and
// | | bottom of parent. The roles are reversed when
// +-------+ | this occurs, and screen is placed to maintain
// | | Screen | parent's relative position along screen.
// | Parent| 1x |
// | 2x | |
// +-------+ |
// +----------+
// Scaled Coordinates
// ^ +----------+
// | | |
// + +----+ |
// |Prnt| Screen |
// | 2x | 1x |
// +----+ |
// | |
// +----------+
//
// Scaled and Unscaled Coordinates
// +--------+ If the two screens are bottom aligned or
// | | right aligned, the ScreenPlacement will
// | +--------+ have an offset of 0 relative to the
// | | | end of the screen.
// | | |
// +--------+--------+
placement := ScreenPlacement{
screen: screen,
parent: parent,
alignment: m.getScreenAlignment(screen, parent),
offset: 0,
offsetReference: BEGIN,
}
screenBegin, screenEnd := 0, 0
parentBegin, parentEnd := 0, 0
switch placement.alignment {
case TOP, BOTTOM:
screenBegin = screen.X
screenEnd = screen.right()
parentBegin = parent.X
parentEnd = parent.right()
case LEFT, RIGHT:
screenBegin = screen.Y
screenEnd = screen.bottom()
parentBegin = parent.Y
parentEnd = parent.bottom()
}
// Since we're calculating offsets, make everything relative to parentBegin
parentEnd -= parentBegin
screenBegin -= parentBegin
screenEnd -= parentBegin
parentBegin = 0
// There are a few ways lines can intersect:
// End Aligned
// SCREEN's offset is relative to the END (BOTTOM or RIGHT).
// +-PARENT----------------+
// +-SCREEN-------------+
//
// Positioning based off of |screenBegin|.
// SCREEN's offset is simply a percentage of its position on PARENT.
// +-PARENT----------------+
// ^+-SCREEN------------+
//
// Positioning based off of |screenEnd|.
// SCREEN's offset is dependent on the percentage of its end position on PARENT.
// +-PARENT----------------+
// +-SCREEN------------+^
//
// Positioning based off of |parentBegin| on SCREEN.
// SCREEN's offset is dependent on the percentage of its position on PARENT.
// +-PARENT----------------+
// ^+-SCREEN--------------------------+
if screenEnd == parentEnd {
placement.offsetReference = END
placement.offset = 0
} else if screenBegin >= parentBegin {
placement.offsetReference = BEGIN
placement.offset = m.scaleOffset(parentEnd, parent.ScaleFactor, screenBegin)
} else if screenEnd <= parentEnd {
placement.offsetReference = END
placement.offset = m.scaleOffset(parentEnd, parent.ScaleFactor, parentEnd-screenEnd)
} else {
placement.offsetReference = BEGIN
placement.offset = m.scaleOffset(screenEnd-screenBegin, screen.ScaleFactor, screenBegin)
}
return placement
}
// Get screen alignment relative to parent (TOP, RIGHT, BOTTOM, LEFT)
func (m *ScreenManager) getScreenAlignment(screen, parent *Screen) Alignment {
maxLeft := max(screen.X, parent.X)
maxTop := max(screen.Y, parent.Y)
minRight := min(screen.right(), parent.right())
minBottom := min(screen.bottom(), parent.bottom())
// Corners touching
if maxLeft == minRight && maxTop == minBottom {
if screen.Y == maxTop {
return BOTTOM
} else if parent.X == maxLeft {
return LEFT
}
return TOP
}
// Vertical edge touching
if maxLeft == minRight {
if screen.X == maxLeft {
return RIGHT
} else {
return LEFT
}
}
// Horizontal edge touching
if maxTop == minBottom {
if screen.Y == maxTop {
return BOTTOM
} else {
return TOP
}
}
return -1 // Shouldn't be reached
}
func (m *ScreenManager) deIntersectScreens(screensPlacements []ScreenPlacement) {
parentIDMap := make(map[string]string)
for _, placement := range screensPlacements {
parentIDMap[placement.screen.ID] = placement.parent.ID
}
treeDepthMap := make(map[string]int)
for _, screen := range m.screens {
id, ok, depth := screen.ID, true, 0
const maxDepth = 100
for id != m.primaryScreen.ID && depth < maxDepth {
depth++
id, ok = parentIDMap[id]
if !ok {
depth = maxDepth
}
}
treeDepthMap[screen.ID] = depth
}
sortedScreens := make([]*Screen, len(m.screens))
copy(sortedScreens, m.screens)
// Sort the screens first by their depth in the screen hierarchy tree,
// and then by distance from screen origin to primary origin. This way we
// process the screens starting at the root (the primary screen), in the
// order of their descendance spanning out from the primary screen.
sort.Slice(sortedScreens, func(i, j int) bool {
s1, s2 := m.screens[i], m.screens[j]
s1_depth := treeDepthMap[s1.ID]
s2_depth := treeDepthMap[s2.ID]
if s1_depth != s2_depth {
return s1_depth < s2_depth
}
// Distance squared
s1_distance := s1.X*s1.X + s1.Y*s1.Y
s2_distance := s2.X*s2.X + s2.Y*s2.Y
if s1_distance != s2_distance {
return s1_distance < s2_distance
}
return s1.ID < s2.ID
})
for i := 1; i < len(sortedScreens); i++ {
targetScreen := sortedScreens[i]
for j := 0; j < i; j++ {
sourceScreen := sortedScreens[j]
if targetScreen.intersects(sourceScreen) {
m.fixScreenIntersection(targetScreen, sourceScreen)
}
}
}
}
// Offset the target screen along either X or Y axis away from the origin
// so that it removes the intersection with the source screen
// This function assume both screens already intersect.
func (m *ScreenManager) fixScreenIntersection(targetScreen, sourceScreen *Screen) {
offsetX, offsetY := 0, 0
if targetScreen.X >= 0 {
offsetX = sourceScreen.right() - targetScreen.X
} else {
offsetX = -(targetScreen.right() - sourceScreen.X)
}
if targetScreen.Y >= 0 {
offsetY = sourceScreen.bottom() - targetScreen.Y
} else {
offsetY = -(targetScreen.bottom() - sourceScreen.Y)
}
// Choose the smaller offset (X or Y)
if math.Abs(float64(offsetX)) <= math.Abs(float64(offsetY)) {
offsetY = 0
} else {
offsetX = 0
}
// Apply the offset
newX := targetScreen.X + offsetX
newY := targetScreen.Y + offsetY
targetScreen.move(newX, newY)
}
func (m *ScreenManager) findAndRemoveTouchingScreens(parent *Screen, screens *[]*Screen) []*Screen {
touchingScreens := []*Screen{}
remainingScreens := []*Screen{}
for _, screen := range *screens {
if m.areScreensTouching(parent, screen) {
touchingScreens = append(touchingScreens, screen)
} else {
remainingScreens = append(remainingScreens, screen)
}
}
*screens = remainingScreens
return touchingScreens
}
func (m *ScreenManager) areScreensTouching(a, b *Screen) bool {
maxLeft := max(a.X, b.X)
maxTop := max(a.Y, b.Y)
minRight := min(a.right(), b.right())
minBottom := min(a.bottom(), b.bottom())
return (maxLeft == minRight && maxTop <= minBottom) || (maxTop == minBottom && maxLeft <= minRight)
}
// Scale |unscaledOffset| to the same relative position on |unscaledLength|
// based off of |unscaledLength|'s |scaleFactor|
func (m *ScreenManager) scaleOffset(unscaledLength int, scaleFactor float32, unscaledOffset int) int {
scaledLength := float32(unscaledLength) / scaleFactor
percent := float32(unscaledOffset) / float32(unscaledLength)
return int(math.Floor(float64(scaledLength * percent)))
}
func (m *ScreenManager) screenNearestPoint(point Point, isPhysical bool) *Screen {
for _, screen := range m.screens {
if isPhysical {
if screen.PhysicalBounds.Contains(point) {
return screen
}
} else {
if screen.Bounds.Contains(point) {
return screen
}
}
}
return m.primaryScreen
}
func (m *ScreenManager) screenNearestRect(rect Rect, isPhysical bool, excludedScreens map[string]bool) *Screen {
var nearestScreen *Screen
var distance, nearestScreenDistance int
for _, screen := range m.screens {
if excludedScreens[screen.ID] {
continue
}
if isPhysical {
distance = rect.distanceFromRectSquared(screen.PhysicalBounds)
} else {
distance = rect.distanceFromRectSquared(screen.Bounds)
}
if nearestScreen == nil || distance < nearestScreenDistance {
nearestScreen = screen
nearestScreenDistance = distance
}
}
if !isPhysical && len(excludedScreens) < len(m.screens)-1 {
// Make sure to give the same screen that would be given by the physical rect
// of this dip rect so transforming back and forth always gives the same result.
// This is important because it could happen that a dip rect intersects Screen1
// more than Screen2 but in the physical layout Screen2 will scale up or Screen1
// will scale down causing the intersection area to change so transforming back
// would give a different rect.
physicalRect := nearestScreen.dipToPhysicalRect(rect)
physicalRectScreen := m.screenNearestRect(physicalRect, true, nil)
if nearestScreen != physicalRectScreen {
if excludedScreens == nil {
excludedScreens = make(map[string]bool)
}
excludedScreens[nearestScreen.ID] = true
return m.screenNearestRect(rect, isPhysical, excludedScreens)
}
}
return nearestScreen
}
func (m *ScreenManager) DipToPhysicalPoint(dipPoint Point) Point {
screen := m.ScreenNearestDipPoint(dipPoint)
return screen.dipToPhysicalPoint(dipPoint, false)
}
func (m *ScreenManager) PhysicalToDipPoint(physicalPoint Point) Point {
screen := m.ScreenNearestPhysicalPoint(physicalPoint)
return screen.physicalToDipPoint(physicalPoint, false)
}
func (m *ScreenManager) DipToPhysicalRect(dipRect Rect) Rect {
screen := m.ScreenNearestDipRect(dipRect)
return screen.dipToPhysicalRect(dipRect)
}
func (m *ScreenManager) PhysicalToDipRect(physicalRect Rect) Rect {
screen := m.ScreenNearestPhysicalRect(physicalRect)
return screen.physicalToDipRect(physicalRect)
}
func (m *ScreenManager) ScreenNearestPhysicalPoint(physicalPoint Point) *Screen {
return m.screenNearestPoint(physicalPoint, true)
}
func (m *ScreenManager) ScreenNearestDipPoint(dipPoint Point) *Screen {
return m.screenNearestPoint(dipPoint, false)
}
func (m *ScreenManager) ScreenNearestPhysicalRect(physicalRect Rect) *Screen {
return m.screenNearestRect(physicalRect, true, nil)
}
func (m *ScreenManager) ScreenNearestDipRect(dipRect Rect) *Screen {
return m.screenNearestRect(dipRect, false, nil)
}
// ================================================================================================
// Exported application-level methods for internal convenience and availability to application devs
func DipToPhysicalPoint(dipPoint Point) Point {
return globalApplication.screenManager.DipToPhysicalPoint(dipPoint)
}
func PhysicalToDipPoint(physicalPoint Point) Point {
return globalApplication.screenManager.PhysicalToDipPoint(physicalPoint)
}
func DipToPhysicalRect(dipRect Rect) Rect {
return globalApplication.screenManager.DipToPhysicalRect(dipRect)
}
func PhysicalToDipRect(physicalRect Rect) Rect {
return globalApplication.screenManager.PhysicalToDipRect(physicalRect)
}
func ScreenNearestPhysicalPoint(physicalPoint Point) *Screen {
return globalApplication.screenManager.ScreenNearestPhysicalPoint(physicalPoint)
}
func ScreenNearestDipPoint(dipPoint Point) *Screen {
return globalApplication.screenManager.ScreenNearestDipPoint(dipPoint)
}
func ScreenNearestPhysicalRect(physicalRect Rect) *Screen {
return globalApplication.screenManager.ScreenNearestPhysicalRect(physicalRect)
}
func ScreenNearestDipRect(dipRect Rect) *Screen {
return globalApplication.screenManager.ScreenNearestDipRect(dipRect)
}

View file

@ -0,0 +1,716 @@
package application_test
import (
"fmt"
"math"
"slices"
"strconv"
"testing"
"github.com/matryer/is"
"github.com/wailsapp/wails/v3/pkg/application"
)
type ScreenDef struct {
id int
w, h int
s float32
parent ScreenDefParent
name string
}
type ScreenDefParent struct {
id int
align string
offset int
}
type ScreensLayout struct {
name string
screens []ScreenDef
}
type ParsedLayout struct {
name string
screens []*application.Screen
}
func exampleLayouts() []ParsedLayout {
layouts := [][]ScreensLayout{
{
// Normal examples (demonstrate real life scenarios)
{
name: "Single 4k monitor",
screens: []ScreenDef{
{id: 1, w: 3840, h: 2160, s: 163.0 / 96, name: `27" 4K UHD 163DPI`},
},
},
{
name: "Two monitors",
screens: []ScreenDef{
{id: 1, w: 3840, h: 2160, s: 163.0 / 96, name: `27" 4K UHD 163DPI`},
{id: 2, w: 1920, h: 1080, s: 1, parent: ScreenDefParent{id: 1, align: "r", offset: 0}, name: `23" FHD 96DPI`},
},
},
{
name: "Two monitors (2)",
screens: []ScreenDef{
{id: 1, w: 1920, h: 1080, s: 1, name: `23" FHD 96DPI`},
{id: 2, w: 1920, h: 1080, s: 1.25, parent: ScreenDefParent{id: 1, align: "r", offset: 0}, name: `23" FHD 96DPI (125%)`},
},
},
{
name: "Three monitors",
screens: []ScreenDef{
{id: 1, w: 3840, h: 2160, s: 163.0 / 96, name: `27" 4K UHD 163DPI`},
{id: 2, w: 1920, h: 1080, s: 1, parent: ScreenDefParent{id: 1, align: "r", offset: 0}, name: `23" FHD 96DPI`},
{id: 3, w: 1920, h: 1080, s: 1.25, parent: ScreenDefParent{id: 1, align: "l", offset: 0}, name: `23" FHD 96DPI (125%)`},
},
},
{
name: "Four monitors",
screens: []ScreenDef{
{id: 1, w: 3840, h: 2160, s: 163.0 / 96, name: `27" 4K UHD 163DPI`},
{id: 2, w: 1920, h: 1080, s: 1, parent: ScreenDefParent{id: 1, align: "r", offset: 0}, name: `23" FHD 96DPI`},
{id: 3, w: 1920, h: 1080, s: 1.25, parent: ScreenDefParent{id: 2, align: "b", offset: 0}, name: `23" FHD 96DPI (125%)`},
{id: 4, w: 1080, h: 1920, s: 1, parent: ScreenDefParent{id: 1, align: "l", offset: 0}, name: `23" FHD (90deg)`},
},
},
},
{
// Test cases examples (demonstrate the algorithm basics)
{
name: "Child scaled, Start offset",
screens: []ScreenDef{
{id: 1, w: 1200, h: 1200, s: 1, name: "Parent"},
{id: 2, w: 1200, h: 1200, s: 1.5, parent: ScreenDefParent{id: 1, align: "r", offset: 600}, name: "Child"},
},
},
{
name: "Child scaled, End offset",
screens: []ScreenDef{
{id: 1, w: 1200, h: 1200, s: 1, name: "Parent"},
{id: 2, w: 1200, h: 1200, s: 1.5, parent: ScreenDefParent{id: 1, align: "r", offset: -600}, name: "Child"},
},
},
{
name: "Parent scaled, Start offset percent",
screens: []ScreenDef{
{id: 1, w: 1200, h: 1200, s: 1.5, name: "Parent"},
{id: 2, w: 1200, h: 1200, s: 1, parent: ScreenDefParent{id: 1, align: "r", offset: 600}, name: "Child"},
},
},
{
name: "Parent scaled, End offset percent",
screens: []ScreenDef{
{id: 1, w: 1200, h: 1200, s: 1.5, name: "Parent"},
{id: 2, w: 1200, h: 1200, s: 1, parent: ScreenDefParent{id: 1, align: "r", offset: -600}, name: "Child"},
},
},
{
name: "Parent scaled, Start align",
screens: []ScreenDef{
{id: 1, w: 1200, h: 1200, s: 1.5, name: "Parent"},
{id: 2, w: 1200, h: 1100, s: 1, parent: ScreenDefParent{id: 1, align: "r", offset: 0}, name: "Child"},
},
},
{
name: "Parent scaled, End align",
screens: []ScreenDef{
{id: 1, w: 1200, h: 1200, s: 1.5, name: "Parent"},
{id: 2, w: 1200, h: 1200, s: 1, parent: ScreenDefParent{id: 1, align: "r", offset: 0}, name: "Child"},
},
},
{
name: "Parent scaled, in-between",
screens: []ScreenDef{
{id: 1, w: 1200, h: 1200, s: 1.5, name: "Parent"},
{id: 2, w: 1200, h: 1500, s: 1, parent: ScreenDefParent{id: 1, align: "r", offset: -250}, name: "Child"},
},
},
},
{
// Edge cases examples
{
name: "Parent order (5 is parent of 4)",
screens: []ScreenDef{
{id: 1, w: 1920, h: 1080, s: 1},
{id: 2, w: 1024, h: 600, s: 1.25, parent: ScreenDefParent{id: 1, align: "r", offset: -200}},
{id: 3, w: 800, h: 800, s: 1.25, parent: ScreenDefParent{id: 2, align: "b", offset: 0}},
{id: 4, w: 800, h: 1080, s: 1.5, parent: ScreenDefParent{id: 2, align: "re", offset: 100}},
{id: 5, w: 600, h: 600, s: 1, parent: ScreenDefParent{id: 3, align: "r", offset: 100}},
},
},
{
name: "de-intersection reparent",
screens: []ScreenDef{
{id: 1, w: 1920, h: 1080, s: 1},
{id: 2, w: 1680, h: 1050, s: 1.25, parent: ScreenDefParent{id: 1, align: "r", offset: 10}},
{id: 3, w: 1440, h: 900, s: 1.5, parent: ScreenDefParent{id: 1, align: "le", offset: 150}},
{id: 4, w: 1024, h: 768, s: 1, parent: ScreenDefParent{id: 3, align: "bc", offset: -200}},
{id: 5, w: 1024, h: 768, s: 1.25, parent: ScreenDefParent{id: 4, align: "r", offset: 400}},
},
},
{
name: "de-intersection (unattached child)",
screens: []ScreenDef{
{id: 1, w: 1920, h: 1080, s: 1},
{id: 2, w: 1024, h: 768, s: 1.5, parent: ScreenDefParent{id: 1, align: "le", offset: 10}},
{id: 3, w: 1024, h: 768, s: 1.25, parent: ScreenDefParent{id: 2, align: "b", offset: 100}},
{id: 4, w: 1024, h: 768, s: 1, parent: ScreenDefParent{id: 3, align: "r", offset: 500}},
},
},
{
name: "Multiple de-intersection",
screens: []ScreenDef{
{id: 1, w: 1920, h: 1080, s: 1},
{id: 2, w: 1024, h: 768, s: 1, parent: ScreenDefParent{id: 1, align: "be", offset: 0}},
{id: 3, w: 1024, h: 768, s: 1, parent: ScreenDefParent{id: 2, align: "b", offset: 300}},
{id: 4, w: 1024, h: 768, s: 1.5, parent: ScreenDefParent{id: 2, align: "le", offset: 100}},
{id: 5, w: 1024, h: 768, s: 1, parent: ScreenDefParent{id: 4, align: "be", offset: 100}},
},
},
{
name: "Multiple de-intersection (left-side)",
screens: []ScreenDef{
{id: 1, w: 1920, h: 1080, s: 1},
{id: 2, w: 1024, h: 768, s: 1, parent: ScreenDefParent{id: 1, align: "le", offset: 0}},
{id: 3, w: 1024, h: 768, s: 1, parent: ScreenDefParent{id: 2, align: "b", offset: 300}},
{id: 4, w: 1024, h: 768, s: 1.5, parent: ScreenDefParent{id: 2, align: "le", offset: 100}},
{id: 5, w: 1024, h: 768, s: 1, parent: ScreenDefParent{id: 4, align: "be", offset: 100}},
},
},
{
name: "Parent de-intersection child offset",
screens: []ScreenDef{
{id: 1, w: 1600, h: 1600, s: 1.5},
{id: 2, w: 800, h: 800, s: 1, parent: ScreenDefParent{id: 1, align: "r", offset: 0}},
{id: 3, w: 800, h: 800, s: 1, parent: ScreenDefParent{id: 1, align: "r", offset: 800}},
{id: 4, w: 800, h: 1600, s: 1, parent: ScreenDefParent{id: 2, align: "r", offset: 0}},
},
},
},
}
parsedLayouts := []ParsedLayout{}
for _, section := range layouts {
for _, layout := range section {
parsedLayouts = append(parsedLayouts, parseLayout(layout))
}
}
return parsedLayouts
}
// Parse screens layout from easy-to-define ScreenDef for testing to actual Screens layout
func parseLayout(layout ScreensLayout) ParsedLayout {
screens := []*application.Screen{}
for _, screen := range layout.screens {
var x, y int
w := screen.w
h := screen.h
if screen.parent.id > 0 {
idx := slices.IndexFunc(screens, func(s *application.Screen) bool { return s.ID == strconv.Itoa(screen.parent.id) })
parent := screens[idx].Bounds
offset := screen.parent.offset
align := screen.parent.align
align2 := ""
if len(align) == 2 {
align2 = string(align[1])
align = string(align[0])
}
x = parent.X
y = parent.Y
// t: top, b: bottom, l: left, r: right, e: edge, c: corner
if align == "t" || align == "b" {
x += offset
if align2 == "e" || align2 == "c" {
x += parent.Width
}
if align2 == "e" {
x -= w
}
if align == "t" {
y -= h
} else {
y += parent.Height
}
} else {
y += offset
if align2 == "e" || align2 == "c" {
y += parent.Height
}
if align2 == "e" {
y -= h
}
if align == "l" {
x -= w
} else {
x += parent.Width
}
}
}
name := screen.name
if name == "" {
name = "Display" + strconv.Itoa(screen.id)
}
screens = append(screens, &application.Screen{
ID: strconv.Itoa(screen.id),
Name: name,
ScaleFactor: float32(math.Round(float64(screen.s)*100) / 100),
X: x,
Y: y,
Size: application.Size{Width: w, Height: h},
Bounds: application.Rect{X: x, Y: y, Width: w, Height: h},
PhysicalBounds: application.Rect{X: x, Y: y, Width: w, Height: h},
WorkArea: application.Rect{X: x, Y: y, Width: w, Height: h - int(40*screen.s)},
PhysicalWorkArea: application.Rect{X: x, Y: y, Width: w, Height: h - int(40*screen.s)},
IsPrimary: screen.id == 1,
Rotation: 0,
})
}
return ParsedLayout{
name: layout.name,
screens: screens,
}
}
func matchRects(r1, r2 application.Rect) error {
threshold := 1.0
if math.Abs(float64(r1.X-r2.X)) > threshold ||
math.Abs(float64(r1.Y-r2.Y)) > threshold ||
math.Abs(float64(r1.Width-r2.Width)) > threshold ||
math.Abs(float64(r1.Height-r2.Height)) > threshold {
return fmt.Errorf("%v != %v", r1, r2)
}
return nil
}
// Test screens layout (DPI transformation)
func TestScreenManager_ScreensLayout(t *testing.T) {
sm := application.ScreenManager{}
t.Run("Child scaled", func(t *testing.T) {
is := is.New(t)
layout := parseLayout(ScreensLayout{screens: []ScreenDef{
{id: 1, w: 1200, h: 1200, s: 1},
{id: 2, w: 1200, h: 1200, s: 1.5, parent: ScreenDefParent{id: 1, align: "r", offset: 600}},
}})
err := sm.LayoutScreens(layout.screens)
is.NoErr(err)
screens := sm.Screens()
is.Equal(len(screens), 2) // 2 screens
is.Equal(screens[0].PhysicalBounds, application.Rect{X: 0, Y: 0, Width: 1200, Height: 1200}) // Parent physical bounds
is.Equal(screens[0].Bounds, screens[0].PhysicalBounds) // Parent no scaling
is.Equal(screens[1].PhysicalBounds, application.Rect{X: 1200, Y: 600, Width: 1200, Height: 1200}) // Child physical bounds
is.Equal(screens[1].Bounds, application.Rect{X: 1200, Y: 600, Width: 800, Height: 800}) // Child DIP bounds
})
t.Run("Parent scaled", func(t *testing.T) {
is := is.New(t)
layout := parseLayout(ScreensLayout{screens: []ScreenDef{
{id: 1, w: 1200, h: 1200, s: 1.5},
{id: 2, w: 1200, h: 1200, s: 1, parent: ScreenDefParent{id: 1, align: "r", offset: 600}},
}})
err := sm.LayoutScreens(layout.screens)
is.NoErr(err)
screens := sm.Screens()
is.Equal(len(screens), 2) // 2 screens
is.Equal(screens[0].PhysicalBounds, application.Rect{X: 0, Y: 0, Width: 1200, Height: 1200}) // Parent physical bounds
is.Equal(screens[0].Bounds, application.Rect{X: 0, Y: 0, Width: 800, Height: 800}) // Parent DIP bounds
is.Equal(screens[1].PhysicalBounds, application.Rect{X: 1200, Y: 600, Width: 1200, Height: 1200}) // Child physical bounds
is.Equal(screens[1].Bounds, application.Rect{X: 800, Y: 400, Width: 1200, Height: 1200}) // Child DIP bounds
})
}
// Test basic transformation between physical and DIP coordinates
func TestScreenManager_BasicTranformation(t *testing.T) {
sm := application.ScreenManager{}
is := is.New(t)
layout := parseLayout(ScreensLayout{screens: []ScreenDef{
{id: 1, w: 1200, h: 1200, s: 1},
{id: 2, w: 1200, h: 1200, s: 1.5, parent: ScreenDefParent{id: 1, align: "r", offset: 600}},
}})
err := sm.LayoutScreens(layout.screens)
is.NoErr(err)
pt := application.Point{X: 100, Y: 100}
is.Equal(sm.DipToPhysicalPoint(pt), pt) // DipToPhysicalPoint screen1
is.Equal(sm.PhysicalToDipPoint(pt), pt) // PhysicalToDipPoint screen1
ptDip := application.Point{X: 1300, Y: 700}
ptPhysical := application.Point{X: 1350, Y: 750}
is.Equal(sm.DipToPhysicalPoint(ptDip), ptPhysical) // DipToPhysicalPoint screen2
is.Equal(sm.PhysicalToDipPoint(ptPhysical), ptDip) // PhysicalToDipPoint screen2
rect := application.Rect{X: 100, Y: 100, Width: 200, Height: 300}
is.Equal(sm.DipToPhysicalRect(rect), rect) // DipToPhysicalRect screen1
is.Equal(sm.PhysicalToDipRect(rect), rect) // DipToPhysicalRect screen1
rectDip := application.Rect{X: 1300, Y: 700, Width: 200, Height: 300}
rectPhysical := application.Rect{X: 1350, Y: 750, Width: 300, Height: 450}
is.Equal(sm.DipToPhysicalRect(rectDip), rectPhysical) // DipToPhysicalRect screen2
is.Equal(sm.PhysicalToDipRect(rectPhysical), rectDip) // DipToPhysicalRect screen2
rectDip = application.Rect{X: 2200, Y: 250, Width: 200, Height: 300}
rectPhysical = application.Rect{X: 2700, Y: 75, Width: 300, Height: 450}
is.Equal(sm.DipToPhysicalRect(rectDip), rectPhysical) // DipToPhysicalRect outside screen2
is.Equal(sm.PhysicalToDipRect(rectPhysical), rectDip) // DipToPhysicalRect outside screen2
}
func TestScreenManager_PrimaryScreen(t *testing.T) {
sm := application.ScreenManager{}
is := is.New(t)
for _, layout := range exampleLayouts() {
err := sm.LayoutScreens(layout.screens)
is.NoErr(err)
is.Equal(sm.PrimaryScreen(), layout.screens[0]) // Primary screen
}
layout := parseLayout(ScreensLayout{screens: []ScreenDef{
{id: 1, w: 1200, h: 1200, s: 1.5},
{id: 2, w: 1200, h: 1200, s: 1, parent: ScreenDefParent{id: 1, align: "r", offset: 600}},
}})
layout.screens[0], layout.screens[1] = layout.screens[1], layout.screens[0]
err := sm.LayoutScreens(layout.screens)
is.NoErr(err)
is.Equal(sm.PrimaryScreen(), layout.screens[1]) // Primary screen
layout.screens[1].IsPrimary = false
err = sm.LayoutScreens(layout.screens)
is.True(err != nil) // Should error when no primary screen found
}
// Test edge alignment between transformation
// (points and rects on the screen edge should transform to the same precise edge position)
func TestScreenManager_EdgeAlign(t *testing.T) {
sm := application.ScreenManager{}
is := is.New(t)
for _, layout := range exampleLayouts() {
err := sm.LayoutScreens(layout.screens)
is.NoErr(err)
for _, screen := range sm.Screens() {
ptOriginDip := screen.Bounds.Origin()
ptOriginPhysical := screen.PhysicalBounds.Origin()
ptCornerDip := screen.Bounds.InsideCorner()
ptCornerPhysical := screen.PhysicalBounds.InsideCorner()
is.Equal(sm.DipToPhysicalPoint(ptOriginDip), ptOriginPhysical) // DipToPhysicalPoint Origin
is.Equal(sm.PhysicalToDipPoint(ptOriginPhysical), ptOriginDip) // PhysicalToDipPoint Origin
is.Equal(sm.DipToPhysicalPoint(ptCornerDip), ptCornerPhysical) // DipToPhysicalPoint Corner
is.Equal(sm.PhysicalToDipPoint(ptCornerPhysical), ptCornerDip) // PhysicalToDipPoint Corner
rectOriginDip := application.Rect{X: ptOriginDip.X, Y: ptOriginDip.Y, Width: 100, Height: 100}
rectOriginPhysical := application.Rect{X: ptOriginPhysical.X, Y: ptOriginPhysical.Y, Width: 100, Height: 100}
rectCornerDip := application.Rect{X: ptCornerDip.X - 99, Y: ptCornerDip.Y - 99, Width: 100, Height: 100}
rectCornerPhysical := application.Rect{X: ptCornerPhysical.X - 99, Y: ptCornerPhysical.Y - 99, Width: 100, Height: 100}
is.Equal(sm.DipToPhysicalRect(rectOriginDip).Origin(), rectOriginPhysical.Origin()) // DipToPhysicalRect Origin
is.Equal(sm.PhysicalToDipRect(rectOriginPhysical).Origin(), rectOriginDip.Origin()) // PhysicalToDipRect Origin
is.Equal(sm.DipToPhysicalRect(rectCornerDip).Corner(), rectCornerPhysical.Corner()) // DipToPhysicalRect Corner
is.Equal(sm.PhysicalToDipRect(rectCornerPhysical).Corner(), rectCornerDip.Corner()) // PhysicalToDipRect Corner
}
}
}
func TestScreenManager_ProbePoints(t *testing.T) {
sm := application.ScreenManager{}
is := is.New(t)
threshold := 1.0
steps := 3
for _, layout := range exampleLayouts() {
err := sm.LayoutScreens(layout.screens)
is.NoErr(err)
for _, screen := range sm.Screens() {
for i := 0; i <= 1; i++ {
isDip := (i == 0)
var b application.Rect
if isDip {
b = screen.Bounds
} else {
b = screen.PhysicalBounds
}
xStep := b.Width / steps
yStep := b.Height / steps
if xStep < 1 {
xStep = 1
}
if yStep < 1 {
yStep = 1
}
pt := b.Origin()
xDone := false
yDone := false
for !yDone {
if pt.Y > b.InsideCorner().Y {
pt.Y = b.InsideCorner().Y
yDone = true
}
pt.X = b.X
xDone = false
for !xDone {
if pt.X > b.InsideCorner().X {
pt.X = b.InsideCorner().X
xDone = true
}
var ptDblTransformed application.Point
if isDip {
ptDblTransformed = sm.PhysicalToDipPoint(sm.DipToPhysicalPoint(pt))
} else {
ptDblTransformed = sm.DipToPhysicalPoint(sm.PhysicalToDipPoint(pt))
}
is.True(math.Abs(float64(ptDblTransformed.X-pt.X)) <= threshold)
is.True(math.Abs(float64(ptDblTransformed.Y-pt.Y)) <= threshold)
pt.X += xStep
}
pt.Y += yStep
}
}
}
}
}
// Test transformation drift over time
func TestScreenManager_TransformationDrift(t *testing.T) {
sm := application.ScreenManager{}
is := is.New(t)
for _, layout := range exampleLayouts() {
err := sm.LayoutScreens(layout.screens)
is.NoErr(err)
for _, screen := range sm.Screens() {
rectPhysicalOriginal := application.Rect{
X: screen.PhysicalBounds.X + 100,
Y: screen.PhysicalBounds.Y + 100,
Width: 123,
Height: 123,
}
// Slide the position to catch any rounding errors
for i := 0; i < 10; i++ {
rectPhysicalOriginal.X++
rectPhysicalOriginal.Y++
rectPhysical := rectPhysicalOriginal
// Transform back and forth several times to make sure no drift is introduced over time
for j := 0; j < 10; j++ {
rectDip := sm.PhysicalToDipRect(rectPhysical)
rectPhysical = sm.DipToPhysicalRect(rectDip)
}
is.NoErr(matchRects(rectPhysical, rectPhysicalOriginal))
}
}
}
}
func TestScreenManager_ScreenNearestRect(t *testing.T) {
sm := application.ScreenManager{}
is := is.New(t)
layout := parseLayout(ScreensLayout{screens: []ScreenDef{
{id: 1, w: 3840, h: 2160, s: 163.0 / 96, name: `27" 4K UHD 163DPI`},
{id: 2, w: 1920, h: 1080, s: 1, parent: ScreenDefParent{id: 1, align: "r", offset: 0}, name: `23" FHD 96DPI`},
{id: 3, w: 1920, h: 1080, s: 1.25, parent: ScreenDefParent{id: 1, align: "l", offset: 0}, name: `23" FHD 96DPI (125%)`},
}})
err := sm.LayoutScreens(layout.screens)
is.NoErr(err)
type Rects map[string][]application.Rect
t.Run("DIP rects", func(t *testing.T) {
is := is.New(t)
rects := Rects{
"1": []application.Rect{
{X: -150, Y: 260, Width: 400, Height: 300},
{X: -250, Y: 750, Width: 400, Height: 300},
{X: -450, Y: 950, Width: 400, Height: 300},
{X: 800, Y: 1350, Width: 400, Height: 300},
{X: 2000, Y: 100, Width: 400, Height: 300},
{X: 2100, Y: 950, Width: 400, Height: 300},
{X: 2350, Y: 1200, Width: 400, Height: 300},
},
"2": []application.Rect{
{X: 2100, Y: 50, Width: 400, Height: 300},
{X: 2150, Y: 950, Width: 400, Height: 300},
{X: 2450, Y: 1150, Width: 400, Height: 300},
{X: 4300, Y: 400, Width: 400, Height: 300},
},
"3": []application.Rect{
{X: -2000, Y: 100, Width: 400, Height: 300},
{X: -220, Y: 200, Width: 400, Height: 300},
{X: -300, Y: 750, Width: 400, Height: 300},
{X: -500, Y: 900, Width: 400, Height: 300},
},
}
for screenID, screenRects := range rects {
for _, rect := range screenRects {
screen := sm.ScreenNearestDipRect(rect)
is.Equal(screen.ID, screenID)
}
}
})
t.Run("Physical rects", func(t *testing.T) {
is := is.New(t)
rects := Rects{
"1": []application.Rect{
{X: -150, Y: 100, Width: 400, Height: 300},
{X: -250, Y: 1500, Width: 400, Height: 300},
{X: 3600, Y: 100, Width: 400, Height: 300},
},
"2": []application.Rect{
{X: 3700, Y: 100, Width: 400, Height: 300},
{X: 4000, Y: 1150, Width: 400, Height: 300},
},
"3": []application.Rect{
{X: -250, Y: 100, Width: 400, Height: 300},
{X: -300, Y: 950, Width: 400, Height: 300},
{X: -1000, Y: 1000, Width: 400, Height: 300},
},
}
for screenID, screenRects := range rects {
for _, rect := range screenRects {
screen := sm.ScreenNearestPhysicalRect(rect)
is.Equal(screen.ID, screenID)
}
}
})
// DIP rect is near screen1 but when transformed becomes near screen2.
// To have a consistent transformation back & forth, screen nearest physical rect
// should be the one given by ScreenNearestDipRect
t.Run("Edge case 1", func(t *testing.T) {
is := is.New(t)
layout := parseLayout(ScreensLayout{screens: []ScreenDef{
{id: 1, w: 1200, h: 1200, s: 1},
{id: 2, w: 1200, h: 1300, s: 1.5, parent: ScreenDefParent{id: 1, align: "r", offset: -20}},
}})
err := sm.LayoutScreens(layout.screens)
is.NoErr(err)
rectDip := application.Rect{X: 1020, Y: 800, Width: 400, Height: 300}
rectPhysical := sm.DipToPhysicalRect(rectDip)
screenDip := sm.ScreenNearestDipRect(rectDip)
screenPhysical := sm.ScreenNearestPhysicalRect(rectPhysical)
is.Equal(screenDip.ID, "2") // screenDip
is.Equal(screenPhysical.ID, "2") // screenPhysical
rectDblTransformed := sm.PhysicalToDipRect(rectPhysical)
is.NoErr(matchRects(rectDblTransformed, rectDip)) // double transformation
})
}
// Unsolved edge cases
func TestScreenManager_UnsolvedEdgeCases(t *testing.T) {
sm := application.ScreenManager{}
is := is.New(t)
// Edge case 1: invalid DIP rect location
// there could be a setup where some dip rects locations are invalid, meaning that there's no
// physical rect that could produce that dip rect at this location
// Not sure how to solve this scenario
t.Run("Edge case 1: invalid dip rect", func(t *testing.T) {
t.Skip("Unsolved edge case")
is := is.New(t)
layout := parseLayout(ScreensLayout{screens: []ScreenDef{
{id: 1, w: 1200, h: 1200, s: 1},
{id: 2, w: 1200, h: 1100, s: 1.5, parent: ScreenDefParent{id: 1, align: "r", offset: 0}},
}})
err := sm.LayoutScreens(layout.screens)
is.NoErr(err)
rectDip := application.Rect{X: 1050, Y: 700, Width: 400, Height: 300}
rectPhysical := sm.DipToPhysicalRect(rectDip)
screenDip := sm.ScreenNearestDipRect(rectDip)
screenPhysical := sm.ScreenNearestPhysicalRect(rectPhysical)
is.Equal(screenDip.ID, screenPhysical.ID)
rectDblTransformed := sm.PhysicalToDipRect(rectPhysical)
is.NoErr(matchRects(rectDblTransformed, rectDip)) // double transformation
})
// Edge case 2: physical rect that changes when double transformed
// there could be a setup where a dip rect at some locations could be produced by two different physical rects
// causing one of these physical rects to be changed to the other when double transformed
// Not sure how to solve this scenario
t.Run("Edge case 2: changed physical rect", func(t *testing.T) {
t.Skip("Unsolved edge case")
is := is.New(t)
layout := parseLayout(ScreensLayout{screens: []ScreenDef{
{id: 1, w: 1200, h: 1200, s: 1.5},
{id: 2, w: 1200, h: 900, s: 1, parent: ScreenDefParent{id: 1, align: "r", offset: 0}},
}})
err := sm.LayoutScreens(layout.screens)
is.NoErr(err)
rectPhysical := application.Rect{X: 1050, Y: 890, Width: 400, Height: 300}
rectDblTransformed := sm.DipToPhysicalRect(sm.PhysicalToDipRect(rectPhysical))
is.NoErr(matchRects(rectDblTransformed, rectPhysical)) // double transformation
})
}
func BenchmarkScreenManager_LayoutScreens(b *testing.B) {
sm := application.ScreenManager{}
layouts := exampleLayouts()
screens := layouts[3].screens
b.ResetTimer()
for i := 0; i < b.N; i++ {
sm.LayoutScreens(screens)
}
}
func BenchmarkScreenManager_TransformPoint(b *testing.B) {
sm := application.ScreenManager{}
layouts := exampleLayouts()
screens := layouts[3].screens
sm.LayoutScreens(screens)
pt := application.Point{X: 500, Y: 500}
b.ResetTimer()
for i := 0; i < b.N; i++ {
sm.DipToPhysicalPoint(pt)
}
}
func BenchmarkScreenManager_TransformRect(b *testing.B) {
sm := application.ScreenManager{}
layouts := exampleLayouts()
screens := layouts[3].screens
sm.LayoutScreens(screens)
rect := application.Rect{X: 500, Y: 500, Width: 800, Height: 600}
b.ResetTimer()
for i := 0; i < b.N; i++ {
sm.DipToPhysicalRect(rect)
}
}

View file

@ -55,9 +55,10 @@ func (s *windowsSystemTray) positionWindow(window *WebviewWindow, offset int) er
}
screenBounds := currentScreen.WorkArea
windowBounds := window.Bounds()
newX := screenBounds.Width - window.Width()
newY := screenBounds.Height - window.Height()
newX := screenBounds.Width - windowBounds.Width - offset
newY := screenBounds.Height - windowBounds.Height - offset
// systray icons in windows can either be in the taskbar
// or in a flyout menu.
@ -66,13 +67,18 @@ func (s *windowsSystemTray) positionWindow(window *WebviewWindow, offset int) er
return err
}
// we only need the traybounds if the icon is in the tray
var trayBounds *Rect
var centerAlignX, centerAlignY int
// we only need the traybounds if the icon is in the tray
if iconIsInTrayBounds {
trayBounds, err = s.bounds()
if err != nil {
return err
}
*trayBounds = PhysicalToDipRect(*trayBounds)
centerAlignX = trayBounds.X + (trayBounds.Width / 2) - (windowBounds.Width / 2)
centerAlignY = trayBounds.Y + (trayBounds.Height / 2) - (windowBounds.Height / 2)
}
taskbarBounds := w32.GetTaskbarPosition()
@ -82,26 +88,28 @@ func (s *windowsSystemTray) positionWindow(window *WebviewWindow, offset int) er
// to adjust the position so the window is centered on the icon
switch taskbarBounds.UEdge {
case w32.ABE_LEFT:
if iconIsInTrayBounds && trayBounds.Y-(window.Height()/2) >= 0 {
newY = trayBounds.Y - (window.Height() / 2)
if iconIsInTrayBounds && centerAlignY <= newY {
newY = centerAlignY
}
window.SetRelativePosition(offset, newY)
newX = screenBounds.X + offset
case w32.ABE_TOP:
if iconIsInTrayBounds && trayBounds.X-(window.Width()/2) <= newX {
newX = trayBounds.X - (window.Width() / 2)
if iconIsInTrayBounds && centerAlignX <= newX {
newX = centerAlignX
}
window.SetRelativePosition(newX, offset)
newY = screenBounds.Y + offset
case w32.ABE_RIGHT:
if iconIsInTrayBounds && trayBounds.Y-(window.Height()/2) <= newY {
newY = trayBounds.Y - (window.Height() / 2)
if iconIsInTrayBounds && centerAlignY <= newY {
newY = centerAlignY
}
window.SetRelativePosition(screenBounds.Width-window.Width()-offset, newY)
case w32.ABE_BOTTOM:
if iconIsInTrayBounds && trayBounds.X-(window.Width()/2) <= newX {
newX = trayBounds.X - (window.Width() / 2)
if iconIsInTrayBounds && centerAlignX <= newX {
newX = centerAlignX
}
window.SetRelativePosition(newX, screenBounds.Height-window.Height()-offset)
}
newPos := currentScreen.relativeToAbsoluteDipPoint(Point{X: newX, Y: newY})
windowBounds.X = newPos.X
windowBounds.Y = newPos.Y
window.SetBounds(windowBounds)
return nil
}
@ -142,7 +150,7 @@ func (s *windowsSystemTray) iconIsInTrayBounds() (bool, error) {
func (s *windowsSystemTray) getScreen() (*Screen, error) {
// Get the screen for this systray
return getScreen(s.hwnd)
return getScreenForWindowHwnd(s.hwnd)
}
func (s *windowsSystemTray) setMenu(menu *Menu) {
@ -257,7 +265,6 @@ func (s *windowsSystemTray) updateIcon() {
if !w32.ShellNotifyIcon(w32.NIM_MODIFY, &nid) {
panic(syscall.GetLastError())
}
return
}
func (s *windowsSystemTray) newNotifyIconData() w32.NOTIFYICONDATA {

View file

@ -44,7 +44,6 @@ type (
size() (int, int)
width() int
height() int
relativePosition() (int, int)
destroy()
reload()
forceReload()
@ -57,7 +56,6 @@ type (
close()
zoom()
setHTML(html string)
setRelativePosition(x int, y int)
on(eventID uint)
minimise()
unminimise()
@ -82,8 +80,14 @@ type (
startResize(border string) error
print() error
setEnabled(enabled bool)
physicalBounds() Rect
setPhysicalBounds(physicalBounds Rect)
bounds() Rect
setBounds(bounds Rect)
position() (int, int)
setPosition(x int, y int)
relativePosition() (int, int)
setRelativePosition(x int, y int)
flash(enabled bool)
handleKeyEvent(acceleratorString string)
getBorderSizes() *LRTB
@ -804,7 +808,73 @@ func (w *WebviewWindow) Height() int {
return InvokeSyncWithResult(w.impl.height)
}
// RelativePosition returns the relative position of the window to the screen
// PhysicalBounds returns the physical bounds of the window
func (w *WebviewWindow) PhysicalBounds() Rect {
if w.impl == nil && !w.isDestroyed() {
return Rect{}
}
var rect Rect
InvokeSync(func() {
rect = w.impl.physicalBounds()
})
return rect
}
// SetPhysicalBounds sets the physical bounds of the window
func (w *WebviewWindow) SetPhysicalBounds(physicalBounds Rect) {
if w.impl == nil && !w.isDestroyed() {
return
}
InvokeSync(func() {
w.impl.setPhysicalBounds(physicalBounds)
})
}
// Bounds returns the DIP bounds of the window
func (w *WebviewWindow) Bounds() Rect {
if w.impl == nil && !w.isDestroyed() {
return Rect{}
}
var rect Rect
InvokeSync(func() {
rect = w.impl.bounds()
})
return rect
}
// SetBounds sets the DIP bounds of the window
func (w *WebviewWindow) SetBounds(bounds Rect) {
if w.impl == nil && !w.isDestroyed() {
return
}
InvokeSync(func() {
w.impl.setBounds(bounds)
})
}
// Position returns the absolute position of the window
func (w *WebviewWindow) Position() (int, int) {
if w.impl == nil && !w.isDestroyed() {
return 0, 0
}
var x, y int
InvokeSync(func() {
x, y = w.impl.position()
})
return x, y
}
// SetPosition sets the absolute position of the window.
func (w *WebviewWindow) SetPosition(x int, y int) {
if w.impl == nil && !w.isDestroyed() {
return
}
InvokeSync(func() {
w.impl.setPosition(x, y)
})
}
// RelativePosition returns the position of the window relative to the screen WorkArea on which it is
func (w *WebviewWindow) RelativePosition() (int, int) {
if w.impl == nil && !w.isDestroyed() {
return 0, 0
@ -816,16 +886,16 @@ func (w *WebviewWindow) RelativePosition() (int, int) {
return x, y
}
// Position returns the absolute position of the window to the screen
func (w *WebviewWindow) Position() (int, int) {
if w.impl == nil && !w.isDestroyed() {
return 0, 0
// SetRelativePosition sets the position of the window relative to the screen WorkArea on which it is.
func (w *WebviewWindow) SetRelativePosition(x, y int) Window {
w.options.X = x
w.options.Y = y
if w.impl != nil {
InvokeSync(func() {
w.impl.setRelativePosition(x, y)
})
}
var x, y int
InvokeSync(func() {
x, y = w.impl.position()
})
return x, y
return w
}
func (w *WebviewWindow) Destroy() {
@ -948,18 +1018,6 @@ func (w *WebviewWindow) SetHTML(html string) Window {
return w
}
// SetRelativePosition sets the position of the window.
func (w *WebviewWindow) SetRelativePosition(x, y int) Window {
w.options.X = x
w.options.Y = y
if w.impl != nil {
InvokeSync(func() {
w.impl.setRelativePosition(x, y)
})
}
return w
}
// Minimise minimises the window.
func (w *WebviewWindow) Minimise() Window {
if w.impl == nil && !w.isDestroyed() {
@ -1193,16 +1251,6 @@ func (w *WebviewWindow) SetEnabled(enabled bool) {
})
}
func (w *WebviewWindow) SetPosition(x int, y int) {
// set absolute position
if w.impl == nil && !w.isDestroyed() {
return
}
InvokeSync(func() {
w.impl.setPosition(x, y)
})
}
func (w *WebviewWindow) processKeyBinding(acceleratorString string) bool {
// Check menu bindings
if w.menuBindings != nil {

View file

@ -1312,6 +1312,38 @@ func (w *macosWebviewWindow) position() (int, int) {
return int(x), int(y)
}
func (w *macosWebviewWindow) bounds() Rect {
// DOTO: do it in a single step + proper DPI scaling
var x, y, width, height C.int
InvokeSync(func() {
C.windowGetPosition(w.nsWindow, &x, &y)
C.windowGetSize(w.nsWindow, &width, &height)
})
return Rect{
X: int(x),
Y: int(y),
Width: int(width),
Height: int(height),
}
}
func (w *macosWebviewWindow) setBounds(bounds Rect) {
// DOTO: do it in a single step + proper DPI scaling
C.windowSetPosition(w.nsWindow, C.int(bounds.X), C.int(bounds.Y))
C.windowSetSize(w.nsWindow, C.int(bounds.Width), C.int(bounds.Height))
}
func (w *macosWebviewWindow) physicalBounds() Rect {
// TODO: proper DPI scaling
return w.bounds()
}
func (w *macosWebviewWindow) setPhysicalBounds(physicalBounds Rect) {
// TODO: proper DPI scaling
w.setBounds(physicalBounds)
}
func (w *macosWebviewWindow) destroy() {
w.parent.markAsDestroyed()
C.windowDestroy(w.nsWindow)

View file

@ -105,8 +105,8 @@ func (w *linuxWebviewWindow) setMaximiseButtonEnabled(enabled bool) {
}
func (w *linuxWebviewWindow) disableSizeConstraints() {
x, y, width, height, scale := w.getCurrentMonitorGeometry()
w.setMinMaxSize(x, y, width*scale, height*scale)
x, y, width, height, scaleFactor := w.getCurrentMonitorGeometry()
w.setMinMaxSize(x, y, width*scaleFactor, height*scaleFactor)
}
func (w *linuxWebviewWindow) unminimise() {
@ -204,6 +204,36 @@ func (w *linuxWebviewWindow) setPosition(x int, y int) {
w.move(x, y)
}
func (w *linuxWebviewWindow) bounds() Rect {
// DOTO: do it in a single step + proper DPI scaling
x, y := w.position()
width, height := w.size()
return Rect{
X: x,
Y: y,
Width: width,
Height: height,
}
}
func (w *linuxWebviewWindow) setBounds(bounds Rect) {
// DOTO: do it in a single step + proper DPI scaling
w.move(bounds.X, bounds.Y)
w.setSize(bounds.Width, bounds.Height)
}
func (w *linuxWebviewWindow) physicalBounds() Rect {
// TODO: proper DPI scaling
return w.bounds()
}
func (w *linuxWebviewWindow) setPhysicalBounds(physicalBounds Rect) {
// TODO: proper DPI scaling
w.setBounds(physicalBounds)
}
func (w *linuxWebviewWindow) run() {
for eventId := range w.parent.eventListeners {
w.on(eventId)

View file

@ -138,19 +138,6 @@ type WebviewWindowOptions struct {
IgnoreMouseEvents bool
}
var WebviewWindowDefaults = &WebviewWindowOptions{
Title: "",
Width: 800,
Height: 600,
URL: "",
BackgroundColour: RGBA{
Red: 255,
Green: 255,
Blue: 255,
Alpha: 255,
},
}
type RGBA struct {
Red, Green, Blue, Alpha uint8
}

View file

@ -11,7 +11,6 @@ import (
"sync"
"syscall"
"time"
"unicode/utf16"
"unsafe"
"github.com/bep/debounce"
@ -49,6 +48,7 @@ type windowsWebviewWindow struct {
hwnd w32.HWND
menu *Win32Menu
currentlyOpenContextMenu *Win32Menu
ignoreDPIChangeResizing bool
// Fullscreen flags
isCurrentlyFullscreen bool
@ -103,34 +103,6 @@ func (w *windowsWebviewWindow) handleKeyEvent(_ string) {
// Unused on windows
}
// getBorderSizes returns the extended border size for the window
func (w *windowsWebviewWindow) getBorderSizes() *LRTB {
var result LRTB
var frame w32.RECT
w32.DwmGetWindowAttribute(w.hwnd, w32.DWMWA_EXTENDED_FRAME_BOUNDS, unsafe.Pointer(&frame), unsafe.Sizeof(frame))
rect := w32.GetWindowRect(w.hwnd)
result.Left = int(frame.Left - rect.Left)
result.Top = int(frame.Top - rect.Top)
result.Right = int(rect.Right - frame.Right)
result.Bottom = int(rect.Bottom - frame.Bottom)
return &result
}
func (w *windowsWebviewWindow) setPosition(x int, y int) {
// Set the window's absolute position
borderSize := w.getBorderSizes()
w32.SetWindowPos(w.hwnd, 0, x-borderSize.Left, y-borderSize.Top, 0, 0, w32.SWP_NOSIZE|w32.SWP_NOZORDER)
}
func (w *windowsWebviewWindow) position() (int, int) {
rect := w32.GetWindowRect(w.hwnd)
borderSizes := w.getBorderSizes()
x := int(rect.Left) + borderSizes.Left
y := int(rect.Top) + borderSizes.Top
left, right := w.scaleToDefaultDPI(x, y)
return left, right
}
func (w *windowsWebviewWindow) setEnabled(enabled bool) {
w32.EnableWindow(w.hwnd, enabled)
}
@ -166,13 +138,6 @@ func (w *windowsWebviewWindow) setTitle(title string) {
w32.SetWindowText(w.hwnd, title)
}
func (w *windowsWebviewWindow) setSize(width, height int) {
rect := w32.GetWindowRect(w.hwnd)
width, height = w.scaleWithWindowDPI(width, height)
w32.MoveWindow(w.hwnd, int(rect.Left), int(rect.Top), width, height, true)
w.chromium.Resize()
}
func (w *windowsWebviewWindow) setAlwaysOnTop(alwaysOnTop bool) {
w32.SetWindowPos(w.hwnd,
lo.Ternary(alwaysOnTop, w32.HWND_TOPMOST, w32.HWND_NOTOPMOST),
@ -250,9 +215,21 @@ func (w *windowsWebviewWindow) run() {
exStyle = options.Windows.ExStyle
}
// ToDo: X, Y should also be scaled, should it be always relative to the main monitor?
var startX, _ = lo.Coalesce(options.X, w32.CW_USEDEFAULT)
var startY, _ = lo.Coalesce(options.Y, w32.CW_USEDEFAULT)
bounds := Rect{
X: options.X,
Y: options.Y,
Width: options.Width,
Height: options.Height,
}
initialScreen := ScreenNearestDipRect(bounds)
physicalBounds := initialScreen.dipToPhysicalRect(bounds)
// Default window position applied by the system
// TODO: provide a way to set (0,0) as an initial position?
if options.X == 0 && options.Y == 0 {
physicalBounds.X = w32.CW_USEDEFAULT
physicalBounds.Y = w32.CW_USEDEFAULT
}
var appMenu w32.HMENU
@ -279,10 +256,10 @@ func (w *windowsWebviewWindow) run() {
w32.MustStringToUTF16Ptr(globalApplication.options.Windows.WndClass),
w32.MustStringToUTF16Ptr(options.Title),
style,
startX,
startY,
w32.CW_USEDEFAULT,
w32.CW_USEDEFAULT,
physicalBounds.X,
physicalBounds.Y,
physicalBounds.Width,
physicalBounds.Height,
parent,
appMenu,
w32.GetModuleHandle(""),
@ -292,7 +269,12 @@ func (w *windowsWebviewWindow) run() {
panic("Unable to create window")
}
w.setSize(options.Width, options.Height)
// Ensure correct window size in case the scale factor of current screen is different from the initial one.
// This could happen when using the default window position and the window launches on a secondary monitor.
currentScreen, _ := w.getScreen()
if currentScreen.ScaleFactor != initialScreen.ScaleFactor {
w.setSize(options.Width, options.Height)
}
w.setupChromium()
@ -422,48 +404,114 @@ func (w *windowsWebviewWindow) enableSizeConstraints() {
}
}
func (w *windowsWebviewWindow) size() (int, int) {
rect := w32.GetWindowRect(w.hwnd)
width := int(rect.Right - rect.Left)
height := int(rect.Bottom - rect.Top)
// Scaling appears to give invalid results...
//width, height = w.scaleToDefaultDPI(width, height)
return width, height
}
func (w *windowsWebviewWindow) update() {
w32.UpdateWindow(w.hwnd)
}
// getBorderSizes returns the extended border size for the window
func (w *windowsWebviewWindow) getBorderSizes() *LRTB {
var result LRTB
var frame w32.RECT
w32.DwmGetWindowAttribute(w.hwnd, w32.DWMWA_EXTENDED_FRAME_BOUNDS, unsafe.Pointer(&frame), unsafe.Sizeof(frame))
rect := w32.GetWindowRect(w.hwnd)
result.Left = int(frame.Left - rect.Left)
result.Top = int(frame.Top - rect.Top)
result.Right = int(rect.Right - frame.Right)
result.Bottom = int(rect.Bottom - frame.Bottom)
return &result
}
func (w *windowsWebviewWindow) physicalBounds() Rect {
// var rect w32.RECT
// // Get the extended frame bounds instead of the window rect to offset the invisible borders in Windows 10
// w32.DwmGetWindowAttribute(w.hwnd, w32.DWMWA_EXTENDED_FRAME_BOUNDS, unsafe.Pointer(&rect), unsafe.Sizeof(rect))
rect := w32.GetWindowRect(w.hwnd)
return Rect{
X: int(rect.Left),
Y: int(rect.Top),
Width: int(rect.Right - rect.Left),
Height: int(rect.Bottom - rect.Top),
}
}
func (w *windowsWebviewWindow) setPhysicalBounds(physicalBounds Rect) {
// // Offset invisible borders
// borderSize := w.getBorderSizes()
// physicalBounds.X -= borderSize.Left
// physicalBounds.Y -= borderSize.Top
// physicalBounds.Width += borderSize.Left + borderSize.Right
// physicalBounds.Height += borderSize.Top + borderSize.Bottom
// Set flag to ignore resizing the window with DPI change because we already calculated the correct size
// for the target position, this prevents double resizing issue when the window is moved between screens
previousFlag := w.ignoreDPIChangeResizing
w.ignoreDPIChangeResizing = true
w32.SetWindowPos(w.hwnd, 0, physicalBounds.X, physicalBounds.Y, physicalBounds.Width, physicalBounds.Height, w32.SWP_NOZORDER|w32.SWP_NOACTIVATE)
w.ignoreDPIChangeResizing = previousFlag
}
// Get window dip bounds
func (w *windowsWebviewWindow) bounds() Rect {
return PhysicalToDipRect(w.physicalBounds())
}
// Set window dip bounds
func (w *windowsWebviewWindow) setBounds(bounds Rect) {
w.setPhysicalBounds(DipToPhysicalRect(bounds))
}
func (w *windowsWebviewWindow) size() (int, int) {
bounds := w.bounds()
return bounds.Width, bounds.Height
}
func (w *windowsWebviewWindow) width() int {
width, _ := w.size()
return width
return w.bounds().Width
}
func (w *windowsWebviewWindow) height() int {
_, height := w.size()
return height
return w.bounds().Height
}
func (w *windowsWebviewWindow) setSize(width, height int) {
bounds := w.bounds()
bounds.Width = width
bounds.Height = height
w.setBounds(bounds)
}
func (w *windowsWebviewWindow) position() (int, int) {
bounds := w.bounds()
return bounds.X, bounds.Y
}
func (w *windowsWebviewWindow) setPosition(x int, y int) {
bounds := w.bounds()
bounds.X = x
bounds.Y = y
w.setBounds(bounds)
}
// Get window position relative to the screen WorkArea on which it is
func (w *windowsWebviewWindow) relativePosition() (int, int) {
// Get monitor for window
monitor := w32.MonitorFromWindow(w.hwnd, w32.MONITOR_DEFAULTTONEAREST)
var monitorInfo w32.MONITORINFO
monitorInfo.CbSize = uint32(unsafe.Sizeof(monitorInfo))
w32.GetMonitorInfo(monitor, &monitorInfo)
screen, _ := w.getScreen()
pos := screen.absoluteToRelativeDipPoint(w.bounds().Origin())
// Relative to WorkArea origin
pos.X -= (screen.WorkArea.X - screen.X)
pos.Y -= (screen.WorkArea.Y - screen.Y)
return pos.X, pos.Y
}
// Get window rect
rect := w32.GetWindowRect(w.hwnd)
// Calculate relative position
x := int(rect.Left) - int(monitorInfo.RcWork.Left)
y := int(rect.Top) - int(monitorInfo.RcWork.Top)
borderSize := w.getBorderSizes()
x += borderSize.Left
y += borderSize.Top
return w.scaleToDefaultDPI(x, y)
// Set window position relative to the screen WorkArea on which it is
func (w *windowsWebviewWindow) setRelativePosition(x int, y int) {
screen, _ := w.getScreen()
pos := screen.relativeToAbsoluteDipPoint(Point{X: x, Y: y})
// Relative to WorkArea origin
pos.X += (screen.WorkArea.X - screen.X)
pos.Y += (screen.WorkArea.Y - screen.Y)
w.setPosition(pos.X, pos.Y)
}
func (w *windowsWebviewWindow) destroy() {
@ -542,16 +590,6 @@ func (w *windowsWebviewWindow) setHTML(html string) {
w.execJS(fmt.Sprintf("document.documentElement.innerHTML = %q;", html))
}
func (w *windowsWebviewWindow) setRelativePosition(x int, y int) {
//x, y = w.scaleWithWindowDPI(x, y)
info := w32.GetMonitorInfoForWindow(w.hwnd)
workRect := info.RcWork
borderSize := w.getBorderSizes()
x -= borderSize.Left
y -= borderSize.Top
w32.SetWindowPos(w.hwnd, w32.HWND_TOP, int(workRect.Left)+x, int(workRect.Top)+y, 0, 0, w32.SWP_NOSIZE)
}
// on is used to indicate that a particular event should be listened for
func (w *windowsWebviewWindow) on(_ uint) {
// We don't need to worry about this in Windows as we do not need
@ -840,45 +878,9 @@ func (w *windowsWebviewWindow) hide() {
w32.ShowWindow(w.hwnd, w32.SW_HIDE)
}
func getScreen(hwnd w32.HWND) (*Screen, error) {
hMonitor := w32.MonitorFromWindow(hwnd, w32.MONITOR_DEFAULTTONEAREST)
var mi w32.MONITORINFOEX
mi.CbSize = uint32(unsafe.Sizeof(mi))
w32.GetMonitorInfoEx(hMonitor, &mi)
var thisScreen Screen
thisScreen.X = int(mi.RcMonitor.Left)
thisScreen.Y = int(mi.RcMonitor.Top)
thisScreen.Size = Size{
Width: int(mi.RcMonitor.Right - mi.RcMonitor.Left),
Height: int(mi.RcMonitor.Bottom - mi.RcMonitor.Top),
}
thisScreen.Bounds = Rect{
X: int(mi.RcMonitor.Left),
Y: int(mi.RcMonitor.Top),
Width: int(mi.RcMonitor.Right - mi.RcMonitor.Left),
Height: int(mi.RcMonitor.Bottom - mi.RcMonitor.Top),
}
thisScreen.WorkArea = Rect{
X: int(mi.RcWork.Left),
Y: int(mi.RcWork.Top),
Width: int(mi.RcWork.Right - mi.RcWork.Left),
Height: int(mi.RcWork.Bottom - mi.RcWork.Top),
}
thisScreen.ID = strconv.Itoa(int(hMonitor))
thisScreen.Name = string(utf16.Decode(mi.SzDevice[:]))
var xdpi, ydpi w32.UINT
w32.GetDPIForMonitor(hMonitor, w32.MDT_EFFECTIVE_DPI, &xdpi, &ydpi)
thisScreen.Scale = float32(xdpi) / 96.0
thisScreen.IsPrimary = mi.DwFlags&w32.MONITORINFOF_PRIMARY != 0
// TODO: Get screen rotation
return &thisScreen, nil
}
// Get the screen for the current window
func (w *windowsWebviewWindow) getScreen() (*Screen, error) {
return getScreen(w.hwnd)
return getScreenForWindow(w)
}
func (w *windowsWebviewWindow) setFrameless(b bool) {
@ -1081,6 +1083,11 @@ func (w *windowsWebviewWindow) WndProc(msg uint32, wparam, lparam uintptr) uintp
mmi := (*w32.MINMAXINFO)(unsafe.Pointer(lparam))
hasConstraints := false
options := w.parent.options
// Using ScreenManager to get the closest screen and scale according to its DPI is problematic
// here because in multi-monitor setup, when dragging the window between monitors with the mouse
// on the side with the higher DPI, the DPI change point is offset beyond the mid point, causing
// wrong scaling and unwanted resizing when using the monitor DPI. To avoid this issue, we use
// scaleWithWindowDPI() instead which retrieves the correct DPI with GetDpiForWindow().
if options.MinWidth > 0 || options.MinHeight > 0 {
hasConstraints = true
@ -1108,14 +1115,16 @@ func (w *windowsWebviewWindow) WndProc(msg uint32, wparam, lparam uintptr) uintp
}
case w32.WM_DPICHANGED:
newWindowSize := (*w32.RECT)(unsafe.Pointer(lparam))
w32.SetWindowPos(w.hwnd,
uintptr(0),
int(newWindowSize.Left),
int(newWindowSize.Top),
int(newWindowSize.Right-newWindowSize.Left),
int(newWindowSize.Bottom-newWindowSize.Top),
w32.SWP_NOZORDER|w32.SWP_NOACTIVATE)
if !w.ignoreDPIChangeResizing {
newWindowRect := (*w32.RECT)(unsafe.Pointer(lparam))
w32.SetWindowPos(w.hwnd,
uintptr(0),
int(newWindowRect.Left),
int(newWindowRect.Top),
int(newWindowRect.Right-newWindowRect.Left),
int(newWindowRect.Bottom-newWindowRect.Top),
w32.SWP_NOZORDER|w32.SWP_NOACTIVATE)
}
w.parent.emit(events.Common.WindowDPIChanged)
}
@ -1181,30 +1190,38 @@ func (w *windowsWebviewWindow) WndProc(msg uint32, wparam, lparam uintptr) uintp
// Make sure to use the provided RECT to get the monitor, because during maximizig there might be
// a wrong monitor returned in multiscreen mode when using MonitorFromWindow.
// See: https://github.com/MicrosoftEdge/WebView2Feedback/issues/2549
monitor := w32.MonitorFromRect(rgrc, w32.MONITOR_DEFAULTTONULL)
screen := ScreenNearestPhysicalRect(Rect{
X: int(rgrc.Left),
Y: int(rgrc.Top),
Width: int(rgrc.Right - rgrc.Left),
Height: int(rgrc.Bottom - rgrc.Top),
})
var monitorInfo w32.MONITORINFO
monitorInfo.CbSize = uint32(unsafe.Sizeof(monitorInfo))
if monitor != 0 && w32.GetMonitorInfo(monitor, &monitorInfo) {
*rgrc = monitorInfo.RcWork
rect := screen.PhysicalWorkArea
maxWidth := options.MaxWidth
maxHeight := options.MaxHeight
if maxWidth > 0 || maxHeight > 0 {
var dpiX, dpiY uint
w32.GetDPIForMonitor(monitor, w32.MDT_EFFECTIVE_DPI, &dpiX, &dpiY)
maxWidth := options.MaxWidth
maxHeight := options.MaxHeight
maxWidth := int32(ScaleWithDPI(maxWidth, dpiX))
if maxWidth > 0 && rgrc.Right-rgrc.Left > maxWidth {
rgrc.Right = rgrc.Left + maxWidth
}
maxHeight := int32(ScaleWithDPI(maxHeight, dpiY))
if maxHeight > 0 && rgrc.Bottom-rgrc.Top > maxHeight {
rgrc.Bottom = rgrc.Top + maxHeight
}
if maxWidth > 0 {
maxWidth = screen.scale(maxWidth, false)
if rect.Width > maxWidth {
rect.Width = maxWidth
}
}
if maxHeight > 0 {
maxHeight = screen.scale(maxHeight, false)
if rect.Height > maxHeight {
rect.Height = maxHeight
}
}
*rgrc = w32.RECT{
Left: int32(rect.X),
Top: int32(rect.Y),
Right: int32(rect.X + rect.Width),
Bottom: int32(rect.Y + rect.Height),
}
w.chromium.SetPadding(edge.Rect{})
} else {
// This is needed to workaround the resize flickering in frameless mode with WindowDecorations
@ -1225,7 +1242,7 @@ func (w *windowsWebviewWindow) WndProc(msg uint32, wparam, lparam uintptr) uintp
func (w *windowsWebviewWindow) DPI() (w32.UINT, w32.UINT) {
if w32.HasGetDpiForWindowFunc() {
// GetDpiForWindow is supported beginning with Windows 10, 1607 and is the most accureate
// GetDpiForWindow is supported beginning with Windows 10, 1607 and is the most accurate
// one, especially it is consistent with the WM_DPICHANGED event.
dpi := w32.GetDpiForWindow(w.hwnd)
return dpi, dpi
@ -1258,12 +1275,8 @@ func (w *windowsWebviewWindow) scaleWithWindowDPI(width, height int) (int, int)
return scaledWidth, scaledHeight
}
func (w *windowsWebviewWindow) scaleToDefaultDPI(width, height int) (int, int) {
dpix, dpiy := w.DPI()
scaledWidth := ScaleToDefaultDPI(width, dpix)
scaledHeight := ScaleToDefaultDPI(height, dpiy)
return scaledWidth, scaledHeight
func ScaleWithDPI(pixels int, dpi uint) int {
return (pixels * int(dpi)) / 96
}
func (w *windowsWebviewWindow) setWindowMask(imageData []byte) {
@ -1739,14 +1752,6 @@ func (w *windowsWebviewWindow) setMinimiseButtonEnabled(enabled bool) {
w.setStyle(enabled, w32.WS_MINIMIZEBOX)
}
func ScaleWithDPI(pixels int, dpi uint) int {
return (pixels * int(dpi)) / 96
}
func ScaleToDefaultDPI(pixels int, dpi uint) int {
return (pixels * 96) / int(dpi)
}
func NewIconFromResource(instance w32.HINSTANCE, resId uint16) (w32.HICON, error) {
var err error
var result w32.HICON

View file

@ -72,6 +72,23 @@ const (
IMAGE_ENHMETAFILE = 3
)
// SetProcessDpiAwareness constants
const (
PROCESS_DPI_UNAWARE = 0
PROCESS_SYSTEM_DPI_AWARE = 1
PROCESS_PER_MONITOR_DPI_AWARE = 2
)
// SetProcessDpiAwarenessContext constants
// Credit: https://github.com/ncruces/zenity
const (
DPI_AWARENESS_CONTEXT_UNAWARE = ^uintptr(1) + 1
DPI_AWARENESS_CONTEXT_SYSTEM_AWARE = ^uintptr(2) + 1
DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE = ^uintptr(3) + 1
DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = ^uintptr(4) + 1
DPI_AWARENESS_CONTEXT_UNAWARE_GDISCALED = ^uintptr(5) + 1
)
// ShowWindow constants
const (
SW_HIDE = 0

View file

@ -10,11 +10,12 @@ import (
type Screen struct {
MONITORINFOEX
Name string
IsPrimary bool
IsCurrent bool
Scale float32
Rotation float32
HMonitor uintptr
Name string
IsPrimary bool
IsCurrent bool
ScaleFactor float32
Rotation float32
}
type DISPLAY_DEVICE struct {
@ -71,8 +72,17 @@ func GetRotationForMonitor(displayName [32]uint16) (float32, error) {
}
func GetAllScreens() ([]*Screen, error) {
var monitorList []MONITORINFOEX
var result []*Screen
var errMessage string
// Get cursor position to determine the current monitor
var cursor POINT
ret, _, _ := procGetCursorPos.Call(uintptr(unsafe.Pointer(&cursor)))
if ret == 0 {
return nil, fmt.Errorf("GetCursorPos failed")
}
// Enumerate the monitors
enumFunc := func(hMonitor uintptr, hdc uintptr, lprcMonitor *RECT, lParam uintptr) uintptr {
monitor := MONITORINFOEX{
MONITORINFO: MONITORINFO{
@ -82,72 +92,51 @@ func GetAllScreens() ([]*Screen, error) {
}
ret, _, _ := procGetMonitorInfo.Call(hMonitor, uintptr(unsafe.Pointer(&monitor)))
if ret == 0 {
return 1 // Continue enumeration
errMessage = "GetMonitorInfo failed"
return 0 // Stop enumeration
}
monitorList = append(monitorList, monitor)
return 1 // Continue enumeration
}
ret, _, _ := procEnumDisplayMonitors.Call(0, 0, syscall.NewCallback(enumFunc), 0)
if ret == 0 {
return nil, fmt.Errorf("EnumDisplayMonitors failed")
}
// Get the active screen
var pt POINT
ret, _, _ = procGetCursorPos.Call(uintptr(unsafe.Pointer(&pt)))
if ret == 0 {
return nil, fmt.Errorf("GetCursorPos failed")
}
hMonitor, _, _ := procMonitorFromPoint.Call(uintptr(unsafe.Pointer(&pt)), MONITOR_DEFAULTTONEAREST)
if hMonitor == 0 {
return nil, fmt.Errorf("MonitorFromPoint failed")
}
var monitorInfo MONITORINFO
monitorInfo.CbSize = uint32(unsafe.Sizeof(monitorInfo))
ret, _, _ = procGetMonitorInfo.Call(hMonitor, uintptr(unsafe.Pointer(&monitorInfo)))
if ret == 0 {
return nil, fmt.Errorf("GetMonitorInfo failed")
}
var result []*Screen
// Iterate through the screens and set the active one
for _, monitor := range monitorList {
thisContainer := &Screen{
screen := &Screen{
MONITORINFOEX: monitor,
HMonitor: hMonitor,
IsPrimary: monitor.DwFlags == MONITORINFOF_PRIMARY,
IsCurrent: rectContainsPoint(monitor.RcMonitor, cursor),
}
thisContainer.IsCurrent = equalRect(monitor.RcMonitor, monitorInfo.RcMonitor)
thisContainer.IsPrimary = monitor.DwFlags == MONITORINFOF_PRIMARY
// Get monitor name
name, err := getMonitorName(syscall.UTF16ToString(monitor.SzDevice[:]))
if err != nil {
name = ""
if err == nil {
screen.Name = name
}
// Get DPI for monitor
var dpiX, dpiY uint
ret = GetDPIForMonitor(hMonitor, MDT_EFFECTIVE_DPI, &dpiX, &dpiY)
if ret != S_OK {
return nil, fmt.Errorf("GetDpiForMonitor failed")
errMessage = "GetDpiForMonitor failed"
return 0 // Stop enumeration
}
// Convert to float32
thisContainer.Scale = float32(dpiX) / 96.0
// Convert to scale factor
screen.ScaleFactor = float32(dpiX) / 96.0
// Get rotation of monitor
rot, err := GetRotationForMonitor(monitor.SzDevice)
if err != nil {
rot = 0
if err == nil {
screen.Rotation = rot
}
thisContainer.Rotation = rot
thisContainer.Name = name
result = append(result, thisContainer)
result = append(result, screen)
return 1 // Continue enumeration
}
ret, _, _ = procEnumDisplayMonitors.Call(0, 0, syscall.NewCallback(enumFunc), 0)
if ret == 0 {
return nil, fmt.Errorf("EnumDisplayMonitors failed: %s", errMessage)
}
return result, nil
}
func equalRect(a RECT, b RECT) bool {
return a.Left == b.Left && a.Top == b.Top && a.Right == b.Right && a.Bottom == b.Bottom
func rectContainsPoint(r RECT, p POINT) bool {
return p.X >= r.Left && p.X < r.Right && p.Y >= r.Top && p.Y < r.Bottom
}

View file

@ -3,6 +3,7 @@
package w32
import (
"fmt"
"syscall"
"unsafe"
)
@ -10,9 +11,23 @@ import (
var (
modshcore = syscall.NewLazyDLL("shcore.dll")
procGetDpiForMonitor = modshcore.NewProc("GetDpiForMonitor")
procGetDpiForMonitor = modshcore.NewProc("GetDpiForMonitor")
procSetProcessDpiAwareness = modshcore.NewProc("SetProcessDpiAwareness")
)
func HasSetProcessDpiAwarenessFunc() bool {
err := procSetProcessDpiAwareness.Find()
return err == nil
}
func SetProcessDpiAwareness(val uint) error {
status, r, err := procSetProcessDpiAwareness.Call(uintptr(val))
if status != S_OK {
return fmt.Errorf("procSetProcessDpiAwareness failed %d: %v %v", status, r, err)
}
return nil
}
func HasGetDPIForMonitorFunc() bool {
err := procGetDpiForMonitor.Find()
return err == nil

View file

@ -9,8 +9,9 @@ package w32
import (
"fmt"
"golang.org/x/sys/windows"
"unsafe"
"golang.org/x/sys/windows"
)
// From MSDN: Windows Data Types
@ -678,6 +679,7 @@ type NOTIFYICONDATA struct {
}
const SPI_GETNOTIFYWINDOWRECT = 0x0040
const SPI_SETWORKAREA = 0x002F
// Taskbar constants
const ABM_GETTASKBARPOS = 0x00000005

View file

@ -9,10 +9,11 @@ package w32
import (
"fmt"
"golang.org/x/sys/windows"
"runtime"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
)
var (
@ -136,6 +137,7 @@ var (
procGetDpiForSystem = moduser32.NewProc("GetDpiForSystem")
procGetDpiForWindow = moduser32.NewProc("GetDpiForWindow")
procSetProcessDPIAware = moduser32.NewProc("SetProcessDPIAware")
procSetProcessDpiAwarenessContext = moduser32.NewProc("SetProcessDpiAwarenessContext")
procEnumDisplayMonitors = moduser32.NewProc("EnumDisplayMonitors")
procEnumDisplayDevices = moduser32.NewProc("EnumDisplayDevicesW")
procEnumDisplaySettings = moduser32.NewProc("EnumDisplaySettingsW")
@ -360,6 +362,11 @@ func GetDpiForWindow(hwnd HWND) UINT {
return uint(dpi)
}
func HasSetProcessDPIAwareFunc() bool {
err := procSetProcessDPIAware.Find()
return err == nil
}
func GetClassName(hwnd HWND) string {
var buf [256]uint16
procGetClassName.Call(
@ -378,6 +385,19 @@ func SetProcessDPIAware() error {
return nil
}
func HasSetProcessDpiAwarenessContextFunc() bool {
err := procSetProcessDpiAwarenessContext.Find()
return err == nil
}
func SetProcessDpiAwarenessContext(ctx uintptr) error {
status, r, err := procSetProcessDpiAwarenessContext.Call(ctx)
if status == 0 {
return fmt.Errorf("SetProcessDpiAwarenessContext failed %d: %v %v", status, r, err)
}
return nil
}
func GetForegroundWindow() HWND {
ret, _, _ := procGetForegroundWindow.Call()
return HWND(ret)