概要
Vim 上で、WZEditor の階層付きテキスト形式(WzMemo)風の折り畳み、およびアウトライン表示を行うプラグインを作ってみました。
ついでと言ってはなんですが、以下の2つにもとりあえず対応させてあります。
- Markdown の 行頭"#"の見出しレベル
- マーカーによる折り畳み(規定では「{{{」と「}}}」で挟まれたブロック)
ワタクシ、プログラミングだけではなく、日本語の文章も Vim で書いているので、こういうのがあるといいなーと思って書きました。
ついでに Vim プラグインを初めて書いた人間の視点というものを詳らかに記していこうと思います。
そのため、ガリガリとプラグインを書いているという人から見たら、稚拙な手法を採用している可能性も多々ありますが、これからプラグイン書いてみたいぜ!という人の一助になれば幸いです。
「ああ、こんなノリでもプラグインらしき物が作れちゃうんだなー」という感じで。
という事なので、Vim script をよくわかっている人が書いた記事ではなく、ド素人が紆余曲折した経緯だと言うことに留意しておいてください。
実はもっとスマートな方法があるにも関わらず、回りくどく書いてしまっていて読みにくい箇所もあるでしょうし、妙にテクニカルに凝っていない分、朴訥な書き方で、改造などもしやすいかもしれません。とセルフフォロー。
環境
GVIM 8.2.1287 (kaoriya 2020/07/24)
自前の_vimrc
や_gvimrc
の内容と干渉して上手く動いているだけかもしれないので、変なところがあれば報告をいただければ確認するかもしれません。
そもそもアウトラインって?
超適当な説明なので、少しでも意味が分かっている人は読み飛ばしてもよい項です。
アウトライン、とは、「章・節・項」などの文章の階層的な構造を一目で見られるようにするものです。
アウトラインがあると、文章全体の流れや構成を整理しやすくなります。
書いてる人間にとって整理しやすくなったからといって、他者にとって読みやすい文章になるかどうかは別問題であるというのは、悲しいことに本稿がテキメンに表しているとおりでありますが。(閑話休題)
その「アウトライン」を表示する機能と、文章編集機能を兼ね備えたソフトウェアの事を、「アウトラインプロセッサ」ナドと呼んだりするようです。
「文章を全体の枠組み(アウトライン構造)から考えるためのソフトウェアである」とかどうとか言われていますが、物としては各々のフォルダ内に一個だけの文書ファイルしか扱えないエクスプローラのようなものです(暴論)
操作する要素としては、「見出し(ノード)」と、「その見出しに対してブロック化された文章」の2つに大別されます。要するに「フォルダ」と「ファイル」みたいなもんです。
まずは効果を感じてみたいという場合は、「起・承・転・結」とか「序・破・急」だとかの見出しを作るだけですら、その即効性・有用性を感じる事が出来ると思います(かな?)
もう少し具体例でいえば、・・・例えば、読書感想文を一気に800文字を書くよりも、
【読書感想文】
├1.この本を選んだ理由 (100~300文字程度)
│ ├この作品のジャンルや、自分の好み分野について
│ ├作品を知った経緯
│ ├作家について興味を持った点等
│ └:
├2.あらすじ説明 (200~300文字程度)
│ ├導入(起承転結の「起」程度)
│ └見所とその感想
├3.感情移入した登場人物 (200~300文字程度)
│├好感を持てた人物
││ ├人物説明やエピソード引用
││ └好きな理由
│└好感を持てなかった人物
│ ├人物説明やエピソード引用
│ └好きになれない理由
└4.最後に (100~300文字程度)
├作家に対して共感 or 反感を持った点
├自分が登場人物ならどうしたと思ったか、とか
└読み終えて自分はこれからどうしていきたいと思ったか、とか
みたいな感じで。
このような雑なアウトライン程度なら、何パターンでも考えだすことができると思います。
書いていくうちに、ノードを足したり引いたり入れ替えたりすることで、アウトライン自体も変わっていくでしょう。
いろいろなアウトラインで書いてみて、最終的に一番しっくりきた感想文を提出することすらできるようになるはずです。
一本の読書感想文を書くのにもヒーヒー言っていたはずなのに、もはや複数の感想文を容易に捏ね上げて、うまくいっている感じがするものを提出する、というスタイルにまで持っていける可能性があるわけです。
小学生のころに知りたかった!
ノードツリー と テキスト
アウトラインプロセッサの構成要素について説明します。
- 階層化されたノードのツリービュー(またはリスト)があります。エクスプローラのフォルダツリーのような感じです。
- ノードのテキスト内容を表示する領域があります。エクスプローラのファイルのリストビューの代わりに文章が一個デーンとある感じです。
以上!
ノードツリーでは、ノードを同一階層内で上下させるどころか、別階層へもホイホイと移動させることができたりします。
例えば、ノードを分けておいた単位で、「あ、これは先に書いとかんといかんヤツや」とか「これは別の章で説明した方がいいかも?」とか、思い立ったが吉日、ペイッと簡単に動かすことができるわけです。
もちろん辻褄が合うように細かいところの修正は必要ですが、「長ーい1つのファイル」の中身を切った張ったするよりかは、大筋を見失いにくい視点を保ったまま作業を進めることができます。
ノードツリー
ノードツリーが、フォルダツリーと違うのは、上から下へ向かう順序関係があるくらいです(という程度の認識の人間がこの記事を書いています)。
このノードツリーの部分が「アウトライン」と呼ばれ、文章全体の構造・構成を表します。
GUI を備えたアウトラインのノードツリーでよくありそうな機能は、ドラッグ&ドロップすることでテキスト内容をコネコネとできるようなやつです。任意のノードを並び順を変えるどころではなく、別のノードのサブノードへ、ヒョイと持って行ったりできてしまいます。
ちなみに、拙作プラグインには、そんな機能はありません。ノードを移動させたい場合は、テキスト上にて、zc
で折り畳んでから dd
して、貼り付けたい場所でp
やP
してください。編集機能は素の 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階層付きテキストとは
行頭から続く「.」の数で、ノードの階層を表す形式です。
一見しただけでは、ただのプレーンテキストファイルです。実にシンプルイズベスト。
アウトラインがメタ情報ではなくテキストとしてガッツリ書かれており、それでいて視覚的にそれほど主張せず、かつ階層を直観的に指定可能です。
プログラムソースの場合はマーカー折り畳みでも問題ないのですが、文書を書いている場合にはマーカーがどうにも邪魔に感じてしまったり、zf
やzd
の操作や、閉じマーカーのインデント量を調整する手間が面倒に感じてしまうことがあり、この 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へ2行追記するだけの簡単な作業です。
~/_vimrc
以下の2行を追記してください。
set foldtext=MyFoldText()
set fillchars-=fold:-
foldtext は 折り畳まれた行の代わりに表示される文字列を返す関数です。
高名なものとして foldCC が存在します。
基本的には、「見出し行の内容をそのまま表示+折り畳み情報」というのが理想なのですが、foldCC では tab インデントされているものが正しく表示されない(?)ようなので、そのあたりを自分なりに書き直したものが、 myFoldText() という捻りのない名前の関数です。
fillchars オプションの fold キーワードは、'foldtext' での空白部分を示します。
必須の設定ではありませんが、規定値が '-' なので無駄な装飾が行われないように取り除いておきます。
WzLikeOutline.vim
~/vimfiles/plugin/
配下に、以下のWzLikeOutline.vim
を格納してください。
私はもっぱら Windows 利用者なので~/.vim
ではなく~/vimfiles
になりますが、その辺りはruntimepath
を踏まえて適当に読み替えてください。
なにせ職場のサーバに入っているのが Vim version 6.x で、folding すら使えないので、端末で gVim を使うのです。HP-UX で、素の vi しかなかった頃よりは全然マシなので、なんにも問題はございません。ええ、ございませんとも。
まずはスクリプト本体。
本体しかないとも言う。(お行儀が悪いかもしれませんが、autoloadに分けるほどでもないかと?)
各部の解説は後述します。
" vi: set fdm=marker noet tw=0 : "Vim global plugin for outline like WzMemo"Last Change:2020/10/09 22:00:17."Maintainer:azuwai"License:This file is placed in the public domain."二重ロード避けif exists("g:loaded_outline_like_wzmemo")finishendifletg:loaded_outline_like_wzmemo=1lets:save_cpo=&cpo
set cpo&vimlets:winid_ol=1 "とりあえず1に?
lets:flg_switch=0lets:flg_leave=0"デフォルトでWZ階層付きテキストを有効にすると、Netrwでディレクトリが折り畳まれ"てしまうので、txt、mdファイルを開いた時だけにしてあります。"(そもそもファイル名がないとアウトライン表示に使っているロケーションリストが正しく動かない)"【初期設定】以下を vimrc へ追加。"set foldtext=MyFoldText()"set fillchars-=fold:- "折り畳み行に無駄な装飾が行われないようにlets:treeTitle='Outline'lets:treeIndent=2auBufNewFile,BufRead *.txt setl fdm=expr fde=WzLikeOutline(v:lnum,'.',1)auBufNewFile,BufRead *.md setl fdm=expr fde=WzLikeOutline(v:lnum,'#',0)auBufNewFile,BufRead *.txt com! MOT call<SID>makeOutlineTree('.',1)auBufNewFile,BufRead *.md com! MOT call<SID>makeOutlineTree('#',0)auBufNewFile,BufRead *.vimcom! MOT call<SID>makeOutlineTree('',0) "fdm=marker想定
auBufNewFile,BufRead *.sql com! MOT call<SID>makeOutlineTree('',0) "fdm=marker想定
"折り畳み見出し行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 .'('. eval('v:foldend - v:foldstart+1').'行)' "追加情報(折り畳み行数)
let myWidth= winwidth(0) "画面幅
let myWidth -=&fdc "折り畳み表示幅を考慮
if&nulet myWidth -=&nuw "行番号表示時は、行番号の桁数を考慮
enlet margin = myWidth - strlen(line)- strlen(tail) "画面幅、文字列長、追加情報から、余白長を求める
if(&tw !=0)&& (&tw <(margin + strlen(line)+ strlen(tail)))let margin -=(margin + strlen(line)+ strlen(tail))-&tw
endiflet tail = printf('%'. margin .'s',' '). tail "追加情報を、画面の右端に追加
return line.tail "(highlightのguibgをnormalと少し変えておくとわかりやすい)endfunction "}}}" WZ階層付きテキスト(WzMemo)風折り畳みfunction! WzLikeOutline(lineno,lv_mark,indentable) abort "{{{let line = getline(a:lineno) "現在行を取得
ifa:indentable==1 "行頭空白除去指定時
let line= substitute(line,'^[[:blank:]]\+','','')enletlv=0leti=0whilei<=&fdc "行頭からfdcまで先頭から'.'の数を数える。
if line[i]==a:lv_markletlv=lv+1elsebreakendifleti=i+1endwhileiflv==0 "現在行が、見出し行ではない場合、
let line = getline(a:lineno+1) "次の行を取得
ifa:indentable==1 "行頭空白除去指定時
let line= substitute(line,'^[[:blank:]]\+','','')enletlv=0leti=0whilei<=&fdc "行頭からfdcまで先頭から'.'の数を数える。
if line[i]==a:lv_markletlv=lv+1elsebreakendifleti=i+1endwhileiflv==0 "次の行も見出し行ではない場合。
return"=" "上の階層を継続
else "次の行が見出し行の場合、階層を区切るために、
returnlv-1 "次の見出し行よりも1つ低い階層にする。
endifelse "見出し行だった場合、自行の階層を返す。
returnlvendifendfunction "}}}"アウトライン表示function!s:makeOutlineTree(lv_mark,indentable) abort "{{{ifs:isOutllineList()==1
exec "normal \<CR>"endif"折り畳み方法の判定if(&fdm !='expr')&& (&fdm !='marker')
echo 'ERROR : only - set ''foldmethod'' = "expr" or "marker"'returnendif"折り畳み方法に応じた検索パターンを編集。ついでに見出し行のハイライト設定もif(&fdm =='expr')let gptn='\M'.a:lv_markif(a:indentable==0)let gptn2 = gptn
let gptn ='^'. gptn
elselet gptn2 ='\m[[:blank:]]*'. gptn
let gptn ='^\m[[:blank:]]*'. gptn
enif&ft !="markdown" "Markdownは標準のハイライトがあるのでそっちを優先
exec 'syn match MyFoldSt "'. gptn .'\m.*"'hi link MyFoldSt FoldLabel
endifelseif(&fdm =='marker')let fmrs = split(&fmr,',')let gptn ='\M'. fmrs[0]let gptn2 = gptn
exec 'syn match MyFoldSt ".*'. gptn .'\m.*"'"FoldLabel は .gvimrc で好きに定義するようにhi link MyFoldSt FoldLabel
endiflet cfdm=&fdm
"ロケーションリストを初期化
exec "lexpr('')""ロケーションリストに追加"exec 'g/' . gptn . '/lad expand("%") . ":" . line(".") . ":-" . getline(".") 'let codenotation=0foriin range(1,line("$"))let wkline= getline(i)if stridx(wkline,'```')==0let codenotation=(!codenotation)endifif!codenotation && match(wkline,gptn)!=-1
exec 'lad expand("%") . ":" .'.i.' . ":-" . getline('.i.')'endifendfor"ロケーションリストを閉じて開き直す
exec 'lclose'vertlwindowlets:prev_winnr=winnr('#')lets:winid_ol= win_getid()call setloclist(s:winid_ol,[],'a',{'title':s:treeTitle})"ロケーションリストをおめかしsetl fdc=0 nonu
setlma
exec '%s/^'. expand("#").'|//e'let lastline= getline('$') "最終見出しの、
let nucol = stridx(lastline,'|')+1 " |区切りが出る位置までをtabstopに設定
exec 'se ts='. nucol
%s/| -/|/e " |区切りの位置を揃える
se et "空白文字に変換
%retab!
%s/ |/|/e "余分な空白を削除
%s/^\([0-9]\+\)\([^0-9]\+\)|/\2\1|/e "桁位置揃え
exec 'setl ts='.s:treeIndent"折り畳みマーカーを削除if(cfdm =='marker')
exec '%s/\M'. fmrs[0].'//'elseif(cfdm =='expr')"exec '%s/|\M' . a:lv_mark . '/|/e'
exec '%s/|'. gptn2 .'/|/e'
exec '%s/\M'.a:lv_mark.'/ /eg'endif
%retab!redraws "TODO:「続けるにはENTERを押すか~」で止まりたくないが、描画は抑止したままにしたい。
"message clearcalls:makeFoldOutline()setl noma
setl nomod
letl:MaxWidth =0foriin range(1,line("$"))let myWidth = eval(strlen(getline(i)))ifl:MaxWidth < myWidth
letl:MaxWidth = myWidth
endifendfor
exec 'setl winwidth='.l:MaxWidth
exec 'setl winminwidth='.(&wiw <30 ? &wiw :"30")"ロケーションリスト移動のリマップを追加
noremap <silent>[f:<C-u>lab<CR> \|zv \|z<CR>
noremap <silent>]f:<C-u>lbel<CR> \|zv \|z<CR>
noremap <silent>[F :<C-u>lfir<CR> \|zv \|z<CR>
noremap <silent>]F :<C-u>llast<CR> \|zv \|z<CR>
noremap <silent><Tab>:call<SID>switch_outline()<CR>if cfdm =='expr'
exec 'noremap <silent> z< :call <SID>incFoldLevel(-1,'''.a:lv_mark.''','''. gptn .''','.a:indentable.')<CR>'
exec 'noremap <silent> z> :call <SID>incFoldLevel(1,'''.a:lv_mark.''','''. gptn .''','.a:indentable.')<CR>'endif
exec s:prev_winnr."wincmd w"endfunction "}}}function!s:makeFoldOutline() "{{{setl fdm=manual
normal zE
let lv_max=0foriin range(1,line('$'))let lv_mod =0let lv_now =s:getFoldLevelOutline(i)forjin range(i+1,line('$'))let lv_pos =s:getFoldLevelOutline(j)if lv_now==0&& lv_pos==0breakelseif lv_pos < lv_now
breakendifif lv_now != lv_pos
let lv_mod =1endifendfor"echom i . '(' .lv_now . ') - ' j . '(' . lv_pos . ')'if1<(j-i)&& lv_mod==1"echom 'folding! ' . i . ' - ' . (j -1)
normal zR
"exec i . ',' . eval(j-1) . 'fold'
exec i
normal V
exec "normal ". eval(j-i-1)."j"
normal zf
if lv_max < foldlevel('.')let lv_max = foldlevel('.')endifendifendforlet lv_max +=1
exec 'setl fdc='.(lv_max <=12 ? lv_max :12)
normal zR
endfunction "}}}function!s:getFoldLevelOutline(lineno) abort "{{{"message clearlet line=getline(a:lineno)letstart=stridx(line,'|')+1 "見出し文字列の先頭から
"echom 'start:' . startleti=startwhilei< strchars(line) "文字数分 ≠Byte数
"echom i . ':' . strcharpart(line,i,1)if strcharpart(line,i,1)!=' ' "非空白を発見するまで
breakendifleti+=s:treeIndentendwhile"echom 'return:' . (i-start) / s:treeIndentreturn(i-start) / s:treeIndentendfunction "}}}"折り畳みレベル一括変更 ※今のところ、+1,-1のみfunction!s:incFoldLevel(delta,lv_mark,gptn,indentable) abort "{{{ifmatch(getline('.'),a:gptn)==-1 "現在行が見出し行ではない場合、
labove "直前の見出し行へ移動
endiflet save_line = line('.') "見出し行の位置を保持
"折り畳みをすべて開く(foldlevelを正しく取得するため)
normal zR
let save_foldlv = foldlevel(line('.')) "現在行の折り畳みレベルを取得
"echom getline('.') . '(' . save_line . ') Lv:' . save_foldlvifa:delta<0 "階層を下げる場合は、予め引いておく。
let save_foldlv-=1endifsetl lazyredraw
foriin range(line('.'),line('$')) "現在行から最終行へ向けて、
"echo i . "行目(" . foldlevel(i) . '/' save_foldlvif foldlevel(i)< save_foldlv "現在のレベルよりも下がるまで続ける
breakendififmatch(getline(i),a:gptn)!=-1 "現在行が見出し行の場合
ifa:delta<0
exec i.'s/\M'.a:lv_mark.'//e'else
exec i.'s/\M'.a:lv_mark.'/'.a:lv_mark.a:lv_mark.'/e'endifendifendfor
exec 'call s:makeOutlineTree('''.a:lv_mark.''','.a:indentable')'setl nolazyredraw
exec save_line
normal zM
normal zO
normal zt
endfunction "}}}"アウトラインウィンドウ切替function!s:switch_outline() abort "{{{let winid_text = getloclist(s:winid_ol,{'filewinid':0}).filewinid
"アウトライン表示の場合ifs:isOutllineList()==1let winnr_text = win_id2tabwin(winid_text)[1]
exec winnr_text ."wincmd w""テキスト表示の場合elseif winid_text == win_getid()let winnr_ol = win_id2tabwin(s:winid_ol)[1]
exec winnr_ol ."wincmd w"endifendfunction "}}}"ウィンドウに入った直後auWinEnter * calls:myWinEnter()function!s:myWinEnter() "{{{"アウトライン表示の場合。ifs:isOutllineList()==1"Ctrl-wやマウスクリックでアウトラインが選択された瞬間に、同期されるのを抑止lets:flg_switch=1ifs:flg_leave==1
exec "quit"endif"アウトライン表示ではないelse"テキスト表示を閉じた後、他のウィンドウがアクティブになった場合ifs:flg_leave==1let winnr_ol = win_id2tabwin(s:winid_ol)[1]
exec winnr_ol ."wincmd q"endifendifendfunction "}}}"アウトライン同期auCursorMoved * calls:outlineSync()function!s:outlineSync() abort "{{{ifs:flg_switch==1lets:flg_switch=0returnendifset lazyredraw
"echom "outlineSync! filename:" . expand("%") . '(' . line('.') . ')'"ロケーションリストの場合ifs:isOutllineList()==1letl:location = getloclist(s:winid_ol)[line('.')-1]"echom 'outlineSync!! outline ' . expand("%") . getline('.') . '('. l:location.lnum . ')'"アウトラインに該当するテキストを先頭に表示してカーソルを移動させる。
exec "normal \<CR>"
normal zt
"直前のウィンドウ(アウトライン)へ戻る。letl:prev_winnr=winnr('#')
exec l:prev_winnr ."wincmd w""echom "outlineSync!!-!" . expand("%") . getline('.')"ロケーションリストではなく、かつ、アウトラインのロケーションリストが存在elseif win_id2tabwin(s:winid_ol)[1]!=0&& getloclist(s:winid_ol,{'title':0}).title ==s:treeTitle"echom "outlineSync!! text outline_winid" . getloclist(s:winid_ol,{'filewinid' : 0}).filewinid . ' , text_winid:' . win_getid()"ロケーションリストが表示しているファイルのウィンドウの場合のみif getloclist(s:winid_ol,{'filewinid':0}).filewinid == win_getid()trylet save_wi = winsaveview()"見出し行上では、一つ上のアウトラインを指してしまうため、1行下へ移動して、上のリストへ移動。
exec "normal \<CR>"
exec ":labove"catch/^Vim\%((\a\+)\)\=:E553:/ "「要素がもうありません」 先頭で要素がもう無い場合
catch/^Vim\%((\a\+)\)\=:E42:/ "「エラーはありません」 gf 等でウィンドウ内が別バッファになった時。
set nolazyredraw
returnfinallycall winrestview(save_wi)endtryendifendifset nolazyredraw
endfunction "}}}auBufWinLeave * calls:myBufWinLeave()function!s:myBufWinLeave() "{{{trylet winid_text = getloclist(s:winid_ol,{'filewinid':0}).filewinid
catch/^Vim\%((\a\+)\)\=:E716:/ "辞書型にキーが存在しません アウトラインが表示されていない場合。
returnendtry"閉じられようとしているバッファと今いるバッファが一致していない場合if expand("%")!= expand('<afile>') " :lclでテキスト側からアウトラインを閉じようとした場合等
returnendif"アウトライン表示以外、かつ、アウトラインが示しているウィンドウの場合ifs:isOutllineList()==0&& winid_text == win_getid()"exec "lclose" E855になるので無理。フラグを立てておいて、WinEnterで判定させる。lets:flg_leave=1endifendfunction "}}}"アウトライン表示のロケーションリストかどうか判定function!s:isOutllineList() "{{{if&buftype !='quickfix' "quixfix or location-list
return0endiflet wi = getwininfo(win_getid())[0]if wi.loclist !=1 "location list!return0endiftrylets:loctitle= getloclist(s:winid_ol,{'title':0}).title
catch/^Vim\%((\a\+)\)\=:E716:/ "辞書型にキーが存在しません アウトラインが表示されていない場合。
return0endtryifs:loctitle!=s:treeTitlereturn0endifreturn1endfunction "}}}let&cpo =s:save_cpo
解説
使い方
まずは使い方から説明します。
各関数の中身については、後のセクションで説明します。
起動、アウトライン再描画
:MOT
としてください。左側にアウトラインが表示されます。
txt拡張子を WzMemo 形式として編集している最中に、.
を押下してノードを作成したり、階層を変更した場合などは、明示的に:MOT
を実行してアウトラインを再描画する必要があります。
現在は、以下の拡張子を編集している場合に、本機能が有効になっています。
拡張子 | 階層指定文字 | 階層指定文字のインデント可否 | 説明 |
---|---|---|---|
txt | . | 可 | WzMemo 形式テキスト。アイディアメモでも備忘録でも、議事でも日記でも作文でも、なんにでも使えます。 |
md | # | 否 | Markdown。見出しレベルを階層としてアウトライン表示します。```で囲まれたコードブロック中の行頭「#」には反応しないようにしてあります。(折り畳みとしては扱われてしまいますが、アウトラインのノードとは見なしません) |
vim | foldmarker に従う | - | Vim Script 編集時に、fdm=marker として関数単位で折り畳んでおくと便利です。 |
sql | foldmarker に従う | - | SQL ファイルを編集する事があったので、追加してみました。 |
ショートカット
アウトライン表示が存在する状態で、以下のショートカットが有効になります。
テキスト上
キー | 機能 |
---|---|
tab | アウトライン表示のウィンドウに移動します。 |
[f | テキスト上で押下すると、上方向の直近のノードへ移動します。 |
]f | テキスト上で押下すると、下方向の直近のノードへ移動します。 |
[F | テキスト上で押下すると、先頭のノードへ移動します。 |
]F | テキスト上で押下すると、最後尾のノードへ移動します。 |
g> | 現在のノードの階層を再帰的に+1し、アウトラインを再描画します。 |
g< | 現在のノードの階層を再帰的に-1し、アウトラインを再描画します。 |
テキスト上でカーソルを移動すると、アウトライン表示側も追尾して反転表示になります。
アウトラインの元になっているテキストを表示しているウィンドウが全て閉じられた場合、アウトラインも自動的に閉じます。
アウトライン上
キー | 機能 |
---|---|
tab | テキストに移動します。カーソル位置は移動しません。 |
Enter | テキストに移動します。該当ノードの先頭にカーソルを移動します。 |
アウトライン上で、上下移動(jk等)すると、テキスト側は該当ノードをウィンドウの最上行にして再描画されます。
該当ノードが折り畳まれていた場合は、折り畳まれていた行を最上行として、カーソル位置をノードの行に合わせようとします。
関数説明
ざっくりとした説明になります。
本体を修正した時に、この部分の修正まで気が回らず、以下に書いてある内容が古いままの可能性があります。。
関数外
この関数の外の部分だけはコードを引用して少し解説してみたいと思います。
※関数の中に入ったら、コメントでも読んでもらった方が確実だと思うので、要点のみ述べます。
二重ロード除け
"二重ロード避け
if exists("g:loaded_outline_like_wzmemo")
finish
endif
let g:loaded_outline_like_wzmemo = 1
コメントの通りです。お呪いの様なものだと思っておいてください。
何度読み込まれても問題ないものなら、必要ないと思いますが、思わぬことを引き起こすことがあるので、とりあえずつけておくのが安パイです。
この辺りの事は、こんな記事を読むよりも、「名無しのvim使い」様のような素晴らしいサイトを読めばバッチリです。
Cのヘッダファイルに書くインクルードガードのようなものです。
・・・と言うような書き方をすると、plugin 配下に置いたファイルがヘッダのようなもので autoload 配下に置いたものがCソースのような関係に見えるかもしれませんが、全然違います。
互換性オプションの副作用回避
let s:save_cpo = &cpo
set cpo&vim
これまたお呪いの様なものです。:h cpoptions
参照ということで。
一言でいうと、"compatible-options (互換オプション)"の副作用を回避するためです。
vimrc とかで このオプションを変更している場合、上手く動作しないことがあるので、こうしておきます。
この辺りの事は、こんな記事を読むよりも、「名無しのvim使い」様のような素晴らしいサイトを読めばバッチリです。
ちなみに、&vim
の意味は、:h cpoptions
を読むだけではわからないので、:h set-default
を参照しましょう。
変数の初期化
let s:winid_ol = 1 "とりあえず1に?
let s:flg_switch = 0
let s:flg_leave = 0
s:winid_ol
は、ロケーションリストのWindow-IDを保持する変数です。
ロケーションリストを開いた時に設定するが、その前にイベントから呼ばれる関数内で使ってしまっていて、値がないと問題があることがあるので、とりあえずの値を入れてあります。
問題が行っていないので、1のままです。実はなにか不味いかもしれません。
s:flg_switch
はカーソル同期のために使用しています。WinEnter イベント時に設定して、CursorMoved イベント時に判定しています。
- WinEnter 時には、アウトライン表示のウィンドウに入った直後に、1を設定します。
- CursorMoved 時には、1が設定されてよ場合に、アウトライン同期処理を行わないようにします。
こうすることで、明示的にアウトライン表示へ移動した場合以外、すなわちウィンドウコマンド(Ctrl-W)や、マウスクリックによってアウトラインのウィンドウが選択された瞬間に、アウトライン同期処理が暴発して、テキスト表示のカーソル位置がかわってしまうことをふせいでいま。す
s:flg_leave
は、BufWinLeave イベント時設定して、WinEnter イベント時に判定しています。
- BufWinLeave 時には、テキストのバッファが取り除かれようとしている場合に、1を設定します。
- WinEnter 時には、1だったら、アウトラインを閉じます。
なんでこんな回りくどいことをしているのかというと、テキストが閉じられようとしている最中には、アウトラインを閉じることができなかったため、「テキストが閉じたよー」という情報を保持しておいて、他のウィンドウがアクティブになった瞬間に、アウトラインを閉じてあげる。という段取りを踏んでいるためです。
let s:treeTitle = 'Outline'
let s:treeIndent = 2
s:treeTitle
は、アウトライン表示の下に表示されるタイトルです。実際には「[ロケーションリスト]Outline」となっています。
s:treeIndent
は、アウトライン表示内で、レベルごとにインデントされる量です。
自動コマンドの設定
詳しくは、:h autocommand
を読むべし。
autocmd.txt
を読むと、今まで穏やかな物腰だった Vim ヘルプ様が、突如として曹殿のような口調に変化したことに戸惑いを覚えるかもしれない。しかも、いきなりガツンとこういう警告が書かれていて、ビビる人もいるかもしれない。
警告: 自動コマンドは大変強力であるので、思いも寄らない副作用をもたらすことがある。テキストを壊さないように注意しなければならない。
・・・各自、注意して使用するように!
au BufNewFile,BufRead *.txt setl fdm=expr fde=WzLikeOutline(v:lnum,'.',1)
au BufNewFile,BufRead *.md setl fdm=expr fde=WzLikeOutline(v:lnum,'#',0)
テキストファイルの場合に、WzMemo 風の折り畳みを有効にしますよ('.')。インデントを許容しますよ(1)。という事です。
Markdown の場合には、#の数のレベルで折り畳みしますよ('#')。インデントは許容しませんよ(0)。ということです。
au BufNewFile,BufRead *.txt com! MOT call <SID>makeOutlineTree('.',1)
au BufNewFile,BufRead *.md com! MOT call <SID>makeOutlineTree('#',0)
au BufNewFile,BufRead *.vim com! MOT call <SID>makeOutlineTree('',0) "fdm=marker想定
au BufNewFile,BufRead *.sql com! MOT call <SID>makeOutlineTree('',0) "fdm=marker想定
拡張子に応じて、起動用のユーザ定義コマンド(:MOT
)を追加します。
ちなみにですが、ユーザ定義コマンドは先頭が大文字である必要があります。詳しくは:h E183
を参照。
テキストファイルの場合は「非空白文字を除いた行頭の'.'」を目印にしてアウトラインを作成しますよ。という指定で、アウトライン作成用の関数を呼び出します。
Markdown の場合には、「行頭'#'」を目印にアウトライン作成しますよ、という(以下略)
.vim
や.sql
の場合は、マーカー折り畳みを想定しているので、第1引数は空で、第2引数は0です。
もし、マーカーで折り畳んでいる他の拡張子のファイルでもアウトライン表示したいなーと思ったら、随時ここに拡張子を付け足せばおk。
ちなみにですが、<SID>
というのは、s:
で定義したローカル関数やらを正しく呼び出すために必要な文字列です。
makeOutlineTreeって、引数が状況によって決まっていて、ユーザーが引数を指定して呼ぶよりも、:MOT
を経由して読んだ方が確実じゃないですか。
だから、スクリプトローカルにしてあるんですけれども、そうすると、今度はユーザ定義コマンド内で call することもできなくなるんですよね。<SID>
で、そういう状態を回避できるようです。
詳しくは、:h <SID>
参照ということで。
MyFoldText()
偉大なる foldCC.vim がなければこの関数はありませんでした。
というか別に set foldtext=FoldCCtext()
のままでも、それほど問題はありませんよ?
こちらは劣化版のようなものです。あちらはいろんな機能がありますし。
個人的に、set noet
な人間なので、こんなものを作る羽目になりました。
WzLikeOutline()
foldexpr
に指定する関数です。
行頭の'.'の数を数えて、折り畳みの深さを求める関数になります。
一番最初に、これが欲しく作り始めたようなものです。せめて折り畳みだけでもいいから、Wzっぽくならないかなー、と。
書いてみたら意外と簡単にできて、テンションが上がったものです。
最初は vimrc にこの関数を直書きしていました。
s:makeOutlineTree()
次に書いたのがコレです。折り畳めたなら、アウトラインを出してみたくなるのがサガと言うものでしょう。
プラグインにしてみようと思ってからは、サクサクと出来上がりました。
s:makeFoldOutline()
アウトライン表示部分の折り畳みを作成する関数です。
ノード自体が増えてくると、アウトライン表示自体が見づらくなってきますから、あった方がいいかなと。
アウトライン表示用の foldtext 関数を書いた方がいいかもしれませんね。
s:getFoldLevelOutline()
上記のs:makeFoldOutline()
の中から呼ばれている関数です。
アウトライン表示が何階層目のノードなのかを求めます。
s:incFoldLevel()
s:makeOutlineTree()
すると定義されるマップに、z>
とz<
があります。
※折り畳み方式が「式(EXPR)」の場合にのみ定義します。
このキー入力に対して、呼び出される関数です。
現在行を含むノードを起点にして、より階層の低いノードのまとまりに対して、レベルを増減させます。
作った本人もあんまり信用していません。動作後のアウトラインの状態はよく確認した方がよいです。
s:switch_outline()
s:makeOutlineTree()
すると定義されるマップに、<Tab>
があります。
TABキーが押された場合に、この関数が呼び出されるようにしてあります。
アウトライン表示と、テキストの間で、フォーカスをトグルします。
s:myWinEnter()
WinEnter イベント時に呼ばれるようにしてあります。
新しいウィンドウに入るということは、すなわち、TABキーによりフォーカスが変わったか、それ以外の理由で変わったかです。
TABキー以外でフォーカスが変わるということは、ウィンドウコマンド(Ctrl-W)か、マウス操作か、ほかのウィンドウが閉じた場合ということになります。
まぁ、そういうような条件で判断しつつ、アレコレしています。
詳しくはソースを見るべし。
s:outlineSync()
CursorMoved イベント時に呼ばれるようにしてあります。
カーソルが動いたときに、テキスト側とアウトライン側のフォーカスを同期させる処理が書いてあります。
このイベントではあまり時間のかかる処理をしない方がよいらしいです。
今の実装具合がどの程度なのか不安ですが、この文章を書いている限りでは、Celeron N3060 という非力 CPU でもつっかえたりするわけではないので、問題ないレベルだと信じたいと思います。
s:myBufWinLeave()
BufWinLeave イベント時に呼ばれるようにしてあります。名前が安直ですね。こういう名前しか付けられないのでスクリプトローカル関数がありがたいです。
テキストウィンドウが閉じたときに、自動的にアウトラインも閉じるための処理を記述してあります。
s:isOutllineList()
現在のウィンドウがアウトライン表示かどうかを判定する関数です。
いろんなところでこの判定を行うので関数化してあります。
最後に
今回 Vim プラグインを書くのも初めて、という人間が作ったものなので、実用性とかよりも、これからプラグイン書いてみたいなーという人が、勇気を持てればいいな、という程度の感覚です。
一応動いているので、自分では使いますけれどもね。
単純にテキストエディタとして Vim が好きでしたが、こんな簡単に機能追加したり、テキスト編集を自動化できるということが分かって、ますます好きになりました。