解决使用多个屏幕时, v2rayN窗口大于当前窗口的问题 获取窗口所在当前屏幕 WPF与WinForms共存

需求

上一篇的需求 "设置窗口的尺寸, 不要超过当前屏幕" 其实并没有解决得很完满.

在上一篇中, 只解决了使用一个屏幕的情况. 虽然从使用的单词来看, 似乎WorkArea应该指当前操作的屏幕, 但是很遗憾, 它在程序世界中的定义不是单词的语义让我们想象的那样. 很明确, 它的定义是主屏幕的工作区域(除去任务栏, 也就是最底下有开始按钮的那一条).


https://learn.microsoft.com/en-us/dotnet/api/system.windows.systemparameters.workarea?view=windowsdesktop-7.0

微软文档标明SystemParameters.WorkArea只表示主屏幕工作区

解决思路

获取程序运行所在当前屏幕, 根据屏幕的尺寸, 位置和DPI, 调整窗口的属性.


具体实践

    这个方案看起来怪怪的, 也许是我没有找到最优解, 欢迎大家在评论中交流.

安装 Microsoft Visual Studio

在 Microsoft Store 里有

https://apps.microsoft.com/store/detail/XPDCFJDKLZJLP8

安装组件选 .NET 桌面开发

Visual Studio安装程序中勾选.NET桌面开发工作负荷


下载源码

https://github.com/2dust/v2rayN

在GitHub打开v2rayN仓库,用Code菜单下载源码ZIP


解压, 进入 v2ray-master 目录, 进入 v2rayN 目录, 打开 .sln 文件.

在v2rayN目录中选中并打开v2rayN.sln解决方案

修改源码

打开 .sln 文件.

引入 WinForms (System.Windows.Forms)

    我搜索了很多, 在使用多个屏幕时, 在纯WPF环境下没有办法获取程序运行的当前屏幕, 只找到用WinForms的方案, 或者用WIN32API. 

在项目.proj文件的属性里面, 可以打开对WinForms的支持.

在v2rayN项目属性中打开,以启用WinForms支持

项目属性里已勾选启用Windows窗体,从而支持System.Windows.Forms


解决引入WinForms后的编译问题

双击编译的错误提示, 打开代码文件以后, 把下面这一段直接增加到文件的头部.

using Application = System.Windows.Application;
using Clipboard = System.Windows.Clipboard;
using DataFormats = System.Windows.DataFormats;
using DataObject = System.Windows.DataObject;
using DragDropEffects = System.Windows.DragDropEffects;
using DragEventArgs = System.Windows.DragEventArgs;
using FontFamily = System.Windows.Media.FontFamily;
using IDataObject = System.Windows.IDataObject;
using KeyEventArgs = System.Windows.Input.KeyEventArgs;
using MessageBox = System.Windows.MessageBox;
using MouseEventArgs = System.Windows.Input.MouseEventArgs;
using OpenFileDialog = Microsoft.Win32.OpenFileDialog;
using Point = System.Windows.Point;
using SaveFileDialog = Microsoft.Win32.SaveFileDialog;
using TextBox = System.Windows.Controls.TextBox;
using UserControl = System.Windows.Controls.UserControl;

用using别名区分WPF与WinForms里同名的对话框和控件类型

不断的编译, 报错, 打开报错的文件, 把这一大段声明丢进去, 再编译... 最终就会全部编译通过了.


在窗口的Windows_Loaded函数中修改窗口属性

修改窗口尺寸不能在构造函数中进行, 因为获取不到DPI缩放信息.

在所有的窗口代码文件里找Window_Loaded函数. 添加设置窗口属性的代码.

在Views目录各窗口的Window_Loaded里添加设置窗口尺寸的代码

比如, 绿色部分是新增的.

Window_Loaded中新增代码按DPI限制窗口不超出当前屏幕

如果窗口代码文件没有Windows_Loaded函数的窗口, 那么需要添加这一函数. 

在 Owner 后面添加一行

this.Loaded += Window_Loaded;
没有Window_Loaded的窗口,在构造函数里订阅Loaded事件

再利用智能提示的功能, 生成方法"Window_Loaded".

智能提示因CS0103生成缺失的Window_Loaded方法

自动帮你生成了函数的架子. 把内容删掉, 填写我们自己的.

删掉自动生成的未实现异常,填入自己的Window_Loaded代码


// 获取屏幕的 DPI 缩放因素
double dpiFactor = 1;
PresentationSource source = PresentationSource.FromVisual(this);
if (source != null)
{
    dpiFactor = source.CompositionTarget.TransformToDevice.M11;
}

// 获取当前屏幕的尺寸 var screen = System.Windows.Forms.Screen.FromHandle(new System.Windows.Interop.WindowInteropHelper(this).Handle); var screenWidth = screen.WorkingArea.Width / dpiFactor; var screenHeight = screen.WorkingArea.Height / dpiFactor; var screenTop = screen.WorkingArea.Top / dpiFactor; var screenLeft = screen.WorkingArea.Left / dpiFactor; var screenBottom = screen.WorkingArea.Bottom / dpiFactor; var screenRight = screen.WorkingArea.Right / dpiFactor;
// 设置窗口尺寸不超过当前屏幕的尺寸 if (this.Width > screenWidth) {     this.Width = screenWidth; } if (this.Height > screenHeight) {     this.Height = screenHeight; }
// 设置窗口不要显示在屏幕外面 if (this.Top < screenTop) {     this.Top = screenTop; } if (this.Left < screenLeft) {     this.Left = screenLeft; } if (this.Top + this.Height > screenBottom) {     this.Top = screenBottom - this.Height; } if (this.Left + this.Width > screenRight) {     this.Left = screenRight - this.Width; }

保存, 编译, 开始调试(或执行). 

用菜单也可以, 用快捷键也可以, 用快捷按钮也可以.

用调试菜单、F5或工具栏按钮开始调试v2rayN


现在你就可以试试看窗口还会不会显示不全了.


========

完


正确答案放在GitHub上

https://github.com/crazypeace/v2rayN/tree/WindowsForms


========

解决过程

我问GPT如何在使用多个屏幕时, 程序窗口尺寸不要超过当前所在屏幕的尺寸.

向GPT询问多屏时如何限制窗口不超过当前屏幕尺寸

代码搬过来.

把GPT给出的按屏幕尺寸限制窗口宽高的代码放进编辑器

编译的时候报错.

错误	CS0234	命名空间“System.Windows”中不存在类型或命名空间名“Forms”(是否缺少程序集引用?)	v2rayN	D:\_work\v2rayN-master1\v2rayN\v2rayN\Views\AddServerWindow.xaml.cs	177	

我试过在代码顶部声明System.Windows.Forms命名空间. 

v2rayN中声明System.Windows.Forms后仍报CS0234缺少程序集引用

但是编译还是不行.

错误	CS0234	命名空间“System.Windows”中不存在类型或命名空间名“Forms”(是否缺少程序集引用?)	v2rayN	D:\_work\v2rayN-master1\v2rayN\v2rayN\Views\AddServerWindow.xaml.cs	10	

我去问GPT了很多次, 很多不同的问法. 但它一直回答的方案都是使用System.Windows.Forms命名空间, 我猜测可能它吃的原料不够广, 或者因为到它采集语料为止的程序员们都喜欢使用这种方案. 

而且它还说, 在WPF环境下也是可以使用System.Windows.Forms命令空间的. 我对.net编程没有涉猎过, 所以我使用我的编程直觉认为WPF和System.Windows.Forms是冲突的, 认为GPT应该是产生了AI幻觉. 然后就在这里卡了很久.

我换了好多种表达方法去问GPT, 就是只得到这一些相似的回答. 我开始猜测也许真的没办法在WPF环境下做到这个效果. 因为GPT甚至有几次回答时把WIN32API都搬出来了.


后来我发现这其实是一个突破口, 我经过google和询问GPT, 找到了让WPF环境可以使用System.Windows.Forms命令空间的方法. 在项目.proj文件的属性里面, 可以打开对System.Windows.Forms的支持.

项目属性里已勾选启用Windows窗体,从而支持System.Windows.Forms

但是打开这个设置以后, 编译的时候就冒出来好多错误. 

启用System.Windows.Forms后错误列表出现多条CS0104不明确引用

有一点编程基础的人看了就明白, 肯定是在WPF和System.Windows.Forms这两个命令空间中, 有一些重名的定义. 

双击错误, 跳转到报错的地方. 有一个给你提示如何修改的功能.

双击CS0104后编辑器提示修复Application的不明确引用

点击这个按钮也行, 使用快捷键也行, 使用这个功能. 选下面这一项.

快速操作里选择System.Windows.Application以消除CS0104

因为:

首先, 这是在项目中引用System.Windows.Forms之间就可以编译的代码, 所以肯定要指定引用不是System.Windows.Forms的那一个定义.

其次, 写成using的形式, 可以只在这个代码文件的头部写一次. 如果是非using的形式, 那么在每次使用到这个定义时都要显式声明, 太麻烦了.


不断地在每一个这样的报错的地方, 选择using不是System.Windows.Forms的定义的方式, 修改完, 再编译. 再报错, 再修改... 最终就可以编译通过.


这个声明反正写得多, 没用到是没毛病的. 所以我趟过了一遍坑之后, 给你一个简单粗暴的方法 ---- 你双击错误提示打开代码文件以后, 把下面这一段直接增加到文件的头部.

using Application = System.Windows.Application;
using Clipboard = System.Windows.Clipboard;
using DataFormats = System.Windows.DataFormats;
using DataObject = System.Windows.DataObject;
using DragDropEffects = System.Windows.DragDropEffects;
using DragEventArgs = System.Windows.DragEventArgs;
using FontFamily = System.Windows.Media.FontFamily;
using IDataObject = System.Windows.IDataObject;
using KeyEventArgs = System.Windows.Input.KeyEventArgs;
using MessageBox = System.Windows.MessageBox;
using MouseEventArgs = System.Windows.Input.MouseEventArgs;
using OpenFileDialog = Microsoft.Win32.OpenFileDialog;
using Point = System.Windows.Point;
using SaveFileDialog = Microsoft.Win32.SaveFileDialog;
using TextBox = System.Windows.Controls.TextBox;
using UserControl = System.Windows.Controls.UserControl;

用using别名区分WPF与WinForms里同名的对话框和控件类型

不断的编译, 报错, 把这一大段声明丢进去, 再编译... 最终就会全部编译通过了.

运行起来看看效果? 不行.

在AddServer2窗口的端口代码处设断点,准备调试窗口尺寸

鼠标在这个位置点一下, 在代码中设置一个断点/breakpoint, 在VS Studio里面显示为一个棕红色的圆点. 它的作用是程序运行到这一句时会暂停下来.

断点停在this.Top小于0则置零,准备再次打开添加服务器窗口

再次使用添加服务器功能, 唤出窗口. 可以看到程序暂停在这里. 注意观察正文的局部变量窗口. 可以看到变量 screenWidth 的值 为1920, 变量screenHeight 的值 为1080.

调试暂停时局部变量显示screenWidth为1920、screenHeight为1080

如果你手里有上一篇的方案实施效果成功的调试环境, 你也可以在类似的位置设置断点. 但是默认没有显示 this.MaxWidth 和 this.MaxHeight 的值.

当然鼠标移上去, 会有显示, 但是如果长期调试工作, 还是在窗口有显示这些你关心的变量的值比较好.

在下图中箭头所指的地方鼠标右键, 菜单中选择"添加监视".

局部变量窗口右键菜单选择添加监视,以显示MaxWidth和MaxHeight

把this.MaxWidth 和 this.MaxHeight添加进去, 可以看到它们的值 是 1280 和 673 . 再联想到DPI 为 175%, 那么说明 System.Windows.Forms 的方法是按像素点计算的, 而WPF是经过了DPI比例转换的.

监视窗口中this.MaxWidth为1280、this.MaxHeight约为673

去问GPT如何查询系统的DPI 或 屏幕缩放比例.

向GPT询问WPF如何按DPI换算窗口坐标,红框标出dpiFactor代码

不能完全照抄, 稍微阅读一下. 写到代码里这样子:

Window_Loaded里用dpiFactor把像素尺寸换算成窗口最大宽高

再编译, 执行, 测试一下. 嗯. 又出现了窗口下边框"缩起来"了的情况.

(我的测试环境是一个大屏幕, 一个小屏幕. 大屏幕为主屏幕. 小屏幕1080p设置为175%)

小屏幕175%缩放下添加服务器窗口下边框被裁切

还是在代码里去看断点暂停时的各个变量. 发现很奇怪的, 窗口的左上角都在屏幕外面了, 但是top属性却不是负数.

断点监视显示窗口已伸出屏幕,但this.Top仍约为507而非负数

再结合我的两个屏幕的环境思考.

双屏排列示意图:大屏为2号,小屏为1号主屏幕

这个窗口应该是下面这么个情况, 所以 top 属性不是负数.

双屏示意图用红线标出窗口伸出小屏后的Top约为507

这样的话, 我们要得到那个屏幕的top. 

代码改成这样:

// 设置窗口不要显示在屏幕外面
if (this.Top < screenTop / dpiFactor)
{
    this.Top = screenTop / dpiFactor;
}
Window_Loaded改用screenTop除以dpiFactor,避免窗口画出屏幕

再测试. 嗯, 没问题了!


实施到每一个窗口

在所有的Windows代码文件里的Window_Loaded函数里也这样修改就行了.

在Views目录各窗口的Window_Loaded里添加设置窗口尺寸的代码

这里面不是每个文件里都有Window_Loaded函数的.

比如, DNS设置窗口就没有这个函数.

解决方案中选中没有Window_Loaded的DNSSettingWindow.xaml

找到初始化函数/构造函数/Constructor , 就是 public 后面跟着的就是窗口的名称的那个位置.

在 Owner 后面添加一行

this.Loaded += Window_Loaded;
没有Window_Loaded的窗口,在构造函数里订阅Loaded事件

再利用智能提示的功能, 生成方法"Window_Loaded".

构造函数里订阅Window_Loaded后,智能提示可生成该方法

自动帮你生成了函数的架子. 把内容删掉, 填写我们自己的.

删掉自动生成的未实现异常,填入自己的Window_Loaded代码


这里也许你会问, 为什么不能像上一篇方案中那样写到初始化函数/构造函数/Constructor里面. 这样不是省事了吗?

好, 我们以主窗口为例, 看一下写到构造函数里是什么效果.

主窗口是这个:

Views目录中选中v2rayN主窗口MainWindow.xaml

我们做如此修改:

主窗口构造函数里按dpiFactor限制MaxWidth和MaxHeight

编译生成的可执行文件在这个位置

v2rayN-master1\v2rayN\v2rayN\bin\Debug\net6.0-windows

资源管理器定位到v2rayN的Debug目录并勾选v2rayN.exe

执行. 乍一看挺好. 但, 如果我们把窗口拖到大屏幕上把尺寸拉高, 然后关闭, 然后在小屏幕的文件管理器上打开可执行文件, 你会发现窗口高度超过屏幕了. 

在小屏幕打开后v2rayN窗口高度超出屏幕,标题栏被裁掉

我们的代码没起作用吗? 在代码中打断点, 看看究竟. 

断点显示窗口尚未显示时PresentationSource为null,取不到DPI

发现, 获取窗口的DPI时, 变量为空/null. 在编程的世界里, 空/null的意思就是不存在. 

看来在窗口显示出来之前, 是获取不到DPI的.

问goolge和GPT的结果都是说不要放在构造函数中, 而是在窗口加载完后获取这个值.

https://stackoverflow.com/questions/11204251/presentationsource-fromvisualthis-returns-null-value-in-wpf

StackOverflow示例:不要在构造函数里用FromVisual取DPI

GPT说明PresentationSource.FromVisual在构造函数中返回null


好了, 对这些没有Window_Loaded函数的窗口, 我们就自己添加, 再把调整窗口大小的代码搬进去.

编译, 执行, 测试.  发现, 在小屏幕生成的窗口, 拖到大屏幕上, 想显示多一点内容, 拖窗口边框拖不动, 窗口最大化但还是只显示那么一点内容. 

原因是我们指定了窗口尺寸的最大值.

修改为, 调整窗口当前尺寸不超过屏幕.

// 设置窗口尺寸不超过当前屏幕的尺寸
if (this.Width > screenWidth / dpiFactor)
{
    this.Width = screenWidth / dpiFactor;
}
if (this.Height > screenHeight / dpiFactor)
{
    this.Height = screenHeight / dpiFactor;
}


再构造不同的大小屏幕之间的位置关系测试.

把小屏幕设置为主屏幕, 把窗口拖到大屏幕上调整为大尺寸, 再退出程序. 然后在小屏幕上打开, 这样程序会在小屏幕上显示.

小屏为主屏时,窗口在大小屏之间几种相对位置的示意图

发现只判断了窗口的top属性是不够的. 窗口的左上角可能会在各种各样奇奇怪怪的位置.

所以应该要判断窗口的整个区域都要在屏幕的范围内.

假设我们对窗口相关的知识一无所知, 去问GPT.

向GPT询问如何判断整个窗口区域都落在当前屏幕内

学习关键逻辑, 代码改成这样:

// 获取屏幕的 DPI 缩放因素
double dpiFactor = 1;
PresentationSource source = PresentationSource.FromVisual(this);
if (source != null)
{
    dpiFactor = source.CompositionTarget.TransformToDevice.M11;
}

// 获取当前屏幕的尺寸 var screen = System.Windows.Forms.Screen.FromHandle(new System.Windows.Interop.WindowInteropHelper(this).Handle); var screenWidth = screen.WorkingArea.Width / dpiFactor; var screenHeight = screen.WorkingArea.Height / dpiFactor; var screenTop = screen.WorkingArea.Top / dpiFactor; var screenLeft = screen.WorkingArea.Left / dpiFactor; var screenBottom = screen.WorkingArea.Bottom / dpiFactor; var screenRight = screen.WorkingArea.Right / dpiFactor;
// 设置窗口尺寸不超过当前屏幕的尺寸 if (this.Width > screenWidth) {     this.Width = screenWidth; } if (this.Height > screenHeight) {     this.Height = screenHeight; }
// 设置窗口不要显示在屏幕外面 if (this.Top < screenTop) {     this.Top = screenTop; } if (this.Left < screenLeft) {     this.Left = screenLeft; } if (this.Top + this.Height > screenBottom) {     this.Top = screenBottom - this.Height; } if (this.Left + this.Width > screenRight) {     this.Left = screenRight - this.Width; }


编译, 执行, 测试. 看起来一切正常了.


评论