Multiline複数行TextBoxで改行/Tabマークを表示

C#(WinForms)標準のテキストボックスで複数行にまたがるような文字列を表示/入力するような画面を作っていて、改行やTabがどこにあるのか見てもわからないのが不便だったので、改行/Tabマークを表示して視認できるよう対応してみました。
出来上がりはこんな感じです。

実現方法

マーク表示については自前で描画処理を追加し、WM_PAINTをフックしてTab/改行の位置に記号文字を書き足しています。
その際にWin32APIのGetScrollInfoを利用してTextBox内のスクロール位置(現在表示対象となっているテキストの範囲)を把握し、テキスト本文が描画されていない部分はこれらのマークも描画しないように対処しています。
また、スクロール操作/キーボード操作/マウス操作時には画面描画を更新最新化することで、最新化前に書き足された記号がゴミとして残らないよう消去しています。

TextBoxEx.csソースコード

WinFormsのTextBoxを継承してTextBoxExというコントロールを作成しました。
以下の2プロパティが追加されています。表示ありにする場合はプロパティをtrueに設定してください。

  • Tabをマーク表示するか指定するプロパティ「ShowTab」
  • 改行をマーク表示するか指定するプロパティ「ShowEOL」

ほか、以下の処理のソースも混じっています。

  • ウォーターマークを表示する場合の内容「WatermarkText」
  • Tabキーのインデント幅設定「TabIndent」:既定は8文字ですが、それ以外の文字数にする場合は整数で設定してください。

Win32API(GetScrollInfo)の部分は別クラスになっています。

using System;
using System.ComponentModel;
using System.Drawing;
using System.Runtime.InteropServices;
using System.Windows.Forms;

/// <summary>
/// 改良版TextBox
/// <para>・WatermarkText:ウォーターマーク機能追加(WM_PAINTで対応)</para>
/// <para>・ShowTab/ShowEOL:Tab/改行マーク表示機能追加(WM_PAINTで対応)</para>
/// <para>・TabIndent:タブインデント幅設定機能</para>
/// <para>・LF改行含む文字列が単一行TextBoxに貼り付けできてしまう不具合を是正</para>
/// </summary>
public class TextBoxEx : TextBox
{
 //ウォーターマーク表示、改行/TABマーク表示-----------------------------
 /// <summary>
 /// テキストが空の場合に表示する文字列を取得・設定します。
 /// </summary>
 [Category("表示")]
 [DefaultValue("")]
 [Description("テキストが空の場合に表示する文字列です。")]
 [RefreshProperties(RefreshProperties.Repaint)]
 public string WatermarkText
 {
     get { return _watermarkText; }
     set
     {
         _watermarkText = value;
         this.Invalidate();
     }
 }
 private string _watermarkText = ""; //ウォーターマーク表示内容text

 /// <summary>
 /// TAB入力文字を表示するかどうかを取得・設定します。
 /// </summary>
 [Category("表示")]
 [DefaultValue(false)]
 [Description("入力されたTAB文字を示すマークを表示するかどうかを取得・設定します。")]
 [RefreshProperties(RefreshProperties.Repaint)]
 public bool ShowTab
 {
     get { return _showTab; }
     set
     {
         _showTab = value;
         this.Invalidate();
     }
 }
 private bool _showTab = false;

 /// <summary>
 /// 複数行入力時に改行マークを表示するかどうかを取得・設定します。
 /// </summary>
 [Category("表示")]
 [DefaultValue(false)]
 [Description("複数行入力時、改行を示すマークを表示するかどうかを取得・設定します。")]
 [RefreshProperties(RefreshProperties.Repaint)]
 public bool ShowEOL
 {
     get { return _showEOL; }
     set
     {
         _showEOL = value;
         this.Invalidate();
     }
 }
 private bool _showEOL = false;

 ///<summary>
 ///描画拡張(テキスト未設定時ウォーターマークを描画、Tab/改行マークを描画)
 ///</summary>
 ///<param name="m"></param>
 protected override void WndProc(ref Message m)
 {
     const int WM_PAINT = 0x000F;
     const int WM_HSCROLL = 0x0114;
     const int WM_VSCROLL = 0x0115;
     const int WM_KEYDOWN = 0x0100;
     const int WM_LBUTTONUP = 0x0202;
     const int WM_MOUSEWHEEL = 0x020A;
     base.WndProc(ref m);
     if (m.Msg == WM_PAINT && string.IsNullOrEmpty(this.Text) && string.IsNullOrEmpty(WatermarkText) == false)
     {
         using (Graphics g = Graphics.FromHwnd(this.Handle))
         {   //ウォーターマークをテキストボックス内の適切な座標に描画
             Rectangle rect = this.ClientRectangle;
             rect.Offset(1, 1);
             TextFormatFlags format = TextFormatFlags.Top | TextFormatFlags.Left | TextFormatFlags.NoPrefix;
             if (this.Multiline && this.WordWrap)
             {   //複数行ならウォーターマークも折り返し&さらに座標調整
                 format |= TextFormatFlags.WordBreak;
                 rect.Offset(2, 1);
             }
             TextRenderer.DrawText(g, WatermarkText, this.Font, rect, SystemColors.GrayText, format);
         }
     }
     else if (m.Msg == WM_PAINT)
     {   //改行・タブのマーク表示を試みる
         TryDraw(this.ShowEOL && this.Multiline, '\n', '\u21B5'); //改行を0x21B5の改行記号で表示
         TryDraw(this.ShowTab, '\t', '\u02EA'); //タブを0x02EAのカギ記号で表示
     }
     else if ((this.ShowTab || this.ShowEOL)
         && (m.Msg == WM_HSCROLL || m.Msg == WM_VSCROLL || m.Msg == WM_KEYDOWN || m.Msg == WM_LBUTTONUP || m.Msg == WM_MOUSEWHEEL))
     {   //スクロール時等に改行・タブのマーク表示が乱れるので画面再描画
         this.Invalidate();
     }
 }
 /// <summary>
 /// 改行・タブマーク等表示
 /// </summary>
 /// <param name="flg">表示要否</param>
 /// <param name="ch">表示対象の制御文字</param>
 /// <param name="dispChar">画面に表示する際に使用する記号マーク文字</param>
 private void TryDraw(bool flg, char ch, char dispChar)
 {   //そもそも表示否だったり、表示対象文字がテキストに含まれていない場合は処理を行わない
     if (flg == false) { return; }
     string text = base.Text;
     int pos = text.IndexOf(ch);
     if (pos < 0) { return; }
     using (Graphics g = this.CreateGraphics())
     {   //現在のTextBoxのスクロール表示範囲と1行当たり描画行高さより、描画範囲を把握
         ScrollInfo info = ScrollInfo.GetScrollInfo(this.Handle, Orientation.Vertical);
         int fontHeight = TextRenderer.MeasureText(g, "■", this.Font).Height;
         int yFrom = this.GetPositionFromCharIndex(0).Y + (info.nPos * FontHeight);
         int yTo = yFrom + ((int)info.nPage * FontHeight);
         while (pos >= 0)
         {   //記号表示位置がスクロール表示範囲内のものであれば、記号も描画
             Point point = this.GetPositionFromCharIndex(pos);
             if (point.Y >= yFrom && point.Y < yTo)
             {
                 TextFormatFlags format = TextFormatFlags.Top | TextFormatFlags.NoPrefix;
                 point.X--; //記号表示位置が右寄りになりすぎるのを是正
                 TextRenderer.DrawText(g, dispChar.ToString(), this.Font, point, SystemColors.GrayText, format);
             }
             pos = text.IndexOf(ch, pos + 1);
         }
     }
 }

 //タブ幅設定機能-------------------------------------------------------
 /// <summary>
 /// タブ表示幅を取得・設定します。
 /// </summary>
 [Category("表示")]
 [DefaultValue(8)]
 [Description("タブのインデント文字数です。複数行エディット コントロールだけに適用されます。")]
 [RefreshProperties(RefreshProperties.Repaint)]
 public int TabIndent
 {
     get { return this._tabIndent; }
     set
     {
         this._tabIndent = value;
         const int EM_SETTABSTOPS = 0x00CB;
         const int DIALOG_TEMPLATE_UNITS = 4; //ダイアログ単位:平均文字幅の1/4と定義されている
         Win32API.SendMessage(this.Handle, EM_SETTABSTOPS, 1, new int[] { _tabIndent * DIALOG_TEMPLATE_UNITS });
         this.Invalidate(); //自動では再描画されないので手動再描画する
     }
 }
 private int _tabIndent = 8; //Windowsのデフォルトは8。ちなみにComboBoxや単一行TextBoxではどうやっても幅変更不可
 private class Win32API
 {
     [DllImport("user32.dll", CharSet = CharSet.Auto)]
     public static extern IntPtr SendMessage(IntPtr hWnd, UInt32 uMsg, int wParam, int[] lParam);
 }

 protected override void OnFontChanged(EventArgs e)
 {   //フォント変更時、そのフォントの文字幅に基づいてタブインデントを再設定
     base.OnFontChanged(e);
     TabIndent = _tabIndent;
 }
 protected override void OnHandleCreated(EventArgs e)
 {   //ハンドル再作成時にタブインデント設定が初期化されてしまうようなので対策
     base.OnHandleCreated(e);
     TabIndent = _tabIndent;
 }

 //不具合対策各種-------------------------------------------------------
 // 単一行テキストボックスにLF含む内容のテキストが貼り付けできてしまう不具合の是正を含めた包括対応
 protected override void OnTextChanged(EventArgs e)
 {   //改行文字を正規化(Multiline・改行文字入力可能でなければ除去)
     string originalText = base.Text;
     string normalizedText = System.Text.RegularExpressions.Regex.Replace(
         originalText,
         @"(\r\n|\r(?!\n)|\n|\u2028|\u2029|\u0085|\0)", //改行文字(範囲を広めにとる)
         (this.Multiline && this.AcceptsReturn) ? "\r\n" : string.Empty);
     if (originalText != normalizedText)
     {   //改行文字の是正または除去が必要なので、実施する
         base.Text = normalizedText;
     }
     else
     {   //(イベントは、改行是正・除去後のOnTextChanged呼び出しから発動させる)
         base.OnTextChanged(e);
         if (this.ShowTab || this.ShowEOL)
         {   //改行マーク等を描画させる
             base.Invalidate();
         }
     }
 }
}

/// <summary>
/// Win32API「SCROLLINFO」構造体、および関連定数・API呼び出しメソッド定義
/// </summary>
[StructLayout(LayoutKind.Sequential)]
public struct ScrollInfo
{
 //各定義項目の詳細は https://msdn.microsoft.com/library/windows/desktop/bb787537 を参照

 /// <summary>構造体サイズ</summary>
 public uint cbSize;
 /// <summary>スクロールバーの取得/設定パラメータ</summary>
 public SIF fMask;
 /// <summary>最小スクロール位置</summary>
 public int nMin;
 /// <summary>最大スクロール位置</summary>
 public int nMax;
 /// <summary>ページサイズ</summary>
 public uint nPage;
 /// <summary>スクロール位置</summary>
 public int nPos;
 /// <summary>スクロールボックス(つまみ)の現在の位置</summary>
 public int nTrackPos;

 [Flags]
 public enum SIF : int
 {
     /// <summary>スクロール範囲を、lpsi パラメータが指す SCROLLINFO 構造体の nMin と nMax の各メンバに格納/設定します。</summary>
     RANGE = 0x1,
     /// <summary>スクロールページを、lpsi パラメータが指す 構造体の nPage メンバに格納/設定します。</summary>
     PAGE = 0x2,
     /// <summary>スクロール位置を、lpsi パラメータが指す SCROLLINFO 構造体の nPos メンバに格納/設定します。</summary>
     POS = 0x4,
     /// <summary>スクロールバーに対して指定した新しいパラメータを適用した結果、スクロールバーが不要になる場合、そのスクロールバーをる代わりに無効にします。</summary>
     DISABLENOSCROLL = 0x8,
     /// <summary>スクロールボックス(つまみ)の現在の位置を、lpsi パラメータが指す SCROLLINFO 構造体の nTrackPos メンバに格納します。ry>
     TRACKPOS = 0x10,
     /// <summary></summary>
     ALL = RANGE | PAGE | POS | DISABLENOSCROLL | TRACKPOS
 }

 /// <summary>
 /// 引数指定handleのコントロールについて、スクロールバーの様々な情報を取得します。
 /// (Win32API「GetScrollInfo」を呼び出すラッパです)
 /// </summary>
 /// <param name="handle">情報取得対象コントロールのHandle</param>
 /// <param name="o">スクロールバーのタイプ(方向)</param>
 /// <returns>SCROLLINFO構造体</returns>
 public static ScrollInfo GetScrollInfo(IntPtr handle, Orientation o)
 {
     ScrollInfo ret = new ScrollInfo();
     ret.cbSize = (uint)Marshal.SizeOf(ret);
     ret.fMask = SIF.PAGE | SIF.POS | SIF.RANGE | SIF.TRACKPOS;
     Win32API.GetScrollInfo(handle, o, out ret);
     return ret;
 }
 private class Win32API
 {
     [DllImport("user32.dll")]
     [return: MarshalAs(UnmanagedType.Bool)]
     public static extern bool GetScrollInfo(IntPtr hWnd, Orientation fnBar, out ScrollInfo lpsi);
 }
}

実物サンプル

このコントロールですが、自作しているgrepツール「TresGrep」の複数行検索キーワード入力欄でいろいろ試行錯誤した結果出来上がりました。
行末に改行がついているのか否か・空白表示されている部分がTabなのかSpaceなのか見分けがつかないと実用上かなり不便だったのですが、このソースでおおむね解消されました。
次バージョン(Ver 0.96)以降のTresGrepに反映される予定です。(それ以前のバージョンでは改行マーク表示のみ不完全に対応しており、一度表示された改行マークが消えないまま残ってしまったりといったおかしな挙動が混じっています)

実のところTresGrepみたいなツールを作っているとこの手の小ネタには事欠ききません。(TresGrepはほぼすべてC#+WinFormsで書かれています。プレビュー表示欄だけわずかにJavaScriptも使用しています)
TresGrepも開発が落ち着いたので、気が向いたら今回みたいな記事を今後も書くかもしれません。(もっとも、WPFのノウハウならともかくいまさらWinFormsについて書き散らしたところでそんなに需要がない気もするのですが・・・・)