10.標準コントロール(3) 〜 コンボボックス(3) 〜

 

※ 修正版をこちらのページにアップしています。将来的にはこのページは削除される可能性があります。

 


1.カレントディレクトリを変えてみる

たいぶ、エクスプローラらしくなってきましたが、実はいろいろと問題があります。
まずはカレントディレクトリが変更できないため、プログラムを立ち上げたときの
ディレクトリの情報しか表示されていませんね。

それじゃなんだということで、今回は、カレントディレクトリを変えてみることにします。
前回までのプログラムでコンボボックスには、今のカレントディレクトリとドライブレターが表示されています。
ためしにCドライブを選択してみましょう。

sdk_110-01.png

なにも、おきません。

コンボボックスのリストが変更されたことを通知するメッセージを捕まえて、
カレントディレクトリを変える処理をしなきゃいかんなと気づいた方は、プログラマ脳になってきています。

コンボボックスのリストが変更されたことは、CBN_SELCHANGE メッセージを補足します。
が、補足の仕方がちょっと複雑ですので解説します。

まず、標準コントロールであるコンボボックスは、何らかの選択入力などがあった場合、
親ウィンドウに対して WM_COMMAND を発行します。
つまり WndProc の第2引数のメッセージには、WM_COMMANDが入っています。
ですから、ウインドウプロシージャの中で、WM_COMMAND メッセージを処理するようにします。

WM_COMMAND
  wNotifyCode = HIWORD(wParam);  // 通知コード
  wID         = LOWORD(wParam);  // 項目ID、 コントロールID、 またはアクセラレータID
  hwndCtl     = (HWND) lParam;   // コントロールのハンドル

WM_COMMAND メッセージは、wParam の下位ワード に 項目ID(コントロールの「ID〜」)がセットされますので
これを捕捉します。今回は、ID_CBOCLIENT ですね。

それじゃー、CBN_SELCHANGE メッセージはどこに入っているのかというと、
第3引数の wParam の上位ワードに格納されています。

以上を踏まえて、WinMain ファイルのメッセージ処理を以下のように修正します。
※ 必要部分のみ抜粋です。


/* -------------------------------------------------------------------------- */
/*          CALLBACK    WndProc                                               */
/*          PARAM       HWND        hWnd        : ウィンドウハンドル          */
/*                      UINT        uMsg        : メッセージ                  */
/*                      WPARAM      wParam      : メッセージの追加情報        */
/*                      LPARAM      lParam      : メッセージの追加情報        */
/*          RETURN      LRESULT                                               */
/*                      メッセージ処理の結果                                  */
/*          REMARK                                                            */
/* -------------------------------------------------------------------------- */
LRESULT CALLBACK WndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
  /* 関数本体部 */
  switch (uMsg)
  {
    case WM_COMMAND:
      switch (LOWORD(wParam))
      {
        case ID_CBOCLIENT:
          switch (HIWORD(wParam))
          {
            case CBN_SELCHANGE:
              OnComboBox_SelChange((HWND) lParam);
              break;
            default:
              return (DefWindowProc(hWnd, uMsg, wParam, lParam));
          }
          break;
        default:
          return (DefWindowProc(hWnd, uMsg, wParam, lParam));
      }
      break;
    case WM_DESTROY:
      PostQuitMessage(0);
      break;
    default:
      return (DefWindowProc(hWnd, uMsg, wParam, lParam));
  }

  return 0L;
}

OnComboBox_SelChange() という関数はまだありませんが、
この関数で、コンボボックスのリスト部が変更された場合の処理を記述することにします。
Message ファイルに以下の関数を追加します。


/* -------------------------------------------------------------------------- */
/*          PROCEDURE   OnComboBox_SelChange                                  */
/*                      HWND        hCtlCombo   : コンボボックスハンドル      */
/*          REMARK                                                            */
/* -------------------------------------------------------------------------- */
void OnComboBox_SelChange(HWND hCtlCombo)
{
  /* 変数宣言部 */
  char              szCurDir[_MAX_DIR];
  int               nIndex;


  /* 関数本体部 */
  nIndex = ComboBox_GetCurSel(hCtlCombo);

  ComboBox_GetLBText(hCtlCombo, nIndex, szCurDir);

  if (! SetCurrentDirectory(szCurDir))
  {
    MessageBox(hCtlCombo, "ディレクトリを変更できません", "ERR", MB_OK);
    return;
  }

  GetComboBoxItems(hCtlCBOClient);
  GetListViewItems(hCtlLVWClient);

  return;
}

まずは、リスト部の何番目が選択されたかを知る必要があります。
そのためには、CB_GETCURSEL メッセージを送ってやる必要があります。

CB_GETCURSE
  wParam  = 0   // 未使用
  lParam  = 0L  // 未使用

ソース上は、マクロ ComboBox_GetCurSel を使用しています。
※前回のMessage ファイルには、WindowsX.h ファイルがインクルードされていませんので、
マクロを使用する場合は、インクルードしておいてください。

リストの項番は、0番目から数えられます。「C\:」を選択したときは、1になります。
実際に表示させてみましょう。

リスト部のテキストを取得するには、CB_GETLBTEXT メッセージを送ってやる必要があります。

CB_GETLBTEXT
  wParam = (WPARAM)           index       // コンボボックスのリスト部の項目インデックス
  lParam = (LPARAM) (LPCSTR)  lpszBuffer  // 文字列バッファのポインタ

ソース上は、マクロ ComboBox_GetLBText を使用しています。

また、現在のカレントディレクトリ情報をコンボボックスの表示部に出力する関数と、
その内容をリストビュウに表示させる関数はすでに前回までに作成しているのでそのまま流用してみます。
これで、カレントディレクトリが移動してそのディレクトリの情報を新たに取得することができるようになりました。


2.リストがどんどん増えていく…

プログラムを実行すると、まずいことに気がつきます。
それは、コンボボックス上のリストが倍になっていることです。

ドライブが、倍になっていますね。Cドライブについては、カレントディレクトリであるため、
GetComboBoxItems 関数のカレントディレクトリを追記するところで1回、
ドライブレターを追記するところでさらに1回増えて計3個表示されています。

これではくどすぎますが、どうすればいいのでしょうか?
方法論とすれば、2個ほど考えられます。

@ 一度リスト部をすべてクリアして、再度取得しなおす
A今リストにあるものと同じだったらリストに追記しない

@の方法だと、カレントディレクトリを移動するたびに、前のカレントディレクトリの情報が
失われてしまいますね。今回はAの方法を採用することにしました。

じゃあ、今リストにあるかどうかを判断するにはどうしたらよいのでしょうか?
CB_ に何かないか探してみると案の定ありました。CB_FINDSTRINGEXACT メッセージです。

CB_FINDSTRINGEXACT
  wParam  = (WPARAM)         indexStart  // 検索開始インデックス
  lParam  = (LPARAM)(LPCSTR) lpszFind    // 検索文字列へのポインタ

似たようなメッセージに CB_FINDSTRING がありますがこのメッセージは前方検索を行います。
また、リストの先頭から検索する場合には、インデックスに -1 を指定する必要があります。

Filelist ファイルの GetComboBoxItems 関数部分を以下のように修正します。


/* -------------------------------------------------------------------------- */
/*          PROCEDURE   GetComboBoxItems                                      */
/*          PARAM       HWND        hCtlCombo   : コンボボックスハンドル      */
/*          RETURN      void                                                  */
/*          REMARK      コンボボックスフォルダ情報取得                        */
/* -------------------------------------------------------------------------- */
void GetComboBoxItems(HWND hCtlCombo)
{
  /* 変数宣言部 */
  char              szCurDir[MAX_PATH + 1];
  char              szDrives[128];


  /* 関数本体部 */
  // カレントディレクトリ取得
  if (! GetCurrentDirectory(_MAX_DIR + 1, szCurDir))
  {
    MessageBox(NULL, "現在のディレクトリを取得できません", "ERR", MB_OK);
    return;
  }

  ComboBox_SetText(hCtlCombo, szCurDir);

  if (ComboBox_FindStringExact(hCtlCombo, 0, szCurDir) == CB_ERR)
  {
    ComboBox_AddString(hCtlCombo, szCurDir);
  }


  // ドライブ名取得
  GetLogicalDriveStrings(sizeof(szDrives), szDrives);

  for (int i = 0; szDrives[i] != '\0'; i++)
  {
    if (ComboBox_FindStringExact(hCtlCombo, 0, &szDrives[i]) == CB_ERR)
    {
      ComboBox_AddString(hCtlCombo, &szDrives[i]);
    }

    // 次のドライブ名へ
    while (szDrives[i] != '\0')
    {
      i++;
    }
  }

  return;
}

ソース上は、マクロ ComboBox_FindStringExact を使用しています。
これで、コンボボックスについては、カレントディレクトリの履歴を残しつつ、
現在のカレントディレクトリ情報をコンボボックスの表示部に出力することが可能になりました。

リストビュウの場合はどうでしょう?こちらは関数の頭で一度クリアをしたほうがよさそうです。
これも、LVM_ に何かないか探してみると案の定ありました。LVM_DELETEALLITEMS メッセージです。

LVM_DELETEALLITEMS
  wParam  = 0   // 未使用
  lParam  = 0L  // 未使用

Filelist ファイルの GetListViewItems 関数部分を以下のように修正します。


/* -------------------------------------------------------------------------- */
/*          PROCEDURE   GetListViewItems                                      */
/*          PARAM       HWND        hCtlLView   : リストビュウハンドル        */
/*          RETURN                                                            */
/*          REMARK      リストビュウファイル情報取得                          */
/* -------------------------------------------------------------------------- */
void GetListViewItems(HWND hCtlLView)
{
  /* 変数宣言部 */
  WIN32_FIND_DATA   Win32FindData;
  HANDLE            hFind;
  LVITEM            LVItem;
  SYSTEMTIME        SystemTime;
  FILETIME          FileTime;


  /* 関数本体部 */
  // リストビュー初期化
  ListView_DeleteAllItems(hCtlLView);

  if (INVALID_HANDLE_VALUE == (hFind = FindFirstFile("*.*", &Win32FindData)))
  {
    if (GetLastError() != ERROR_NO_MORE_FILES)
    {
      MessageBox(NULL, "ファイル一覧の取得に失敗しました", "ERR", MB_OK);
      return;
    }
  }

  do
  {
    // ルートディレクトリ・カレントディレクトリは無視
    if (lstrcmp(Win32FindData.cFileName, "." ) == 0 ||
        lstrcmp(Win32FindData.cFileName, "..") == 0)
    {
      continue;
    }

    // 1項目(名前)
    LVItem.mask     = LVIF_TEXT;
    LVItem.iItem    = 0;
    LVItem.iSubItem = 0;
    LVItem.pszText  = Win32FindData.cFileName;
    ListView_InsertItem(hCtlLView, &LVItem);

    // 2項目(サイズ)
    LVItem.iSubItem = 1;
    if (Win32FindData.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY)
    {
      wsprintf(LVItem.pszText, "%s", "<DIR>");
    }
    else
    {
      wsprintf(LVItem.pszText, "%d", Win32FindData.nFileSizeHigh * MAXDWORD + Win32FindData.nFileSizeLow);
    }
    ListView_SetItem(hCtlLView, &LVItem);

    // 3項目(更新日時)
    LVItem.iSubItem = 2;
    // 協定世界時(UTC)を地域標準時に変更
    FileTimeToLocalFileTime(&Win32FindData.ftLastWriteTime, &FileTime);
    FileTimeToSystemTime(&FileTime, &SystemTime);
    wsprintf(LVItem.pszText, "%d/%02d/%02d %d:%02d", SystemTime.wYear, SystemTime.wMonth,
                                                     SystemTime.wDay,  SystemTime.wHour,  SystemTime.wMinute);
    ListView_SetItem(hCtlLView, &LVItem);
  }
  while (FindNextFile(hFind, &Win32FindData));

  FindClose(hFind);

  // ソート
  ListView_SortItemsEx(hCtlLView, LVWCmpProc, hCtlLView);

  return;
}


ソース上は、マクロ ListView_DeleteAllItems を使用しています。

3.サブクラス!?

さて、エクスプローラではコンボボックスのエディット欄に入力して Enterキー を押すと
そのディレクトリに移動しますよね。
ですが、このプログラムでは、ぶーと音が鳴るだけで何にもおきません。ちょっとつらいです。

じゃあ、コンボボックスのエディット部に入力されたことを通知するメッセージを探せばいいんだなと
思った方は正解に近そうですが、どんなにまってもメインのウインドウにそんなメッセージは来ないのです。

実は、コンボボックスは、エディット部とリスト部からなっていて、それぞれコントロールという
ウインドウなのです。 Spy++ というツールでみるとよくわかります。
※ Spy++ は、VisualStudio に標準で付いてくるツールで、プロセス、スレッド、ウィンドウ、
およびウィンドウ メッセージをグラフィック表示します。

つまり、コンボボックスのエディット部に入力されたことを通知するメッセージは、
メインのウインドウには通知されませんが、エディット部のウインドウにはしっかり通知されています。
では、どのようにしてエディット部にきたメッセージをとらえればいいのでしょうか?

ちょっと立ち戻って考えると、メインウインドウの通知メッセージは、WndProc() に来ています。
そして、この WndProc() は、メインウインドウを作成するときに、ウインドウクラスの lpfnWndProc として
登録していたのでありました。逆に言うとウインドウプロシージャの登録をしたからこそ、
メッセージは、WndProc() に送られてくるのです。

ですが、コントロールを作るときは、ウインドウプロシージャの登録は行いませんでした。
実は、コントロール作成時にウインドウプロシージャがこっそり作られているのです。
で、エディット部に入力されたことを通知するメッセージは、このウインドウプロシージャに送られていたのです。

ということは、このウインドウプロシージャを捕まえることができれば、そこに送られてくるメッセージを
補足することができるのではないでしょうか。
そして、そのウインドウプロージャに新たな処理を書いてしまえばいいのです。
ですが、困ったことにコンボボックスのエディット部のウインドウプロシージャは、
システムがこっそり作ったプロシージャのため、われわれが手出しできないのです。

実はここからが、すごいことをやってしまします。
それは、コンボボックスのエディット部のウインドウプロシージャを自分が手出しできる
(ソース内に記述した)プロシージャにすり替えてしまうのです。

いやー、いかにも大胆でが、システムが作ったプロシージャを自分が作ったプロシージャに変更するには、
SetClassLong() を使います。

LONG SetWindowLong(
  HWND hWnd,      // ウィンドウのハンドル
  int nIndex,     // 取得する値のオフセット
  LONG dwNewLong  // 新しい値
);

第2引数には、ウインドウプロシージャを書き換えたいので GWL_WNDPROC を指定します。
第3引数には、新しいウインドウプロシージャのアドレスを指定します。

SetWindowLong() は、戻り値として指定したウィンドウの変更される前の
ウインドウプロシージャのアドレスを返します。

ちょっとここで注意したいのが、第1引数のウインドウハンドルです。コンボボックスのハンドルを指定しても
意味がありません。コンボボックスの子どものエディット部のハンドルを指定します。

子ウィンドウのハンドルを取得するには、 GetWindow() を使用します。

LONG GetWindowLong(
  HWND hWnd,     // 元ウィンドウのハンドル
  UINT uCmd      // 関係
);

第2引数には、子ウィンドウのハンドルを取得したいので GW_CHILD を指定します。
GetWindowLong() は、戻り値として指定したウィンドウの 子ウインドウのハンドル を返します。

こういったやり方を、サブクラス化 と呼んだりしていますが、名前なんかはどうでもよくて
大事なのは、こんなやり方もできるということです。

では、サブクラス化をするには、どこでやるのがいいのでしょうか?
メッセージを取りこぼす可能性が一番低いのは、コンボボックスが作成されてすぐですね。

そこで、WinMain ファイルの WinCtlInit 関数部分を以下のように修正します。
※ 必要部分のみ抜粋です。


/* -------------------------------------------------------------------------- */
/*          FUNCTION    WinCtlInit                                            */
/*          PARAM       LPCSTR      szClassName   : ウィンドウクラス名        */
/*                      HWND        hParentWnd    : 親ウィンドウのハンドル    */
/*                      UINT        uID           : 子ウィンドウID          */
/*                      LPVOID      lpParam       : アイテム情報              */
/*                      UINT        uSize         : アイテムのサイズ          */
/*          RETURN      HWND        : 作成されたウィンドウのハンドル          */
/*          REMARK      コントロールウインドウの作成                          */
/* -------------------------------------------------------------------------- */
HWND WinCtlInit(LPCTSTR szClassName, HWND hParentWnd, UINT uID, LPVOID lpParam, UINT uSize)
{
  /* 変数宣言部 */
  HWND              hControl;
  HWND              hCBOEdit;
  HINSTANCE         hInstance;
  DWORD             dwStyle;


  /* 関数本体部 */
  // インスタンス取得
  hInstance = (HINSTANCE) GetWindowLong(hParentWnd, GWL_HINSTANCE);

  // コンボボックス作成
  if (lstrcmp(szClassName, WC_COMBOBOX) == 0)
  {
    dwStyle  = WS_CHILD | WS_VISIBLE | CBS_SORT | CBS_DROPDOWN | CBS_AUTOHSCROLL;
    hControl = CreateWindowEx(WS_EX_CLIENTEDGE,
                              szClassName,
                              NULL,
                              dwStyle,
                              10,
                              5,
                              380,
                              100,
                              hParentWnd,
                              (HMENU) uID,
                              hInstance,
                              NULL);

    if (! hControl)
    {
      return NULL;
    }

    SendMessage(hControl, WM_SETFONT, (WPARAM) GetStockObject(DEFAULT_GUI_FONT), MAKELPARAM(true, 0));
    ComboBox_LimitText(hControl, _MAX_PATH);

    // エディット部を探す
    hCBOEdit = GetWindow(hControl, GW_CHILD);

    // サブクラス化
    lpCBEDefProc = (WNDPROC) SetWindowLong(hCBOEdit, GWL_WNDPROC, (LONG) CBEWndProc);
  }

  return hControl;
}

CBEWndProc() というのが自分が手出しできる(ソース内に記述した)プロシージャですね。

ちょっと気になるのは、第3引数には、新しいウインドウプロシージャのアドレスを指定するはずが、
関数名そのままを記述しています。いいんでしょうか?

はい、大丈夫です。関数は式の中では、すべて「関数へのポインタ」に読み替えられるからです。
早速、CBEWndProc() を、WinMain ファイルに記述します。


/* -------------------------------------------------------------------------- */
/*          CALLBACK    CBEWndProc                                            */
/*          PARAM       HWND        hCtlCBOEdit : ウィンドウハンドル          */
/*                      UINT        uMsg        : メッセージ                  */
/*                      WPARAM      wParam      : メッセージの追加情報        */
/*                      LPARAM      lParam      : メッセージの追加情報        */
/*          RETURN      LRESULT                                               */
/*                      メッセージ処理の結果                                  */
/*          REMARK                                                            */
/* -------------------------------------------------------------------------- */
LRESULT CALLBACK CBEWndProc(HWND hCtlCBOEdit, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
  /* 関数本体部 */
  switch (uMsg)
  {
    case WM_CHAR:
      // リターンキーが押された
      if ((TCHAR) wParam == VK_RETURN)
      {
        OnComboBox_SelChange(GetParent(hCtlCBOEdit));
      }
      else
      {
        return (CallWindowProc(lpCBEDefProc, hCtlCBOEdit, uMsg, wParam, lParam));
      }
      break;
    default:
      return (CallWindowProc(lpCBEDefProc, hCtlCBOEdit, uMsg, wParam, lParam));
  }

  return 0L;
}

このプロシージャは、コンボボックスのエディット部のウインドウプロシージャですから、
コールバック関数である必要がありますね。

自分が手出しできる(ソース内に記述した)プロシージャですが、送られてくるすべてのメッセージを
処理できるはずもありません、そんなメッセージは、元のプロシージャに戻して処理させるようにします。

幸い、SetWindowLong() は、戻り値として指定したウィンドウの変更される前のウインドウプロシージャの
アドレスが返っているので、これを WNDPROC型のグローバル変数として取っておきます。
元のプロシージャを呼び出すには、 CallWindowProc() を使用します。

LRESULT CallWindowProc(
  WNDPROC lpPrevWndFunc,  // 元のウィンドウプロシージャ
  HWND hWnd,             // ウィンドウのハンドル
  UINT Msg,              // メッセージ
  WPARAM wParam,         // メッセージの最初のパラメータ
  LPARAM lParam          // メッセージの2番目のパラメータ
);

これで、カレントディレクトリ表示機能が完成したと思ったら、エラーが出ます。
OnComboBox_SelChange() 関数の中で、SetCurrentDirectory に失敗しているようです。

!? コンボボックスの内容が取得できてないのでしょうか?
実は、CB_GETLBTEXT メッセージでは、コンボボックスのリスト部のデータしか取得することができません。
では、エディット部のデータを取得するにはどうしたら良いのでしょうか?

GetWindowText() を使って取得します。ちょっとややこしいですね。
Message ファイルの 以下のOnComboBox_SelChange() 関数を変更します。


/* -------------------------------------------------------------------------- */
/*          PROCEDURE   OnComboBox_SelChange                                  */
/*                      HWND        hCtlCombo   : コンボボックスハンドル      */
/*          REMARK                                                            */
/* -------------------------------------------------------------------------- */
void OnComboBox_SelChange(HWND hCtlCombo)
{
  /* 変数宣言部 */
  char              szCurDir[_MAX_DIR];
  int               nIndex;


  /* 関数本体部 */
  nIndex = ComboBox_GetCurSel(hCtlCombo);

  // エディット部なら入力文字列取得
  if (nIndex == -1)
  {
    ComboBox_GetText(hCtlCombo, szCurDir, sizeof(szCurDir));
  }
  else
  {
    ComboBox_GetLBText(hCtlCombo, nIndex, szCurDir);
  }

  if (! SetCurrentDirectory(szCurDir))
  {
    MessageBox(hCtlCombo, "ディレクトリを変更できません", "ERR", MB_OK);
    return;
  }

  GetComboBoxItems(hCtlCBOClient);
  GetListViewItems(hCtlLVWClient);

  return;
}

CB_GETCURSEL メッセージを送ってやると、リストの項番は、0番目から数えられますが、
エディット部は、常に -1 になります。そのため、-1 かどうかで、リスト部から取得すればいいのか、
エディット部から取得すればいいのかわかりますね。


/* -------------------------------------------------------------------------- */
/*          PROCEDURE   OnComboBox_SelChange                                  */
/*                      HWND        hCtlCombo   : コンボボックスハンドル      */
/*          REMARK                                                            */
/* -------------------------------------------------------------------------- */
void OnComboBox_SelChange(HWND hCtlCombo)
{
  /* 変数宣言部 */
  char              szCurDir[_MAX_DIR];
  int               nIndex;


  /* 関数本体部 */
  nIndex = ComboBox_GetCurSel(hCtlCombo);

  // エディット部なら入力文字列取得
  if (nIndex == -1)
  {
    ComboBox_GetText(hCtlCombo, szCurDir, sizeof(szCurDir));
  }
  else
  {
    ComboBox_GetLBText(hCtlCombo, nIndex, szCurDir);
  }

  if (! SetCurrentDirectory(szCurDir))
  {
    MessageBox(hCtlCombo, "ディレクトリを変更できません", "ERR", MB_OK);
    return;
  }

  GetComboBoxItems(hCtlCBOClient);
  GetListViewItems(hCtlLVWClient);

  return;
}

本日のソース: FTPManager109.zip

※小言

GetComboBoxItems() 関数ですが、OnCreate() 関数からも、OnComboBox_SelChange() 関数からも
呼ばれていますね。このように一つの関数を違うところから共用して使うというのはよくあります。

そのメリットとは、何でしょうか?
一番のメリットは、バグが見つかったときに修正するのが楽ということが挙げられます。
似たようなロジックが、いろんな箇所に点在していると、プログラムのメンテナンス性が悪くなります。
また、ロジックを共通化することにより、このGetComboBoxItems() 関数は、だんだん汎用的になり、
このプログラム以外にも使える箇所が出てくるかもわかりません。

実際、プログラムを作るときにすべての関数を1から作るのは稀で、自作・他作(標準関数を含む)
の関数を使いまわしているのです。
その反面、新たな機能を追加するときに、今までの機能に不具合が出ないか、調べる必要も出てくるかと思います。

また、GetComboBoxItems() 関数と、GetListViewItems() 関数を分けていますが、
ここら辺は、好き嫌いが分かれるところかもわかりませんが、視認性と機能の面から私は分けています。

皆さんは、同じようにする必要は全くありませんので、好きなように改造してみてください。


Last Update 2014/02/24 21:24
[Index] [Prev] [Next]