GUIスクリプティングなAppleScript環境を快適にする

GUIスクリプティングはとっても便利だ。これはOSを操作するAppleScriptと言えるので、基本的にマウスやキーボードの操作なら何でも操作できる可能性を秘めている。

しかし、メニューやショートカットキーを操作するのは意外と大変。操作するまでの事前の手順や書き方が煩雑だったり、ちょっとしたことで操作できない状態によく陥る...。もっとシンプルに、確実に操作できる環境を手に入れたいのだ!

基本

  • 例:選択項目をSpotlight検索する
    • キーワードをコピー&ペーストするよりは、選択中の文字列がそのままキーワードになればもっと幸せになるかと思って。
(*ファイル名:spotlight_with_selection.scpt*)
delay 0.5 --Quicksilverから利用する時は、このひと呼吸が必要
tell application "System Events" --ショートカット操作をする限り、GUIスクリプティングは無効でも動作する
  --tell application "APP_NAME" to activate--必要に応じて、アプリケーション"APP_NAME"をアクティブにする
  keystroke "c" using {command down} --command-C (コピー)
  keystroke space using {control down} --command-space (Spotlightの起動)
  delay 0.1 --ショートカットを連携させる時に、ひと呼吸が必要な場合もある
  keystroke "v" using {command down} --command-V (ペースト)
end tell
  • 例:メールを送信した時にエンコードを自動に設定する
    • メールを返信した時の送信済みメールの文字化けを自動的に解消する。自分で書いたメールが文字化けするなんて堪えられない...。
(*ファイル名:mail_encoding_auto.scpt*)
on adding folder items to this_folder after receiving added_items
tell application "Finder" to open added_items tell application "System Events" tell process "Mail" set frontmost to true keystroke "1" using {command down, option down} --自動 keystroke "w" using {command down} click menu item "メッセージビューア" of menu "ウインドウ" of menu bar item "ウインドウ" of menu bar 1 keystroke "w" using {command down} keystroke "n" using {command down, option down} end tell end tell end adding folder items to

親切バージョン

  • もし、システム環境設定 >> ユニバーサルアクセス >> 補助装置にアクセスできるようにする にチェックがないとGUIスクリプティングが動かず悩むことになる。
  • だから、以下のようなチェックをしておくと、人に優しいスクリプトになる。
delay 0.5 --Quicksilverを利用する時は、このひと呼吸が必要 tell application "System Events" if UI elements enabled then --ショートカット操作をする限り、GUIスクリプティングは無効でも動作する --操作対象のアプリを最前面に設定する(スクリプトメニューから実行する場合必要) tell process "Mail" set frontmost to true keystroke "1" using {command down, option down} --自動 keystroke "w" using {command down} click (menu item "メッセージビューア" of menu "ウインドウ" of menu bar item "ウインドウ" of menu bar 1) delay 0.1 --ショートカットを連携させる時に、ひと呼吸が必要な場合もある keystroke "w" using {command down} keystroke "n" using {command down, option down} end tell else tell application "System Preferences" activate set current pane to pane "com.apple.preference.universalaccess" set msg to "GUIスクリプティングが利用可能になっていません。\n\"補助装置にアクセスできるようにする\" にチェックを入れて続けますか?" display dialog msg buttons {"キャンセル", "チェックを入れて続ける"} with icon note end tell set UI elements enabled to true delay 1 tell application "System Preferences" to quit delay 1 end if end tell
  • 上記を雛形として、あとは tell process "xxxx" 〜 end tell ブロック内を必要なコードに置き換えれば良いのだが、
  • 毎回、コピー&ペーストをして書き換えていたのでは、長期的にはプログラマに優しくない。
    • コードの重複を生むみ、修正するときの修正箇所の多さに四苦八苦する。
    • コードの見渡しが悪く、全体を読み難くなる。(長ったらしいコードがダラダラと続くので)


そこで、機能ごとに再利用できる一般的な部品として切り出しておくことが重要になる。

GUIスクリプティングのチェックを一般化する

  • GUIスクリプティングの有効・無効をチェックする部分は、ほとんど修正無しで、以下のように切り出すことが出来た。
--GUIスクリプティングが無効なら、有効にすることを勧めるメッセージを出力する
on check()
  tell application "System Events"
    if UI elements enabled is false then
      tell application "System Preferences"
        activate
        set current pane to pane "com.apple.preference.universalaccess"
        set msg to "GUIスクリプティングが利用可能になっていません。\n\"補助装置にアクセスできるようにする\" にチェックを入れて続けますか?"
        display dialog msg buttons {"キャンセル", "チェックを入れて続ける"} with icon note
      end tell
      set UI elements enabled to true
      delay 1
      tell application "System Preferences" to quit
      delay 1
    end if
  end tell
end check

メニュー操作を便利にする

  • メニュー操作のポイントは、実際にメニュー操作を行う以下のコード部分。
click menu item "メッセージビューア" of menu "ウインドウ" of menu bar item "ウインドウ" of menu bar 1
  • of を利用した表現は見難いので、以下のように 's を利用した逆順の表現に変形してみた。
click menu bar 1's menu bar item "ウインドウ"'s menu "ウインドウ"'s menu item "メッセージビューア"
  • そして、操作するメニューの階層によって、コード表現は以下のように変化した。(3階層まである編集メニューの例に変更)
click menu bar 1's menu bar item "編集" --編集
click menu bar 1's menu bar item "編集"'s menu "編集"'s menu item "検索" --編集/検索
click menu bar 1's menu bar item "編集"'s menu "編集"'s menu item "検索"'s menu "検索"'s menu item "次を検索" --編集/検索/次を検索
  • つまり、1階層目のメニューバーだけは特殊だけど、2階層目以降は同じパターンの繰り返しなのだ。
    • 1階層目のみ:menu bar 1's menu bar item "ooo"'s
    • 2階層目以降:menu "ooo"'s menu item "xxxx"'s
  • 操作したい最終階層の'sを削除すれば、それがメニューを操作するコードになる。
  • 上記のような仕組みは理解できたのだが、人間が見て一目で分かる書き方とは言えない...。
  • 理想としては、"編集/検索/次を検索" のようなパス文字列でメニュー操作を実現したい。
  • 最初はメニューパスが何回層あるかを確認して if で階層ごとに分けて対応してみた。
  • しかし、これでは操作する階層分だけ対応するコードを書く必要がある。
    • せいぜい5階層ぐらいまで対応すれば、ほとんどの操作は問題ないのかもしれないが...。
  • そこで、ちょっと工夫して、最終的には以下のようにしてみた。(ついでに、アイテム番号によるパス指定にも対応した。)
on click_menu(app_name, menu_path)
  if menu_path is "" then
    error "menu_path が入力されていません。"
  end if
  if app_name is "" then
    set app_name to frontmost_app()
  end if
  set mp to split(menu_path, "/")
  
  tell application "System Events"
    tell process app_name
      if "AppleScript Runner" is in my every_process() or frontmost is false then
        set frontmost to true
      end if
      
      if mp's length = 1 then
        menu bar 1's (menu bar item (my to_number(mp's item 1)))
      else
        --menu bar 1's menu bar item (mp's item 1)'s menu (mp's item 1)
        menu bar 1's (menu bar item (my to_number(mp's item 1)))'s menu 1
        repeat with i from 2 to mp's length
          if i < mp's length then
            --result's menu item (mp's item i)'s menu (mp's item i)
            result's (menu item (my to_number(mp's item i)))'s menu 1
          else
            result's (menu item (my to_number(mp's item i)))
          end if
        end repeat
      end if
      
      click result --click:クリックする/pick:選択する--ほぼ同等だが、アイコンメニューにはclickが必須
      delay 0.1 --連続してメニューを操作する時、ひと呼吸必要
    end tell
  end tell
end click_menu



--起動中のアプリケーション名をリストで取得する
on every_process()
  tell application "System Events"
    processes's name
  end tell
end every_process

--最前面のアプリケーション名を取得する
on frontmost_app()
  tell application "System Events"
    set name_list to processes's name whose frontmost is true
    name_list's first item
  end tell
end frontmost_app



on split(sourceText, separator)
  if sourceText = "" then return {}
  set oldDelimiters to AppleScript's text item delimiters
  set AppleScript's text item delimiters to {separator}
  set theList to text items of sourceText
  set AppleScript's text item delimiters to oldDelimiters
  return theList
end split

on to_number(str)
  try
    str as number
  on error
    str
  end try
end to_number
  • これで、以下のようにものすごくシンプルに書けるようになった!
click_menu("Mail", "ウインドウ/メッセージビューア")

--以下のコードと同等
(*
tell application "System Events"
  tell process "Mail"
    set frontmost to true    
    click menu item "メッセージビューア" of menu "ウインドウ" of menu bar item "ウインドウ" of menu bar 1
  end tell
end tell
*)

キーボードショートカットを便利にする

  • キー操作でも、上記メニュー操作と同じように、シンプルな書き方と便利さを追求してみた。
on shortcut(app_name, key_text)
  if key_text is "" or key_text is {} then
    error "key_text が入力されていません。"
  end if
  if (count of key_text) = 1 then
    set key_list to split(key_text's first item, "-")
  else
    set key_list to split(key_text, "-")
  end if
  set last_key to downcase(key_list's last item)
  
  set modifier_key to {}
  if "command" is in key_list then set modifier_key to modifier_key & command down
  if "option" is in key_list then set modifier_key to modifier_key & option down
  if "control" is in key_list then set modifier_key to modifier_key & control down
  if "shift" is in key_list then set modifier_key to modifier_key & shift down
  
  if last_key is "delete" then
    set last_key to 51 --delete
  else if last_key is "esc" then
    set last_key to 53 --esc
  else if last_key is "←" then
    set last_key to 123 --
  else if last_key is "→" then
    set last_key to 124 --
  else if last_key is "↓" then
    set last_key to 125 --
  else if last_key is "↑" then
    set last_key to 126 --
  else if last_key is "space" then
    set last_key to space
  else if last_key is "tab" then
    set last_key to tab
  else if last_key is "return" then
    set last_key to return
  else if last_key's length is 3 then
    try
      set last_key to last_key as number
    end try
  end if
  
  press_key(app_name, last_key, modifier_key)
end shortcut

--キー操作を実行する
--利用例:
--  press_key("1", command down)
--  press_key(126, command down)
on press_key(app_name, normal_key, modifier_key)
  if app_name is "" then
    set app_name to frontmost_app()
  end if
  
  tell application "System Events"
    tell process app_name
      if "AppleScript Runner" is in my every_process() or frontmost is false then
        set frontmost to true
      end if
      
      if my is_number(normal_key) then
        key code normal_key using modifier_key
      else
        keystroke normal_key using modifier_key
      end if
    end tell
  end tell
end press_key



--起動中のアプリケーション名をリストで取得する
on every_process()
  tell application "System Events"
    processes's name
  end tell
end every_process

--最前面のアプリケーション名を取得する
on frontmost_app()
  tell application "System Events"
    set name_list to processes's name whose frontmost is true
    name_list's first item
  end tell
end frontmost_app



on do_ruby_script(ruby_code)
  set shell_code to "ruby -e \"puts(" & ruby_code & ")\""
  do shell script shell_code
end do_ruby_script

on downcase(str)
  do_ruby_script("'" & str & "'.downcase")
end downcase

on split(sourceText, separator)
  if sourceText = "" then return {}
  set oldDelimiters to AppleScript's text item delimiters
  set AppleScript's text item delimiters to {separator}
  set theList to text items of sourceText
  set AppleScript's text item delimiters to oldDelimiters
  return theList
end split

on is_number(num)
  if num = {} or num = "" then
    false
  else
    (count of num) is 0
  end if
end is_number
  • これで、以下のようにシンプルに書けるようになった!
shortcut("Mail", "command-option-N")

--以下のコードと同等
(*
tell application "System Events"
  tell process "Mail"
    set frontmost to true    
    keystroke "n" using {command down, option down}
  end tell
end tell
*)

最初のひと呼吸

  • 出来上がった便利なAppleScriptは、自分の場合、Quicksilverでショートカットを割り当てて実行することが多い。
  • この時ちょっとしたコツが必要で、スクリプトを実行する最初のところで、ひと呼吸おく必要があるのだ。(環境にもよるが0.2〜0.5秒くらい)
  • このひと呼吸をおかないと、せっかく作った便利なスクリプトが正常に動作しないことがある。(理由は分からない...。)
  • このひと呼吸を click_menu や shortcut に織り交ぜてしまうと、操作する度にひと呼吸おいてしまって、処理が遅くなってしまう。
  • 処理の最初に delay 0.5 と書いても良いのだが、Quicksilverのショートカットから実行するときだけ必要なので、以下のようにしてみた。
--初期化処理(Quicksilverからの起動なら、ひと呼吸置いて実行する)
on init()
  if is_from_quicksilver() then
    delay 0.2
  end if
end init



--Quicksilverから起動しているかどうか
on is_from_quicksilver()
  try
    my name as text
    false
  on error
    true
  end try
end is_from_quicksilver

ライブラリとして保存して利用

  • 以上、出来上がったスクリプトを ~/Library/Scripts/_gui.scpt として保存した。
  • このスクリプト(_gui.scpt)は、以下のようにして再利用することができる。(基本の2例を書き直してみた)
(*ファイル名:spotlight_with_selection.scpt*)
property GUI : load script file ((path to scripts folder as text) & "_gui.scpt")
--set GUI to load script file ((path to scripts folder as text) & "_keyboard.scpt")

GUI's init()
--GUI's check() --キー操作のみの場合は不要
GUI's shortcut("", "command-c")
GUI's shortcut("", "control-space")
GUI's shortcut("", "command-V")
  • property GUI : load script file...としてスクリプトを取り込めば...
    • 上記スクリプト保存時に、_gui.scptもスクリプトオブジェクトとしてプロパティ変数GUIに取り込まれ、同じファイルに保存される。
    • この状態は、spotlight_with_selection.scptに_gui.scptが含まれているので、_gui.scptの存在に関係なく実行できる。
    • このことは、_gui.scptを変更しても、その変更はspotlight_with_selection.scptを再保存するまで反映されないことも意味する。
  • set GUI to load script file...としてスクリプトを取り込めば...
    • 上記スクリプト実行時に、_gui.scptがスクリプトオブジェクトとしてローカル変数GUIに取り込まれる。
    • ローカル変数は保持されず、実行時に読み込まれるので、_gui.scptも実行時に必ず必要になる。
    • _gui.scptを変更すると、その変更はspotlight_with_selection.scptを実行した時に反映される。
(*ファイル名:mail_encoding_auto.scpt*)
property GUI : load script file ((path to scripts folder as text) & "_gui.scpt")

on adding folder items to this_folder after receiving added_items
  tell application "Finder" to open added_items
  
  GUI's init()
  GUI's check()
  GUI's shortcut("Mail", "command-option-1")
  GUI's shortcut("", "command-W")
  GUI's click_menu("", "ウインドウ/メッセージビューア")
  GUI's shortcut("", "command-W")
  GUI's shortcut("", "command-option-N")
end adding folder items to


かなりシンプルに書けるようになった!便利なスクリプトをいっぱい作って楽しよう!