2
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

[C#] そこそこ使いやすいUIオートメーションの拡張メソッド

Posted at

Windowsで使えるUIオートメーション(.NET Frameworkではなく、COMオブジェクトの方)で、そこそこ使いやすい拡張メソッドを作りました。

セットアップ

ありがたいことに、UIオートメーションのCOMオブジェクトをC#クラスに宣言しているNugetライブラリがあるので使わせていただく。🙏

dotnet new console
dotnet add package Interop.UIAutomationClient

後述のUIA.csを作成しておく。(Program.csに直接コピペOK👍)

使用例

下記はマイクの音量を0にするコード。
サウンド設定からマイクのプロパティを開いてレベルタブを表示した状態で実行する。
※ マイクの音量が勝手に変わってしまう挙動を抑えるために実装したモノ(この仕様よく分からない)

using Interop.UIAutomationClient;

UIA.Instance.AutoSetFocus = 0; // 操作したときにウィンドウがアクティブにならないようにする

var root = UIA.Instance.GetRootElement(); // ルート(デスクトップ)を取得する

// タイトル 'サウンド' のウィンドウを取得
// -> その子の中でタイトル 'マイクのプロパティ' を取得
// -> その子孫の中でクラス名 'msctls_trackbar32' を取得
// -> 存在しなければ例外
var micTrackbar = root
    .GetChildren().FirstOrDefault(c => c.CurrentName == "サウンド")
    ?.GetChildren().FirstOrDefault(c => c.CurrentName == "マイクのプロパティ")
    ?.FindDescendant(e => e.CurrentClassName == "msctls_trackbar32")
    ?? throw new InvalidOperationException("マイクのトラックバーが見つかりません。");

// RangeValuePatternを取得
var rangeValue = micTrackbar.GetPattern<IUIAutomationRangeValuePattern>();
rangeValue.SetValue(0); // 音量を0に設定

Dumpメソッド

「クラス名とかわかんねーよ」というときのためにDumpメソッドがあります。

root
    .GetChildren().FirstOrDefault(c => c.CurrentName == "サウンド")
    ?.GetChildren().FirstOrDefault(c => c.CurrentName == "マイクのプロパティ")
    ?.Dump();

とすると、標準出力にコントロール名、オートメーションID、クラス名、名前が出力されます。

Window[50032], Id: , Class: #32770, Name: マイクのプロパティ
 Pane[50033], Id: , Class: #32770, Name:
  Pane[50033], Id: , Class: #32770, Name:
   Group[50026], Id: 1510, Class: Button, Name: マイク
   Slider[50015], Id: 1511, Class: msctls_trackbar32, Name: マイク
    Button[50000], Id: , Class: , Name: 左へドラッグ
    Thumb[50027], Id: , Class: , Name: 表示位置
    Button[50000], Id: , Class: , Name: 右へドラッグ
   Text[50020], Id: 1516, Class: Static, Name: 89
   CheckBox[50002], Id: 1512, Class: Button, Name: ミュート(M)
 Button[50000], Id: 1, Class: Button, Name: OK
 Button[50000], Id: 2, Class: Button, Name: キャンセル
 Button[50000], Id: 12321, Class: Button, Name: 適用(A)
 Tab[50018], Id: 12320, Class: SysTabControl32, Name:
  TabItem[50019], Id: , Class: , Name: 全般
  TabItem[50019], Id: , Class: , Name: 聴く
  TabItem[50019], Id: , Class: , Name: レベル
  TabItem[50019], Id: , Class: , Name: 詳細
 TitleBar[50037], Id: TitleBar, Class: , Name:
  MenuBar[50010], Id: SystemMenuBar, Class: , Name: システム
   MenuItem[50011], Id: , Class: , Name: システム
  Button[50000], Id: Close, Class: , Name: 閉じる

また、Dump(2)とすると2階層まで出力するように制限します。

DumpPatternsメソッド

さらに、「IUIAutomationRangeValuePattern?どれ使えばいいの??」というときは、DumpPatternsメソッドを使います。

root
    .GetChildren().FirstOrDefault(c => c.CurrentName == "サウンド")
    ?.GetChildren().FirstOrDefault(c => c.CurrentName == "マイクのプロパティ")
    ?.FindDescendant(e => e.CurrentClassName == "msctls_trackbar32")
    ?.DumpPatterns();

とすると、利用できるパターンとプロパティ値を列挙します。

# Pattern: RangeValuePattern[10003]
CurrentValue: 89
CurrentIsReadOnly: 0
CurrentMaximum: 100
CurrentMinimum: 0
CurrentLargeChange: 0
CurrentSmallChange: 1
# Pattern: LegacyIAccessiblePattern[10018]
CurrentChildId: 0
CurrentName: マイク
CurrentValue: 89
CurrentDescription:
CurrentRole: 51
CurrentState: 1048576
CurrentHelp:
CurrentKeyboardShortcut:
CurrentDefaultAction:

DumpDumpPropsは元のIUIAutomationElementを返すので、メソッドチェーンの間に挟めます。(デバッグ目的)

var element = root
    .GetChildren().FirstOrDefault(c => c.CurrentName == "サウンド")
    ?.Dump(1)
    ?.GetChildren().FirstOrDefault(c => c.CurrentName == "マイクのプロパティ")
    ?.Dump(1)
    ?.FindDescendant(e => e.CurrentClassName == "msctls_trackbar32")
    ?.Dump(1).DumpPatterns();

UIA.cs

using Interop.UIAutomationClient;

static class UIA
{
    public static readonly CUIAutomation8Class Instance = new CUIAutomation8Class();

    public static IEnumerable<IUIAutomationElement> GetChildren(this IUIAutomationElement parent)
    {
        var walker = Instance.RawViewWalker;
        for (var child = walker.GetFirstChildElement(parent); child != null; child = walker.GetNextSiblingElement(child))
        {
            yield return child;
        }
    }

    public static IUIAutomationElement? FindDescendant(this IUIAutomationElement parent, Func<IUIAutomationElement, bool> predicate)
    {
        foreach (var child in parent.GetChildren())
        {
            if (predicate(child)) return child;

            var hit = child.FindDescendant(predicate);
            if (hit != null) return hit;
        }

        return null;
    }


    private static Dictionary<Type, int>? patternIdDict;

    public static T GetPattern<T>(this IUIAutomationElement element) => element.GetPatternAs<T>() ?? throw new InvalidOperationException($"Pattern type '{typeof(T)}' is null.");

    public static T? GetPatternAs<T>(this IUIAutomationElement element)
    {
        if (patternIdDict == null)
        {
            patternIdDict = new Dictionary<Type, int>();
            var assembly = typeof(IUIAutomationElement).Assembly;
            foreach (var patternIdField in typeof(UIA_PatternIds).GetFields())
            {
                var patternName = patternIdField.Name[4..^2];
                var patternId = (int)patternIdField.GetValue(null)!;
                var patternTypeName = $"Interop.UIAutomationClient.IUIAutomation{patternName}";
                var patternType = assembly.GetType(patternTypeName) ?? throw new InvalidOperationException($"{patternTypeName} not found.");
                patternIdDict.Add(patternType, patternId);
            }
        }

        if (!patternIdDict.TryGetValue(typeof(T), out var id))
        {
            throw new ArgumentException($"Type '{typeof(T)}' is not UIA pattern type.", nameof(T));
        }

        return (T?)element.GetCurrentPattern(id);
    }

    public static IUIAutomationElement Dump(this IUIAutomationElement element, int limitNest = -1)
    {
        element.Dump(limitNest, 0);
        return element;
    }

    private static void Dump(this IUIAutomationElement element, int limitNest, int nest)
    {
        Console.Write(new string(' ', nest));
        Console.WriteLine($"{GetControlTypeName(element.CurrentControlType)}[{element.CurrentControlType}], Id: {element.CurrentAutomationId}, Class: {element.CurrentClassName}, Name: {element.CurrentName}");

        if (limitNest >= 0 && nest >= limitNest) return;

        foreach (var child in element.GetChildren())
        {
            child.Dump(limitNest, nest + 1);
        }
    }

    private static Dictionary<int, string>? controlTypeNameDict;

    private static string GetControlTypeName(int id)
    {
        if (controlTypeNameDict == null)
        {
            controlTypeNameDict =
                typeof(UIA_ControlTypeIds)
                .GetFields()
                .ToDictionary(f => (int)f.GetValue(null)!, f => f.Name[4..^13]);
        }

        return controlTypeNameDict.TryGetValue(id, out var name) ? name : string.Empty;
    }


    public static IUIAutomationElement DumpPatterns(this IUIAutomationElement element)
    {
        if (valueDumpers == null)
        {
            var dumpers = new List<Action<IUIAutomationElement>>();
            var assembly = typeof(IUIAutomationElement).Assembly;
            foreach (var patternIdField in typeof(UIA_PatternIds).GetFields())
            {
                var patternName = patternIdField.Name[4..^2];
                var patternId = (int)patternIdField.GetValue(null)!;
                var patternTypeName = $"Interop.UIAutomationClient.IUIAutomation{patternName}";
                var patternType = assembly.GetType(patternTypeName) ?? throw new InvalidOperationException($"{patternTypeName} not found.");
                var patternProps = patternType.GetProperties().Where(p => !p.Name.StartsWith("Cached")).ToArray();
                dumpers.Add(element =>
                {
                    var pattern = element.GetCurrentPattern(patternId);
                    if (pattern == null) return;

                    Console.WriteLine($"# Pattern: {patternName}[{patternId}]");
                    foreach (var prop in patternProps)
                    {
                        try
                        {
                            Console.WriteLine($"{prop.Name}: {prop.GetValue(pattern)}");
                        }
                        catch (Exception ex)
                        {
                            Console.WriteLine($"{prop.Name}: ERROR! {ex.Message}");
                        }
                    }
                });
            }

            valueDumpers = dumpers;
        }

        foreach (var dumper in valueDumpers)
        {
            dumper(element);
        }

        return element;
    }

    private static List<Action<IUIAutomationElement>>? valueDumpers;
}

おわりに

.NET 6になってからVSCodeを使ってお手軽にC#書けて最高最高!
ちなみにPowerShellでは使えません。COMオブジェクトの扱いが違うので。C#でラッパークラスを作って間接的に操作するしかありません。😢

2
5
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
2
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?