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 }