Window.showModal() は「そのウィンドウを表示し、閉じられるまで他のウィンドウを
操作させずにブロックする」API。 WINVER (win32) では従来から実装されていたが、
SDL / generic ビルドでは長らく未実装で TVPModalWindowIsNotSupported を投げていた。
本ドキュメントは両者の実装方針と、実装上の落とし穴をまとめる。
WINVER (win32/) |
SDL / generic (generic/ + sdl3/) |
|
|---|---|---|
| ループ | tTVPWindow::ShowModal が Application->HandleMessage() を回す |
SDL3WindowForm::ShowWindowAsModal が SDL_PollEvent + AppEvent/AppIterate/Dispatch を回す |
| 他ウィンドウの抑止 | Application->DisableWindows() (EnableWindow(FALSE)) |
SDL_SetWindowParent + SDL_SetWindowModal (OS レベル) + エンジン側の入力フィルタ |
| 終了条件 | modal_result_ != 0 |
同じ (generic の in_mode_ / modal_result_) |
| 閉じる経路 | tTVPWindow::Close が in_mode_ を見て modal_result_ を立てる |
TTVPWindowForm::Close / OnCloseQueryCalled が同様に立てる |
状態 (in_mode_ / modal_result_) と close 経路の扱いは generic/environ/WindowForm.{h,cpp}
に共通で置き、ネストループの実装だけを SDL3WindowForm::ShowWindowAsModal が
override する。 ループを回せないプラットフォーム (LIB ビルド等) は基底実装のまま
TVPModalWindowIsNotSupported を投げる。
SDL3 のメインループはコールバック方式 (SDL_AppEvent / SDL_AppIterate) でネスト
できないため、モーダル中は SDL_PumpEvents + SDL_PollEvent でイベントを取り出し、
app->AppEvent() / AppIterate() / SendPadEvent() / RequestUpdate() / Dispatch()
を自分で呼ぶ。 Elements の SDLElementsModalRunner::PumpModalLoop と同じ方式。
KRKRZ_USE_REPL 時は TVPDrainREPL() も回し、モーダル中も REPL / Agent で
操作・観測できるようにする。 wasm では SDL_Delay の代わりに JSPI の
krkrz_jspi_wait_frame() で次フレームまで suspend する (ブラウザのイベントループを
止めないため)。
SDL_SetWindowParent + SDL_SetWindowModal が効く環境 (Windows / X11 / Wayland) では
OS 側が親ウィンドウを無効化してくれる。 効かない環境と、既にキューに積まれていた
イベントのために、SDL3Application::AppEvent でも
TTVPWindowForm::GetModalWindowForm() を見てモーダル以外のウィンドウ宛の
ユーザ入力 (キー/マウス/タッチ/ドロップ/閉じる要求) を捨てる。 リサイズや再描画
などのシステム系は通す。
SDL は「閉じる要求が来たとき、親を持たない可視ウィンドウが 1 枚以下なら
SDL_EVENT_QUIT を送る」(src/events/SDL_windowevents.c)。 モーダル化のために
SDL_SetWindowParent を呼ぶとモーダルウィンドウはこの数から外れるので、
モーダルの × を押しただけでアプリが終了してしまう。 モーダル中だけ
SDL_HINT_QUIT_ON_LAST_WINDOW_CLOSE を "0" にして退避・復元する。
モバイル / コンソール等では 2 枚目の SDL_CreateWindow 自体が失敗する。
その場合 mWindow が null になるので TVPModalWindowIsNotSupported を投げる。
呼び出し側 (ゲームスクリプト) でそもそもモーダルを出さない作りにするのが前提。
モーダルに限らず「2 枚目以降の Window」を開くと踏む問題。 window_multi
デモ (data/window_multi/) 作成時に判明して修正した。
generic/environ/WindowForm.h の位置 / サイズ / borderStyle / stayOnTop /
fullScreen / setZoom は「モバイル・コンソール = 全画面 1 枚」を想定した
空実装のままで、SDL デスクトップでも left/top/width/height が 0、
fullScreen が常に true を返していた。 SDL3WindowForm でこれらを override
して実装した (sdl3/environ/form.cpp)。
- 外側サイズ ⇔ クライアントサイズの変換は
SDL_GetWindowBordersSizeの枠幅で行う (吉里吉里のWindow.widthは装飾込み、SDL の window size はクライアント)。 borderStyleは SDL に対応概念が無いのでSDL_SetWindowBordered(枠) +SDL_SetWindowResizable(リサイズ可否) の 組み合わせへ写像し、値そのものは保持して返す。setZoomは generic 側 (TTVPWindowForm::SetZoom) に実装した。 「レイヤ サイズ×倍率」をウィンドウの内側サイズにし、実際の拡縮は viewport の フィット計算 (CalcDestRect) に任せる。 WINVER は DestRect にしか倍率を 使わないため windowed では見た目が変わらない (差異はTODO.mdに記録)。
GL 描画デバイス (SDL のデスクトップ既定は SDLOGLDrawDevice) では、2 枚目の
ウィンドウも 1 枚目の GL コンテキストを共有する。 DestroyNativeWindow が
そのコンテキストを無条件に破棄していたため、サブウィンドウを閉じただけで
メイン画面が真っ白になっていた。
- コンテキストは最後の参照ウィンドウが消えるときだけ破棄する。
SDL_DestroyWindowは破棄対象がカレントだった場合にコンテキストのカレントを 外す。 破棄後に生存ウィンドウへSDL_GL_MakeCurrentし直すこと。 張り直さないと直後のテクスチャ更新が「カレント無し」で黙って捨てられ、 ダーティ矩形だけ消費されて画面が古いまま止まる (今度は白ではなく 「一部が更新されない」形で出るので気付きにくい)。- ウィンドウ毎に別コンテキストを作る案は不可。 レイヤ更新時のテクスチャ アップロードは「そのときカレントの」コンテキストへ行くため、もう一方の ウィンドウの画面が更新されなくなる。
tTVPElementsDialogManager::PaintOverlay には「提示デバイスが切り替わったら
既存ダイアログを現行デバイスへ移設する」処理がある (GL デモで drawDevice を
差し替えてもパネルが出るようにするためのもの)。 これは同じウィンドウ内での
デバイス差し替えを想定したもので、ウィンドウが 2 枚あると毎フレーム両方が
PaintOverlay を呼ぶため overlay がウィンドウ間を往復し、サブウィンドウを
閉じた瞬間に UnregisterDialogHost でパネルごと teardown されていた。
→ 移設と active_device の更新は メインウィンドウ (TVPMainWindow) の
device が提示したときだけ行う。 host 未指定時の解決も、active_device が
無ければメインウィンドウの device を優先する。 overlay ダイアログをサブ
ウィンドウに出すことは現状サポートしない。
Layer.onClick / Window.onClick は、ウィンドウ側が「左ボタンの離し」で
クリックイベントを投函することで発生する。 これは win32 の tTVPWindow::Proc
(WM_LBUTTONUP → OnMouseClick → tTVPOnClickInputEvent) にしか無く、
generic (SDL) ビルドでは onClick が一度も発火していなかった (KAG の
ButtonLayer などクリック駆動の UI が全滅する)。
TTVPWindowForm::OnMouseUp に win32 と同じ意味論のクリック生成を追加した:
- 左ボタンの離しで、押した位置 (
LastMouseDownX/Y) を使って発火 - ダブルクリック時は発火しない (
AM_MOUSE_DBLCLK→OnMouseDoubleClickで抑止) - 発火順序も win32 に合わせ、click → mouseup の順
SDL 側は SDL_EVENT_MOUSE_BUTTON_DOWN の clicks >= 2 を見て AM_MOUSE_DBLCLK を
先に送る (win32 の WM_*BUTTONDBLCLK → OnMouseDoubleClick + OnMouseDown と同じ順)。
tTVPTimerThread::HandleWake() (メインスレッド) は、以前
TVPTimerCSをFire()の間ずっと握り、PendingEventsAvailable(「wake 投函済み」ラッチ) を最後に false へ戻す
という作りだった。 Fire() の先ではタイマーハンドラが走る
(TVPTimer は直接、TJS の Timer はイベント配送経由) ので、そこから
showModal のネストループへ入ると HandleWake が戻らない。 その結果
- タイマースレッドが
TVPTimerCS待ちで停止する - ラッチが true のままなので wake も二度と投函されない
となり、モーダル表示中は TJS の Timer も tTVPSystemControl の 50ms 監視タイマー
(= イベント配送の駆動源) も完全に止まっていた。 実案件では
onCloseQuery → askYesNo → showModal がまさにこの経路
(SystemWatchTimerTimer → TVPDeliverAllEvents → … → ShowModal) を通る。
対処 (common/utils/TimerThread.cpp):
Pendingをロック下でスナップショット (swap) し、PendingEventsAvailableを Fire の前に下ろす。 ネストループ内でも新しい wake を受け取れる。Fire()はロックの外で呼ぶ。 ペンディング数はTakePendingCount()で ロック下に取り出してゼロにしてから渡すので、Fire 中にTriggerが来ても 「PendingCount == 0 → pending へ再登録」の経路に乗りティックを落とさない。- 作業用配列をメンバ (
ProcWork) からローカルへ移し、HandleWake の再入で 壊れないようにした。
WINVER の NativeEventQueueImplement::PostEvent は PostMessage の戻り値を捨てて
いた。 PostMessage はスレッドのメッセージキューが上限 (既定 10000) に達すると
失敗するため、「ラッチを立てたのに wake は届かない」= 以後タイマーが永久に
動かない状態になり得た (メッセージを大量に投函する実験で再現)。
PostEvent を bool 返しにし、tTVPTimerThread::Execute は投函に成功したときだけ
ラッチを立てるようにした。 generic 側は SendAppEvent が失敗時にリトライキューへ
積むので従来どおり。
TVPAgentInject* は generic / WINVER とも、モーダル表示中はモーダルウィンドウを
注入先にする (実入力と同じくモーダル中はそれしか操作できないのが正)。
- generic:
TTVPWindowForm::GetModalWindowForm() - WINVER:
TVPGetModalWindowForm()(TVPModalWindowListの最後尾)
また WINVER の Agent 注入は WndProc を通らないため、クリックイベントが生成されず
Layer.onClick が発火しない (= ボタンが反応しない) 問題があった。
TVPAgentInjectMouseButton の up 側で、実入力 (WM_LBUTTONUP) と同じ順序で
OnMouseClick → OnMouseUp を呼ぶようにした。 generic は OnMouseUp 自体が
クリックを生成するので追加不要。
これで -replfile からの Agent.click でモーダルダイアログを両ビルドとも検証できる。