---
title: "「no drag hooks」と書いた Framis に drag hook を後から入れた"
description: "Framis の product vision には「No drag hooks, no edge snapping」と明記していました。それでも Window を drag で動かすたびにホットキーで Arrange Mode に入り直すのが面倒になり、drag 中に修飾キー (既定 Control) を押すと overlay が出る動線を後付けで入れました。\"opt-in only\" を保つための `DragScrollMonitor` の State machine、Hold/Toggle の使い分け、修飾キー判定で気をつけた点を扱います。"
category: "Apple"
tags: ["macOS","Framis","Swift","window-manager"]
publishedAt: "2026-06-05"
lastmod: "2026-06-05"
---

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

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

```text
通常 drag: 何も起こらない (no drag hooks のまま)
drag 中に Control を押す: overlay が出る → カーソルの zone に置く
```

::card[https://framis.labee.dev/]

この記事では、「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 の `localMonitor` と `globalMonitor` を張って、`leftMouseDown` / `leftMouseDragged` / `leftMouseUp` / `flagsChanged` / `scrollWheel` を見ています。

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

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

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

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

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

## Hold / Toggle の使い分け

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

```swift
enum DragScrollActivationMode: String, CaseIterable, Identifiable {
  case hold
  case toggle
}
```

- `.hold`: 修飾キーを押している間だけ `activated` を維持。手を離すと overlay が消える
- `.toggle`: 修飾キーの「押した瞬間」で `activated` を切り替え。指を離しても overlay は残る

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

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

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

```swift
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 で再度切り替わると意図と逆になります。

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

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

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

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

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

::card[https://github.com/LabeeHive/Framis-releases]

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

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

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