2022-01-01

Emacsでdisplayプロパティを使って改行を置き換えると非常に遅くなる件

私はDiredをファイル名が一番左に来るように改造して使っているのですが、ファイル数が多いディレクトリを開くと動作が重くなって困ることが度々ありました(一時的に効果を切れば回避できます)。

オーバーレイが多いから仕方が無いくらいに思っていたのですが、今日少し調べたら原因は行末の "\n" を "文字列… \n" に置き換えているのが原因だと分かりました。オーバーレイでもテキストプロパティでも関係ありません。

次のコードは "\n" を "EOL\n" に置き換えるdisplayプロパティがついた文字列を20000行追加するものです(バッファにはオーバーレイではなくテキストプロパティのdisplayプロパティが設定されます)。

(dotimes (_ 20000)
  (insert "1234567890" (propertize "\n" 'display "EOL\n"))) ;;NG

結果の見た目は次のようになります。

1234567890EOL
1234567890EOL
...19997行略...
1234567890EOL

scratchバッファで実行した後バッファの末尾でprevious-line(C-p)してみると一行上に移動するのに1秒程度かかります。上に行けば行くほど時間は短くなり、バッファの冒頭付近では全く気がつかないくらいの時間になります。

"\n" を置換しなければこの現象は発生しません。例えば "0" を "0EOL" に置換しても(見た目は同じですが)全く遅くはなりません。

(dotimes (_ 20000)
  (insert "123456789" (propertize "0" 'display "0EOL") "\n")) ;;OK

オーバーレイのbefore-stringで"\n"の前に文字列を挿入しても(C-pは)遅くなりません(挿入自体の時間はテキストプロパティに比べてややかかります)。

(dotimes (_ 20000)
  (insert "1234567890\n")
  (let ((ov (make-overlay (1- (point)) (point)))) ;;\nのところを覆う
    (overlay-put ov 'before-string "EOL") ;;OK
    (overlay-put ov 'evaporate t)))

また、空の範囲のオーバーレイを許容するのであれば、"0"と"\n"の間にオーバーレイを挟むこともできます。この場合displayプロパティは効かないのでbefore-stringかafter-stringを使うことになります(evaporateが使えないので消すのが面倒になるので注意)。

(dotimes (_ 20000)
  (insert "1234567890\n")
  (let ((ov (make-overlay (1- (point)) (1- (point))))) ;;\nの前の空の範囲!
    (overlay-put ov 'after-string "EOL"))) ;;OK: before-stringでも同じ。displayは空の範囲では表示されないので使えない
;; 消すときは (remove-overlays (point-min) (point-max)) あたりで。

displayプロパティで "\n" 込みの文字列で置換してしまうと、やっぱり遅くなるわけです(激重注意)。

(dotimes (_ 20000)
  (insert "1234567890\n")
  (let ((ov (make-overlay (1- (point)) (point)))) ;;\nのところを覆う
    (overlay-put ov 'display "EOL\n") ;;NG
    (overlay-put ov 'evaporate t)))

しかもテキストプロパティに比べて格段に遅いです。一行上に移動するのに何十秒もかかります。

私が"\n"を置換したかったのは、そうしないとカーソルをファイル名の末尾に置けないからです。例えば上の問題が起きないどのケースを使用しても"0"の直後にカーソルを置くことができません。"0"を指しているところでforward-charすると"0"の直後ではなく"EOL"の直後に飛んでしまいます。"0"を"0EOL"に置換した場合ならともかく、"\n"にbefore-stringをかけたときはbefore-stringの前にカーソルが来て欲しいものですが残念ながらそうはなりません。diredで表示を変えるだけならそれほど問題にはならないのですが、wdiredでファイル名を直接編集するときには問題になります(対策はwdiredが起動したら一時的に効果を消すくらいか)。

面白いのは一行下に移動するnext-line(C-n)は遅くならないこと。また、同じ一行上に移動するのでもM-: (forward-line -1)では遅くなりません。(previous-line)は(forward-line -1)に比べると色々な処理を追加で行っているので、そのどこかに原因があるのでしょう。previous-line → line-move → line-move-1 → vertical-motion と呼び出していて、vertical-motionはindent.cの中にあり細々とした処理をしているので追っていませんがdisplayとか'\n'とかが出てくるのでそのあたりで何かあるのでしょう。

ちなみに、連続した行でなければ問題は起きません。

(dotimes (_ 20000)
  ;; 最初に\nを入れる
  (insert "\n1234567890" (propertize "\n" 'display "EOL\n"))) ;;OK

1行空行を入れるととたんに問題は起きなくなります。

重いのは嫌なので結局一番速いテキストプロパティで改行の一つ前の文字を置き換えるように変更しました。

Improve performance · misohena/dired-details-r@c7699cb

(2022-01-02追記) before-stringの前にカーソルが置けないと書きましたが、cursorプロパティを使うと置けることに気がつきました。次のコードを使うと、previous-lineで遅くならず(\nをdisplayプロパティで置き換えていないので)、かつ、0とEOLの間にカーソルが置けて('cursor 1の部分の効果)、さらにそこで文字を入力するとEOLの前に挿入されます(make-overlayの第四引数の効果)。

(dotimes (_ 20000)
  (insert "1234567890\n")
  (let ((ov (make-overlay (1- (point)) (point) nil t))) ;;\nのところを覆う。直前に入力した文字はオーバーレイに含めない。
    (overlay-put ov 'before-string (propertize "EOL" 'cursor 1)) ;;EOLのテキストプロパティに1を付けるとなぜかEOLの直前にカーソルを置けるようになる。
    (overlay-put ov 'evaporate t)))

cursorテキストプロパティはマニュアルを読んでも正直意味が分からないので、なぜこうなるのかは不明です。

dired-details-rですが、大量のオーバーレイは移動こそ重くならないまでも追加と削除には時間がかかるので、テキストプロパティのままで行こうと思います。カーソルの移動に問題が残りますが我慢できないほどではないです。いや、行数で実装を切り替えるというのもアリですかね……?

(2022-01-06追記) wdiredでファイル名末尾にカーソルが置けないのがやっぱりストレスなので上記cursorプロパティを使う方法をdired-details-rに採用しました。オーバーレイはテキストプロパティよりも遅いので、1000行越えたらテキストプロパティに切り替える(+wdired起動時は表示を戻す)という荒技も組み合わせました。なおcursorプロパティの挙動は相変わらずよく分かっていません。

Fix issue can't move to the end of file names in wdired mode · misohena/dired-details-r@ae2f690