ㅈㅅㄹ

Emacs를 쓰게 되면서 한 가지 안 좋아진 점은, 왼쪽 새끼 손가락 쪽 인대의 피로도가 예전에 비해 꽤나 많이 높아 졌다는 것이다. 보통 이런 증상을 Repetitive strain injury(RSI), 혹은 좀 더 specific하게는 Emacs pinky라고 하던데, Emacs 쓰기 이전 vi나 source insight를 사용하던 때는 싸구려 멤브레인 키보드로도 그닥 장시간 작업 해도 피로감이 없었으나, Emacs를 쓰고 난 이후부터 증상이 슬슬 나타나기 시작했다. 나름대로는 이 증상을 완화시켜보겠다고 Ctrl 키를 Caps Lock 위치로 바꾼다거나 비싼 키보드를 지른다거나 했지만 (물론 이런 노력을 하지 않던 예전에 비해 확실히 덜하긴 하지만) 여전히 퇴근할 때 쯤에는 왼팔꿈치 근처나 새끼 손가락쪽 인대가 묵직하게 저려 오는 느낌이 있다.


그래서 최대한 왼쪽 새끼손가락으로 모드 키, 즉 Ctrl Alt Shift를 누르는 회수를 줄이려는 노력을 계속 하고 있는데, 이것도 그 중의 한 가지 일환이다. 아래의 소스에서 보듯 my:read-only-mode 마이너 모드는, buffer-read-only가 non-nil일 때, 즉 버퍼가 읽기 전용 모드일 때 모드 키 없이 일반 문자로 커서 이동을 할 수 있게 해 준다. 마치 read-only 상태를 vi에서의 modal 상태처럼 다룬다고 보면 될 것 같다.


그러나 특정 메이저/마이너 모드에서는 특정 버퍼를 읽기 전용으로 띄우고 모드 키 없는 바인딩을 자체적으로 추가하기도 하는데, 보통 이 경우는 단순히 커서 이동 뿐만 아니라 이동 된 위치의 아이템에 대한 상세 정보를 다른 윈도우에 띄우기도 한다. 가령 magit에서 n이나 p 키는 라인이 아니라 섹션 단위로 이동하는 역할을 하고 때에 따라선 옮겨진 위치의 정보를 다른 윈도우에 표시하는 등 부가적인 기능을 수행하기도 하는데, 이와 같이 해당 키에 기존에 정의된 바인딩이 있다면 해당 내용을 단순한 커서 이동으로 덮어 쓰지 않도록 해 줄 필요가 있다.

(defconst my:read-only-keybind-alist '(
  ("p" . previous-line)
  ("n" . next-line)
  ("f" . forward-char)
  ("b" . backward-char)
  ("a" . beginning-of-line)
  ("e" . end-of-line)

  ;; vi-like
  ("h" . (lambda ()
           (interactive)
           (if (bolp) (error "Beginning of line")
             (backward-char))))
  ("l" . (lambda ()
           (interactive)
           (if (eolp) (error "End of line")
             (forward-char))))
  ("j" . next-line)
  ("k" . previous-line)

  ;; page scroll
  ("SPC" . my:scroll-up-command)
  ("u" . my:scroll-down-command)

  ) "key binding for read-only buffer")

(defvar my:read-only-overridden-keys nil)
(make-variable-buffer-local 'my:read-only-overridden-keys)

(defface my:read-only-face
  '((t :foreground "yellow" :background "red"))
  "Used for readonly lighter"
  :group 'basic-faces)

(defvar my:read-only-mode-lighter
  (list " " (propertize "Readonly" 'face `my:read-only-face)))
(put 'my:read-only-mode-lighter 'risky-local-variable t)

(define-minor-mode my:read-only-mode
  "Control-key-less keybind for read-only buffer."
  :lighter my:read-only-mode-lighter
  :variable buffer-read-only
  (let ((overwritten-keys my:read-only-overridden-keys))
    (if buffer-read-only
        (progn (dolist (keybind my:read-only-keybind-alist)
                 (unless (and (current-local-map)
                              (lookup-key (current-local-map) (kbd (car keybind))))
                   (add-to-list 'overwritten-keys (car keybind))
                   (buffer-local-set-key (kbd (car keybind)) (cdr keybind))))
               (setq my:read-only-overridden-keys overwritten-keys))
      (dolist (key overwritten-keys)
        (buffer-local-set-key (kbd key) nil))
      (setq my:read-only-overridden-keys nil)))
  (read-only-mode (if buffer-read-only 1 -1)))

buffer-local-set-key 함수는 EmacsWiki에서 참고한 것으로, Emacs 자체(혹은 최소한 GNU Emacs)에서 버퍼별로 keymap을 할당할 수 있는 방법이 없기 때문에 해당 버퍼 전용 minor mode를 만들어서 거기에다 키를 바인딩한다.

;; https://www.emacswiki.org/emacs/BufferLocalKeys
(defun buffer-local-set-key (key func)
      (interactive "KSet key on this buffer: \naCommand: ")
      (let ((name (format "%s-magic" (buffer-name))))
        (eval
         `(define-minor-mode ,(intern name)
            "Automagically built minor mode to define buffer-local keys."))
        (let* ((mapname (format "%s-map" name))
               (map (intern mapname)))
          (unless (boundp (intern mapname))
            (set map (make-sparse-keymap)))
          (eval
           `(define-key ,map ,key func)))
        (funcall (intern name) t)))

위와 같이 정의했다면 my:read-only-mode을 원래 read-only-mode의 키인 C-x C-q에 지정하자. 내 경우는 왼쪽 모드키를 덜 누르는 게 지상과제이니 만큼 <f12>에도 바인딩해 주었다.

(global-set-key (kbd "C-x C-q") 'my:read-only-mode) ; override default key
(global-set-key (kbd "<f12>") 'my:read-only-mode)

이제 C-x C-q이나 <f12>을 눌러 버퍼를 읽기 전용으로 세팅하면 모드 키를 누르지 않고 이동을 할 수 있게 되지만, 기본적으로 읽기 전용 모드인 버퍼, 특히 *Help* 버퍼처럼 모드 키 없이 커서 이동이 가능한 바인딩이 되어 있지 않은 버퍼들에 대해서도 읽다보면 라인이나 페이지 이동을 자주 쓸 일이 있으므로 위 키 바인딩을 적용할 필요가 있다.

;; add my:read-only-mode when the buffer-read-only is set
(add-hook 'buffer-list-update-hook
          (lambda ()
            (with-current-buffer (current-buffer)
              (when (and buffer-read-only
                         (not (member 'my:read-only-mode minor-mode-list)))
                (my:read-only-mode 1)))))

buffer-list-update-hook에 hook을 추가하여 버퍼가 읽기 전용일 때, 즉 buffer-read-only 버퍼 로컬 변수가 non-nil일 때, my:read-only-mode가 적용이 되어 있지 않다면 적용하도록 해 주었다. buffer-list-update-hookGNU Emacs Lisp Manual에서 아래와 같이 설명하듯,

This is a normal hook run whenever the buffer list changes. Functions (implicitly) running this hook are get-buffer-create (see Creating Buffers), rename-buffer (see Buffer Names), kill-buffer (see Killing Buffers), bury-buffer (see above) and select-window (see Selecting Windows).
버퍼 리스트의 순서나 element가 추가/삭제 될 경우 호출 되므로 이 시점에 my:read-only-mode를 적용하도록 하는 것이 가장 이상적으로 보였다.


그러나 이렇게 적용하고 난 후 magit-popup-mode에서는 키가 바인딩 되는 시점의 문제인지 오히려 my:read-only-mode 의 키 바인딩 때문에 기존 magit popup의 키가 제대로 적용되지 않는 문제가 생겼다. 대충 봐선 타이밍 이슈로 보이긴 하는 데 더 이상 깊게 들여다보고 싶지도 않고, 사실 magit popup에서는 커서 이동이 거의 필요가 없으므로, (차후에 또 어떤 메이저 모드에서 말썽일지 모르므로 준비 차원에서) my:read-only-mode를 적용하지 않을 blacklist를 작성하는 식으로 처리했다. 아래는 위의 hook을 수정한 결과이다.

(defconst my:read-only-mode-blacklist '(
  magit-popup-mode
  ) "The blacklist which can not run well with my:read-only-mode.")

;; add my:read-only-mode when the buffer-read-only is set
(add-hook 'buffer-list-update-hook
          (lambda ()
            (with-current-buffer (current-buffer)
              (when (and buffer-read-only
                         (not (member major-mode my:read-only-mode-blacklist))
                         (not (member 'my:read-only-mode minor-mode-list)))
                (my:read-only-mode 1)))))

코드를 작성할 때야 어쩔 수 없어도, 읽을 때는 읽기 전용 모드 켜 놓고 모드 키 없이 이동하니 한결 손이 편해지는 느낌이다. 사실 일하면서 코드 작성하는 것 보다 읽는 시간이 훨씬 많은 것 같은 데 그 때마다 라인이나 페이지 이동할 때마다 모드 키 입력이 필요하던 걸 이런 식으로 줄여 놓으니 확실히 차이가 보이는 듯. 사실 누가 보면, 이럴거면 viper-modeevil-mode 쓰지 왜 그러냐 하겠지만 기본적인 이동 키 외의 vi 키는 고작 1년 안썼다고 익숙치가 않기도 하고 다시 vi 바인딩으로 돌아가는 건 초가 삼간 태우러 가는 일을 하게 될 것 같은 기분이 들기도 해서 그다지 끌리지 않는다.