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 }