3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Vimで、WZ階層付きテキスト風折り畳み

Last updated at Posted at 2020-10-11

概要

Vim 上で、WZEditor の階層付きテキスト形式(WzMemo)風の折り畳み、およびアウトライン表示を行うプラグインを作ってみました。

ついでと言ってはなんですが、以下の2つにもとりあえず対応させてあります。

  • Markdown の 行頭"#"の見出しレベル
  • マーカーによる折り畳み(規定では「{{{」と「}}}」で挟まれたブロック)

ワタクシ、プログラミングだけではなく、日本語の文章も Vim で書いているので、こういうのがあるといいなーと思って書きました。

ついでに Vim プラグインを初めて書いた人間の視点というものを詳らかに記していこうと思います。
そのため、ガリガリとプラグインを書いているという人から見たら、稚拙な手法を採用している可能性も多々ありますが、これからプラグイン書いてみたいぜ!という人の一助になれば幸いです。

「ああ、こんなノリでもプラグインらしき物が作れちゃうんだなー」という感じで。

という事なので、Vim script をよくわかっている人が書いた記事ではなく、ド素人が紆余曲折した経緯だと言うことに留意しておいてください。

実はもっとスマートな方法があるにも関わらず、回りくどく書いてしまっていて読みにくい箇所もあるでしょうし、妙にテクニカルに凝っていない分、朴訥な書き方で、改造などもしやすいかもしれません。とセルフフォロー。

環境

GVIM 8.2.1287 (kaoriya 2020/07/24)

自前の_vimrc_gvimrcの内容と干渉して上手く動いているだけかもしれないので、変なところがあれば報告をいただければ確認するかもしれません。

そもそもアウトラインって?

超適当な説明なので、少しでも意味が分かっている人は読み飛ばしてもよい項です。

アウトライン、とは、「章・節・項」などの文章の階層的な構造を一目で見られるようにするものです。
アウトラインがあると、文章全体の流れや構成を整理しやすくなります。

ノードツリー と テキスト

アウトラインプロセッサの構成要素について説明します。

  • 階層化されたノードのツリービュー(またはリスト)があります。
  • ノードのテキスト内容を表示する領域があります。

以上!

ノードツリーでは、ノードを同一階層内で上下させるどころか、別階層へもホイホイと移動させることができたりします。

もちろん辻褄が合うように細かいところの修正は必要ですが、「長ーい1つのファイル」の中身を切った張ったするよりかは、大筋を見失いにくい視点を保ったまま作業を進めることができます。

ノードツリー

このノードツリーの部分が「アウトライン」と呼ばれ、文章全体の構造・構成を表します。

GUI を備えたアウトラインのノードツリーでよくありそうな機能は、ドラッグ&ドロップすることでテキスト内容をコネコネとできるようなやつです。任意のノードを並び順を変えるどころではなく、別のノードのサブノードへ、ヒョイと持って行ったりできてしまいます。

ちなみに、拙作プラグインには、そんな機能はありません。ノードを移動させたい場合は、テキスト上にて、zcで折り畳んでから ddして、貼り付けたい場所でpPしてください。編集機能は素の vim 頼みです。もしや、何か不満があるでしょうか?いいえ vimmer なら無い筈ですね。

(アウトライン表示上でddとかやると、テキストの該当位置に移動してからzcddする…という案もあったのですが、アウトライン表示とテキストの折り畳み状態が完全には同一にはならないことがあるので、考えるのを止めました。具体的に言うと、WzMemo 形式の同一階層の見出しが空行無しで連続している場合とか、Markdown のコードブロックの中にコメントなどで行頭「#」が入っちゃう場合などです。折り畳み機能を使わず、自前でアウトラインの情報から自ノード配下のテキストを対象に切り取る事もできるのかもしれませんが、今のところ、そこまでやる気はないです。)

また、別階層へ移動させた場合には、自分でノードのレベルを変えてやる必要があります。(マーカーの入れ子で折り畳んでいるときにはあんまり関係ないんですけれどもね)

一応、再帰的にノードの階層を変更するコマンドも用意してありますが、一度目の階層変更により同一階層になったノードが、次の階層変更コマンドの範囲に加わってしまうため、安易に何度も使うとアウトラインがぐちゃぐちゃになる恐れがあります。

テキスト

テキストは、ノードに対応する文章のブロックです。

該当ノード分のみのテキストしか表示されないものもあれば、全文が連続してダーっと入っていて、選択したノードの箇所が表示されるものもあります。拙作プラグインは後者のように振舞います。

世のアウトラインプロセッサ達は、当然、テキストエディタ的な編集機能を持っています。装飾も行なえるようなワープロ的な編集機能、というか、もはやそれどころではない機能を持っているものもあります。画像が貼れたり、リンクが張れたり。果ては音声や動画まで張れたりするかも!(いわゆるハイパーテキストですな)

・・・拙作プラグインでは、当然そんなことが出来るようになるワケがなく、純然たるプレーンテキストファイルだけが対象です。そもそも、WZMemo 形式ってそういうものですしね。

いわんや、本稿の出発点からして、Vim のパワフルな編集機能の恩恵を一身に受けながら編集作業を行う、その事こそが至上命題なのです。

「アウトラインプロセッサのテキスト編集機能が、多少 Vim ライクになっている」という程度の事で満足ができるのなら、こんな事はしていません。

Vim を用いたアウトライン編集への歩み

冒頭の繰り返しになりますが、私、Vimmer の末席に辛うじてその身を置かせてもらっている弱卒でありますからして、プログラミングは勿論のこと、日本語の文章だろうが、議事録だろうが、日記だろうが、GTD だろうが、バレットジャーナルだろうが、兎に角なんでもかんでも Vim で書きたいという、ごくごく自然な欲求を持って生きています。幸運にも生涯の伴侶とも言うべきエディタと出会うことができた者の末路健やかで平和な日々の暮し方と言えるでしょう。

プログラミングを行う時には、ctags を使ったりする程度で、まぁまぁ満足快適に過ごしていたのですが、問題は日本語の文章を書く時です。

それなりに長い間、折り畳み機能を使って、それなりに過ごしてまいりましたが、折り畳み機能を使っていけばいく程に、アウトライン表示機能がどんどんどんどんと欲しくなってきてしまいました。

折り畳み表示(folding)とは

Vim の持っている機能です。読んで字のごとく、行内容を折り畳むことで、一時的に非表示にします。

そうすることで、長い文章でも見通し良く編集を行うことができます。

Vim のイカした点として、折り畳んだ状態で、ddp とかすると、一塊の単位でカット&ペーストできるので、推敲にも重宝します。そのまま >> とかでインデントとかもできちゃいます。折り畳んだ行に対して、s/xx/yy/gとかやると、そのヒトカタマリの内部だけを対象に置換できちゃいます。非常にイカス機能です。

Vim をアウトラインプロセッサ的に使おうとした場合、目の付け所にならないワケがない機能と言えるでしょう。言えるよね?

折り畳みの方式(foldmethod)として、手動(manual)、インデント(indent)、式(expr)、マーカー(marker)、構文(syntax)、差分(diff)があります。

アウトラインプロセッサとして使おうとした場合の折り畳み方式は、インデント、または、マーカーが選択肢になるでしょう。

ということで、インデントとマーカーについてだけ簡単に説明をば。
(他の折り畳み方式については割愛します)

インデントによる折り畳み

最初はインデントによる折り畳みを使って、何とか Vim をアウトラインプロセッサとして使ってみようと頑張ってみました。
何と言っても字下げを行うだけで、アウトラインを表すことができるのなら、というのが魅力的でした。

Vim をアウトラインプロセッサ的に使いたい、というような目的でググると、「fdm=indent でおk。」みたいなものもチラホラとヒットします。(年代的なものもありますし、もちろん VOoM や unite-outline みたいな、プラグインを導入している人もいますけどね)

その頃の私はまだプラグインという存在そのものを食わず嫌いしており、ネイキッドな Vim で何とかなるんじゃないのかと甘く考えていたのですが、インデントの場合には、以下の問題があり断念しました。

例えば、このようなファイルの場合・・・・

title
    index1
    text1
        index1-1
        text1-1

        index1-2
        text1-2

    index2
    text2

各行の「折り畳みレベル(foldlevel)」は、こうなります。

  1 title                   foldlevel=0
  2     index1              foldlevel=1
  3     text1               foldlevel=1
  4         index1-1        foldlevel=2
  5         text1-1         foldlevel=2
  6                         foldlevel=2 ←同一階層で見出しを分けたいが、分けられない。ここで畳むと「index1-1」で畳まれる
  7         index1-2        foldlevel=2
  8         text1-2         foldlevel=2
  9                         foldlevel=2
 10     index2              foldlevel=1 ←同上。ここで畳むと、「index1」で畳まれる
 11     text2               foldlevel=1

同一の折り畳みレベルが続く間は、一つの折り畳みになってしまうため、同一階層内に複数のノードを持つことができないという事になります。

また編集状態によって、折り畳まれる範囲が繋がるのか繋がらないのかに差が出ることがあります。

上記の例の場合、一気に1~11行目までを張り付ければ上のようになるのですが、いったん6~7行を切り取ってから、張り付けなおすと、7行目はいきなり foldlevel=2 から始まり、2行目から始まる折り畳みと泣き別れになる、というような事態に遭遇します。

インデントによる折り畳み用の foldexpr を自分なりに書いてみても良いかもしれませんね。

ちなみに、当プラグインのアウトライン表示側の Folding は manual でシコシコと自前で折り畳んでおります。
空行の混入を想定する必要がなく、 nomodifiable(変更不可) に指定してあるアウトライン表示ならではの泥臭い対処法ですね。

随時変更が加わるテキストに対して、全行捜査が必要な処理を foldexpr として登録するわけにいきませんからね。

マーカーによる折り畳み

次は、マーカーによる折り畳みで、何とか Vim 上でアウトラインプロセッサのようなことを実現しようと思いました。

インデントに比べると、アウトライン構造を確実に表すためにテキストに付与される情報が増えてしまいますが、それでも ネイキッドな Vim で実現できるエコでクリーンで強力な仕組みという点に抗いがたい魅力を感じていました。

Vim をアウトラインプロセッサ的に使いたい、というような目的でググると、「:set fdm=marker して、後は :help foldingを読めばおk。」みたいなものもチラホラとヒットします。

で、まぁ、確かに、ぶっちゃけこれで問題はありませんでした。

デフォルトの閉じた折り畳みの可読性的にやや難ありなことと、デフォルトのマーカーである {{{}}}が若干ウザい ことを除けば、ほぼほぼ満足できるものです。

デフォルトの閉じた折り畳みの表示(foldtext()関数)が気に入らなかったことについては、foldCC.vim が殆ど解決してくれました。

マーカーも、ハイライトを工夫するなどの対策は必要でしたが、見慣れてこればそれほど気にならなくなってきます。

ソースファイル等では、折り畳みマーカー部分がコメントアウトされていても問題なく折り畳みできるところや、階層の入れ子が明確でノードの移動にも強い、という優れた点もあります。

もはや残された課題は、非 Vimmer (観測範囲内では私以外の全員)から、ナニコレ扱いを受ける程度の些事だけでした。(モードライン程度の追加情報なら気にならない人達でも、テキストの至る所に{{{}}}が記入されていると流石にギョッとするようです)

しばらくハッキリと「不満がある」とまでは言えないが、完全に満足したわけでもない状態で過ごしていましたが、やはり、もっと簡単に折り畳みができないかと思い、ふとした弾みに、WzMemo 風の折り畳みを自作できないだろうか、と考えるようになりました。

このマーカー折り畳みにより実現された「微妙に快適なテキスト編集環境」が、より快適な折り畳み、および、アウトラインへの渇望をもたらしたといっても過言ではないでしょう。人の欲とは限りないものですね。

ちなみに、この foldCC.vim のおかげでプラグインという方向性への忌避感がだいぶ薄らぎました。・・・後は rogue.vim とか?
とはいえ、未だにプラグインマネージャを入れてプラグインをガンガン入れる、という気にまではなりませんが。

なにせ、未だに自由にインターネットに繋がらない環境に飼殺されている社畜でありますからして。(閑話休題)

WZ階層付きテキストとは

行頭から続く「.」の数で、ノードの階層を表す形式です。
一見しただけでは、ただのプレーンテキストファイルです。実にシンプルイズベスト。
アウトラインがメタ情報ではなくテキストとしてガッツリ書かれており、それでいて視覚的にそれほど主張せず、かつ階層を直観的に指定可能です。

プログラムソースの場合はマーカー折り畳みでも問題ないのですが、文書を書いている場合にはマーカーがどうにも邪魔に感じてしまったり、zfzdの操作や、閉じマーカーのインデント量を調整する手間が面倒に感じてしまうことがあり、この WzMemo 形式を Vim 上で実現したいと考えるようになりました。

かつて WZEditor ユーザーだったこともあり、この形式のテキスト資産を保有していることも理由に挙げられます。

ググったところ、foldexpr にチョロっとワンライナーして、1階層のみ対応させているものは見つかったのですが、2段3段・・・とネストできるものは見つかりませんでした。

そこで、まず最初に WzLikeOutline() という foldexpr 関数を自作しました。
その勢いで勉強がてらにプラグインの形にしてみようと思い立ち、アウトライン表示部分まで作りました。

WzMemo 形式を使用する事で得られる副次的な効能として、Windows上で幅を利かせているエディタ等でもサポートされている場合があるため、比較的説明が楽、かつ、肩身の狭い思いをしなくて済みます。

本家の WZ Editor では、行頭の「.」だけだったかどうか記憶が曖昧ですが、拙作プラグインでは行頭から空白文字でインデントされていても見出しとして扱うようにしてあります。

※Markdown 編集時はインデントされている「#」は見出しとして扱わないようにしてあります。

本家 WZ Editor では下記のような事ができたはずですが、残念ながら拙作プラグインでは正しく折り畳みできません。
アウトライン表示はできるのですが、折り畳みはできないのです。
同一の折り畳みレベルの行が連続していると、ひと固まりで折り畳まれてしまうのです。

例えばこんな文章の場合

.見出し1
..見出し1-2
ほげほげ
..見出し1-3
ふがふが

こういう状態になっています。

.見出し1         foldlevel=1
..見出し1-2    foldlevel=2
ほげほげ          foldlevel=1 ←次行が行頭"."始まりなので、現在の折り畳みを終了させるため、折り畳みレベル-1
..見出し1-3    foldlevel=2
ふがふが          foldlevel=2

「ほげほげ」の行を折り畳むことができません。
正しく折り畳んでもらうには、こうする必要があります。

.見出し1         foldlevel=1
..見出し1-2    foldlevel=2
ほげほげ          foldlevel=2
                  foldlevel=1 ←上記の事象を回避するために、空行を挟む必要があります。
..見出し1-3    foldlevel=2
ふがふが          foldlevel=2

この情けない実態を見ればお察しな通り、実際にはプラグインという程の物ではなく、foldmethod=exprとして、foldexpr=func_HOGE()として、50行足らずのfunction! func_HOGE()~endfunctionを vimrc に書き加えるだけで実現可能なものです。(厳密にはもうちょっとアレコレありますが、大体そんなもんです。)

残りのプラグインの行数は、ほぼアウトライン表示に関わる処理に費やされています。

設定方法

ファイルの配置と、vimrcへ3行追記するだけの簡単な作業です。

~/_vimrc

以下の3行を追記してください。

set foldtext=MyFoldText()
set fillchars-=fold:-
set fdc=4

foldtext は 折り畳まれた行の代わりに表示される文字列を返す関数です。
高名なものとして foldCC が存在します。

基本的には、「見出し行の内容をそのまま表示+折り畳み情報」というのが理想なのですが、foldCC では tab インデントされているものが正しく表示されない(?)ようなので、そのあたりを自分なりに書き直したものが、 myFoldText() という捻りのない名前の関数です。

fillchars オプションの fold キーワードは、'foldtext' での空白部分を示します。
必須の設定ではありませんが、規定値が '-' なので無駄な装飾が行われないように取り除いておきます。

fdc は、折り畳み状態を表示するための列数を指定します。
もっと深い階層のアウトラインを書くことがあるのであれば、適宜大きな値にしてください。
最大値は 12 です。

WzLikeOutline.vim

~/vimfiles/plugin/配下に、以下のWzLikeOutline.vimを格納してください。

私はもっぱら Windows 利用者なので~/.vimではなく~/vimfilesになりますが、その辺りはruntimepathを踏まえて適当に読み替えてください。

なにせ職場のサーバに入っているのが Vim version 6.x で、folding すら使えないので、端末で GVim を使うのです。
Vim 8.1なので:labがない、とかいうチャチなレベルではない、ってやつです。
それでも、HP-UX で、素の vi しかなかった頃よりは全然マシなので、なんにも問題はございません。ええ、ございませんとも。

まずはスクリプト本体。
本体しかないとも言う。(お行儀が悪いかもしれませんが、autoloadに分けるほどでもないかと?)

各部の解説は後述します。

今どきcp932と言うのも何なので、UTF-8で保存してください。

WzLikeOutline.vim
" vi: set fdm=marker et tw=0 :

"Vim global plugin for outline like WzMemo
"Last Change:2021/01/08 10:51:34.
"Maintainer:azuwai
"License:This file is placed in the public domain.

"【初期設定】以下を vimrc へ追加。
"set foldtext=MyFoldText()
"set fillchars-=fold:-              "折り畳み行に無駄な装飾が行われないように

scriptencoding utf-8

"二重ロード避け
if exists("g:loaded_outline_like_wzmemo")
  finish
endif
let g:loaded_outline_like_wzmemo = 1

"互換性オプションを退避して規定値に設定
let s:save_cpo = &cpo
set cpo&vim

"定数
let s:treeTitle = 'Outline' | lockvar s:treeTitle
let s:treeIndent = 2        | lockvar s:treeIndent
let s:treeMinWidth=40       | lockvar s:treeMinWidth

"変数
let w:winid_ol = 0      "新規ウィンドウには存在しないので、各イベントでexistsする
let s:flg_switch = 0
let s:flg_leave = 0

"デフォルトでWZ階層付きテキストを有効にすると、Netrwでディレクトリが折り畳まれ
"てしまうので、txt、mdファイルを開いた時だけにしてあります。
"(そもそもファイル名がないとアウトライン表示に使っているロケーションリストが正しく動かない)
au BufNewFile,BufRead *.txt setl fdm=expr fde=WzLikeOutline('.',1)
au BufNewFile,BufRead *.md  setl fdm=expr fde=WzLikeOutline('#',0)

au BufNewFile,BufRead *.txt com! -buffer MOT call <SID>makeOutlineTree('.',1) | call <SID>outlineSync()
au BufNewFile,BufRead *.md  com! -buffer MOT call <SID>makeOutlineTree('#',0) | call <SID>outlineSync()
au BufNewFile,BufRead *.vim com! -buffer MOT call <SID>makeOutlineTree('',0)  | call <SID>outlineSync()    "fdm=marker想定
au BufNewFile,BufRead *.sql com! -buffer MOT call <SID>makeOutlineTree('',0)  | call <SID>outlineSync()    "fdm=marker想定

"保存時の自動更新を有効に
au! BufWrite *.txt,*.md,*.vim,*.sql call <SID>updateOutline()


"一定時間キー入力なしで自動更新
au CursorHold *.txt,*.md,*.vim,*.sql call <SID>updateOutline()



"折り畳み見出し行
function! MyFoldText() abort "{{{
    let line=getline(v:foldstart)                                       "現在行の内容を、そのまま見出し文字列に
    let line = substitute(line,'\t',printf('%' . &ts . 's',' '),'g')    "タブ文字をtabstop数分の空白に置換
    let tail = '[Lv:'. v:foldlevel .']'                                 "追加情報(階層)
    let tail = tail .'(' . printf('%'. strwidth(line('$')) .'s',eval('v:foldend - v:foldstart+1')) .' line)'
                                                                        "追加情報(折り畳み行数)

    let myWidth= winwidth(0)                                            "画面幅
    let myWidth -= &fdc                                                 "折り畳み表示幅を考慮
    if &nu
        let myWidth -= &nuw                                             "行番号表示時は、行番号の桁数を考慮
    endif
    let margin  = myWidth - strwidth(line) - strwidth(tail)             "画面幅、文字列長、追加情報から、余白長を求める

    if (&tw != 0) && ( &tw < (margin + strwidth(line) + strwidth(tail)) )
        let margin -= (margin + strwidth(line) + strwidth(tail)) -&tw
    endif
    let tail = printf('%' . margin .'s',' ') . tail                     "追加情報を、画面の右端に追加
    return line.tail                                                    "(highlightのguibgをnormalと少し変えておくとわかりやすい)
endfunction "}}}

" WZ階層付きテキスト(WzMemo)風折り畳み
function! WzLikeOutline(lv_mark,indentable) abort "{{{
    let line = getline(v:lnum)      "現在行を取得
    if a:indentable == 1                "行頭空白除去指定時
        let line= substitute(line,'^[[:blank:]]\+','','')
    en
    let lv=0
    let i=0
    while i <= &fdc                     "行頭からfdcまで先頭から'.'の数を数える。
        if line[i] == a:lv_mark
            let lv=lv+1
        else
            break
        endif
        let i=i+1
    endwhile

    if lv==0                            "現在行が、見出し行ではない場合、
        let line = getline(v:lnum+1)    "次の行を取得
        if a:indentable == 1            "行頭空白除去指定時
            let line= substitute(line,'^[[:blank:]]\+','','')
        en
        let lv=0
        let i=0
        while i <= &fdc                 "行頭からfdcまで先頭から'.'の数を数える。
            if line[i] == a:lv_mark
                let lv=lv+1
            else
                break
            endif
            let i=i+1
        endwhile
        if lv==0                "次の行も見出し行ではない場合。
            return "="          "上の階層を継続
        else                    "次の行が見出し行の場合、階層を区切るために、
            return lv-1         "次の見出し行よりも1つ低い階層にする。
        endif
    else                        "見出し行だった場合、自行の階層を返す。
        return lv
    endif
endfunction "}}}


"アウトライン表示
function! s:makeOutlineTree(lv_mark,indentable) abort "{{{

    "if s:isOutllineList() == 1
    "    silent exec "normal \<CR>"
    "endif

    "折り畳み方法の判定
    if ( &fdm != 'expr' ) && ( &fdm != 'marker' )
        echo 'ERROR : only - set ''foldmethod'' = "expr" or "marker"'
        return
    endif

    if exists("w:winid_ol") == 0
        return
    endif

    "バッファに変更なし、かつ、アウトラインウィンドウ表示中の場合
    if &mod == 0 && win_id2tabwin(w:winid_ol)[1] != 0
        return
    endif

    set lazyredraw
    let save_wi = winsaveview()

    "折り畳み方法に応じた検索パターンを編集。ついでに見出し行のハイライト設定も
    if (&fdm == 'expr')
        let gptn= '\M' . a:lv_mark
        if (a:indentable == 0 )
            let gptn2 = gptn
            let gptn = '^' . gptn
        else
            let gptn2 = '\m[[:blank:]]*' . gptn
            let gptn = '^\m[[:blank:]]*' . gptn
        en
        if &ft != "markdown"            "Markdownは標準のハイライトがあるのでそっちを優先
            silent exec 'syn match MyFoldSt "' . gptn .'\m.*"'
            hi link MyFoldSt FoldLabel
        endif
    elseif (&fdm == 'marker')
        let fmrs = split(&fmr, ',')
        let gptn = '\M' . fmrs[0]
        let gptn2 = gptn
        silent exec 'syn match MyFoldSt ".*' . gptn .'\m.*"'
                                        "FoldLabel は .gvimrc で好きに定義するように
        hi link MyFoldSt FoldLabel
    endif
    let cfdm=&fdm

    "ロケーションリストを初期化
    silent exec "lexpr('')"
    "silent exec 'lclose'

    "ロケーションリストに追加
    "exec 'g/' . gptn . '/lad expand("%") . ":" . line(".") . ":-" . getline(".") '
    let codenotation=0
    let enableOutline=0
    for i in range(1,line("$"))
        let wkline= getline(i)
        if stridx(wkline,'```') == 0
            let codenotation= (!codenotation)
        endif
        if !codenotation && match(wkline,gptn) != -1
            silent exec 'lad expand("%") . ":" .' . i . ' . ":-" . getline(' . i .')'
            let enableOutline=1
        endif
    endfor

    "ノードが見つからなかった場合は終了
    if enableOutline ==0
        call winrestview(save_wi)
        set nolazyredraw
        return
    endif

    let winid_tx = win_getid()                      "テキストウィンドウのWindow-IDを取得

    if win_id2tabwin(w:winid_ol)[1] == 0            "ロケーションリストが存在しない場合、
        silent keepjumps vert lwindow                         "ロケーションリスト(アウトライン)を開いてアクティブにする
        let w:winid_ol = win_getid()                    "アクティブウィンドウ(アウトライン)のwindow-idを取得
                                                    "ロケーションリストが存在する場合、
    else                                            "既に存在しているアウトラインウィンドウをアクティブにする
        silent exec "keepjumps ".win_id2tabwin(w:winid_ol)[1] . "wincmd w"
    endif
    silent call setloclist(w:winid_ol, [], 'a', {'title' : s:treeTitle})
    let l:winid_ol = w:winid_ol                     "ローカル変数に保持

    "ロケーションリストをおめかし
    setl fdc=0 nonu
    setl ma
    silent exec '%s/^' . expand("#") . '|//e'
    let lastline= getline('$')              "最終見出しの、
    let nucol = stridx(lastline,'|')+1      " | 区切りが出る位置までをtabstopに設定
    if nucol % 2 != 0
        let nucol +=1
    endif
    silent exec 'se ts=' . nucol
    silent %s/| -/\t|/e                             " | 区切りの位置を揃える
    se et                                   "空白文字に変換
    silent %retab!
    silent %s/ |/|/e                               "余分な空白を削除
    silent %s/^\([0-9]\+\)\([^0-9]\+\)|/\2\1|/e    "桁位置揃え
    silent exec 'setl ts=' . s:treeIndent

    "ロケーションリストから折り畳みマーカーを削除
    if (cfdm == 'marker')
        silent exec 'keepjumps %s/\M' . fmrs[0] . '//'
    elseif(cfdm == 'expr')
        "exec '%s/|\M' . a:lv_mark . '/|/e'
        silent exec 'keepjumps %s/|' . gptn2 . '/|/e'
        silent exec 'keepjumps %s/\M' . a:lv_mark . '\{1,}//e'
    endif
    silent exec 'keepjumps %s/|\m[[:blank:]]\+/|/e'
    call s:setIndentOutline()           "foldlevelでインデント
    keepjumps %retab!

    call s:makeFoldOutline()                        "ロケーションリストに折り畳みを設定
    setl nomod
    setl noma                                       "完成したので、変更不可にする。

    let l:MaxWidth = 0
    for i in range(1,line("$"))
        let myWidth = eval(strwidth(getline(i)))
        if l:MaxWidth < myWidth
            let l:MaxWidth = myWidth
        endif
    endfor
    silent exec 'setl winwidth=' . eval(l:MaxWidth + &fdc)
    silent exec 'setl winminwidth=' . eval(( &wiw < s:treeMinWidth ? &wiw : s:treeMinWidth) - &fdc)
    let w:save_wiw = &wiw
    let w:save_wmw = &wmw

    "ロケーションリスト移動のリマップを追加
    nnoremap <silent> [f :<C-u>lab<CR> \|zv \|z<CR>
    nnoremap <silent> ]f :<C-u>lbel<CR> \|zv \|z<CR>
    nnoremap <silent> [F :<C-u>lfir<CR> \|zv \|z<CR>
    nnoremap <silent> ]F :<C-u>llast<CR> \|zv \|z<CR>
    "nnoremap <silent> <Tab> : call <SID>switch_outline()<CR>
    nnoremap <silent> <C-Tab> : call <SID>switch_outline()<CR>

    if cfdm == 'expr'
        silent exec 'noremap <silent> z< :call <SID>incFoldLevel(-1,''' . a:lv_mark . ''',''' . gptn . ''',' . a:indentable . ')<CR>'
        silent exec 'noremap <silent> z> :call <SID>incFoldLevel(1,''' . a:lv_mark . ''',''' . gptn . ''',' . a:indentable. ')<CR>'
    endif

    "テキストウィンドウをアクティブに戻して
    silent exec "keepjumps ".win_id2tabwin(winid_tx)[1] . "wincmd w"
    "テキストウィンドウのウィンドウローカル変数に、アウトラインのwindow-idを保持させる
    let w:winid_ol = l:winid_ol

    let w:save_wiw = &wiw
    let w:save_wmw = &wmw

    call s:myWinEnter()

    call winrestview(save_wi)
    set nolazyredraw
endfunction "}}}

function! s:setIndentOutline() "{{{
    "★keepjumps!
    if exists("w:winid_ol") == 0
        return
    endif

    "準備。アウトラインウィンドウをアクティブ
    let winnr_ol = win_id2tabwin(w:winid_ol)[1]
    silent exec "keepjumps ".winnr_ol . "wincmd w" 

    "アウトラインウィンドウの全行 "{{{
    for i in range(1,line('$'))
        "テキストの該当行に移動して、折り畳みレベルを取得してアウトラインウィンドウに戻る。
        silent exec "keepjumps normal ".i."G"
        silent exec "keepjumps normal \<CR>"
        let myFoldLv = eval(foldlevel(line('.')) -1)
        silent exec "keepjumps ".winnr_ol . "wincmd w"

        "echom myFoldLv . ' ' . getline(i)

        "折り畳みレベル分の字下げを生成{{{
        let myIndent = printf('%' . &ts * myFoldLv . 's',' ')
        "echom 'id:"' . myIndent . '"'

        if myFoldLv > 0
            silent exec 'keepjumps s/|/|' . myIndent . '/'
        endif"}}}

    endfor"}}}
endfunction "}}}

function! s:makeFoldOutline() "{{{
    setl fdm=manual
    normal zE
    let lv_max=0
    for i in range(1,line('$'))
        let lv_mod = 0
        let lv_now = s:getFoldLevelOutline(i)
        for j in range(i+1,line('$'))
            let lv_pos = s:getFoldLevelOutline(j)
            if lv_now != lv_pos
                let lv_mod = 1
            endif
            if lv_now==0 && lv_pos==0
                break
            elseif lv_pos <= lv_now
                break
            elseif j == line('$')
                let j += 1
                break
            endif
        endfor
        "echom i . '(' .lv_now . ') - ' j . '(' . lv_pos . ')'
        if lv_mod==1 && (1 < (j-i) || j == line('$') )
            "echom 'folding! ' . i . ' - ' . (j -1)
            normal zR
            silent exec "keepjumps normal ".i."G"
            normal V
            silent exec "keepjumps normal " . eval(j-i-1) . "j"
            normal zf
            if lv_max < foldlevel('.')
                let lv_max = foldlevel('.')
            endif
        endif
    endfor
    let lv_max +=1
    silent exec 'setl fdc=' . (lv_max <= 12 ? lv_max : 12)
    normal zR
endfunction "}}}

function! s:getFoldLevelOutline(lineno) abort "{{{
    "message clear
    let line=getline(a:lineno)
    let start=stridx(line,'|')+1        "見出し文字列の先頭から
    "echom 'start:' . start
    let i=start
    while i < strchars(line)            "文字数分 ≠Byte数
        "echom i . ':' . strcharpart(line,i,1)
        if strcharpart(line,i,1) != ' ' "非空白を発見するまで
            break
        endif
        let i += s:treeIndent
    endwhile
    "echom 'return:' . (i-start) / s:treeIndent

    return (i-start) / s:treeIndent
endfunction "}}}

"折り畳みレベル一括変更 ※今のところ、+1,-1のみ
function! s:incFoldLevel(delta,lv_mark,gptn,indentable) abort "{{{
    if match(getline('.'),a:gptn) == -1     "現在行が見出し行ではない場合、
        labove                              "直前の見出し行へ移動
    endif

    let save_line = line('.')               "見出し行の位置を保持

    "折り畳みをすべて開く(foldlevelを正しく取得するため)
    normal zR

    let save_foldlv = foldlevel(line('.'))  "現在行の折り畳みレベルを取得
    "echom getline('.') . '(' . save_line . ') Lv:' . save_foldlv

    if a:delta < 0                          "階層を下げる場合は、予め引いておく。
        let save_foldlv-=1
    endif

    set lazyredraw
    for i in range(line('.'),line('$'))     "現在行から最終行へ向けて、
        "echo i . "行目(" . foldlevel(i) . '/' save_foldlv
        if  foldlevel(i) < save_foldlv      "現在のレベルよりも下がるまで続ける
            break
        endif

        if match(getline(i),a:gptn) != -1   "現在行が見出し行の場合
            if a:delta < 0
                silent exec i . 's/\M' . a:lv_mark . '//e'
            else
                silent exec i . 's/\M' . a:lv_mark . '/' . a:lv_mark . a:lv_mark . '/e'
            endif
        endif
    endfor
    silent exec 'call s:makeOutlineTree(''' . a:lv_mark . ''',' . a:indentable ')'
    set nolazyredraw

    silent exec "keepjumps normal ".save_line."G"
    normal zM
    normal zO
    normal zt

endfunction "}}}

"アウトラインウィンドウ切替
function! s:switch_outline() abort "{{{
    if exists("w:winid_ol") == 0
        return
    endif

    try
    let winid_tx =  getloclist(w:winid_ol,{'filewinid' : 0}).filewinid
    catch /^Vim\%((\a\+)\)\=:E716:/ "辞書型にキーが存在しません アウトラインが表示されていない場合。
        return
    endtry

    "アウトライン表示の場合
    if s:isOutllineList() == 1
        let winnr_tx = win_id2tabwin(winid_tx)[1]
        silent exec "keepjumps ".winnr_tx . "wincmd w"

    "テキスト表示の場合
    elseif winid_tx == win_getid()
        let winnr_ol = win_id2tabwin(w:winid_ol)[1]
        silent exec "keepjumps ".winnr_ol . "wincmd w"
    endif

endfunction "}}}

"ウィンドウに入った直後
au WinEnter * call s:myWinEnter()
function! s:myWinEnter() "{{{
    if exists("w:winid_ol") == 0
        return
    endif

    "アウトライン表示の場合。
    if s:isOutllineList() == 1
        setl rnu
        "Ctrl-wやマウスクリックでアウトラインが選択された瞬間に、同期されるのを抑止
        let s:flg_switch = 1

        if s:flg_leave == 1
            silent exec "quit"
            return
        endif

        if exists("w:save_wiw")
            silent exec 'setl wiw=' . w:save_wiw
        endif

    "アウトライン表示ではない
    else
        "テキスト表示を閉じた後、他のウィンドウがアクティブになった場合
        if s:flg_leave == 1
            let winnr_ol = win_id2tabwin(w:winid_ol)[1]
            silent exec "keepjumps ".winnr_ol . "wincmd q"
            return
        endif

        "ロケーションリストではなく、かつ、アウトラインのロケーションリストが存在
        if win_id2tabwin(w:winid_ol)[1] != 0 && getloclist(w:winid_ol,{'title' : 0}).title == s:treeTitle
            silent exec 'setl wiw=' . eval(&columns - w:save_wmw)
        endif

    endif
endfunction "}}}

"ウィンドウを離れる前
au WinLeave * call s:myWinLeave()
function! s:myWinLeave() "{{{
    if exists("w:winid_ol") == 0
        return
    endif

    "アウトライン表示の場合。
    if s:isOutllineList() == 1
        setl nornu
    endif
endfunction "}}}

"アウトライン同期
au CursorMoved * call s:outlineSync()
function! s:outlineSync() abort "{{{
    if exists("w:winid_ol") == 0
        return
    endif

    if s:flg_switch == 1
        let s:flg_switch = 0
        return
    endif

    if exists("s:outlineSyncStart")                             "前回実行時刻を保持している場合
        let seconds = reltimefloat(reltime(s:outlineSyncStart)) "経過時間を取得
        let s:outlineSyncStart= reltime()                       "現在時刻を保存
        if seconds <= 0.1
            if exists("s:timer")
                call timer_stop(s:timer)
            endif
            let s:timer = timer_start(200,function('s:MyTimerHandler'))
            return
        endif
        "echo seconds
    else                                                        "前回実行時刻を保持していないわけだから、
        let s:outlineSyncStart= reltime()                       "保持しておいて、処理を続行
    endif

    set lazyredraw

    "echom "outlineSync! filename:" . expand("%") . '(' .  line('.') . ')'

    "ロケーションリストの場合
    if s:isOutllineList() == 1
        let l:location = getloclist(w:winid_ol)[line('.')-1]
        "echom 'outlineSync!! outline  ' . expand("%") . getline('.') . '('. l:location.lnum . ')'

        "アウトラインに該当するテキストを先頭に表示してカーソルを移動させる。
        silent exec "keepjumps normal \<CR>"
        normal zt

        "直前のウィンドウ(アウトライン)へ戻る。
        let l:prev_winnr=winnr('#')
        silent exec "keepjumps ".l:prev_winnr . "wincmd w"
        "echom "outlineSync!!-!" . expand("%") . getline('.')

    "ロケーションリストではなく、かつ、アウトラインのロケーションリストが存在
    elseif win_id2tabwin(w:winid_ol)[1] != 0 && getloclist(w:winid_ol,{'title' : 0}).title == s:treeTitle
        "echom "outlineSync!! text  outline_winid" . getloclist(w:winid_ol,{'filewinid' : 0}).filewinid . ' , text_winid:' .  win_getid()

        "ロケーションリストが表示しているファイルのウィンドウの場合のみ
        if  getloclist(w:winid_ol,{'filewinid' : 0}).filewinid == win_getid()
            try
                let save_wi = winsaveview()
                "見出し行上では、一つ上のアウトラインを指してしまうため、1行下へ移動して、上のリストへ移動。
                silent exec "keepjumps normal \<CR>"
                silent exec "keepjumps labove"
            catch /^Vim\%((\a\+)\)\=:E553:/ "「要素がもうありません」 先頭で要素がもう無い場合
            catch /^Vim\%((\a\+)\)\=:E42:/  "「エラーはありません」  gf 等でウィンドウ内が別バッファになった時。
                set nolazyredraw
                return
            finally
                call winrestview(save_wi)
            endtry
        endif

    endif
    set nolazyredraw

endfunction "}}}

function! s:MyTimerHandler(timer) "{{{
    call s:outlineSync()
endfunction "}}}

au BufWinLeave * call s:myBufWinLeave()
function! s:myBufWinLeave() "{{{
    if exists("w:winid_ol") == 0
        return
    endif

    try
        let winid_tx =  getloclist(w:winid_ol,{'filewinid' : 0}).filewinid
    catch /^Vim\%((\a\+)\)\=:E716:/ "辞書型にキーが存在しません アウトラインが表示されていない場合。
        return
    endtry

    "閉じられようとしているバッファと今いるバッファが一致していない場合
    if expand("%") != expand('<afile>')     " :lcl でテキスト側からアウトラインを閉じようとした場合等
        return
    endif

    "アウトライン表示以外、かつ、アウトラインが示しているウィンドウの場合
    if s:isOutllineList() == 0 && winid_tx == win_getid()
        "exec "lclose"  E855になるので無理。フラグを立てておいて、WinEnterで判定させる。
        let s:flg_leave=1
    endif

endfunction "}}}

"アウトライン表示のロケーションリストかどうか判定
function! s:isOutllineList() "{{{
    if exists("w:winid_ol") == 0
        return
    endif

    if &buftype != 'quickfix'                   "quixfix or location-list
        return 0
    endif

    let wi = getwininfo(win_getid())[0]
    if wi.loclist != 1                          "location list!
        return 0
    endif

    try
        let s:loctitle= getloclist(w:winid_ol,{'title' : 0}).title
    catch /^Vim\%((\a\+)\)\=:E716:/ "辞書型にキーが存在しません アウトラインが表示されていない場合。
        return 0
    endtry

    if s:loctitle != s:treeTitle
        return 0
    endif

    return 1
endfunction "}}}

"アウトライン更新
function! s:updateOutline() "{{{
    if exists("w:winid_ol") == 0 || w:winid_ol == 0
        return
    endif

    "バッファに変更あり、かつ、アウトラインウィンドウ表示中の場合
    if &mod && win_id2tabwin(w:winid_ol)[1] != 0
        silent exec "MOT"
    endif
endfunction "}}}

"互換性オプションを復元
let &cpo = s:save_cpo

解説

使い方

まずは使い方から説明します。
各関数の中身については、後のセクションで説明します。

起動、アウトライン再描画

:MOTとしてください。左側にアウトラインが表示されます。

txt拡張子を WzMemo 形式として編集している最中に、.を押下してノードを作成したり、階層を変更した場合などは、明示的に:MOTを実行してアウトラインを再描画する必要があります。

ユーザ定義コマンド名が重複していなければ:MO等の短縮形で起動することも可能です。

現在は、以下の拡張子を編集している場合に、本機能が有効になっています。

拡張子 階層指定文字 階層指定文字のインデント可否 説明
txt . WzMemo 形式テキスト。アイディアメモでも備忘録でも、議事でも日記でも作文でも、なんにでも使えます。
md # Markdown。見出しレベルを階層としてアウトライン表示します。
```で囲まれたコードブロック中の行頭「#」には反応しないようにしてあります。(折り畳みとしては扱われてしまいますが、アウトラインのノードとは見なしません)
vim 'foldmarker'に従う Vim Script 編集時に、fdm=markerとして関数単位で折り畳んでおくと便利です。
sql 'foldmarker'に従う SQL ファイルを編集する事があったので、追加してみました。

ショートカット

アウトライン表示が存在する状態で、以下のショートカットが有効になります。

テキスト上

キー 機能
Ctrl-Tab アウトライン表示のウィンドウに移動します。
[f テキスト上で押下すると、上方向の直近のノードへ移動します。
]f テキスト上で押下すると、下方向の直近のノードへ移動します。
[F テキスト上で押下すると、先頭のノードへ移動します。
]F テキスト上で押下すると、最後尾のノードへ移動します。
g> 現在のノードの階層を再帰的に+1し、アウトラインを再描画します。
g< 現在のノードの階層を再帰的に-1し、アウトラインを再描画します。

テキスト上でカーソルを移動すると、アウトライン表示側も追尾して反転表示になります。

アウトラインの元になっているテキストを表示しているウィンドウが全て閉じられた場合、アウトラインも自動的に閉じます。

アウトライン上

キー 機能
Ctrl-Tab テキストに移動します。カーソル位置は移動しません。
Enter テキストに移動します。該当ノードの先頭にカーソルを移動します。

アウトライン上で、上下移動(jk、と言わず、:h up-down-motionsのお好きな方法で)すると、テキスト側は該当ノードをウィンドウの最上行にして再描画されます。

該当ノードが折り畳まれていた場合は、折り畳まれていた行を最上行として、カーソル位置をノードの行に合わせようとします。

関数説明

ざっくりとした説明になります。
本体を修正した時に、この部分の修正まで気が回らず、以下に書いてある内容が古いままの可能性があります。。

関数外

この関数の外の部分だけはコードを引用して少し解説してみたいと思います。
※関数の中に入ったら、コメントでも読んでもらった方が確実だと思うので、要点のみ述べます。

二重ロード除け

"二重ロード避け
if exists("g:loaded_outline_like_wzmemo")
  finish
endif
let g:loaded_outline_like_wzmemo = 1

コメントの通りです。お呪いの様なものだと思っておいてください。
何度読み込まれても問題ないものなら、必要ないと思いますが、思わぬことを引き起こすことがあるので、とりあえずつけておくのが安パイです。

この辺りの事は、こんな記事を読むよりも、「名無しのvim使い」様のような素晴らしいサイトを読めばバッチリです。

Cのヘッダファイルに書くインクルードガードのようなものです。
・・・と言うような書き方をすると、plugin 配下に置いたファイルがヘッダのようなもので autoload 配下に置いたものがCソースのような関係に見えるかもしれませんが、全然違います。

vim helpにて、すでに書かれていました。:h 41.10の中の、パッケージングのセクションをご一読ください。

互換性オプションの副作用回避

let s:save_cpo = &cpo
set cpo&vim
(略)
let &cpo = s:save_cpo

これまたお呪いの様なものです。:h cpoptions参照ということで。
一言でいうと、"compatible-options (互換オプション)"の副作用を回避するためです。
vimrc とかで このオプションを変更している場合、上手く動作しないことがあるので、こうしておきます。

この辺りの事は、こんな記事を読むよりも、「名無しのvim使い」様のような素晴らしいサイトを読めばバッチリです。

ちなみに、&vimの意味は、:h cpoptionsを読むだけではわからないので、:h set-defaultを参照しましょう。

vim helpに、すでに記述が存在していました。:h use-cpo-saveをご覧あれ。

定数の定義

let s:treeTitle = 'Outline' | lockvar s:treeTitle
let s:treeIndent = 2        | lockvar s:treeIndent
let s:treeMinWidth=40       | lockvar s:treeMinWidth

s:treeTitleは、アウトライン表示の下に表示されるタイトルです。実際には「[ロケーションリスト]Outline」となっています。

s:treeIndentは、アウトライン表示内で、レベルごとにインデントされる量です。

s:treeMinWidthは、テキストウィンドウにフォーカスがあるときに、アウトラインウィンドウの幅を縮めて表示するときの最小幅です。

こういうものをグローバル変数にしておいて、vimrcなんかに書いてあった場合はそれを優先する。みたいなことをすると、カスタマイズの余地が生まれてくるんでしょうね。

変数の初期化

let w:winid_ol = 0      "新規ウィンドウには存在しないので、各イベントでexistsする

w:winid_olは、ロケーションリストのWindow-IDを保持する変数です。
ロケーションリストを開いた時に設定しますが、その前にイベントから呼ばれる関数内で使ってしまっていて、値がないとエラーになるので、各地でexistsにより存在確認を行っています。

w:はウィンドウローカル変数を意味します。ロケーションリストを作成した瞬間に保持しますが、その時にはロケーションリストウィンドウ側にしか保持されないため、テキストウィンドウ側にフォーカスが戻った後、そちらにも改めて保持するようにしています。同じ名前ですが、別の実体です。

let s:flg_switch = 0

s:flg_switchはカーソル同期のために使用しています。WinEnter イベント時に設定して、CursorMoved イベント時に判定しています。

  • WinEnter 時には、アウトライン表示のウィンドウに入った直後に、1を設定します。
  • CursorMoved 時には、1が設定されている場合に、アウトライン同期処理を行わないようにします。

こうすることで、明示的にアウトライン表示へ移動した場合以外、すなわちウィンドウコマンド(Ctrl-W)や、マウスクリックによってアウトラインのウィンドウが選択された瞬間に、アウトライン同期処理が暴発して、テキスト表示のカーソル位置がかわってしまうことを防いでいます。

let s:flg_leave = 0

s:flg_leaveは、BufWinLeave イベント時設定して、WinEnter イベント時に判定しています。

  • BufWinLeave 時には、テキストのバッファが取り除かれようとしている場合に、1を設定します。
  • WinEnter 時には、1だったら、アウトラインを閉じます。

なんでこんな回りくどいことをしているのかというと、テキストが閉じられようとしている最中には、アウトラインを閉じることができなかったため、「テキストが閉じたよー」という情報を保持しておいて、他のウィンドウがアクティブになった瞬間に、アウトラインを閉じてあげる。という段取りを踏んでいるためです。

自動コマンドの設定

詳しくは、:h autocommand を読むべし。

autocmd.txtを読むと、今まで穏やかな物腰だった Vim ヘルプ様が、突如として軍曹殿のような口調に変化したことに戸惑いを覚えるかもしれない。しかも、いきなりガツンとこういう警告が書かれていて、ビビる人もいるかもしれない。

警告: 自動コマンドは大変強力であるので、思いも寄らない副作用をもたらすことがある。
テキストを壊さないように注意しなければならない。

・・・各自、注意して使用するように!

au BufNewFile,BufRead *.txt setl fdm=expr fde=WzLikeOutline('.',1)
au BufNewFile,BufRead *.md	setl fdm=expr fde=WzLikeOutline('#',0)

テキストファイルの場合に、WzMemo 風の折り畳みを有効にしますよ('.')。インデントを許容しますよ(1)。という事です。
Markdown の場合には、#の数のレベルで折り畳みしますよ('#')。インデントは許容しませんよ(0)。ということです。

au BufNewFile,BufRead *.txt com! -buffer MOT call <SID>makeOutlineTree('.',1) | call <SID>outlineSync()
au BufNewFile,BufRead *.md  com! -buffer MOT call <SID>makeOutlineTree('#',0) | call <SID>outlineSync()
au BufNewFile,BufRead *.vim com! -buffer MOT call <SID>makeOutlineTree('',0)  | call <SID>outlineSync()    "fdm=marker想定
au BufNewFile,BufRead *.sql com! -buffer MOT call <SID>makeOutlineTree('',0)  | call <SID>outlineSync()    "fdm=marker想定

拡張子に応じて、起動用のユーザ定義コマンド(:MOT)を追加します。
ちなみにですが、ユーザ定義コマンドは先頭が大文字である必要があります。詳しくは:h E183を参照。

テキストファイルの場合は「非空白文字を除いた行頭の'.'」を目印にしてアウトラインを作成しますよ。という指定で、アウトライン作成用の関数を呼び出します。

Markdown の場合には、「行頭'#'」を目印にアウトライン作成しますよ、という(以下略)

.vim.sqlの場合は、マーカー折り畳みを想定しているので、第1引数は空で、第2引数は0です。
もし、マーカーで折り畳んでいる他の拡張子のファイルでもアウトライン表示したいなーと思ったら、随時ここに拡張子を付け足せばおk。

ちなみにですが、<SID>というのは、s:で定義したローカル関数やらを正しく呼び出すために必要な文字列です。

makeOutlineTreeって、引数が状況によって決まっていて、ユーザーが引数を指定して呼ぶよりも、:MOTを経由して読んだ方が確実じゃないですか。
だから、スクリプトローカルにしてあるんですけれども、そうすると、今度はユーザ定義コマンド内で call することもできなくなるんですよね。<SID>で、そういう状態を回避できるようです。

詳しくは、:h <SID>参照ということで。

"保存時の自動更新を有効に
au! BufWrite *.txt,*.md,*.vim,*.sql call <SID>updateOutline()

ファイルの上書き保存時に、updateOutline()という関数を呼びます、という意味です。
アウトラインを自動更新します。

"一定時間キー入力なしで自動更新
au CursorHold *.txt,*.md,*.vim,*.sql call <SID>updateOutline()

コメントに書いてある通りです。
前々からコメントアウト状態で書いたり戻したりしてましたが、今回、実験的に有効にしてみました。
何か問題があるかも。。?

MyFoldText()

偉大なる foldCC.vim がなければこの関数はありませんでした。
というか別に set foldtext=FoldCCtext()のままでも、それほど問題はありませんよ?
こちらは劣化版のようなものです。あちらはいろんな機能がありますし。

個人的に、set noetな人間なので、こんなものを作る羽目になりました。

ミソはここです。

let line = substitute(line,'\t',printf('%' . &ts . 's',' '),'g') "タブ文字をtabstop数分の空白に置換

printf関数の変換指示子に、「%9s」で9バイトに揃えて出力する、というのがあります。
空白を出力する時に、これを使うことで、tabstop数分の空白、というのを容易に拵えることができました。

WzLikeOutline()

foldexprに指定する関数です。
行頭の'.'の数を数えて、折り畳みの深さを求める関数になります。
行頭に'.'が見つからない行だった場合は、直前の行のレベルを引き継ぎます。('='を返却する)
ただし、次の行が行頭に'.'を持つ行だった場合は、折り畳みを終了させるため、直前行レベル-1を返します。

一番最初に、これが欲しく作り始めたようなものです。せめて折り畳みだけでもいいから、Wzっぽくならないかなー、と。
書いてみたら意外と簡単にできて、テンションが上がったものです。

ただし、同一階層を連続させようとすると空行が必要というのは、作成当初はかなり嫌でしたが、今となってはそれほど気にならなくなりました。

最初は vimrc にこの関数を直書きしていました。

関数の引数を、関数内で参照するときにはa:をつける、という流儀すら知らなかったレベルから始めた自分でも:h fold-exprを参照すれば、この程度は書けるようになりました。素晴らしいヘルプが最初からついていることが Vim の数多い美点の一つでしょう。しかも日本語化されているので大助かり!

各行に対して評価されるのであまり遅くするなとか、副作用があってはいけないとか、'='や'a'や's'は極力使うなとか、色々と注文が多いですが、裏を返せばとてもシッカリしたドキュメントで実に安心感がありますね。

s:makeOutlineTree()

次に書いたのがコレです。折り畳めたなら、アウトラインを出してみたくなるのがサガと言うものでしょう。
プラグインにしてみようと思ってからは、サクサクと出来上がりました。

:vimgrepから連想して、QuickFix を利用しようと思いつき、quickfix.txtを流し読んでいて、:h caddeの例を見た瞬間に、これで勝つる。と思ったのを覚えています。

また、quickfix.txtを読んでいて、Location-list という物がある事を知り、バッファローカルな方がいいかな、とそちらに鞍替えしました。

実際には、:h caddeの例のままでは、Markdown のコードブロック内の#始まりの行(今まで書いてみた記事で言うと、AHKのディレクティブの事ですね)がアウトラインのノード扱いされてしまうため、1行1行見て回る処理を書く羽目になりました。

出来上がったロケーションリストの中身をnormalやexeを使って、コネコネとしたのは面白い感覚でした。懐かしきバッチファイルを書いているような。とはいえそれなりの事が出来るし、shell の中で sed や akw を使うよりも楽だし、これは良いな、と。

もはや、qでキーマクロを押し間違えないようにポチポチするよりも、これをワンライナーしたほうがいい時もあるんじゃないか、と思えるほどです。

エディタとしての姿勢と、スクリプトとしての姿勢にブレがない、というか、キーボードオンリーという操作体系や、モードという概念と、こういうバッチ処理の相性が良かったんでしょうねぇ。

ちなみに、ここまでで言えることは Vim のヘルプがとにかくスゴい、の一言です。
Vim を普段使いしていたので、Exコマンドなどに親しんではいましたが、それを差し引いても、まったく読んだことのないヘルプを初めて読んで、これほどすんなりと腑に落ちるのは、ただただ不思議です。

s:setIndentOutline()

式の折り畳みの時には、.#の数分だけインデントすればよかったのですが、マーカーの折り畳みのアウトライン表示ではテキスト側から折り畳みレベルをとってきて、インデントするようにしました。

投稿初期にはなかった関数です。

マーカー行のテキストに自前で行っているインデントでそれっぽく、アウトライン側にもインデントされていたため、必要性に気が付いていませんでした。

テキストの折り畳みレベル×Tabstop 分だけ、アウトラインをインデントしています。
.#を置換するという荒業では、見出し文字列中に.#が入っている場合、それも置換してしまうので、常にこの関数を使用します。

s:makeFoldOutline()

アウトライン表示部分の折り畳みを作成する関数です。
ノード自体が増えてくると、アウトライン表示自体が見づらくなってきますから、あった方がいいかなと。
アウトライン表示用の foldtext 関数を書いた方がいいかもしれませんね。

(2021/12/22追記)
アウトライン表示側が折り畳まれた状態で、アウトライン再構築処理が走ると、メチャクチャになるという問題は把握済みです。
そのうち気が向いたら直すかも。この関数に問題があるわけじゃなくて、アウトラインを作ってるほうの問題ですが。。

s:getFoldLevelOutline()

上記のs:makeFoldOutline()の中から呼ばれている関数です。
アウトライン表示が何階層目のノードなのかを求めます。

s:incFoldLevel()

s:makeOutlineTree()すると定義されるマップに、z>z<があります。
※折り畳み方式が「式(EXPR)」の場合にのみ定義します。

このキー入力に対して、呼び出される関数です。

現在行を含むノードを起点にして、より階層の低いノードのまとまりに対して、レベルを増減させます。
作った本人もあんまり信用していません。動作後のアウトラインの状態はよく確認した方がよいです。

s:switch_outline()

s:makeOutlineTree()すると定義されるマップに、<Ctrl-Tab>があります。

Ctrl-Tabが押された場合に、この関数が呼び出されるようにしてあります。

アウトライン表示と、テキストの間で、フォーカスをトグルします。

文章下手が迂闊に解説しようとするよりも、:h winnr()および:h :wincmd参照、の一言に尽きるでしょう。

s:myWinEnter()

WinEnter イベント時に呼ばれるようにしてあります。
新しいウィンドウに入るということは、すなわち、上述のCtrl-Tabキーによりアウトラインウィンドウとテキストウィンドウの間でフォーカス移動があったか、それ以外の理由で変わったかです。
Ctrl-Tab以外でフォーカスが変わるということは、ウィンドウコマンド(Ctrl-W)か、マウス操作か、ほかのウィンドウが閉じた場合ということになります。

まぁ、そういうような条件で判断しつつ、アレコレしています。
詳しくはソースを見るべし。後は、:h autocmd-eventsですね。

s:myWinLeave()

WinLeave イベント時に呼ばれます。

アウトラインウィンドウに入った時(WinEnter)に表示させた相対的な行番号を、アウトラインウィンドウから離れる時(WinLeave)に、相対的な行番号を非表示にします。

s:outlineSync()

CursorMoved イベント時に呼ばれるようにしてあります。
カーソルが動いたときに、テキスト側とアウトライン側のフォーカスを同期させる処理が書いてあります。

このイベントではあまり時間のかかる処理をしない方がよいらしいです。
今の実装具合がどの程度なのか不安ですが、この文章を書いている限りでは、Celeron N3060 という非力 CPU でもつっかえたりするわけではないので、問題ないレベルだと信じたいと思います。
→CursorMoved イベントが0.1秒以下の間隔で連続で発生した場合、0.2秒後に発火するタイマーを仕込むだけにして、同期処理は行わないようにしました。
キーリピートやそれに近い状態が発生するなどvimmerとしては恥ずべき醜態なのですが、たまには発生してしまいますよね。

ここのキモはset lazyredrawでしょう。vbaを書いている人になら、Application.ScreenUpdating = Falseって言えば通じるやつです。

内部的には、ウィンドウをパカパカとアッチコッチしているのですが、見た目上は動かないので同じウィンドウにいる感覚でカーソル移動を続けられるわけですな。素晴らしい。

s:MyTimerHandler(timer)

上記のCursorMoved イベントが0.1秒以下の間隔で連続で発生した場合、このタイマーハンドラを仕込むようにしてあります。何をするのかというと、outlineSyncを呼び出しているだけです。
0.2秒後に発火するようにしておいてあるので、outlineSyncの中で止められずに、同期処理がおこなわれます。

s:myBufWinLeave()

BufWinLeave イベント時に呼ばれるようにしてあります。名前が安直ですね。こういう名前しか付けられないのでスクリプトローカル関数がありがたいです。

テキストウィンドウが閉じたときに、自動的にアウトラインも閉じるための処理を記述してあります。

s:isOutllineList()

現在のウィンドウがアウトライン表示かどうかを判定する関数です。
いろんなところでこの判定を行うので関数化してあります。

s:updateOutline()

アウトラインを再描画します。
バッファが変更されていないときは再描画を行いません。
また、アウトラインウィンドウを閉じている場合にも再描画は行いません。

・・・という条件分岐で、MOTを実行しているだけです。

現在思いついている改善点

  • ジャンプリストが汚れる。:keebjを使うようにする?
    →ツリー作成時にアウトライン対象行がジャンプリストに入ってしまうのは避けられなかった。
  • Tab をアウトラインとの切り替えに使ったせいで、 Ctrl+i でジャンプリストを辿ることができなくなっている。割り当てを考える。
    Ctrl-Tabに置き換えた。使いづらい。
  • アウトライン側にフォーカスがあるときには、se rnu nuするようにしてみたい。

最後に

今回 Vim プラグインを書くのも初めて、という人間が作ったものなので、実用性とかよりも、これからプラグイン書いてみたいなーという人が、勇気を持てればいいな、という程度の感覚です。

一応動いているので、自分では使いますけれどもね。

単純にテキストエディタとして Vim が好きでしたが、こんな簡単に機能追加したり、テキスト編集を自動化できるということが分かって、ますます好きになりました。

あとは、何といってもVim のヘルプファイルが、とにかくすごい。・・・これに尽きます。

この記事でも、そこらじゅうで:h なんちゃららと書いてまいりましたが、とにかく本当に充実していて、しかもCtrl-]Kを使って縦横無尽に関連ヘルプを飛び回って参照できます。ためになるサンプルも随所に書かれていて、ネットや書籍に頼る必要もほとんどないくらいなのです。

WzMemo を使いたいなー、という切っ掛けから、なかなか良いものを知る機会になりました。

3
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?