1 /**
2  * System tray (notification area) icons.
3  *
4  * `TrayIcon` wraps `Shell_NotifyIconW`. It shows an icon with a tooltip in the
5  * notification area, optionally a balloon notification and a right-click context
6  * menu, and fires `onDoubleClicked` when the icon is double-clicked (the usual
7  * "restore the window" gesture).
8  *
9  * The icon sends its mouse notifications to the owner window as a private
10  * application message; `Window` routes that message here via
11  * `dispatchTrayMessage`. Call `remove()` before the owner window is torn down —
12  * a lingering tray icon can leave screen-reader focus in a bad state.
13  */
14 module deft.controls.trayicon;
15 
16 version (Windows):
17 
18 import core.sys.windows.windows;
19 import core.sys.windows.shellapi;
20 
21 import deft.events;
22 import deft.menu : Menu;
23 import deft.util.strings;
24 import deft.window : Window;
25 
26 /// Private window message the tray icon uses to report mouse activity.
27 enum UINT trayCallbackMessage = WM_APP + 100;
28 
29 private __gshared TrayIcon[uint] g_trayIcons;
30 private __gshared uint g_nextTrayId = 1;
31 
32 /**
33  * Route a tray callback message to the matching `TrayIcon`.
34  *
35  * `id` is the icon id (the `wParam` of the callback message); `mouseMsg` is the
36  * mouse message (`lParam`). Returns `true` if a matching icon handled it.
37  */
38 bool dispatchTrayMessage(uint id, uint mouseMsg)
39 {
40 	if (auto icon = id in g_trayIcons)
41 	{
42 		(*icon).handleMouse(mouseMsg);
43 		return true;
44 	}
45 	return false;
46 }
47 
48 /// Copy a D string into a fixed-size wide buffer, NUL-terminating and clipping.
49 private void copyToWBuffer(string s, WCHAR[] dest)
50 {
51 	auto src = s.toWStringz;
52 	size_t i = 0;
53 	for (; i + 1 < dest.length && src[i] != '\0'; ++i)
54 		dest[i] = src[i];
55 	dest[i] = '\0';
56 }
57 
58 /// A notification-area icon owned by a top-level window.
59 class TrayIcon
60 {
61 	private Window owner_;
62 	private uint id_;
63 	private HICON icon_;
64 	private string tooltip_;
65 	private Menu contextMenu_;
66 	private bool added_;
67 
68 	/// Fired when the icon is double-clicked with the left mouse button.
69 	Event!() onDoubleClicked;
70 
71 	/**
72 	 * Create a tray icon for `owner` with the given tooltip.
73 	 *
74 	 * The icon is added to the notification area immediately; set an icon image
75 	 * with `setIcon` (until then the area shows a blank slot).
76 	 */
77 	this(Window owner, string tooltip)
78 	{
79 		owner_ = owner;
80 		tooltip_ = tooltip;
81 		id_ = g_nextTrayId++;
82 		g_trayIcons[id_] = this;
83 		add();
84 	}
85 
86 	private NOTIFYICONDATAW baseData()
87 	{
88 		NOTIFYICONDATAW nid;
89 		nid.cbSize = NOTIFYICONDATAW.sizeof;
90 		nid.hWnd = owner_.handle;
91 		nid.uID = id_;
92 		return nid;
93 	}
94 
95 	private void add()
96 	{
97 		auto nid = baseData();
98 		nid.uFlags = NIF_MESSAGE | NIF_TIP;
99 		nid.uCallbackMessage = trayCallbackMessage;
100 		copyToWBuffer(tooltip_, nid.szTip[]);
101 		if (icon_ !is null)
102 		{
103 			nid.uFlags |= NIF_ICON;
104 			nid.hIcon = icon_;
105 		}
106 		Shell_NotifyIconW(NIM_ADD, &nid);
107 		added_ = true;
108 	}
109 
110 	/// Set the icon image shown in the notification area.
111 	void setIcon(HICON icon)
112 	{
113 		icon_ = icon;
114 		auto nid = baseData();
115 		nid.uFlags = NIF_ICON;
116 		nid.hIcon = icon;
117 		Shell_NotifyIconW(NIM_MODIFY, &nid);
118 	}
119 
120 	/// Change the hover tooltip text.
121 	void setTooltip(string text)
122 	{
123 		tooltip_ = text;
124 		auto nid = baseData();
125 		nid.uFlags = NIF_TIP;
126 		copyToWBuffer(text, nid.szTip[]);
127 		Shell_NotifyIconW(NIM_MODIFY, &nid);
128 	}
129 
130 	/// Show a balloon notification with `title` and `text`.
131 	void showBalloon(string title, string text)
132 	{
133 		auto nid = baseData();
134 		nid.uFlags = NIF_INFO;
135 		copyToWBuffer(title, nid.szInfoTitle[]);
136 		copyToWBuffer(text, nid.szInfo[]);
137 		Shell_NotifyIconW(NIM_MODIFY, &nid);
138 	}
139 
140 	/// Set the menu shown on right-click (or Apps key while the icon is active).
141 	void setContextMenu(Menu menu)
142 	{
143 		contextMenu_ = menu;
144 	}
145 
146 	/**
147 	 * Remove the icon from the notification area.
148 	 *
149 	 * Call this before destroying the owner window. Idempotent. (Named `remove`
150 	 * rather than `destroy` to avoid shadowing druntime's built-in `destroy`.)
151 	 */
152 	void remove()
153 	{
154 		if (!added_)
155 			return;
156 		auto nid = baseData();
157 		Shell_NotifyIconW(NIM_DELETE, &nid);
158 		g_trayIcons.remove(id_);
159 		added_ = false;
160 	}
161 
162 	/// React to a mouse message forwarded from the owner window.
163 	private void handleMouse(uint mouseMsg)
164 	{
165 		switch (mouseMsg)
166 		{
167 		case WM_LBUTTONDBLCLK:
168 			onDoubleClicked.fire();
169 			break;
170 
171 		case WM_RBUTTONUP:
172 		case WM_CONTEXTMENU:
173 			if (contextMenu_ !is null && owner_ !is null && owner_.handle !is null)
174 			{
175 				POINT pt;
176 				GetCursorPos(&pt);
177 				// Foreground + the trailing null post are the documented fix for
178 				// a tray menu that otherwise won't dismiss on an outside click.
179 				SetForegroundWindow(owner_.handle);
180 				TrackPopupMenu(contextMenu_.handle,
181 					TPM_LEFTALIGN | TPM_RIGHTBUTTON,
182 					pt.x, pt.y, 0, owner_.handle, null);
183 				PostMessageW(owner_.handle, WM_NULL, 0, 0);
184 			}
185 			break;
186 
187 		default:
188 			break;
189 		}
190 	}
191 }