1 /** 2 * Top-level windows. 3 * 4 * `Window` is a `Widget` backed by a `WS_OVERLAPPEDWINDOW`. It exposes the two 5 * lifecycle events most applications care about — `onClose` (cancellable) and 6 * `onResize` — and acts as the root of a widget tree. 7 */ 8 module deft.window; 9 10 version (Windows): 11 12 import core.sys.windows.windows; 13 14 import deft.app : Application; 15 import deft.controls.control : Control, routeCommand, routeNotify; 16 import deft.controls.statusbar : StatusBar; 17 import deft.controls.timer : dispatchTimer, stopTimersFor; 18 import deft.controls.trayicon : dispatchTrayMessage, trayCallbackMessage; 19 import deft.events; 20 import deft.layout : Sizer; 21 import deft.menu : MenuBar, dispatchMenuCommand; 22 import deft.util.strings; 23 import deft.widget; 24 import deft.platform.win32.init : deftWindowClassName, hInstance; 25 import deft.platform.win32.wndproc : lookupWidget; 26 27 /// Count of live top-level `Window`s, so the app quits when the last one closes. 28 private __gshared int g_topLevelWindowCount; 29 30 /** 31 * Arguments passed to `Window.onClose` handlers. 32 * 33 * A handler may set `cancel = true` to veto the close — used for 34 * minimize-to-tray and confirm-before-exit flows. 35 */ 36 struct CloseEventArgs 37 { 38 bool cancel = false; 39 } 40 41 /// A top-level application window. 42 class Window : Widget 43 { 44 /// Fired when the window is about to close; set `args.cancel` to veto. 45 Event!(CloseEventArgs*) onClose; 46 47 /// Fired on resize with the new client width and height. 48 Event!(int, int) onResize; 49 50 /** 51 * Force this window to be treated as the application's main window: destroying 52 * it always quits the message loop (`PostQuitMessage`), even if other windows 53 * remain. 54 * 55 * It defaults to `false` because, by default, the framework already quits when 56 * the *last* top-level window is destroyed — so a single-window app needs no 57 * configuration, and closing a secondary window never tears the app down. Set 58 * it to `true` only when one specific window should end the app regardless of 59 * the others. 60 */ 61 bool isMainWindow = false; 62 63 /// Minimum outer window size in pixels (0 = no minimum), enforced on resize. 64 private int minWidth_; 65 private int minHeight_; 66 67 /// Optional root sizer that arranges the window's contents on resize. 68 private Sizer rootSizer_; 69 70 /// Optional status bar docked at the bottom; its height is reserved in layout. 71 private StatusBar statusBar_; 72 73 /// Optional menu bar; retained so the GC keeps its handle alive. 74 private MenuBar menuBar_; 75 76 /// This window's accelerator table (its menu's shortcuts), or null. 77 private HACCEL accelTable_; 78 79 /// Control id of the designated default button (0 = none). 80 private int defaultButtonId_; 81 82 /** 83 * Create and show-ready a top-level window with the given title and size 84 * (the size is the outer window size, in pixels). 85 */ 86 this(string title, int width, int height) 87 { 88 // Defensive: guarantee process initialization (common controls, COM, and 89 // — crucially — per-monitor DPI awareness) before the first window exists, 90 // even if the caller forgot to call `Application.initialize()`. Idempotent. 91 Application.instance.initialize(); 92 93 handle_ = CreateWindowExW( 94 WS_EX_CONTROLPARENT, // let the dialog manager recurse into child controls 95 deftWindowClassName.ptr, 96 title.toWStringz, 97 WS_OVERLAPPEDWINDOW, 98 CW_USEDEFAULT, CW_USEDEFAULT, 99 width, height, 100 null, // no parent 101 null, // no menu 102 hInstance(), 103 null); 104 105 registerHandle(); 106 107 if (handle_) 108 ++g_topLevelWindowCount; 109 110 RECT rc; 111 if (handle && GetWindowRect(handle, &rc)) 112 bounds_ = Rect.fromRECT(rc); 113 else 114 bounds_ = Rect(0, 0, width, height); 115 } 116 117 /** 118 * Set the window's icon, shown in the title bar, the taskbar and the Alt+Tab 119 * switcher. Pass the small and (optionally) large variants; if `large` is 120 * null the `small` icon is used for both. Load an icon with `loadIcon` 121 * (from the executable's resources) or `loadIconFromFile`. 122 */ 123 void setIcon(HICON small, HICON large = null) 124 { 125 if (!handle) 126 return; 127 SendMessageW(handle, WM_SETICON, ICON_SMALL, cast(LPARAM) small); 128 SendMessageW(handle, WM_SETICON, ICON_BIG, 129 cast(LPARAM)(large !is null ? large : small)); 130 } 131 132 /** 133 * Set the smallest outer size the user may resize the window to, in pixels. 134 * Pass `0, 0` to remove the constraint. Without a minimum the layout engine 135 * clamps to zero but the user can still shrink the window until its contents 136 * collapse; a floor keeps a real app usable. 137 */ 138 void setMinimumSize(int width, int height) 139 { 140 minWidth_ = width < 0 ? 0 : width; 141 minHeight_ = height < 0 ? 0 : height; 142 } 143 144 /// Show the window and force an initial paint. 145 override void show() 146 { 147 visible_ = true; 148 if (handle) 149 { 150 ShowWindow(handle, SW_SHOW); 151 UpdateWindow(handle); 152 } 153 } 154 155 /// Set the window title bar text. 156 void setTitle(string title) 157 { 158 if (handle) 159 SetWindowTextW(handle, title.toWStringz); 160 } 161 162 /// Ask the window to close (drives the same path as the close button). 163 void close() 164 { 165 if (handle) 166 SendMessageW(handle, WM_CLOSE, 0, 0); 167 } 168 169 /** 170 * Install the root sizer and immediately lay it out over the client area. 171 */ 172 void setSizer(Sizer sizer) 173 { 174 rootSizer_ = sizer; 175 relayout(); 176 } 177 178 /** 179 * Dock a status bar at the bottom of the window. Its height is reserved so 180 * the root sizer's content never overlaps it. 181 */ 182 void setStatusBar(StatusBar statusBar) 183 { 184 statusBar_ = statusBar; 185 relayout(); 186 } 187 188 /** 189 * Attach a menu bar to the window and install its keyboard accelerators. 190 * Re-lays out the contents, since the menu reduces the client area. 191 */ 192 void setMenuBar(MenuBar menuBar) 193 { 194 menuBar_ = menuBar; 195 if (handle && menuBar !is null) 196 { 197 SetMenu(handle, menuBar.handle); 198 DrawMenuBar(handle); 199 if (accelTable_ !is null) 200 DestroyAcceleratorTable(accelTable_); 201 accelTable_ = menuBar.buildAcceleratorTable(); 202 relayout(); 203 } 204 } 205 206 /** 207 * This window's accelerator table (the shortcuts of its attached menu), or 208 * null. The message loop translates accelerators against the *active* 209 * window's table, so each window keeps its own shortcuts. 210 */ 211 HACCEL acceleratorTable() @safe pure nothrow @nogc 212 { 213 return accelTable_; 214 } 215 216 /// Re-run the root sizer over the current client area, if one is set. 217 void relayout() 218 { 219 auto rc = getClientRect(); 220 layoutContents(rc.width, rc.height); 221 } 222 223 /// Lay out the root sizer over the client area minus any docked status bar. 224 private void layoutContents(int width, int height) 225 { 226 if (statusBar_ !is null) 227 { 228 statusBar_.reposition(); 229 height -= statusBar_.getHeight(); 230 if (height < 0) 231 height = 0; 232 } 233 if (rootSizer_ !is null) 234 rootSizer_.layout(Rect(0, 0, width, height)); 235 } 236 237 /** 238 * Designate the button activated by Enter when focus is on a non-button 239 * control. A focused push button is always its own default regardless of 240 * this setting (native dialog behavior). Pass null to clear. 241 */ 242 void setDefaultButton(Control button) 243 { 244 defaultButtonId_ = button is null ? 0 : button.controlId; 245 } 246 247 /// Move keyboard focus to the first focusable child control, if any. 248 void focusFirstControl() 249 { 250 HWND first = firstFocusableChild(); 251 if (first !is null) 252 SetFocus(first); 253 } 254 255 /// The handle of the first visible, enabled, tab-stop control, or null. 256 private HWND firstFocusableChild() 257 { 258 return firstFocusableIn(children); 259 } 260 261 override LRESULT processMessage(UINT msg, WPARAM wParam, LPARAM lParam) 262 { 263 switch (msg) 264 { 265 case WM_CLOSE: 266 auto args = CloseEventArgs(false); 267 onClose.fire(&args); 268 if (!args.cancel && handle) 269 DestroyWindow(handle); 270 return 0; 271 272 case WM_SIZE: 273 immutable int w = LOWORD(cast(DWORD) lParam); 274 immutable int h = HIWORD(cast(DWORD) lParam); 275 layoutContents(w, h); 276 onResize.fire(w, h); 277 return 0; 278 279 case WM_GETMINMAXINFO: 280 // Enforce the minimum outer size the user can drag the window down to. 281 if (minWidth_ > 0 || minHeight_ > 0) 282 { 283 auto mmi = cast(MINMAXINFO*) lParam; 284 if (minWidth_ > 0) 285 mmi.ptMinTrackSize.x = minWidth_; 286 if (minHeight_ > 0) 287 mmi.ptMinTrackSize.y = minHeight_; 288 return 0; 289 } 290 return super.processMessage(msg, wParam, lParam); 291 292 case WM_COMMAND: 293 // Menu and accelerator commands carry a null lParam; controls carry 294 // their HWND. Try the menu registry first for the former. 295 if (cast(HWND) lParam is null 296 && dispatchMenuCommand(LOWORD(cast(DWORD) wParam))) 297 return 0; 298 if (routeCommand(wParam, lParam)) 299 return 0; 300 return super.processMessage(msg, wParam, lParam); 301 302 case WM_TIMER: 303 if (dispatchTimer(cast(uint) wParam)) 304 return 0; 305 return super.processMessage(msg, wParam, lParam); 306 307 case WM_NOTIFY: 308 if (routeNotify(lParam)) 309 return 0; 310 return super.processMessage(msg, wParam, lParam); 311 312 case DM_GETDEFID: 313 // The dialog manager (IsDialogMessage) asks for the default command 314 // id to activate on Enter. A focused push button is its own default, 315 // matching native dialogs; otherwise fall back to the designated 316 // default button. Without this, Enter on a button does nothing until 317 // the control is re-focused via Tab. 318 HWND focused = GetFocus(); 319 if (focused !is null && IsChild(handle, focused)) 320 { 321 auto code = cast(uint) SendMessageW(focused, WM_GETDLGCODE, 0, 0); 322 if (code & (DLGC_DEFPUSHBUTTON | DLGC_UNDEFPUSHBUTTON)) 323 { 324 int id = GetDlgCtrlID(focused); 325 if (id != 0) 326 return (cast(LRESULT) DC_HASDEFID << 16) | (id & 0xFFFF); 327 } 328 } 329 if (defaultButtonId_ != 0) 330 return (cast(LRESULT) DC_HASDEFID << 16) | (defaultButtonId_ & 0xFFFF); 331 return super.processMessage(msg, wParam, lParam); 332 333 case DM_SETDEFID: 334 defaultButtonId_ = cast(int)(wParam & 0xFFFF); 335 return TRUE; 336 337 case WM_SETFOCUS: 338 // When the window itself receives focus, hand it to the first 339 // focusable child so keyboard users (and screen readers) land on a 340 // real control rather than the bare window client. 341 HWND first = firstFocusableChild(); 342 if (first !is null) 343 { 344 SetFocus(first); 345 return 0; 346 } 347 return super.processMessage(msg, wParam, lParam); 348 349 case WM_DESTROY: 350 // Release resources tied to this window's lifetime before it goes: 351 // stop its timers (their native counterparts die with the HWND, but 352 // the registry entries would linger) and free its accelerator table. 353 stopTimersFor(this); 354 if (accelTable_ !is null) 355 { 356 DestroyAcceleratorTable(accelTable_); 357 accelTable_ = null; 358 } 359 360 // Quit when this window is the explicitly designated main window, or 361 // when it is the last live top-level window — so a single-window app 362 // ends on close while closing a secondary window leaves the app running. 363 if (g_topLevelWindowCount > 0) 364 --g_topLevelWindowCount; 365 if (isMainWindow || g_topLevelWindowCount == 0) 366 PostQuitMessage(0); 367 return 0; 368 369 default: 370 if (msg == trayCallbackMessage 371 && dispatchTrayMessage(cast(uint) wParam, cast(uint) lParam)) 372 return 0; 373 return super.processMessage(msg, wParam, lParam); 374 } 375 } 376 } 377 378 /** 379 * The accelerator table of the top-level window `active`, or null. 380 * 381 * The message loop calls this each iteration with `GetActiveWindow()` so menu 382 * shortcuts are scoped to whichever window is active — in a multi-window app 383 * each window's shortcuts work only while it is focused, rather than the last 384 * window to attach a menu owning the shortcuts for all of them. 385 */ 386 HACCEL activeWindowAcceleratorTable(HWND active) 387 { 388 if (active is null) 389 return null; 390 if (auto window = cast(Window) lookupWidget(active)) 391 return window.acceleratorTable(); 392 return null; 393 }