課題
論文を読むときはPDFが好きで、Adobe Acrobatを愛用している。しかし長年悩まされている問題があった。
問題:ページ境界での「ガクン」
Acrobatには「ページごとに表示」と「スクロール表示」の2つの表示モードがある。自分の好みは断然スクロール表示なのだが、PDF側にページごとの表示設定が埋め込まれていると、Acrobat側の初期設定を無視してそちらが優先されてしまう。
これが地味にストレスになる。
ページの終わりの方を読んでいると、突然ガクンとページが切り替わる。今どこを読んでいたか見失う。特に数式や図表が多い論文だと、前後の文脈を行き来しながら読むので、この挙動は本当につらい。
毎回手動で「表示」→「ページ表示」→「スクロールを有効にする」を選択すればいいのだが、論文を開くたびにこれをやるのは面倒すぎる。
解決策:Hammerspoonで自動化
macOSユーザーならHammerspoonという強力な自動化ツールが使える。Luaスクリプトでキーボード操作やGUI操作を自動化できる。
今回はChatGPTの力を借りて、以下の動作をするスクリプトを書いた:
- Acrobatがアクティブになったタイミングで自動的にスクロール表示に切り替える
- Control+7(キーバインドは変更可)で手動でも切り替えられる
実装
~/.hammerspoon/init.luaに以下のようなコードを追加する:
-- Acrobat: 「表示 > ページ表示 > スクロールを有効にする」を未チェック時だけON(GUI)
-- 手動: Ctrl+7
-- 自動: Acrobatが前面(activated) / Acrobatウィンドウの生成・フォーカス・タイトル変化
-- レート制限: 30秒窓で2回まで。3回目以降は30秒クールダウン。
local HOTKEY_MODS, HOTKEY_KEY = {"ctrl"}, "7"
-- 連打防止(内部)
local MIN_GAP_SEC = 0.6
local lastRun = 0
-- 自動トリガーの抑制(2回発火したら30秒待つ)
local BURST_WINDOW_SEC = 30
local BURST_MAX = 2
local COOLDOWN_SEC = 30
local burstCount = 0
local burstStart = 0
local cooldownUntil = 0
local function nowSec() return hs.timer.secondsSinceEpoch() end
local function allowAutoRun()
local t = nowSec()
if t < cooldownUntil then return false end
if burstStart == 0 or (t - burstStart) > BURST_WINDOW_SEC then
burstStart = t
burstCount = 0
end
burstCount = burstCount + 1
if burstCount > BURST_MAX then
cooldownUntil = t + COOLDOWN_SEC
return false
end
return true
end
local function collect(e, pred, out, d)
out, d = out or {}, d or 0
if not e or d > 60 then return out end
if pred(e) then out[#out+1] = e end
local ch = e.AXChildren
if ch then for _,c in ipairs(ch) do collect(c, pred, out, d+1) end end
return out
end
local function menubarItem(ax, title)
local mb = collect(ax, function(x) return x.AXRole=="AXMenuBar" end)[1]
if not mb or not mb.AXChildren then return end
for _,c in ipairs(mb.AXChildren) do
if c.AXRole=="AXMenuBarItem" and c.AXTitle==title then return c end
end
end
local function findMenuItem(ax, title)
for _,m in ipairs(collect(ax, function(x) return x.AXRole=="AXMenu" end)) do
for _,it in ipairs(collect(m, function(x) return x.AXRole=="AXMenuItem" end)) do
if it.AXTitle==title then return it end
end
end
end
local function press(e) if e then e:performAction("AXPress") end end
local function show(e) if e then e:performAction("AXShowMenu") end end
local function isAcrobat(app)
if not app then return false end
local bid = app:bundleID() or ""
local name = app:name() or ""
return (bid:find("com%.adobe%.Acrobat") ~= nil)
or (bid:find("com%.adobe%.Reader") ~= nil)
or (name:find("Acrobat") ~= nil)
end
local function ensureScrollOn(app)
local t = nowSec()
if (t - lastRun) < MIN_GAP_SEC then return false end
lastRun = t
app = app or hs.application.frontmostApplication()
if not isAcrobat(app) then return false end
local ax = hs.axuielement.applicationElement(app)
local view = menubarItem(ax, "表示"); if not view then return false end
press(view); hs.timer.usleep(150000)
local page = findMenuItem(ax, "ページ表示"); if not page then return false end
show(page); hs.timer.usleep(150000)
local scroll = findMenuItem(ax, "スクロールを有効にする")
if not scroll then
press(page); hs.timer.usleep(150000)
scroll = findMenuItem(ax, "スクロールを有効にする")
end
if not scroll then return false end
local mark = scroll.AXMenuItemMarkChar
if not (type(mark)=="string" and mark~="") then press(scroll) end
return true
end
-- 手動
hs.hotkey.bind(HOTKEY_MODS, HOTKEY_KEY, function()
ensureScrollOn()
end)
-- 自動:少し待って数回リトライ(AcrobatのUI準備待ち)
local function kick(app, isAuto)
if isAuto and not allowAutoRun() then return end
if not isAcrobat(app) then return end
local tries = {0.15, 0.45, 0.9}
for _,dt in ipairs(tries) do
hs.timer.doAfter(dt, function()
local a = hs.application.frontmostApplication() or app
if isAcrobat(a) then
a:activate()
ensureScrollOn(a)
end
end)
end
end
-- 1) 他アプリ→Acrobatに切替(activated)
hs.application.watcher.new(function(appName, event, app)
if event ~= hs.application.watcher.activated then return end
-- app が nil の場合があるので前面を取り直す
local a = hs.application.frontmostApplication() or app or (appName and hs.application.get(appName))
if a and isAcrobat(a) then
kick(a, true)
end
end):start()
-- 2) Acrobatで何か開いた/フォーカスが変わった(window系イベント)
hs.window.filter.new(false)
:subscribe({hs.window.filter.windowCreated, hs.window.filter.windowFocused, hs.window.filter.windowTitleChanged},
function(win)
local a = win and win:application() or hs.application.frontmostApplication()
if a and isAcrobat(a) then
kick(a, true)
end
end)
コードを保存したら、メニューバーのHammerspoonアイコンから「Reload Config」を選択。
使い方
- 自動モード: Acrobatに切り替えるだけで勝手にスクロール表示になる
-
手動モード:
Control+7を押すとその場で切り替え
キーバインドを変えたい場合は、最後の行の{"ctrl"}, "7"を好みに変更する。例えば{"cmd", "shift"}, "s"にすればCmd+Shift+Sになる。
注意点
- メニュー項目の名前は日本語環境を想定している。英語環境なら
"View","Page Display","Enable Scrolling"などに変更が必要 - Acrobatのバージョンによってメニュー構造が異なる可能性がある
- ChatGPTで書いたコードなので細かい挙動は完全に把握していないが、とりあえず動いている
まとめ
小さな不満でも積み重なるとストレスになる。Hammerspoonのようなツールを使えば、アプリケーション側が対応していない細かいカスタマイズも自力で解決できる。
論文を読む時間が少しでも快適になれば、それだけ内容に集中できる。こういう環境整備は地味だけど大事だと思う。