Engineering

「no drag hooks」と書いた Framis に drag hook を後から入れた

  • macOS
  • Framis
  • Swift
  • window-manager
  • UI

Framis の product vision には「No drag hooks, no edge snapping」と明記していました。常駐 UI で「うっかり触ってウィンドウが飛ぶ」事故を避けるため、起動はホットキーだけに振り切っていました。

それでも、Window を手で drag して動かしている途中に「これを左に置きたい」と頭の中で決まることが続き、毎回 drag を放してホットキーで Arrange Mode に入り直す動線が地味に効いてきました。結局、drag 中に修飾キー (既定は Control、設定で Option / Shift / Cmd への切り替え可) を押すと Arrange Mode の overlay が出る動線を後付けで入れました。

通常 drag: 何も起こらない (no drag hooks のまま)
drag 中に Control を押す: overlay が出る → カーソルの zone に置く
FramisZones first. Windows second. | FramisA macOS window manager that flips the script. Pick the spot from a floating panel, then choose what goes there. Free, native, keyboard-driven.

この記事では、「no drag hooks」と書いた前提を崩した理由、DragScrollMonitor の State machine、Hold/Toggle の使い分け、修飾キー判定で気をつけた点を順に扱います。

「no drag hooks」を書いた狙いと、それを崩した理由

product-vision.md の柱の1つは、Framis を触っていない時間にうっかり起動して事故が起きないようにすることでした。drag フックや edge snapping は、ユーザーが「整理したい」と思っていなくても誤って発火しがちなので、明示的に切ってありました。

実装が育ってきて分かったのは、「整理したくない時間の事故」と「整理したい時間の入口」を同じハードルで扱っていた点です。drag で Window を動かしている時点で、ユーザーは少なくともウィンドウ位置に関心が向いています。そこに「修飾キー」というもう1段の意思表示を載せれば、誤発火の心配と「いま整理したい」の動線が両立できそうでした。

書き直したのは「opt-in only」の運用解釈です。drag 中に何かを押すまで何も起きない、押した瞬間から overlay が出る、という形なら、明示的な意思表示は残ります。「常駐したくない」「うっかり触りたくない」という当初のねらいは保ったまま、入口を1本足した、という判断に落ち着きました。

DragScrollMonitor の State machine

実装は DragScrollMonitor.swift という Service にまとめました。NSEvent の localMonitorglobalMonitor を張って、leftMouseDown / leftMouseDragged / leftMouseUp / flagsChanged / scrollWheel を見ています。

enum State: Equatable {
  case idle
  case pendingDrag
  case dragging
  case activated
}

遷移の流れはこうなっています。

  • idlependingDrag: 左クリックで mouse down。カーソル下の Window を AX で capture
  • pendingDragdragging: マウスが少しでも動くと進む。capture が失敗していたら retry
  • draggingactivated: ここで修飾キー (既定 Control) を押すと overlay 表示
  • activatedidle: 左マウスを離すと、そのときカーソルがある zone に capture 済み Window を配置して終了

State を 4 段階に分けているのは、副作用を起こす境界を明示するためです。activated に入った瞬間にだけ overlay が現れ、activated の終端でだけ Window が動きますpendingDragdragging のままなら、ユーザーから見て「修飾キーを押していない普通の drag」と区別がつきません。これが Framis の opt-in 性を担保している部分です。

leftMouseDown の時点で Window capture を試みているのは、AX 経由の Window ID 取得が drag 開始直前のフレームで失敗することがあるためです。dragging 中にも最大5回まで windowIDRetryThrottle = 0.1 のスロットルで retry する設計にしています。

Hold / Toggle の使い分け

activationMode には .hold.toggle の2つの選択肢を用意しました。設定画面で切り替えられます。

enum DragScrollActivationMode: String, CaseIterable, Identifiable {
  case hold
  case toggle
}
  • .hold: 修飾キーを押している間だけ activated を維持。手を離すと overlay が消える
  • .toggle: 修飾キーの「押した瞬間」で activated を切り替え。指を離しても overlay は残る

drag は片手とマウスなので、修飾キーをずっと押し続ける .hold だと別の指が窮屈になる手の置き方があります。.toggle はその逃げ道として用意しました。トリガーキーを押している間だけ動く .hold のほうが「離した瞬間 = 確定」の感覚は素直なので、既定は .hold にしてあります。

修飾キー判定で気をつけたところ

NSEvent.modifierFlags をそのまま比較すると、CapsLockNumericPad のビットが混ざってきます。判定したいのは Shift / Control / Option / Command の4種類だけです。

static let activationModifierMask: NSEvent.ModifierFlags = [
  .shift, .control, .option, .command,
]

let matched =
  flags.intersection(Self.activationModifierMask) == triggerModifiers

deviceIndependentFlagsMask を素朴に使うと CapsLock がついた瞬間に判定が崩れます。Framis ではマスクを自前で切って exact match する形にしています。

.toggle のときは press エッジ(押し下げの瞬間)でしか toggle しないようにしています。flagsChanged は押した瞬間と離した瞬間の両方で飛んでくるので、release で再度切り替わると意図と逆になります。

case .toggle:
  // press edge (false → true) でのみ toggle、release では何もしない
  guard !wasModifiersMatched, matched else { return }
  handleToggle()

wasModifiersMatched を直前の判定結果として保持しておき、false → true の瞬間だけ handleToggle() を呼ぶ作りです。

drag 中の scroll で layout を切り替える

副次効果として、activated 中のスクロールで layout を前後に切り替えられるようにしました。

case .scrollWheel:
  // momentumPhase が空 ([]) でない = 慣性スクロール由来 → filter
  guard event.momentumPhase == [] else { return }
  handleScrollDelta(event.scrollingDeltaY, timestamp: event.timestamp)

Magic Trackpad の慣性スクロールがそのまま入ると意図しない切り替えが連発するため、momentumPhase == [] で慣性由来のイベントを落としています。scrollingDeltaY を accumulate していき、scrollMinDelta(=2.0) を超えたら方向だけ delegate に投げます。

「Window を持ち上げたまま、画面のレイアウトを切り替えて、置きたい場所に置く」一連の動作をマウスから手を離さずにできる体験が、結果として一番効きました。

GitHubGitHub - LabeeHive/Framis-releases: Framis — Zone-first window management for macOSFramis — Zone-first window management for macOS. Contribute to LabeeHive/Framis-releases development by creating an account on GitHub.

vision の文面ではなく狙いで判断する

「no drag hooks」を取り下げて drag hook を入れた、と書くと product vision の方針転換に見えますが、書いてみると「opt-in only」の解釈を運用側で広げただけです。drag という別チャネルでも、「修飾キーを押す」明示的な意思表示があるまで何も起こらない設計を保てれば、当初のねらいは崩れません。

vision の文面を絶対視するより、文面の狙いに照らして運用を組み直すほうが、ユーザーが楽になる場面が出てきます。今回の drag フック追加もその例で、次に何か入れるときも同じ判断軸で考えるつもりです。