1 /**
2  * Modal dialogs, built on the native Win32 dialog manager.
3  *
4  * `Dialog` is a real dialog-class window (`#32770`): it is created from an
5  * in-memory dialog template with `CreateDialogIndirectParamW` (the runtime
6  * equivalent of an `.rc` `DIALOGEX` resource) and driven through
7  * `IsDialogMessageW`/`DefDlgProc`. Using the genuine dialog construct means the
8  * OS gives us the things a screen reader and keyboard user need, for free:
9  *
10  * - oleacc reports the window as `ROLE_SYSTEM_DIALOG`, so JAWS and NVDA announce
11  *   it as a dialog on open and read its child controls as dialog contents — no
12  *   custom `IAccessible` proxy or role annotation required.
13  * - The dialog manager handles Tab/Shift+Tab and arrow-key groups, maps Escape
14  *   to Cancel and Enter to the default button, and tracks the default push
15  *   button — all natively.
16  *
17  * Content is arranged with a sizer; `addStandardButtons` appends a right-aligned
18  * OK/Cancel-style button row wired to dismiss the dialog with the matching
19  * `DialogResult`. Controls are ordinary child windows parented to the dialog.
20  */
21 module deft.controls.dialog;
22 
23 version (Windows):
24 
25 import core.sys.windows.windows;
26 
27 import deft.controls.button : Button;
28 import deft.controls.control : routeCommand, routeNotify;
29 import deft.controls.label : Label;
30 import deft.controls.textbox : TextBox, TextBoxStyle;
31 import deft.i18n : tr;
32 import deft.layout : HBox, Sizer, VBox;
33 import deft.util.strings;
34 import deft.widget;
35 import deft.platform.win32.init : hInstance;
36 
37 /// The outcome of a modal dialog.
38 enum DialogResult
39 {
40 	/// The dialog is still open or was dismissed without a decision.
41 	none,
42 	/// The user confirmed (OK).
43 	ok,
44 	/// The user canceled.
45 	cancel,
46 	/// The user answered yes.
47 	yes,
48 	/// The user answered no.
49 	no,
50 }
51 
52 /// The set of standard buttons `addStandardButtons` creates.
53 enum ButtonSet
54 {
55 	/// A single OK button.
56 	ok,
57 	/// OK and Cancel.
58 	okCancel,
59 	/// Yes and No.
60 	yesNo,
61 }
62 
63 /**
64  * A modal dialog window.
65  *
66  * Build content with `setSizer`, optionally add a standard button row, then
67  * call `showModal`, which blocks until the dialog is dismissed and returns the
68  * `DialogResult`. The dialog's child controls remain valid after `showModal`
69  * returns, so their values can be read; call `dispose` when finished with it.
70  */
71 class Dialog : Widget
72 {
73 	private HWND parentHandle_;
74 	private int width_;
75 	private int height_;
76 	private DialogResult result_ = DialogResult.none;
77 	private bool modalDone_;
78 	private Sizer rootSizer_;
79 	private Sizer contentSizer_;
80 	private Sizer buttonSizer_;
81 
82 	/**
83 	 * Create a modal dialog parented to `parent` (any widget; the dialog is
84 	 * owned by, centered on, and modal to that widget's top-level window).
85 	 */
86 	this(Widget parent, string title, int width, int height)
87 	{
88 		parentHandle_ = parent !is null && parent.handle !is null
89 			? GetAncestor(parent.handle, GA_ROOT) : null;
90 		width_ = width;
91 		height_ = height;
92 		this.parent_ = parent;
93 
94 		auto template_ = buildDialogTemplate(title, width, height);
95 
96 		handle_ = CreateDialogIndirectParamW(
97 			hInstance(),
98 			cast(LPCDLGTEMPLATE) template_.ptr,
99 			parentHandle_,
100 			&dialogProc,
101 			cast(LPARAM) cast(void*) this);
102 
103 		registerHandle();
104 	}
105 
106 	/**
107 	 * Set the dialog's content sizer. The content is laid out above any standard
108 	 * button row added with `addStandardButtons`.
109 	 */
110 	void setSizer(Sizer sizer)
111 	{
112 		contentSizer_ = sizer;
113 		rebuildRoot();
114 	}
115 
116 	/// Re-run the root sizer over the dialog's client area.
117 	void relayout()
118 	{
119 		if (rootSizer_ !is null)
120 			rootSizer_.layout(getClientRect());
121 	}
122 
123 	/**
124 	 * Add a right-aligned row of standard buttons at the bottom of the dialog,
125 	 * each wired to dismiss the dialog with the matching `DialogResult`. The
126 	 * OK/Yes button becomes the dialog's default push button (activated by Enter
127 	 * via the native dialog manager).
128 	 *
129 	 * Button captions are localized automatically: an app translation (looked up
130 	 * via `tr` under the keys `deft.button.ok` / `.cancel` / `.yes` / `.no`) wins;
131 	 * otherwise the operating system's own localized text is used, so OK/Cancel/
132 	 * Yes/No match the user's Windows language even with no catalog installed.
133 	 */
134 	void addStandardButtons(ButtonSet set)
135 	{
136 		auto row = new HBox();
137 		// A flexible empty cell pushes the buttons to the right edge.
138 		row.addSizer(new HBox()).proportion(1);
139 
140 		final switch (set)
141 		{
142 		case ButtonSet.ok:
143 			addButton(row, okText(), DialogResult.ok, true);
144 			break;
145 		case ButtonSet.okCancel:
146 			addButton(row, okText(), DialogResult.ok, true);
147 			addButton(row, cancelText(), DialogResult.cancel, false);
148 			break;
149 		case ButtonSet.yesNo:
150 			addButton(row, yesText(), DialogResult.yes, true);
151 			addButton(row, noText(), DialogResult.no, false);
152 			break;
153 		}
154 
155 		buttonSizer_ = row;
156 		rebuildRoot();
157 	}
158 
159 	private Button addButton(HBox row, string text, DialogResult result, bool isDefault)
160 	{
161 		auto button = new Button(this, text);
162 		if (isDefault)
163 		{
164 			// Mark it the default push button so the dialog manager fires it on
165 			// Enter and a screen reader announces it as the default.
166 			SendMessageW(button.handle, BM_SETSTYLE, BS_DEFPUSHBUTTON, TRUE);
167 			SendMessageW(handle, DM_SETDEFID, cast(WPARAM) button.controlId, 0);
168 		}
169 		button.onClicked ~= { endModal(result); };
170 		row.add(button).pad(Padding.all(4));
171 		return button;
172 	}
173 
174 	private void rebuildRoot()
175 	{
176 		auto root = new VBox();
177 		if (contentSizer_ !is null)
178 			root.addSizer(contentSizer_).proportion(1);
179 		if (buttonSizer_ !is null)
180 			root.addSizer(buttonSizer_).pad(Padding.all(8));
181 		rootSizer_ = root;
182 		relayout();
183 	}
184 
185 	/**
186 	 * Show the dialog modally: disable the parent window, pump messages through
187 	 * the dialog manager until `endModal` is called (or the dialog is closed),
188 	 * then re-enable the parent and return the result.
189 	 */
190 	DialogResult showModal()
191 	{
192 		centerOnParent();
193 
194 		if (parentHandle_ !is null)
195 			EnableWindow(parentHandle_, FALSE);
196 
197 		ShowWindow(handle, SW_SHOW);
198 		SetForegroundWindow(handle);
199 		focusFirstControl();
200 
201 		modalDone_ = false;
202 		MSG msg;
203 		while (!modalDone_)
204 		{
205 			int got = GetMessageW(&msg, null, 0, 0);
206 			if (got <= 0)
207 			{
208 				// WM_QUIT: re-post it so the outer loop also exits, then stop.
209 				if (got == 0)
210 					PostQuitMessage(cast(int) msg.wParam);
211 				break;
212 			}
213 
214 			// IsDialogMessageW gives native dialog keyboard handling: Tab/arrow
215 			// groups, Escape -> Cancel, Enter -> default button.
216 			if (!IsDialogMessageW(handle, &msg))
217 			{
218 				TranslateMessage(&msg);
219 				DispatchMessageW(&msg);
220 			}
221 		}
222 
223 		if (parentHandle_ !is null)
224 		{
225 			EnableWindow(parentHandle_, TRUE);
226 			SetForegroundWindow(parentHandle_);
227 		}
228 		ShowWindow(handle, SW_HIDE);
229 		return result_;
230 	}
231 
232 	/// Dismiss the dialog with `result`, breaking out of the modal loop.
233 	void endModal(DialogResult result)
234 	{
235 		result_ = result;
236 		modalDone_ = true;
237 		// Wake the modal GetMessage loop so it re-checks `modalDone_`.
238 		if (handle)
239 			PostMessageW(handle, WM_NULL, 0, 0);
240 	}
241 
242 	/// Dispatch a dialog-procedure message; returns TRUE when handled.
243 	private INT_PTR handleMessage(UINT msg, WPARAM wParam, LPARAM lParam)
244 	{
245 		switch (msg)
246 		{
247 		case WM_COMMAND:
248 			// Control notifications carry the control HWND; route them to the
249 			// originating control (this is how a default button's Enter press and
250 			// a Cancel button's click reach their handlers).
251 			if (cast(HWND) lParam !is null)
252 				return routeCommand(wParam, lParam) ? TRUE : FALSE;
253 			// The dialog manager posts IDCANCEL on Escape and IDOK on Enter when
254 			// no control consumes it.
255 			switch (LOWORD(cast(DWORD) wParam))
256 			{
257 			case IDOK:
258 				endModal(DialogResult.ok);
259 				return TRUE;
260 			case IDCANCEL:
261 				endModal(DialogResult.cancel);
262 				return TRUE;
263 			default:
264 				return FALSE;
265 			}
266 
267 		case WM_NOTIFY:
268 			return routeNotify(lParam) ? TRUE : FALSE;
269 
270 		case WM_SIZE:
271 			relayout();
272 			return FALSE;
273 
274 		case WM_CLOSE:
275 			endModal(DialogResult.cancel);
276 			return TRUE;
277 
278 		default:
279 			return FALSE;
280 		}
281 	}
282 
283 	/// Move focus to the first focusable control, descending into containers.
284 	private void focusFirstControl()
285 	{
286 		HWND first = firstFocusableIn(children);
287 		if (first !is null)
288 			SetFocus(first);
289 	}
290 
291 	private void centerOnParent()
292 	{
293 		RECT prc;
294 		bool haveParent = parentHandle_ !is null && GetWindowRect(parentHandle_, &prc) != 0;
295 		if (!haveParent)
296 			SystemParametersInfoW(SPI_GETWORKAREA, 0, &prc, 0);
297 
298 		int x = prc.left + ((prc.right - prc.left) - width_) / 2;
299 		int y = prc.top + ((prc.bottom - prc.top) - height_) / 2;
300 		SetWindowPos(handle, null, x, y, width_, height_, SWP_NOZORDER);
301 	}
302 }
303 
304 /**
305  * The dialog procedure shared by all `Dialog`s. Stores the owning `Dialog` in
306  * `GWLP_USERDATA` on `WM_INITDIALOG` and forwards every later message to it.
307  * `extern(Windows)` and `nothrow`: a D throwable must never escape into the OS.
308  */
309 private extern (Windows) INT_PTR dialogProc(HWND hwnd, UINT msg, WPARAM wParam,
310 	LPARAM lParam) nothrow
311 {
312 	try
313 	{
314 		if (msg == WM_INITDIALOG)
315 		{
316 			SetWindowLongPtrW(hwnd, GWLP_USERDATA, cast(LONG_PTR) lParam);
317 			return TRUE;
318 		}
319 
320 		auto dialog = cast(Dialog) cast(void*) GetWindowLongPtrW(hwnd, GWLP_USERDATA);
321 		if (dialog !is null)
322 			return dialog.handleMessage(msg, wParam, lParam);
323 	}
324 	catch (Throwable)
325 	{
326 		// Never propagate a D throwable through the Win32 dialog dispatcher.
327 	}
328 	return FALSE;
329 }
330 
331 /**
332  * Build an in-memory dialog template (the runtime form of an `.rc` `DIALOGEX`)
333  * for an empty, non-visible modal dialog with the given title and pixel size.
334  *
335  * The header layout is the documented `DLGTEMPLATE` byte layout (18 bytes),
336  * written at explicit offsets to avoid struct padding, followed by the no-menu
337  * marker, the default-class marker, and the null-terminated title. Sizes are
338  * converted from pixels to dialog units via `GetDialogBaseUnits`.
339  */
340 private ubyte[] buildDialogTemplate(string title, int widthPx, int heightPx)
341 {
342 	immutable int baseUnits = GetDialogBaseUnits();
343 	immutable int baseX = baseUnits & 0xFFFF;
344 	immutable int baseY = (baseUnits >> 16) & 0xFFFF;
345 	immutable short cx = cast(short)(baseX > 0 ? cast(long) widthPx * 4 / baseX : widthPx);
346 	immutable short cy = cast(short)(baseY > 0 ? cast(long) heightPx * 8 / baseY : heightPx);
347 
348 	// Convert the title to a null-terminated wide string and measure it.
349 	auto titleW = title.toWStringz;
350 	size_t titleLen = 0;
351 	while (titleW[titleLen] != '\0')
352 		++titleLen;
353 	++titleLen; // include the terminating NUL
354 
355 	enum size_t headerSize = 18; // packed DLGTEMPLATE
356 	enum size_t menuOffset = 18;
357 	enum size_t classOffset = 20;
358 	enum size_t titleOffset = 22;
359 
360 	auto buf = new ubyte[titleOffset + titleLen * wchar.sizeof];
361 
362 	void putU32(size_t off, uint v) { *(cast(uint*)(buf.ptr + off)) = v; }
363 	void putU16(size_t off, ushort v) { *(cast(ushort*)(buf.ptr + off)) = v; }
364 
365 	immutable DWORD style = WS_POPUP | WS_CAPTION | WS_SYSMENU | DS_MODALFRAME;
366 
367 	putU32(0, style);          // style
368 	putU32(4, 0);              // dwExtendedStyle
369 	putU16(8, 0);              // cdit: zero controls
370 	putU16(10, 0);             // x (dialog units)
371 	putU16(12, 0);             // y
372 	putU16(14, cast(ushort) cx); // cx
373 	putU16(16, cast(ushort) cy); // cy
374 	putU16(menuOffset, 0);     // no menu
375 	putU16(classOffset, 0);    // default dialog class (#32770)
376 
377 	auto titleDst = cast(wchar*)(buf.ptr + titleOffset);
378 	foreach (i; 0 .. titleLen)
379 		titleDst[i] = titleW[i];
380 
381 	return buf;
382 }
383 
384 /**
385  * Prompt for a single line of text.
386  *
387  * Shows a modal dialog with a prompt label, a text field and OK/Cancel buttons.
388  * Returns the entered text, or `null` if the user dismisses it.
389  */
390 string showInputDialog(Widget parent, string title, string prompt,
391 	string initialValue = "")
392 {
393 	auto dialog = new Dialog(parent, title, 420, 180);
394 	scope (exit)
395 		dialog.dispose();
396 
397 	auto content = new VBox();
398 	auto label = new Label(dialog, prompt);
399 	auto input = new TextBox(dialog, initialValue, TextBoxStyle.singleLine);
400 	content.add(label).pad(Padding.all(8));
401 	content.add(input).pad(Padding.symmetric(8, 0));
402 
403 	dialog.setSizer(content);
404 	dialog.addStandardButtons(ButtonSet.okCancel);
405 
406 	input.selectAll();
407 
408 	if (dialog.showModal() == DialogResult.ok)
409 		return input.getText();
410 	return null;
411 }
412 
413 // Standard-button caption ids in user32.dll's localized string table
414 // (800 = OK, 801 = Cancel, 805 = &Yes, 806 = &No), the same strings the native
415 // message box uses — so Deft's custom dialog buttons match the OS language.
416 private enum uint sidOK = 800;
417 private enum uint sidCancel = 801;
418 private enum uint sidYes = 805;
419 private enum uint sidNo = 806;
420 
421 private string okText() { return standardText("deft.button.ok", "OK", sidOK); }
422 private string cancelText() { return standardText("deft.button.cancel", "Cancel", sidCancel); }
423 private string yesText() { return standardText("deft.button.yes", "&Yes", sidYes); }
424 private string noText() { return standardText("deft.button.no", "&No", sidNo); }
425 
426 /**
427  * Resolve a standard button's caption. Precedence: an application translation
428  * (via `tr` under `key`) wins; otherwise the operating system's own localized
429  * string; otherwise the English default. So the buttons follow the user's
430  * Windows language with no catalog, yet an app can still override them.
431  */
432 private string standardText(string key, string english, uint sysId)
433 {
434 	auto translated = tr(key);
435 	if (translated != key)
436 		return translated;
437 	auto os = loadSystemString(sysId);
438 	return os.length != 0 ? os : english;
439 }
440 
441 /// Load a localized string from user32.dll's resource table (empty on failure).
442 private string loadSystemString(uint id)
443 {
444 	HMODULE user32 = GetModuleHandleW("user32.dll"w.ptr);
445 	if (user32 is null)
446 		return "";
447 	wchar[256] buf;
448 	int n = LoadStringW(user32, id, buf.ptr, cast(int) buf.length);
449 	if (n <= 0)
450 		return "";
451 	return fromWString(buf[0 .. n]);
452 }