Hatena::ブログ(Diary)

備忘録

2011-12-05

[] Emacs は外部 elisp がなくても強い (Emacs Advent Calendar jp: 2011 5日目) 17:49

この記事は Emacs Advent Calendar jp: 2011 の5日目です. 4日目は HKey さんの パスをまとめよう でした. 6日目は id:kiwanami さんです.kiwanami さんの elisp にはいつもお世話になっているので,とても楽しみです.

今回は外部 elisp に頼らない,Emacs に標準で入っているけどあまり知られていなさそうな便利機能,あるいは数行で書けるカスタマイズや便利コマンドを紹介したいと思います.紹介するのは以下の機能です.

  • 連続 pop-mark
  • プレフィックスキーを増やす
  • パスを1階層ずつ削除するコマンド
  • 相対的なカーソル位置を動かさないスクロール

について順番に説明していきたいと思います.

連続 pop-mark

Emacs のバッファでは,C-SPC あるいは C-@ (`set-mark-command') でカーソルの位置にマークを付けることができます.このマークとカーソルの間が選択領域になり,その領域をコピーしたりキルしたりする機能はみんな使っていることと思います.

`set-mark-command' 以外にも `isearch' や `beginning-of-buffer' を実行した際などに人知れずマークを変更しているコマンドがあります.これらのコマンドでマークが変更されるたびに,古いマークは `mark-ring' にスタックのように保存されていきます*1

この `mark-ring',実は C-u C-SPC (`pop-mark' というコマンドを実行) によって古いマークを順番に辿っていくことができます.具体的に使い方を見てみましょう.

|This is a test sentence. `pop-mark' can restore position of cursor.

Emacs のバッファ内に上記のような文章があり,| がカーソルの位置,[] がマークの位置であるとします.ここで C-s ('isearch-forward`) で "sentence" を検索し RET で検索を終了するとカーソル位置は

[]This is a test sentence|. `pop-mark' can restore position of cursor.

このように移動します.この時,`isearch' によって文頭の位置にマークが変更されています.さらに "cursor" で検索して RET すると

This is a test sentence[]. `pop-mark' can restore position of cursor|.

こうなります.先程と同様に `isearch' によって検索前のカーソル位置にマークが変更されています.文頭にあった1つ前のマークは `mark-ring' にプッシュして保存されています.ここで C-u C-SPC をタイプすると

[]This is a test sentence|. `pop-mark' can restore position of cursor.

このようにカーソル位置がマークの位置に戻ります.マーク自体も `mark-ring' から取り出してきた1つ前のマークの位置に戻ります.これが `pop-mark' の機能です.さらにもう一回 C-u C-SPC をタイプすると

|This is a test sentence[]. `pop-mark' can restore position of cursor.

またマークの位置にカーソルを移動します.すなわち最初のカーソル位置に戻ることになります.このようにして,古いマークをどんどん辿っていくことができます.ソースを見ている際にある関数が呼ばれているので,関数名を `isearch' してその関数定義を確認した後,また元の場所に戻ってくるといったようなことがこの機能を使えば簡単に出来ます.

しかし連続でマークを辿る際に C-u C-SPC C-u C-SPC ... を連続で入力するのはめんどくさい.ので

;; enable to pop `mark-ring' repeatedly like C-u C-SPC C-SPC ...
(setq set-mark-command-repeat-pop t)

この設定を init.el に書いておくと C-u C-SPC C-SPC C-SPC... のように C-SPC を連続で入力するだけで,連続でマークを辿れるようになります.`pop-mark' は非常に簡便かつ便利な機能なので,ぜひ使ってみてください.

プレフィックスキーを増やす

Emacs をカスタマイズし始め自分で独自のキーバインドを増やし始めると,割り当てるキーが不足してくるものです.これを解決するには複数の機能をまとめたコマンドを使う,キーを増やすような外部 elisp(key-chord.el とか)といった対策があると思います.しかしここではもっと簡単な,いらない1ストロークのキーをプレフィックスキーにして,2ストロークキーを増やす方法を説明したいと思います.

まずおそらく一番使用頻度が低いであろう C-q (`quoted-insert') を潰して,プレフィックスキーにすることにします.最も単純には,以下のようにすれば2ストロークキーを定義することができます.

(define-key global-map (kbd "C-q") nil)
(define-key global-map (kbd "C-q" "C-q") 'quoted-insert)
(define-key global-map (kbd "C-q" "C-t") 'toggle-truncate-lines)

しかしこれは直接2ストロークを指定しているので,プレフィックスキーを C-q から C-z したいといったことがめんどくさかったりします.そこで自分独自のキーマップを定義して,そのキーマップをプレフィックスキーに割り当てる方法のほうがなにかと便利です.その場合はこのようになります.

(defvar my-original-map (make-sparse-keymap) "My original keymap binded to C-q.")
(define-key global-map (kbd "C-q") my-original-map)

新しいキーマップは `make-sparse-keymap' で作成することができるので,それを好きな名前で定義します.そのキーマップを C-q に割り当てれば C-q がプレフィックスキーになります.`define-key' の最後の引数はキーマップそのものを指定するのでクオートしないことに注意してください.あとは `my-original-map' にコマンドを割り当てればどんどん2ストロークキーを定義することができます.

上では,プレフィックスキーに直接キーマップを割り当てましたが,キーマップを呼び出すための関数を割り当ててもよいです.こんな感じです.

(defvar my-original-map (make-sparse-keymap) "My original keymap binded to C-q.")
(defalias 'my-original-prefix my-original-map)
(define-key global-map (kbd "C-q") 'my-original-prefix)

`defalias' で `my-original-prefix' の定義をキーマップにします.これで,普通のコマンドのように `define-key' でプレフィックスキーに割り当てられます*2.2番目と3番目の方法の違いは,`describe-bindings' で表示される名前が変わってきます.2番目の場合,C-q は Prefix Command と表記されます.関数が割り当てられていないので名前がわからないということでしょう.3番目であれば,C-q は my-original-prefix と表記されることになり,なんのためのプレフィックスかが一目瞭然となります.どちらを選ぶかは好みになるでしょうか.

また,`define-prefix-command' を使えば `defvar' と `defalias' をひとまとめにすることもできます.

(define-prefix-command 'my-original-map)
(define-key global-map (kbd "C-q") 'my-original-map)

キーマップを保持する変数名と,それを呼び出す関数名が同じ `my-original-map' になりますが,それが気にならなければこの方法でもいいと思います.しかし,これだと DOCSTRING が書けないので個人的には3番目の方法で書いています.

自分のオリジナルキーマップの一部を晒してみます.

;; original key map (bind to C-q)
(defvar my-original-map (make-sparse-keymap)
  "My original keymap binded to C-q.")
(defalias 'my-original-prefix my-original-map)
(define-key global-map (kbd "C-q") 'my-original-prefix)
(define-key my-original-map (kbd "C-q") 'quoted-insert)
(define-key my-original-map (kbd "C-t") 'toggle-truncate-lines)
(define-key my-original-map (kbd "C-l") 'linum-mode)
(define-key my-original-map (kbd "C-r")
  '(lambda () (interactive) (revert-buffer nil t t)))
(define-key my-original-map (kbd "C-c") 'column-highlight-mode)
(define-key my-original-map (kbd "TAB") 'auto-complete) ; あえて手動で補完したい時

おおむねトグル系のコマンドや,使用頻度は高くないけどたまーに必要なものを割り当てています.C-q C-q の `quoted-insert' は特殊文字を入力する際に必要になります.C-q C-t の `toggle-truncate-lines' はバッファの折り返しをトグル,C-q C-l は行番号の表示をトグルします.この2つは結構頻繁に切り替えたいので,割り当てておくと便利です.

C-q C-r は警告なしで `revert-buffer' します.Dropbox で共有したファイルを編集していると,別の場所で編集したファイルを開きなおすことがあるので割り当てました.最後の2つは外部 elisp の関数です.`column-highlight-mode' はカーソルのあるカラムをハイライトします.elisp を書く際にインデントが揃っているか確認するのに便利です.`auto-complete' は自動的に補完をしてくれる関数ですが,たまに手動で補完を開始したい時があるので割り当てています.

おまけですが,すでに定義されているキーマップを別のプレフィックスキーに割り当てることも当然出来ます.

(define-key global-map (kbd "C-4") 'ctl-x-4-prefix)
(define-key global-map (kbd "C-5") 'ctl-x-5-prefix)
(defalias 'ctl-x-r-prefix ctl-x-r-map)
(define-key global-map (kbd "S-C-r") 'ctl-x-r-prefix)

こうすると,C-x 4 f (`find-file-other-window') や C-x r t (`string-rectangle') といった長ったらしい3ストロークのキーを2ストロークで入力できるようになります.特に C-x 4 の other-window 系の関数はが2ストロークで使えるのは超絶便利です*3

パスを1階層ずつ削除

`find-file' などでプロンプトにパスを入力する際,現在のディレクトリがプロンプトにあらかじめ入力されておりカーソルがその右端に置かれている場合が多くあります.同じディレクトリのファイルを入力する場合はいいのですが,他のディレクトリのファイル名を入力したい場合もあり,いちいちパスの階層を backspace などで削除するのも手間です.

というわけで,パスを1階層ずつ削除するコマンドを書きました.

(defun my-minibuffer-delete-parent-directory ()
  "Delete one level of file path."
  (interactive)
  (let ((current-pt (point)))
    (when (re-search-backward "/[^/]+/?" nil t)
      (forward-char 1)
      (delete-region (point) current-pt))))
(define-key minibuffer-local-map (kbd "M-^") 'my-minibuffer-delete-parent-directory)

このコマンドでカーソルの左にある "/" までを削除してくれます.例えば,プロンプトで "~/.emacs.d/site-lisp/migemo.el" が入力されている状態で3回コマンドを実行すると以下のようになります.

Find File: ~/.emacs.d/site-lisp/migemo.el|
Find File: ~/.emacs.d/site-lisp/|
Find File: ~/.emacs.d/|
Find File: ~/|

短いコマンドですが,効果は上々です.パスが "~/" だけになったときに上の階層にさかのぼれないなどの問題はありますが,自分では非常に対症療法な対策しか思いつかないので,ハックしてくれる方募集中です.

`minibuffer-local-map' に割り当てればプロンプト中で使うことができます.M-^ に割り当てたのは,`global-map' で M-^ に割り当てられている `delete-indentation' とイメージが似てるなーと思ったらからです*4.機能的には <C-backspace> でもイメージしやすいかもしれません.

相対的なカーソル位置を動かさないスクロール

C-v (`scroll-up') をタイプするとバッファ内の画面を上にスクロールさせることができます.この時カーソルはウィンドウの一番上に移動してしまいます.これでは C-v でバッファ内の目的の場所まで画面をスクロールした後,ウィンドウの一番上から C-n などで目的の行まで行移動をすることになります.

この挙動は個人的にあまり好みではありませんでした.編集しているときは大概カーソルはウィンドウの真ん中辺りにあるのだから,カーソルはその位置を保ったままスクロールし,真ん中から細かい行移動をする方が効率的かなーと思いました.Vi/Vim の C-d, C-u がちょうどカーソルを動かさずに画面をスクロールします.Emacs にはそんなコマンドはないようなので,Emacs Lisp の練習がてら自分で書いてみました.

まず相対的なカーソル位置を保存しないといけないので,

  • ウィンドウ内でカーソルが何行目にあるかを取得する関数

が必要になります.またバッファが折り返されている場合,論理行数ではなく物理行数を数える必要があります.そのためには

  • 文字列の幅(カラム数)を返す関数

が必要になります.その2つの関数が以下のようになります.

(defun my-count-lines-window ()
  "Count lines relative to the selected window. The number of lines begins 0."
  (interactive)
  (let* ((window-string (buffer-substring-no-properties (window-start) (point)))
         (line-string-list (split-string window-string "\n"))
         (line-count 0)
         line-count-list)
    (setq line-count (1- (length line-string-list)))
    (unless truncate-lines      ; consider folding back
      ;; `line-count-list' is list of the number of physical lines which each logical line has.
      (setq line-count-list (mapcar '(lambda (str)
                                       (/ (my-count-string-columns str) (window-width)))
                                    line-string-list))
      (setq line-count (+ line-count (apply '+ line-count-list))))
    line-count))

(defun my-count-string-columns (str)
  "Count columns of string. The number of column begins 0."
  (with-temp-buffer
    (insert str)
    (current-column)))

`my-count-lines-window' でカーソル位置がウィンドウ内の何行目かがわかります.折り返しの境界近くにカーソルがあると1ぐらいずれるかもしれませんが,大体の場合は大丈夫のはずです.

この2つの関数さえできてしまえば,あとは 'scroll-up' と `scroll-down' がカーソル位置を保つようにアドバイスします.

(defadvice scroll-up (around scroll-up-relative activate)
  "Scroll up relatively without move of cursor."
  (let ((line (my-count-lines-window)))
    ad-do-it
    (move-to-window-line line)))

(defadvice scroll-down (around scroll-down-relative activate)
  "Scroll down relatively without move of cursor."
  (let ((line (my-count-lines-window)))
    ad-do-it
    (move-to-window-line line)))

これで,C-v でカーソル移動がしなくなり心持ち負担が減ったように思います.

ついでに,先ほど話しに出した Vi/Vim の C-d, C-u にあたる半画面スクロールや1行ずつスクロールするキーバインドも定義しています.

(define-key global-map (kbd "H-d")
  '(lambda () (interactive) (scroll-up (/ (window-height) 2))))
(define-key global-map (kbd "H-u")
  '(lambda () (interactive) (scroll-down (/ (window-height) 2))))

(define-key global-map (kbd "H-n") '(lambda (arg) (interactive "p") (scroll-up arg)))
(define-key global-map (kbd "H-p") '(lambda (arg) (interactive "p") (scroll-down arg)))

1行スクロールはソースコードを1行ずつ読み進めるのに便利だったりします.

実は `my-count-string-columns' を書いた後,`string-width' という関数があることに気づきました.`string-width' も文字列の幅を取得する関数で全く同じ目的の関数です.ただ `string-width' はタブ文字を固定幅で数えるみたいなので,`my-count-string-columns' の方がより正確なのかと思います.そのまま使うことにしました.

おわりに

あまりまとまりのない項目を長々と書いてしまいましたが,このように細かい挙動を自分の好きにカスタマイズできるのが,やはり Emacs の強いところだということが伝われば幸いです.

*1:ちなみに,`isearch' は C-g で検索を終了すると,カーソルが検索を開始する前の位置に戻りマークは変更されません.C-g 以外の要因で検索が終了した場合のみマークが保存されます

*2:`ctl-x-4-prefix' や `ctl-x-5-prefix' は subr.el でこのようにして定義されています

*3:ただし端末上では C-4 や S-C-r といったキーが使えないのが残念です

*4:この話とは全く関係有りませんが,`delete-indentation' も便利なコマンドなので使ってみることをお勧めします

rudirudi 2011/12/05 13:18 「パスを1階層ずつ削除」ですが、私は
(define-key minibuffer-local-completion-map "\C-w" 'backward-kill-word)
としています。

condotticondotti 2011/12/06 10:55 「パスを1階層ずつ削除」ですが、私は M-DEL(backward-kill-word)です。

mhayashi1120mhayashi1120 2011/12/07 23:47 あれ。`string-width' って `tab-width' の影響ありません?
たぶん、`my-count-string-columns' も。

> Tabs in STRING are always taken to occupy `tab-width' columns.

(let ((tab-width 1)) (string-width "\t"))
=> 1

(let ((tab-width 2)) (string-width "\t"))
=> 2

kbkbkbkb1kbkbkbkb1 2011/12/08 04:01 > rudyさん、condottiさん
`backward-kill-word' だとパスに記号や空白が含まれるとその前までしか削除されないので、あえて自作しました。単語単位で削除したい場合もあるので自分は `global-map' の M-h に `backward-kill-word' をわりあてて使っています。

> mhayshi1120さん
本文中の固定幅というのは、「タブ文字の幅は `tab-width' の値に固定されて数えられる」という意図でした。
実際のバッファでは、タブ文字の前にある文字によってタブ文字の幅は変わってきますが、`my-count-string-columns' はそこが正確に数えられます。
もちろん両関数とも `tab-width' によって返す値は変わってきます。
以下の例を見れば、わかっていただけると思います。

(let ((tab-width 2))
(string-width "\t") ; => 2
(my-count-string-columns "\t") ; => 2
(string-width "a\t") ; => 3
(my-count-string-columns "a\t") ; => 2
(string-width "aa\t") ; => 4
(my-count-string-columns "aa\t") ; => 4
)
(let ((tab-width 4))
(string-width "\t") ; => 4
(my-count-string-columns "\t") ; => 4
(string-width "a\t") ; => 5
(my-count-string-columns "a\t") ; => 4
(string-width "aa\t") ; => 6
(my-count-string-columns "aa\t") ; => 4
)

tkf41tkf41 2011/12/08 17:32 scroll-up/down は、以下の設定で同じことができるみたいです。
(setq scroll-preserve-screen-position t)
http://superuser.com/a/184421