PowerShellでマルチモニターの用途切り替えをワンクリック化した話
はじめに
1台のPCに、モニターを何枚もつないでいませんか。
仕事や作業では2枚、3枚と並べると快適ですが、「今はこのモニターはいらない」というタイミングが意外とあります。たとえば次のような場面です。
- ゲームに集中したいので、ゲーム用のモニター以外は消したい
- オンライン会議のとき、共有する画面以外は映したくない
- 逆に、作業中は、ゲーム用や撮影用のモニターは消しておきたい
私の場合は、レーシングコックピットを持っているので、レースをするときは「レース用のモニター1枚だけ」を映したくなります。作業用のモニターが点いたままだと、画面の端にウィンドウが映り込んだり、マウスカーソルが隣の画面に逃げたりして、地味に邪魔なのです。
同じように、レーシングシミュレータを楽しんでいる方や、用途ごとにモニターを分けて使っている方は多いと思います。この記事は、そんな方に向けて、デスクトップやタスクバーのアイコンをクリックするだけで、モニターの組み合わせを切り替える方法を紹介します。
条件は、Windows標準のPowerShellだけで作ること。理由は、フリーのツールを入れるのに少し抵抗があったからです。「名前の知られたツールだけど、知らないサイトから入手するのは避けたい」という方にも、読んでもらえるはずです。
検証環境: Windows(バージョンは各自追記)/ Windows PowerShell 5.1 / モニター3枚(GPU・型番は各自追記)
私のモニター構成
私の環境は、次の3枚です。
| 名前 | 役割 |
|---|---|
| DISPLAY1 | メインモニター(作業用) |
| DISPLAY2 | メインの右隣に置いた縦置きモニター(作業用) |
| DISPLAY3 | レーシングシミュレータ用モニター |
やりたいことは、次の3つです。
- 作業モードでは、DISPLAY3 を消灯する
- シミュモードでは、DISPLAY3 だけを映して、DISPLAY1/2 を消灯する
- 縦置きモニターの向きや配置が、何度往復しても崩れない
方針: PowerShell + Windows API
追加のソフトなしで、ディスプレイの構成を操作する手段は、主に2つあります。
-
DisplaySwitch.exe(Windows標準)
Win + Pと同じ動作をコマンドから呼べます。/internal、/external、/extend、/cloneがあります。ただし、「特定のモニターだけを消す」といった細かい制御はできません。 -
ChangeDisplaySettingsEx(user32.dll)
位置、解像度、向き、メインの指定、有効/無効を細かく設定できます。PowerShell のAdd-Typeから呼べます。
結論から言うと、最終的にはこの2つを併用することになりました。ここから先は、実際にぶつかった壁を順番に紹介します。同じところで詰まった方の参考になれば嬉しいです。
最初に: DISPLAY番号は設定画面の番号と一致しない
まず、一番最初のつまずきです。Windowsの設定画面で「識別」を押すと、モニターに番号が出ますよね。私は、その番号をそのままスクリプトに書きました。ところが、実際に動かしてみると、割り当てが食い違っていました。
設定画面の番号と、API が使う \\.\DISPLAYn は、一致するとは限りません。
そこで、読み取り専用の status モードで、実際の値を確認しました。
Name X Y Width Height Freq Bpp Orient
---- - - ----- ------ ---- --- ------
\\.\DISPLAY3 5280 0 3440 1440 180 32 0
\\.\DISPLAY1 0 0 3840 2160 240 32 0
\\.\DISPLAY2 3840 -387 1440 2560 144 32 3
縦置きの DISPLAY2 は Orient=3 で、幅と高さが入れ替わっています。この結果から、割り当てを次のように確定しました。
$SimDisplay = '\\.\DISPLAY3'
$WorkDisplays = @('\\.\DISPLAY1', '\\.\DISPLAY2')
ポイント: 設定画面の番号を信じず、まず status で実際の値を確認しましょう。
第1の壁: 日本語コメントで構文エラー
「コメントは日本語で書いたほうが分かりやすい」と思って、最初のスクリプトには日本語を入れていました。UTF-8(BOMなし)で保存して実行すると、こんなエラーが出ました。
発生場所 C:\Tools\display\display-mode.ps1:149 文字:10
+ 'work' {
+ ~~~~~
文字列に終端記号 ' がありません。
しかも、エラーの中で日本語のコメントが 繧・(0,0) 縺ョ繝励Λ繧、繝槭Μ… のように文字化けしていました。
原因は文字コードです。Windows PowerShell 5.1 は、BOMなしのUTF-8ファイルをシフトJIS(ANSI)として読みます。 日本語コメントが誤って解釈され、その中のバイトがクォートや改行を壊していたのでした。
対処は2つあります。
- スクリプトを UTF-8 (BOM付き) で保存する
- コメントとメッセージを ASCIIのみ にする
誰かに配るスクリプトなら、環境に左右されない後者がおすすめです。この記事のスクリプトも、ASCIIだけで書いてあります。
第2の壁: work で、消したモニターが戻らない
次の壁です。sim(シミュ用だけを表示)は、一発で成功しました。ところが、work で元に戻そうとすると、こんな警告が出て復帰しませんでした。
警告: \\.\DISPLAY1: failed (code=-1)
警告: \\.\DISPLAY2: failed (code=-1)
-1 は DISP_CHANGE_FAILED を表すコードです。これだけでは、原因の見当がつきません。
最初に、次の2つの対処を試しました。
- 復帰時に、
EnumDisplaySettingsW(dev, -2, ...)(ENUM_REGISTRY_SETTINGS)で取得した設定を土台にする - シミュ用モニターを先に元の位置へ動かして、(0,0) を空けてから他を戻す
しかし、警告は消えませんでした。結論として、無効にしたモニターを、ChangeDisplaySettingsEx だけで再接続するのは、私の環境ではうまくいきませんでした。
最終的な解決策は、再接続だけを DisplaySwitch.exe /extend(Win + P の「拡張」と同じ)に任せることでした。その後で、保存しておいた位置・解像度・向きを適用し直します。
& "$env:SystemRoot\System32\DisplaySwitch.exe" /extend
Start-Sleep -Seconds 4
これで、作業用のモニターが意図どおりに戻りました。なお、-1 が返った根本的な理由(なぜAPIが再接続を拒否したのか)までは、特定できていません。同じ症状の方は、まずこの回避策を試してみてください。
第3の壁: work で、シミュ用のモニターまで映ってしまう
作業用のモニターが戻るようになって喜んだのも束の間、今度はシミュ用の DISPLAY3 まで映ってしまいました。 保存した3枚すべてを復元していたのが原因です。
作業モードでは、シミュ用は消しておきたいので、次のように変えました。
- 作業用のモニターだけを、保存した配置で復元する
- 最後に、シミュ用を
Disableする
完成したスクリプト
C:\Tools\display\display-mode.ps1 として保存してください。
# display-mode.ps1 (ASCII only: safe for Windows PowerShell 5.1)
# DISPLAY1 = main / DISPLAY2 = portrait sub / DISPLAY3 = sim
# Usage: status (read only) -> save (store current layout) -> sim / work
# Recovery: press Win+P and choose "Extend".
param(
[Parameter(Mandatory = $true)]
[ValidateSet('status', 'save', 'sim', 'work')]
[string]$Mode
)
$SimDisplay = '\\.\DISPLAY3'
$WorkDisplays = @('\\.\DISPLAY1', '\\.\DISPLAY2')
$AllDisplays = @($SimDisplay) + $WorkDisplays
$ProfilePath = Join-Path $PSScriptRoot 'display-profile.json'
Add-Type -TypeDefinition @'
using System;
using System.Runtime.InteropServices;
public static class Disp {
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
public struct DEVMODE {
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] public string dmDeviceName;
public ushort dmSpecVersion;
public ushort dmDriverVersion;
public ushort dmSize;
public ushort dmDriverExtra;
public uint dmFields;
public int dmPositionX;
public int dmPositionY;
public uint dmDisplayOrientation;
public uint dmDisplayFixedOutput;
public short dmColor;
public short dmDuplex;
public short dmYResolution;
public short dmTTOption;
public short dmCollate;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] public string dmFormName;
public ushort dmLogPixels;
public uint dmBitsPerPel;
public uint dmPelsWidth;
public uint dmPelsHeight;
public uint dmDisplayFlags;
public uint dmDisplayFrequency;
public uint dmICMMethod;
public uint dmICMIntent;
public uint dmMediaType;
public uint dmDitherType;
public uint dmReserved1;
public uint dmReserved2;
public uint dmPanningWidth;
public uint dmPanningHeight;
}
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
static extern bool EnumDisplaySettingsW(string dev, int mode, ref DEVMODE dm);
[DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "ChangeDisplaySettingsExW")]
static extern int ChangeEx(string dev, ref DEVMODE dm, IntPtr hwnd, uint flags, IntPtr lp);
[DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "ChangeDisplaySettingsExW")]
static extern int ChangeExNull(string dev, IntPtr dm, IntPtr hwnd, uint flags, IntPtr lp);
const uint DM_POSITION = 0x20, DM_DISPLAYORIENTATION = 0x80, DM_BITSPERPEL = 0x40000,
DM_PELSWIDTH = 0x80000, DM_PELSHEIGHT = 0x100000, DM_DISPLAYFREQUENCY = 0x400000;
const uint CDS_UPDATEREGISTRY = 1, CDS_SET_PRIMARY = 0x10, CDS_NORESET = 0x10000000;
static DEVMODE New() {
DEVMODE dm = new DEVMODE();
dm.dmSize = (ushort)Marshal.SizeOf(typeof(DEVMODE));
return dm;
}
public static DEVMODE Get(string dev) {
DEVMODE dm = New();
EnumDisplaySettingsW(dev, -1, ref dm);
return dm;
}
public static int Set(string dev, int x, int y, uint w, uint h, uint freq, uint bpp, uint orient, bool primary) {
DEVMODE dm = New();
EnumDisplaySettingsW(dev, -2, ref dm); // start from the registry settings
dm.dmSize = (ushort)Marshal.SizeOf(typeof(DEVMODE));
dm.dmFields = DM_POSITION | DM_PELSWIDTH | DM_PELSHEIGHT | DM_BITSPERPEL
| DM_DISPLAYFREQUENCY | DM_DISPLAYORIENTATION;
dm.dmPositionX = x; dm.dmPositionY = y;
dm.dmPelsWidth = w; dm.dmPelsHeight = h;
dm.dmDisplayFrequency = freq; dm.dmBitsPerPel = bpp;
dm.dmDisplayOrientation = orient;
uint flags = CDS_UPDATEREGISTRY | CDS_NORESET;
if (primary) flags |= CDS_SET_PRIMARY;
return ChangeEx(dev, ref dm, IntPtr.Zero, flags, IntPtr.Zero);
}
public static int Disable(string dev) {
DEVMODE dm = New();
dm.dmFields = DM_POSITION | DM_PELSWIDTH | DM_PELSHEIGHT;
dm.dmPelsWidth = 0; dm.dmPelsHeight = 0;
return ChangeEx(dev, ref dm, IntPtr.Zero, CDS_UPDATEREGISTRY | CDS_NORESET, IntPtr.Zero);
}
public static int Commit() {
return ChangeExNull(null, IntPtr.Zero, IntPtr.Zero, 0, IntPtr.Zero);
}
}
'@
function Get-Info($name) {
$dm = [Disp]::Get($name)
[pscustomobject]@{
Name = $name
X = $dm.dmPositionX
Y = $dm.dmPositionY
Width = $dm.dmPelsWidth
Height = $dm.dmPelsHeight
Freq = $dm.dmDisplayFrequency
Bpp = $dm.dmBitsPerPel
Orient = $dm.dmDisplayOrientation
}
}
function Restore-One($p, $primary) {
$r = [Disp]::Set($p.Name, $p.X, $p.Y, $p.Width, $p.Height, $p.Freq, $p.Bpp, $p.Orient, $primary)
if ($r -ne 0) { Write-Warning "$($p.Name): failed (code=$r)" }
}
function Load-Profile {
if (-not (Test-Path $ProfilePath)) { throw "Run 'save' first: $ProfilePath" }
Get-Content $ProfilePath -Raw | ConvertFrom-Json
}
switch ($Mode) {
'status' {
$AllDisplays | ForEach-Object { Get-Info $_ } | Format-Table -AutoSize
}
'save' {
$infos = $AllDisplays | ForEach-Object { Get-Info $_ }
if ($infos | Where-Object { $_.Width -eq 0 }) {
throw 'A monitor is disabled. Run save while all 3 monitors are on.'
}
$infos | ConvertTo-Json | Set-Content $ProfilePath -Encoding UTF8
Write-Host "Saved: $ProfilePath"
}
'sim' {
$profile = Load-Profile
$sim = $profile | Where-Object { $_.Name -eq $SimDisplay }
# make the sim display the primary at (0,0), then disable the work monitors
$sim.X = 0; $sim.Y = 0
Restore-One $sim $true
foreach ($d in $WorkDisplays) { [void][Disp]::Disable($d) }
[void][Disp]::Commit()
}
'work' {
$profile = Load-Profile
# re-attach all monitors the same way as Win+P "Extend"
& "$env:SystemRoot\System32\DisplaySwitch.exe" /extend
Start-Sleep -Seconds 4
# restore only the work monitors, then turn the sim monitor off
foreach ($p in ($profile | Where-Object { $_.Name -ne $SimDisplay })) {
Restore-One $p (($p.X -eq 0) -and ($p.Y -eq 0))
}
[void][Disp]::Disable($SimDisplay)
$r = [Disp]::Commit()
if ($r -ne 0) { Write-Warning "commit failed (code=$r)" }
}
}
4つのモードの役割
| モード | 動作 |
|---|---|
status |
3枚の位置・解像度・向きを表示します(読み取りのみ) |
save |
現在の配置を display-profile.json に保存します(3枚とも表示した状態で実行) |
sim |
DISPLAY3をメインにして、DISPLAY1/2を無効にします |
work |
拡張で再接続し、DISPLAY1/2を保存した配置に戻して、DISPLAY3を無効にします |
自分の環境に合わせて書き換える箇所
読者のみなさんは、モニターの数や役割が違うと思います。書き換えるのは、スクリプト冒頭のこの2行だけです。
$SimDisplay = '\\.\DISPLAY3'
$WorkDisplays = @('\\.\DISPLAY1', '\\.\DISPLAY2')
-
$SimDisplayには、「その用途のときだけ映したいモニター」を指定します。 -
$WorkDisplaysには、「その用途のときは消したいモニター」を並べます。 - 番号は、先に
statusで確認してください。
実行手順
まずは、何も変更しない status から始めましょう。
# 1. 状態を確認する(何も変更しません)
powershell -ExecutionPolicy Bypass -File C:\Tools\display\display-mode.ps1 status
# 2. 普段の作業配置で、3枚とも表示した状態で保存する
powershell -ExecutionPolicy Bypass -File C:\Tools\display\display-mode.ps1 save
# 3. 切り替える
powershell -ExecutionPolicy Bypass -File C:\Tools\display\display-mode.ps1 sim
powershell -ExecutionPolicy Bypass -File C:\Tools\display\display-mode.ps1 work
-ExecutionPolicy Bypass は、そのコマンド1回にだけ効きます。PC全体の実行ポリシーは変わらないので安心してください。
復帰の保険: 画面が戻らなくなったら、
Win + Pで「拡張」を選びます。simを試す前に、この方法を覚えておいてください。simの後は作業用のモニターが消えるので、Win + Rでpowershellを開いてworkを実行する手順も、先に確認しておくと安心です。
デスクトップにショートカットを作る
毎回コマンドを打つのは面倒ですよね。ここからが、この記事の本題です。クリックするだけで切り替わる、ショートカットのアイコンを作りましょう。
次のスクリプトを、PowerShell に貼り付けて実行すると、デスクトップに2つのショートカットができます。
$script = 'C:\Tools\display\display-mode.ps1'
$desktop = [Environment]::GetFolderPath('Desktop')
$shell = New-Object -ComObject WScript.Shell
foreach ($m in @(
@{ Name = 'Work Mode'; Arg = 'work'; Key = 'CTRL+ALT+F10' },
@{ Name = 'Sim Mode'; Arg = 'sim'; Key = 'CTRL+ALT+F9' }
)) {
$lnk = $shell.CreateShortcut((Join-Path $desktop ($m.Name + '.lnk')))
$lnk.TargetPath = 'powershell.exe'
$lnk.Arguments = "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$script`" $($m.Arg)"
$lnk.WorkingDirectory = 'C:\Tools\display'
$lnk.Hotkey = $m.Key
$lnk.IconLocation = 'imageres.dll,109'
$lnk.Save()
}
Write-Host "Created in: $desktop"
ここで、一つ注意があります。このスクリプトは、実行して初めてショートカットが作られます。 私も最初は、コードを受け取っただけで「アイコンが見当たらない」と戸惑いました。実行したら、デスクトップを確認してください。
- 作成先は、
[Environment]::GetFolderPath('Desktop')で取得しています。OneDriveの同期が有効だと、デスクトップの場所がC:\Users\<ユーザー名>\OneDrive\Desktopのように変わることがあるためです。 -
-WindowStyle Hiddenで、PowerShell のウィンドウを出さずに実行します。 - 作ったアイコンは、右クリックからタスクバーに固定することもできます。
ホットキーが、すでに使われていた話
ホットキーも付けてみました。最初は Ctrl + Alt + S にしたのですが、押してみると、もとから入っていたHPのユーティリティのホットキーが反応してしまいました。
メーカー製のPCは、独自のホットキーを常駐させていることがあります。そこで、Ctrl + Alt + F9(シミュ)と Ctrl + Alt + F10(作業)に変更しました。Fキーの組み合わせなら、衝突は少ないはずです。ホットキーを使わない場合は、Key を '' にしてください。
切り替え後の注意
- シミュモードでは、DISPLAY3 だけが映ります。デスクトップのアイコンは、映っている画面に出るので、そこから「Work Mode」を押せば戻せます。
- モニターの並びや解像度を変えたときは、
Win + Pで拡張にして、3枚とも映した状態で配置を直してから、saveをやり直してください。 -
workは、一度3枚を拡張で映してから、シミュ用を消します。切り替えの最中に、一瞬だけ3枚が映ります。
まとめ
1台のPCで、モニターを「作業用」と「シミュ用」に分けて切り替える仕組みを、PowerShell だけで作りました。
- 設定画面の番号を信用せず、
statusで実際の\\.\DISPLAYnを確認する - PowerShell 5.1 のスクリプトは、日本語を入れるなら BOM付きUTF-8、そうでなければASCIIのみにする
- 無効にしたモニターを
ChangeDisplaySettingsExだけで戻すのは、私の環境ではうまくいきませんでした。再接続はDisplaySwitch.exe /extendに任せ、配置の復元だけをAPIで行う構成に落ち着きました - 試す前に、
Win + P(拡張)で戻せることを確認しておく - ホットキーは、メーカー製PCの常駐ソフトと衝突しやすいので、Fキーなどを選ぶ
今回は3枚構成でしたが、$SimDisplay と $WorkDisplays を書き換えれば、「作業用/ゲーム用」「会議用/資料用」など、別の用途分けにも使えるはずです。「このモニター、今はいらないな」と感じたことのある方は、ぜひ試してみてください。
動作確認は、私の環境のみです。GPUやドライバによっては、挙動が異なる可能性があります。
simを試す前に、必ずsaveと復帰手順を確認してください。