GuideMulti-monitorPer-monitor DPIWindows UIA + macOS AX

How to use two computer screens, from a developer's perspective

Every guide for this keyword has the same shape. Buy a cable. Plug it in. Open Display settings. Click Extend these displays. That is correct for setup and it is covered well by Microsoft, Dell, HP, and Lenovo. This page is about the other half: how software tracks which window is on which screen, why each monitor has its own DPI scale factor, and what breaks when an automation tool tries to click something on screen two. All anchored to real code: Terminator's Monitor struct at crates/terminator/src/lib.rs:272, and a real Windows UIA bug fixed in commit e36b9785 on April 2, 2026.

T
Terminator
12 min read
4.9from Open-source, MIT
Monitor struct with 8 fields (id, name, x, y, w, h, scale_factor, work_area)
is_visible_on_any_monitor() replaces a broken Windows UIA check
desktop.list_monitors(), get_active_monitor(), capture_monitor()
element.monitor() on any UIElement

What the consumer guides leave out

Microsoft Support, Dell, HP, Lenovo, and every productivity blog all answer the same version of this question: which cable, which port, how to drag the monitor icons around in Display Settings. If you are setting up a second monitor at your desk, those guides are fine. They stop where the interesting part starts.

Once two displays are attached, your operating system has to answer a new set of questions on your behalf. Which monitor does this window belong to? What is the DPI of the monitor the cursor is on right now? If I click at pixel (3520, 540), which physical screen does that land on? If I take a screenshot, do I get both monitors stitched together, or one at a time, and at what resolution? These are developer questions. They are also the questions where multi-monitor automation breaks.

The rest of this page walks through each of them with real code from Terminator, a cross-platform desktop automation framework. Terminator is open source, MIT, and it has to handle every multi-monitor edge case on both Windows and macOS, so the answers are not hypothetical.

Where automation tools talk to your monitors

On the left: everything an agent or script might ask. On the right: the platform APIs that answer. In the middle: Terminator's Monitor abstraction that normalises them. This is the contract consumer guides never draw, because from the user's side it is invisible.

Agent or script -> Terminator -> OS multi-monitor APIs

list_monitors()
get_active_monitor()
element.monitor()
capture_monitor()
Monitor struct
Windows UIA + xcap
macOS AXUIElement + NSScreen
Linux xrandr / Wayland
SPI_GETWORKAREA

What a monitor actually is, to software

Windows Display Settings shows each screen as an icon with a number on it. From your code's point of view, a monitor is a struct with these eight fields. If you squint, this list is also the list of things that can be different between your two screens and trip up a naive click.

crates/terminator/src/lib.rs

The six calls that cover every multi-monitor question

Every question a dual-monitor agent has to answer reduces to one of these. Each one is a method on Desktop or UIElement, implemented the same way in the Rust, Python, and Node SDKs.

list_monitors()

Returns every connected display with id, name, position, size, and scale factor. Used internally by every other monitor call.

get_primary_monitor()

The one the taskbar sits on. On Windows this is the only monitor whose work_area is populated by default.

get_active_monitor()

The monitor that contains the currently focused window. Useful when your agent should do something 'on the screen the user is looking at.'

element.monitor()

On any UIElement, returns the Monitor it sits on. This is the piece no consumer tool exposes.

capture_monitor(m)

Screenshot of a single monitor sized to its native pixel buffer (resolution times scale factor). Not a crop of a stitched image.

capture_all_monitors()

Returns Vec<(Monitor, ScreenshotResult)>. Each entry is its own image, correct per-display DPI.

Listing every monitor in six lines

No capture. No clicks. Just enumeration. The output from this exact call is what drives every other multi-monitor decision the rest of an agent makes.

list-monitors.ts

What that looks like on a real dual-monitor rig

The runnable example lives at crates/terminator/examples/monitor_management.rs. Here is the output on a setup with a 2560x1440 primary at 100% scaling and a 1920x1080 secondary at 125% scaling positioned to its right. The two infos at the end are the thing nobody writes about: the captured image of DISPLAY2 is 0x0, not 1920x1080, because the capture respects scale_factor.

bash

Numbers you can check yourself

All four come from the Terminator source. The line numbers are line numbers in the published repo; the commit SHA is the exact multi-monitor fix; the eight fields are the eight fields of the Monitor struct; the diff lines are the size of the fix.

0Fields on the Monitor struct
0Line of Monitor in lib.rs
0Lines added by the UIA fix
0Line of is_visible_on_any_monitor
71 added, 31 removed, one file

Stop asking Windows UIA whether the element is offscreen. Ask every monitor whether its rectangle intersects the element's bounds. If any of them says yes, the element is visible.

Commit e36b9785 on 2026-04-02, crates/terminator/src/platforms/windows/element.rs

The is_offscreen() trap on screen two

This is the anchor fact for this page. Before April 2, 2026, if your app was on your secondary monitor and an agent asked Terminator to click something in it, the click would be refused. Not with a clean "element is on monitor 2" message. With a generic ElementNotVisible error. The reason was one call into Windows UIA.

validate_clickable() called self.element.is_offscreen(). That maps to IUIAutomationElement::get_CurrentIsOffscreen on the underlying COM object. UIA decides 'offscreen' by comparing bounds to the primary monitor only, so any element whose bounds start past the primary's right edge was reported as offscreen. Perfectly visible buttons on screen two were refused.

  • Element on monitor 2 -> UIA says offscreen -> click refused
  • Generic ElementNotVisible error, no hint about monitors
  • Worked fine in single-monitor testing
  • Reproducible by anyone with two screens, not platform-specific

The code, before and after

Left: the removed call that was wrong on multi-monitor setups. Right: the replacement helper. Same file. Same line range. One function call became one loop, and the framework stopped lying to itself about screen two.

crates/terminator/src/platforms/windows/element.rs

// Old behavior (removed in commit e36b9785)
// crates/terminator/src/platforms/windows/element.rs (pre-fix)

fn validate_clickable(&self) -> Result<(), AutomationError> {
    // ...
    if self.element.is_offscreen()? {
        return Err(AutomationError::ElementNotVisible(
            "Element is offscreen".into()));
    }
    // ...
}

// What actually happened on a dual-monitor rig:
//
//   is_offscreen() asked UIA.
//   UIA compared element bounds to the primary monitor only.
//   Element on monitor 2 -> bounds outside primary -> returns true.
//   Terminator refused the click, even though the button was clearly
//   visible to the user. No error message pointed at monitors.
-84% lines of real multi-monitor math

Consumer view vs developer view

Same six questions, answered from both sides. The left column is what Dell and Microsoft write about. The right column is what your code actually has to know if it wants to work across two screens.

FeatureConsumer setup guidesTerminator Monitor API
What an input tool calls 'x, y'Display Settings treats screen 2 as its own thing. You drag icons around to reorder.Terminator uses the OS global coordinate space. Screen 2 at x=2560 means a click at x=2700 lands on screen 2, column 140.
Negative coordinatesIf you never drag the icons, you never see them. Consumer guides never mention negatives exist.If monitor 2 is physically left of monitor 1, its top-left can be (-1920, 0). Monitor.x: i32 is signed on purpose.
Per-monitor DPIWindows has had per-monitor DPI since 10 1607. No consumer guide mentions scale_factor.Monitor.scale_factor: f64 is stored per display. A 27-inch 4K at 150% next to a 24-inch 1080p at 100% is the normal case.
Taskbar-excluded areaWhatever the OS draws.Monitor.work_area: Option<WorkAreaBounds>. Populated for the primary monitor via SPI_GETWORKAREA. None for secondary on Windows, same as full bounds on non-Windows.
Screenshot of just screen twoWin+Shift+S, drag over the right rectangle.desktop.capture_monitor(&m) returns a pixel buffer sized to that monitor's resolution times its scale factor. Not a crop.
Which window is on which screenEyeball it.element.monitor() walks the UI tree to the window root, reads its bounds, and returns the Monitor whose rect contains the top-left corner.

What happens when an agent clicks a button on screen two

Tracing one click end to end makes the multi-monitor plumbing concrete. This is the selector path, not the pixel path, so the coordinates are derived from the OS accessibility tree, not from an image.

1

Agent asks for click_element with a selector

role:Button && name:Save. No coordinates, no screenshot. The selector is resolved against the Windows UIA tree (or macOS AX tree on Mac).

2

Terminator finds the element and reads its bounds

bounds come back as a rect in the global coordinate space, which spans every monitor. If the button is on screen 2 at x=2560+400, the x returned is 2960, not 400.

3

validate_clickable() checks visibility

This is the step that used to call is_offscreen() and get the wrong answer. It now calls is_visible_on_any_monitor() and iterates xcap::Monitor::all(). Any monitor whose rect intersects the element's rect counts as visible.

4

Focus restore captures the current cursor and window

Terminator is explicit about not stealing your cursor. Before moving it, the current position and foreground window are saved so they can be put back after the click.

5

SendInput fires with global, normalised coordinates

On Windows the click goes through SendInput with MOUSEEVENTF_ABSOLUTE. Coordinates are normalised to the 0-65535 range across the full virtual screen (GetSystemMetrics(SM_CXSCREEN) + SM_CYSCREEN), so screen two is first-class.

6

A UI diff is captured and returned

Before/after tree, plus a screenshot of the monitor containing the target window, not a stitched image. The agent sees what changed next turn.

Practical tips if you are setting screens up for the first time

The developer side is this page. The user side is short enough that it fits below. If you are stuck on setup, these five points plus the Windows and Dell links in the FAQ cover 90% of it.

  • Match resolutions if you can. Mixing 1440p and 1080p is fine but you will see the DPI jump as the cursor crosses the boundary.
  • Drag the monitor icons in Display Settings until their arrangement matches your desk. This controls where the mouse crosses from one screen to the other, and it is what decides the sign of Monitor.x.
  • Pick a primary that makes sense. It is the one the taskbar and most new windows default to, and on Windows it is the only one whose work_area is exposed cleanly.
  • If one monitor looks blurry, check its scaling percentage. Per- monitor DPI means 100% on a 4K screen will look tiny, and 150% on a 1080p screen will look pixelated. Pick a ratio that gives roughly the same logical size on both.
  • If you automate anything, store the active monitor at the start of the session. Users dock and undock laptops, which changes the monitor topology mid-run.

Automate across both your screens

Terminator is an MIT-licensed desktop automation framework. Same API on Windows and macOS, per-monitor DPI handled, Monitor struct with real position and scale, and a fresh fix so clicks on screen two work correctly. If you are building an agent, a recorder, or a window manager, start here.

Read the source on GitHub

Questions about two-screen setups

How do I set up two computer screens in the first place?

Plug the second monitor into a spare video port on your PC (HDMI, DisplayPort, USB-C with DP Alt Mode, or Thunderbolt). On Windows, right-click the desktop, choose Display settings, and under Multiple displays pick Extend these displays. Drag the two monitor icons so their arrangement matches the physical one on your desk (this is what decides where your mouse crosses from one screen to the other). On macOS, System Settings > Displays, drag the preview thumbnails, uncheck Mirror. On Linux, xrandr or your desktop environment's display panel. That is the hardware and OS side. The rest of this page is about the software side: how programs know which window is on which screen, and why automation tools sometimes get that wrong.

Why does Terminator have its own Monitor struct instead of using the OS one?

Because there is no single 'OS one.' Windows gives you MonitorFromWindow returning an HMONITOR handle, plus GetMonitorInfo for bounds, plus GetDpiForMonitor for scale, plus SPI_GETWORKAREA for taskbar. macOS has NSScreen. Linux depends on the compositor. Terminator normalises all of that into one struct defined at crates/terminator/src/lib.rs line 272, with eight fields: id, name, is_primary, width, height, x, y, scale_factor, work_area. Under the hood, Windows enumeration goes through the xcap crate (see crates/terminator/src/platforms/windows/engine.rs), and the work_area for the primary display is filled in via SPI_GETWORKAREA. Secondary monitor work_area on Windows is None because Windows does not expose it cleanly, and the codebase says so explicitly.

What was the actual multi-monitor bug you mention?

Windows UI Automation has an IUIAutomationElement method called get_CurrentIsOffscreen. It returns a VARIANT_BOOL telling you whether the element is considered offscreen. On a single-monitor setup it works fine. On a dual-monitor setup, UIA's notion of 'offscreen' is too narrow: any element whose bounds sit outside the primary monitor's rectangle is reported as offscreen, even if it is clearly visible on a secondary monitor. Terminator's validate_clickable used to call this and refuse the click. The fix, commit e36b9785 landed on April 2, 2026, replaces that call with is_visible_on_any_monitor, which iterates xcap::Monitor::all() and runs an axis-aligned bounding box intersection per monitor. The diff is 71 added, 31 removed, all in crates/terminator/src/platforms/windows/element.rs. The code is above.

What does 'global coordinate space' mean in practice?

When you click at (x, y) with a low-level input API like Windows SendInput with MOUSEEVENTF_ABSOLUTE, the coordinates are not relative to any single monitor. They are positions in one virtual screen rectangle that contains all monitors. If your primary monitor is 2560x1440 at (0, 0) and your secondary is 1920x1080 positioned to its right, the secondary's usable range is roughly (2560, 0) to (4480, 1080). An agent that wants to click the center of the secondary monitor clicks at (3520, 540). If monitor 2 is above or to the left, coordinates are negative. Terminator stores this directly on the Monitor struct, Monitor.x and Monitor.y are i32 (signed), and Monitor.contains_point(x, y) at lib.rs line 311 is how you ask 'does this pixel belong to this display?'

Why does scale_factor matter?

Because 'same pixel count' and 'same physical size' are not the same thing. A 27-inch 4K monitor at 150% scaling renders a button 1.5 times bigger in pixels than a 1080p monitor at 100% scaling, even though the UI is the same 'logical' size. If your automation measures in logical pixels and the machine renders in physical pixels, you click the wrong spot. Terminator stores scale_factor: f64 per monitor so the caller can decide which space to work in. When Terminator captures a specific monitor via desktop.capture_monitor(monitor), the returned image is sized to native pixel resolution (resolution times scale factor), not the logical size shown in Windows Display Settings. That is why the terminal output above shows DISPLAY2 captured at 2400x1350 even though Display Settings says 1920x1080.

How do I know which monitor a specific window is on?

In Terminator, every UIElement has a .monitor() method that returns the Monitor containing it. The implementation walks up to the top-level window, reads its bounds via the platform accessibility API (UIA on Windows, AXUIElement on macOS), and returns the first monitor whose rectangle contains the window's top-left corner. No other high-level automation framework exposes this cleanly. Playwright does not (it is browser-only). PyAutoGUI does not (it reads raw mouse coords). xdotool does not. You typically have to call GetWindowRect yourself, then MonitorFromRect, then translate the HMONITOR to something your code understands. The SDK methods are documented at packages/terminator-js README and crates/terminator/src/lib.rs line 638 (list_monitors), 678 (get_active_monitor), 730 (capture_monitor).

Can I capture only the secondary monitor?

Yes. desktop.list_monitors() returns them in order. Pick the one you want (by id, by name, by !is_primary, or by coordinates), then call desktop.capture_monitor(&monitor). Under the hood this calls xcap::Monitor::capture_image() on the specific monitor handle, which gives you its own pixel buffer, not a crop of a stitched multi-monitor screenshot. The MCP server exposes a simpler shape: the capture_screenshot tool has an entire_monitor: bool flag. When true, Terminator captures the full monitor containing the target window, not the window itself. That is the only monitor-specific MCP tool parameter; the lower-level monitor enumeration APIs are only exposed through the Rust, Python, and Node SDKs.

What does 'active monitor' mean exactly?

The monitor containing the currently focused window. Not the monitor the mouse cursor is on, not the 'primary' monitor in Display Settings. Rationale: when an agent is doing work for a user who has Chrome on screen 2 and Slack on screen 1, 'active' should mean wherever the user is working. Terminator implements this at crates/terminator/src/lib.rs line 678 via desktop.engine.get_active_monitor(). On Windows it calls GetForegroundWindow, then MonitorFromWindow, then resolves that to a Monitor. On macOS it asks AX for the frontmost application's main window frame and matches it against the enumerated monitors. This is the monitor most agent scripts should default to when the user says 'take a screenshot' without specifying one.

Why don't consumer guides cover any of this?

Because the consumer keyword 'how to use two computer screens' is almost always about setup: which cable, which port, how to extend the desktop in Display Settings, how to choose a primary. Those guides are correct for a user at a desk plugging things in. They have no reason to cover coordinate spaces, per-monitor DPI, or accessibility tree APIs. That leaves a real gap for anyone writing software that has to work across two screens: RDP tools, screen recorders, automation frameworks, game launchers, window managers. This page is for them. The source of truth sits in Terminator's monitor code: lib.rs 272-325 (Monitor struct and helpers), windows/engine.rs 3284-3501 (Windows enumeration and capture), windows/element.rs 315-371 (multi-monitor visibility check), examples/monitor_management.rs (runnable example).

terminatorDesktop automation SDK
© 2026 terminator. All rights reserved.