0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

PowerShellでマルチモニターの用途切り替えをワンクリック化した話

0
Posted at

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つあります。

  1. スクリプトを UTF-8 (BOM付き) で保存する
  2. コメントとメッセージを 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 と復帰手順を確認してください。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?