Author Archives: AKIYAMA

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

2022-01-01 ,

org-downloadで保存前にファイル名を入力する

私はこれまでorg-downloadを保存するファイル名が「orgファイル名_タイムスタンプ_ダウンロードファイル名」 になるように設定して使っていたのですが、十中八九後からファイル名を変更しなければならなくていちいちファイルとリンクの両方を修正しなければならず面倒くさいなーと思っていました。

よく考えたらorg-download-file-format-function変数に指定する関数内でread-file-nameを呼び出せば良いだけですね。この変数はなぜかdefcustomではなくdefvarなのですが、使っているのはfuncallする一箇所だけ。カスタマイズするのに使っても問題ないように見えます。私はファイル名にorgファイル名を入れるために既に書き替えていたのでそこを修正すれば良いだけでした。

(setq org-download-file-format-function 'my-org-download-file-format)
(defun my-org-download-file-format (filename)
  (read-file-name
   "File Name: "
   nil nil nil
   (concat
    (if-let ((fn (buffer-file-name)))
        (concat (file-name-base fn) "_"))
    (format-time-string "%Y%m%d_%H%M%S_")
    filename)))

ついでに前回作ったメニュー(org-cmenu)に登録。

これまでHydraでorg-download用のメニューを作っていたので、それをtransientに書き替えてorg-cmenuのInsertメニューに追加しました。

現在の設定はだいたいこんな感じ。

(autoload #'org-download-clipboard "org-download")
(autoload #'org-download-yank "org-download")
(require 'transient)
(transient-define-prefix my-org-download ()
  "Insert an image."
  ["Copy an image from:"
   ("c" "Clipboard" org-download-clipboard)
   ("y" "Full-path or URL on kill-ring" org-download-yank)
   ("a" "All monitors" my-org-download-screenshot-all)
   ("p" "Primary monitor" my-org-download-screenshot-primary)
   ("f" "Foreground window" my-org-download-screenshot-active-window)
   "or drop from a local image file."])

(with-eval-after-load "org-cmenu-setup"
  (transient-append-suffix
    'org-cmenu-insert
    '(0 -1 -1) ;;Insertメニューの一番最後に追加
    '("D" "org-download" my-org-download)))

(defun my-org-download-screenshot-all ()
  (interactive)
  (my-org-download-screenshot "screenshot-all.ps1"))

(defun my-org-download-screenshot-primary ()
  (interactive)
  (my-org-download-screenshot "screenshot-primary.ps1"))

(defun my-org-download-screenshot-active-window ()
  (interactive)
  (my-org-download-screenshot "screenshot-activewin.ps1"))

(defun my-org-download-screenshot (script-name)
  (require 'org-download)
  (let ((org-download-screenshot-method
         (format
          "powershell %s %%s"
          (expand-file-name
           (concat
            my-org-download-script-path ;;別途設定のこと
            script-name)))))
    (message "Waiting 3 seconds...")
    (sleep-for 3)
    (message nil)
    (org-download-screenshot)))

WindowsなのでPowerShellを使用してスクリーンショットを撮っていますが、スクリプトはWindowsのコマンドラインからスクリーンショットを撮る(PowerShell)で紹介したものです。

……あ、今気がついたのですが、org-download-rename-at-pointなんてコマンドがあるんですね。ファイル名とリンクを一緒にリネームしてくれるようです。まぁ、別に毎回ファイル名を確認してくれた方が良いんじゃないでしょうか。沢山の画像をドロップしまくるような使い方をするなら毎回確認されると困るでしょうけど、私はそういう使い方はしませんし。でもファイル名とリンクの同時リネームは便利だからorg-cmenuに加えておこっと。

(追記:追加しました。orgのリンクを書き替えただけでその先のファイル名も変わってるなんてマジカル!)

Add feature to rename linked file · misohena/org-cmenu@8805f27

2021-12-30 ,

org-mode で現在の構文要素に応じて適切なメニューを出す

org-cmenuというのを作りました。

misohena/org-cmenu: Context Sensitive Menu for Emacs Org Mode

org-modeって機能は沢山あるしキー割り当てももの凄く沢山あって大変です。とても全部は覚えられないし覚えたとしてもすぐに忘れます。それに何か新しい機能を作ったとして、それをどのキーに割り当てるのかを決めるのも一苦労です。そして自分で作った機能だとしても結局たまにしか使わなければ忘れます。私はorg-modeを使っていると、人間というのは何かを覚え続けてはいられない存在なのだと言うことを痛感させられるんです。

それはともかく、最近また作業を効率化すべく新しい機能を追加したいと思ったのですが、それを実行するキー割り当てを考えていくうちに、やはりこれは文脈に応じて切り替わるメニューのようなものが必要だろうと思うに至ったのでした。

コンテキストメニュー。一般的には対象を右クリックするとそれに対する操作がポップアップで表示されるあれです。まぁ、Emacsなのでマウス用のポップアップメニューにしても仕方が無いので画面下にテキストで表示するのですが、いずれにせよ対象を選びそれに対する操作の中から選ぶというインタフェースは記憶すべきことを大幅に減らしてくれる素晴らしい仕組みです。覚えておくべきなのはそのメニューを開く操作のみです。対象以外で使う操作はメニューに出てこないのでメニューの中から操作を探す手間も最小限です。

Emacsでキー操作用のメニューを作るパッケージとしてはHydraが有名ですが、Hydraは複数行にわたる巨大な文字列でレイアウトを指定するので固定のメニューを作るのには直感的で良いのですが、変化の大きいメニューを作るには向かない気がします(気がするだけで工夫次第で何とかなるのかもしれませんが)。なので最近良く耳にするようになったtransient.elを試してみたのが前回前々回のお話しでした。

transientを使ったとしても色々問題はあったのですが、それはひとまず置いておいて、まずはorg-modeで現在のポイントがある地点の構文要素を割り出す方法についてご紹介したいと思います。

org-elementの使い方

org-modeには標準でorg-elementというのが入っていて、現在位置の構文要素を簡単に割り出せるようになっています。

バッファ(文書)全体を解析する org-element-parse-buffer という関数もありますが、部分的な解析には org-element-at-point と org-element-context という関数が使いやすいです。この二つの関数は共にセクション(見出しと見出しで区切られた範囲)以上の解析は行わず、現在のポイントの近くにある要素を返す関数です。 org-element-at-point は行内要素を含まない大きな構造のみを返し、org-element-context は内部で org-element-at-point を使用しつつ、行内の細かい要素も解析して一番限定された狭い要素を返します。

なので結論から言えば、現在のポイントにある構文要素に関する情報を得たければ (org-element-context) だけでおしまいです。

org-element-context の結果は、例えば次ような リスト です。

(bold
 (:begin 1739
  :end 1745
  :contents-begin 1740
  :contents-end 1743
  :post-blank 1
  :parent (paragraph
           (:begin 1728
            :end 1750
            :contents-begin 1728
            :contents-end 1749
            :post-blank 1
            :post-affiliated 1728
            :parent nil))))

これは実際に上の太字になっているところで M-: (org-element-context) を実行した結果です。boldで始まるノードの親(:parent)としてparagraphで始まるノードが続いているのが見て取れます。その上はsection、headlineと続くはずなのですが、org-element-context(というかorg-element-at-point)はそこまでは解析しません。

ノードから情報を取得するには org-element-type や org-element-property 関数(アクセッサ)を使用します。次のように。

(let ((datum (org-element-context)))
  (list
    (org-element-type datum) ;; => bold
    (org-element-property :begin datum) ;; => 1739
    (org-element-property :end datum))) ;; => 1745

要素の開始点と終了点は必ず取得できるので、これだけで全ての要素に適用出来るコマンドを記述できます。例えば要素全体のマーク(選択)やカット、コピーを実装するのは簡単です。

(defun my-org-kill-element (datum)
  (interactive (list (org-element-context)))
  (kill-region (org-element-property :begin datum)
               (org-element-property :end datum))) ;;本当は色々細かい問題があるのだけどまぁいいや……

org-element-type関数が返すboldやparagraphといったシンボルは type と呼ばれていて構文要素の種類を表します。

構文要素の種類

ところで皆さんはorg-modeにどのくらいの種類の構文要素があるかご存じですか?

org-elements.elの中には次のように書かれている部分があります。

(defconst org-element-all-elements
  '(babel-call center-block clock comment comment-block diary-sexp drawer
               dynamic-block example-block export-block fixed-width
               footnote-definition headline horizontal-rule inlinetask item
               keyword latex-environment node-property paragraph plain-list
               planning property-drawer quote-block section
               special-block src-block table table-row verse-block)
  "Complete list of element types.")

(defconst org-element-all-objects
  '(bold citation citation-reference code entity export-snippet
         footnote-reference inline-babel-call inline-src-block italic line-break
         latex-fragment link macro radio-target statistics-cookie strike-through
         subscript superscript table-cell target timestamp underline verbatim)
  "Complete list of object types.")

リストの中のシンボルがtypeの種類を表しています。org-element-all-elementsの中にあるのが30、org-element-all-objectsの中にあるのが24で計54種類にもなります(org-version 9.5.1 時点)。

org-element.elでは、bold等の行内に現れる要素のことを object と呼んでいて(HTMLで言えばインライン要素?)、それ以上の大きな要素のことを element と呼んでいるようです(HTMLで言えばブロックレベル要素?)。両者を合わせたもの(+α)はdatumと呼んでいる箇所が多いですが、普通にelementと呼んでいる場所もありややこしいです。

シンボルの名前だけだと何を意味しているのか分かりにくいので、全てのtypeを使用したorg-mode文書を作成して理解を深めました。

https://raw.githubusercontent.com/misohena/org-cmenu/main/examples/all-types.org

Emacsの中で見ないと構文がハイライトされないので見づらいですね……。参考までにtypeとそれに関連したマニュアルへのリンクも載せておきます。

all-elements
keyword headline section planning drawer property-drawer node-property clock center-block quote-block verse-block example-block comment-block dynamic-block export-block special-block src-block babel-call paragraph footnote-definition fixed-width comment plain-list item table table-row diary-sexp horizontal-rule inlinetask latex-environment
all-objects
bold underline italic code strike-through verbatim subscript superscript entity inline-babel-call inline-src-block line-break link target radio-target statistics-cookie footnote-reference table-cell timestamp macro export-snippet citation citation-reference latex-fragment

沢山あって大変ですが、やりたいことはこの全要素を網羅したorg-mode文書の各地点で適切なメニューを表示するということになります。

org-elementとtransientをつなぐ

transientには条件毎にメニューの項目を無効化する仕組みがあり、それを使ってメニューの内容を現在の文脈に合わせることもできます。つまり一つのメニュー(transient prefix)を定義して、その中に全部のコマンドを追加して所々述語で無効化していくわけです。

しかしそれよりは、まずtypeとそれに対して使えるコマンドの一覧表があって、そこからtypeごとのメニューを生成してしまった方がずっと楽な気がします。雑然とコマンドが並んでも分かりづらいので、カテゴリー分けするための情報はある程度持たせる必要はありますが。

なので次のような構造を考えました。

type -> group -> ( command[key desc func] | string | group(入れ子) )

まず事前にtypeとgroupに対してcommandを登録していきます。commandが使用できるtypeに対してのみ登録していきます。例えばリンクを開くコマンドはlinkというidを持つtype内の適当なグループに追加します。上のmy-org-kill-elementのように沢山の種類の要素に適用出来るコマンドもあるので、追加先はリストやallのような別名でも指定できるようにしておきます。

そしてメニューを開くときに、org-elementで現在のtypeを割り出し、そのtypeに応じたメニューをgroupから組み立ててtransient-define-prefixで定義して実行します。

org-cmenu

そんな感じのやり方で作ったのがorg-cmenuです。

misohena/org-cmenu: Context Sensitive Menu for Emacs Org Mode

org-cmenu-setup.elというファイルが前述の「typeとgroupに対してcommandを登録していく」処理になり、ここがメニューの内容を決めています。既存のorg-modeのコマンドを分類して追加しているほか、新たに欲しい機能はorg-cmenu-tools.elの方に書いてから追加しています。

メニューの構造を管理したり実際にメニューを表示する部分はorg-cmenu.elに入っています。コアの部分ですがこれだけでは何も表示されません。

これらを使うための設定は次のようになります。

;; メニューを開くorg-cmenuコマンド実行時にorg-cmenu.elを読み込む
(autoload 'org-cmenu "org-cmenu")
;; org-mode内にorg-cmenuコマンドを起動するキーを割り当てる
(add-hook 'org-mode-hook
          (lambda ()
            ;; お好きなキーを設定してください
            (define-key org-mode-map (kbd "C-^") #'org-cmenu)))

;; org-cmenu.elが読み込まれたらorg-cmenuコマンドが起動する前にメニュー内容を登録する
(with-eval-after-load "org-cmenu"
  (require 'org-cmenu-setup) ;;自分専用のsetupファイルに差し替えることもできる
  ;; ここで自分専用のコマンドを追加することもできる
)

リストに対するメニューを開く例

実際にall-types.orgのplain-listがあるところでメニューを起動すると次のようになります。

リストの段落の中でメニューを起動した様子
図1: リストの段落の中でメニューを起動した様子

現在の構文要素はparagraphです。段落に対してできることはまだそれほど多くはありません。何かobjectを挿入したり、全体をカット・コピーしたりするくらいでしょうか。

^キーを押すと一つ上の親要素が選択されます。2回押してplain-listを選択したところが次です。

親要素を選択したところ
図2: 親要素を選択したところ

リスト全体に対しては段落よりももう少し色々な操作ができるようになっています。 リスト全体をチェックボックス化したりLispのリストにしてコピーしたり。

リンクに対するメニュー

次は画像リンクに対して #+CAPTION#+ATTR_HTML を追加する例。

画像リンクに対して属性を追加する例
図3: 画像リンクに対して属性を追加する例

実は今回のメニューはこれがやりたいが為に作ったものでした。何か専用のキーを割り当てるのもバカバカしいちょっとしたことでした。でもCAPTIONやHTML属性(主にwidthやclass)の設定はこの記事を書くためだけでも既に何度も使用しています。

ちなみに #+CAPTION:#+ATTR_HTML:#+NAME: といった部分はorg-elementではaffiliated keywordsと呼ばれていて、原則的には全てのelement(非object)に付加できるようになっています(現実的には例外あり。table-cell等には付けようがない)。org-elementが返すノードの:beginプロパティはこのaffiliated keywordを含む要素全体の先頭になります。affiliated keywordを除いた先頭は:post-affiliatedプロパティで取得できます。

その他、リンクに対してはパス・説明部分の編集、リンク先を開く、パス・ファイル名のコピー、ファイル情報表示等を行えるようにしました。

画像リンクに対する操作一覧
図4: 画像リンクに対する操作一覧

これを作成しているときに初めて知ったのですが、 C-c C-o (org-open-at-point)によるリンクのオープンは、C-uを一回付けるとEmacs優先で、C-uを二回付けると外部アプリ優先(Windowsだとw32-shell-execute)で開くようになっていたんですね。知りませんでした……。

私は画像リンクに対してはプライベートな設定で撮影位置の地図表示やコピー、撮影日時のコピーなんかも加えています。この間やっていたことの続きですね。

表に対するメニュー

次はtable、table-row、table-cellに対する操作の例。

表に対して色々な操作をする例
図5: 表に対して色々な操作をする例

通常のカーソル移動の操作(C-f, C-b, C-p, C-n, C-a, C-e, M-<, M->)だけでセル単位で移動できるようにしてみました。

そのままC-SPCでリージョンを作成すれば複数のセルを矩形で選択、カット・コピー、ペーストできます。

TABやS-TABによるカラム幅の伸縮も非常に直感的になりました。

tableやtable-rowに対する操作をtable-cell選択時にも表示するかは迷ったのですが、あると便利なものは表示しておくことにしました。現在はファイルエクスポートやS式コピーといった頻繁には使わない機能は^でtableを選択しないと出てこないようにしてあります。

Insertメニュー

sectionやparagraph等で表示されるInsertメニュー。まだ発展途上ですが、objectは一通り挿入できるようになっています。elementはまだまだです。

Insertメニュー
図6: Insertメニュー

特に注目したいのがentityの挿入。entityについては以前org-modeで文字をエスケープする方法でも触れましたが、狙った文字を探すのが案外面倒なんですよね。なので、正引き・逆引きの両方に対応した補完入力を付けることで簡単に狙った文字をentityとして追加できるようにしました。C-^ i e & RETで \amp{} と入力されます。

Export Snippetなんかも(普段使わないので)地味に書き方を忘れたりするので便利です。もう書き方を検索する必要はありません!

上付き文字、下付き文字、entityに対するメニュー

superscript、subscript、entityに対しては org-toggle-pretty-entities が候補に出るようになっています。

prettyを有効にしたところ
図7: prettyを有効にしたところ

使っているところでC-^ pを押せば切り替えられるので多少は便利かもしれません。こういう地味な機能を盛り込むのも躊躇無くできるのがorg-cmenuの良い所です。

マニュアルを開く

ところで全てのメニューに ? キーとして Manual というのが書いてあるのにお気づきでしょうか。?キーを押すとその構文要素について書かれているorg-modeマニュアルの該当部分がブラウザで開きます。

これで覚えておかなければならない事がさらに減りますね。

最後に

まだまだ手つかずの構文要素が残っていますが全て埋める必要は無いでしょう。

考えていくといくらでもアイデアが出てきてキリがありません。必要になったときにちょくちょく追加していくことにします。

スニペットの挿入のような個人的なものも後から簡単に追加できるのが良いですね。