Wednesday, February 03, 2010

Multiple Monitor Class

Almost three years ago, I wrote a blog post on handling multiple monitors. Since then, I’ve refactored the code so all the monitor-handling code is in one place.

There are actually two classes: SFSize, which simply has properties that represent the dimensions of a monitor, and SFMonitors, which does the work. SFMonitors is actually a subclass of SFSize because it uses those same properties for the virtual desktop (all combined monitors if there’s more than one).

Here’s the code for SFSize:

define class SFSize as Custom
nLeft = -1
nRight = -1
nTop = -1
nBottom = -1
nWidth = 0
nHeight = 0

function nLeft_Assign(tnValue)
This.nLeft = tnValue
This.SetWidth()
endfunc

function nRight_Assign(tnValue)
This.nRight = tnValue
This.SetWidth()
endfunc

function nTop_Assign(tnValue)
This.nTop = tnValue
This.SetHeight()
endfunc

function nBottom_Assign(tnValue)
This.nBottom = tnValue
This.SetHeight()
endfunc

function SetWidth
with This
.nWidth = .nRight - .nLeft
endwith
endfunc

function SetHeight
with This
.nHeight = .nBottom - .nTop
endwith
endfunc
enddefine


SFMonitors has several methods. Init sets up the Windows API functions we’ll need and gets the dimensions for the primary monitor:



define class SFMonitors as SFSize
nMonitors = 0
&& the number of monitors available

function Init
local loSize

* Declare the Windows API functions we'll need.

declare integer MonitorFromPoint in Win32API ;
long x, long y, integer dwFlags
declare integer GetMonitorInfo in Win32API ;
integer hMonitor, string @lpmi
declare integer SystemParametersInfo in Win32API ;
integer uiAction, integer uiParam, string @pvParam, integer fWinIni
declare integer GetSystemMetrics in Win32API integer nIndex

* Determine how many monitors there are. If there's only one, get its size.
* If there's more than one, get the size of the virtual desktop.

with This
.nMonitors = GetSystemMetrics(SM_CMONITORS)
if .nMonitors = 1
loSize = .GetPrimaryMonitorSize()
.nRight = loSize.nRight
.nBottom = loSize.nBottom
store 0 to .nLeft, .nTop
else
.nLeft = GetSystemMetrics(SM_XVIRTUALSCREEN)
.nTop = GetSystemMetrics(SM_YVIRTUALSCREEN)
.nRight = GetSystemMetrics(SM_CXVIRTUALSCREEN) - abs(.nLeft)
.nBottom = GetSystemMetrics(SM_CYVIRTUALSCREEN) - abs(.nTop)
endif .nMonitors = 1
endwith
endfunc


GetPrimaryMonitorSize returns an SFSize object for the primary monitor. Note that this takes into account the Windows Taskbar and any other desktop toolbars, which reduce the size of the available space.



  function GetPrimaryMonitorSize
local lcBuffer, ;
loSize
lcBuffer = replicate(chr(0), 16)
SystemParametersInfo(SPI_GETWORKAREA, 0, @lcBuffer, 0)
loSize = createobject('SFSize')
with loSize
.nLeft = ctobin(substr(lcBuffer, 1, 4), '4RS')
.nTop = ctobin(substr(lcBuffer, 5, 4), '4RS')
.nRight = ctobin(substr(lcBuffer, 9, 4), '4RS')
.nBottom = ctobin(substr(lcBuffer, 13, 4), '4RS')
endwith
return loSize
endfunc


Pass GetMonitorSize X and Y coordinates and it’ll figure out what monitor contains that point and return an SFSize object containing its dimensions, again accounting for the Taskbar.



  function GetMonitorSize(tnX, tnY)
local loSize, ;
lhMonitor, ;
lcBuffer
loSize = createobject('SFSize')
lhMonitor = MonitorFromPoint(tnX, tnY, MONITOR_DEFAULTTONEAREST)
if lHMonitor > 0
lcBuffer = bintoc(40, '4RS') + replicate(chr(0), 36)
GetMonitorInfo(lhMonitor, @lcBuffer)
with loSize
.nLeft = ctobin(substr(lcBuffer, 21, 4), '4RS')
.nTop = ctobin(substr(lcBuffer, 25, 4), '4RS')
.nRight = ctobin(substr(lcBuffer, 29, 4), '4RS')
.nBottom = ctobin(substr(lcBuffer, 33, 4), '4RS')
endwith
endif lHMonitor > 0
return loSize
endfunc
enddefine


SFMonitors uses the following constants:



#define MONITOR_DEFAULTTONULL    0 
#define MONITOR_DEFAULTTOPRIMARY 1
#define MONITOR_DEFAULTTONEAREST 2

#define SM_XVIRTUALSCREEN 76 && virtual left
#define SM_YVIRTUALSCREEN 77 && virtual top
#define SM_CXVIRTUALSCREEN 78 && virtual width
#define SM_CYVIRTUALSCREEN 79 && virtual height
#define SM_CMONITORS 80 && number of monitors


Here’s some code that uses SFMonitors. Code (not shown here) before the following code reads a form’s previous Height, Width, Top, and Left from somewhere (such as the Registry) from the last time the user had it open into custom nHeight, nWidth, nTop, and nLeft properties, and then sizes and moves the form (referenced in loForm) to those values. This code makes sure the form isn’t off the screen, which can happen if, for example, the user had the form open on a second monitor but now only has one monitor, such as an undocked laptop. Note that this code uses several SYSMETRIC() functions to determine the height and width of the window border and title bar, since those values aren’t included in a form’s Height and Width. Also note in the comment a workaround for a peculiarity with an “in top-level form” being restored to a different monitor than the top-level form it’s associated with.



loMonitors = newobject('SFMonitors', 'SFMonitors.prg')

* For desktop or dockable forms, get the size of the virtual desktop. If
* there's only one monitor, use the primary monitor size. Otherwise, use the
* size of whichever monitor the form is on.

if pemstatus(loForm, 'Desktop', 5) and (loForm.Dockable = 1 or ;
loForm.Desktop or loForm.ShowWindow = 2)
if loMonitors.nMonitors = 1
loSize = loMonitors
else
loSize = loMonitors.GetMonitorSize(.nLeft, .nTop)
endif loMonitors.nMonitors = 1
lnMaxLeft = loSize.nLeft
lnMaxTop = loSize.nTop
lnMaxWidth = loSize.nWidth
lnMaxHeight = loSize.nHeight
lnMaxRight = loSize.nRight
lnMaxBottom = loSize.nBottom

* For any other forms, use the size of _screen.

else
lnMaxLeft = 0
lnMaxTop = 0
lnMaxWidth = _screen.Width
lnMaxHeight = _screen.Height
lnMaxRight = lnMaxWidth
lnMaxBottom = lnMaxHeight
endif pemstatus(loForm ...

* Only restore Height and Width if the form is resizable.

llTitleBar = pemstatus(loForm, 'TitleBar', 5) and loForm.TitleBar = 1
lnBorderStyle = iif(pemstatus(loForm, 'BorderStyle', 5), ;
loForm.BorderStyle, 0)
if lnBorderStyle = 3
loForm.Width = min(max(.nWidth, 0, loForm.MinWidth), lnMaxWidth)
loForm.Height = min(max(.nHeight, 0, loForm.MinHeight), lnMaxHeight)
endif lnBorderStyle = 3

* Calculate the total width of the form, including the window borders.

if llTitleBar
lnTotalWidth = loForm.Width + ;
iif(loForm.BorderStyle = 3, sysmetric(3), sysmetric(12)) * 2
else
lnTotalWidth = loForm.Width + ;
icase(lnBorderStyle = 0, 0, ;
lnBorderStyle = 1, sysmetric(10), ;
lnBorderStyle = 2, sysmetric(12), ;
sysmetric(3)) * 2
endif llTitleBar
do case

* If we're past the left edge, move it to the left edge.

case .nLeft < lnMaxLeft
loForm.Left = lnMaxLeft

* If we're past the right edge of the screen, move it to the right edge.

case .nLeft + lnTotalWidth > lnMaxRight
loForm.Left = lnMaxRight - lnTotalWidth

* We're cool, so put it where it was last time. If this form has ShowWindow
* set to 1-In Top-Level Form and the current top-level form is on a
* different monitor than the saved position, do this code twice; the first
* time, it gives a value that places the form on the wrong monitor but it
* works the second time.

otherwise
loForm.Left = .nLeft
loForm.Left = .nLeft
endcase

* Calculate the total height of the form, including the title bar and window
* borders.

if llTitleBar
lnTotalHeight = loForm.Height + sysmetric(9) + ;
icase(lnBorderStyle = 3, sysmetric(4), sysmetric(13)) * 2
else
lnTotalHeight = loForm.Height + ;
icase(lnBorderStyle = 0, 0, ;
lnBorderStyle = 1, sysmetric(11), ;
lnBorderStyle = 2, sysmetric(13), ;
sysmetric(4)) * 2
endif llTitleBar
do case

* If we're past the top edge, move it to the top edge.

case .nTop < lnMaxTop
loForm.Top = lnMaxTop

* If we're past the bottom edge of the screen, move it to the bottom edge.
* Note that we have to account for the height of the title bar and top and
* bottom window frame.

case .nTop + lnTotalHeight > lnMaxBottom
loForm.Top = lnMaxBottom - lnTotalHeight

* We're cool, so put it where it was last time.

otherwise
loForm.Top = .nTop
endcase

4 comments:

Anonymous said...

Hi Doug,

This routine is super!! Although, in Windows 7, VFP exhibits some really strange behavior, like _Screen.width is equal to 250. And the MonitorFromPoint returns a huge negative number? I'm working on some work arounds and will let you know when I get these issues resolved. Thanks again for sharing this with us...really appreciate it!

Scott Malinowski

P.S. Sorry, I missed SouthWest Fox this year. It wasn't in the boss's budget...maybe next year.

Doug Hennig said...

Hi Scott.

I'm using this in Windows 7 and am not seeing anything weird.

Doug

Anonymous said...

Hi Doug,

Really? Come to think of it, the only problem I am having is when the form was on monitor 2 and then you run from a single monitor. The form is still on monitor 2 which doesn't exist, so you can't see it. Weird, huh?

Thanks again!

Scott

Doug Hennig said...

That's exactly why I created these classes. As you can see from the final set of code, it moves the form from a non-existent second monitor to the primary. You can see that effect in Stonefield Query, for example.

Doug