1 /** 2 * Native report-mode list view (`SysListView32`). 3 * 4 * `ListView` wraps the Win32 list-view common control in report (details) mode: 5 * a grid of rows and columns with single selection, full-row select, grid lines 6 * and a clickable header. It exposes column and item management, selection, 7 * per-item user data, column ordering, and delegate-based events for selection 8 * changes, activation (double-click / Enter) and context-menu requests. Because 9 * it is a real native control, it brings MSAA accessibility for free. 10 */ 11 module deft.controls.listview; 12 13 version (Windows): 14 15 import core.sys.windows.windows; 16 import core.sys.windows.commctrl; 17 18 import deft.controls.control; 19 import deft.events; 20 import deft.util.strings; 21 import deft.widget; 22 23 /// Horizontal alignment for a list-view column's text. 24 enum ColumnAlign 25 { 26 /// Left-aligned (`LVCFMT_LEFT`). 27 left, 28 /// Center-aligned (`LVCFMT_CENTER`). 29 center, 30 /// Right-aligned (`LVCFMT_RIGHT`). 31 right, 32 } 33 34 /** 35 * How a column should auto-size itself to fit, for `ListView.autoSizeColumn`. 36 * 37 * This is the analog of WinForms' negative column-width sentinels: `content` 38 * matches `-1` (fit the data) and `header` matches `-2` (fit the header text). 39 */ 40 enum ColumnAutoSize 41 { 42 /// Fit the widest cell in the column (`LVSCW_AUTOSIZE`; WinForms `-1`). 43 content, 44 /** 45 * Fit the header text (`LVSCW_AUTOSIZE_USEHEADER`; WinForms `-2`). Applied to 46 * the *last* column the native control instead stretches it to fill the list's 47 * remaining width — the usual way to make a final column absorb the slack. 48 */ 49 header, 50 } 51 52 /// Map a `ColumnAlign` to its `LVCFMT_*` flag. 53 private int columnAlignFmt(ColumnAlign align_) @safe pure nothrow @nogc 54 { 55 final switch (align_) 56 { 57 case ColumnAlign.left: 58 return LVCFMT_LEFT; 59 case ColumnAlign.center: 60 return LVCFMT_CENTER; 61 case ColumnAlign.right: 62 return LVCFMT_RIGHT; 63 } 64 } 65 66 /// A native list view in report (details) mode. 67 class ListView : Control 68 { 69 private int columnCount_; 70 71 /// Keeps GC-allocated item data reachable; see `setItemData`. 72 private void*[] retainedData_; 73 74 /// Fired when the selected row changes; argument is the new selected index. 75 Event!(int) onSelectionChanged; 76 /// Fired when a row is activated (double-click or Enter); argument is the row index. 77 Event!(int) onItemActivated; 78 /** 79 * Fired when a context menu is requested, carrying the relevant row index 80 * (-1 if none) and the screen position to show the menu at. Raised both by a 81 * mouse right-click (row under the cursor) and by the keyboard — the Apps key 82 * or Shift+F10 — in which case the row is the selected one and the position is 83 * anchored to it. The screen coordinates can be passed to `showPopupMenu`. 84 */ 85 Event!(int, MouseEventArgs) onContextMenu; 86 87 /** 88 * Create a report-mode list view as a child of `parent`. 89 * 90 * The control is single-select, always shows the selection, takes part in tab 91 * navigation and has a border. Full-row selection and grid lines are enabled. 92 */ 93 this(Widget parent) 94 { 95 super(parent, "SysListView32", 96 LVS_REPORT | LVS_SINGLESEL | LVS_SHOWSELALWAYS | WS_TABSTOP | WS_BORDER); 97 98 SendMessageW(handle, LVM_SETEXTENDEDLISTVIEWSTYLE, 0, 99 LVS_EX_FULLROWSELECT | LVS_EX_GRIDLINES); 100 101 // Subclass so WM_CONTEXTMENU (mouse right-click and the Apps/Shift+F10 102 // keys) can be turned into onContextMenu — keyboard access is essential. 103 subclass(); 104 } 105 106 /** 107 * Append a column with the given header `title`, pixel `width` and text 108 * alignment. Returns the new column's index. 109 */ 110 int addColumn(string title, int width, ColumnAlign align_ = ColumnAlign.left) 111 { 112 LVCOLUMNW col; 113 col.mask = LVCF_TEXT | LVCF_WIDTH | LVCF_FMT | LVCF_SUBITEM; 114 col.fmt = columnAlignFmt(align_); 115 col.cx = width; 116 col.pszText = cast(LPWSTR) title.toWStringz; 117 col.iSubItem = columnCount_; 118 119 SendMessageW(handle, LVM_INSERTCOLUMNW, columnCount_, cast(LPARAM)&col); 120 return columnCount_++; 121 } 122 123 /// Change the header text of column `col` (e.g. when the UI language changes). 124 void setColumnTitle(int col, string title) 125 { 126 LVCOLUMNW c; 127 c.mask = LVCF_TEXT; 128 c.pszText = cast(LPWSTR) title.toWStringz; 129 SendMessageW(handle, LVM_SETCOLUMNW, col, cast(LPARAM)&c); 130 } 131 132 /// Set the pixel width of column `col`. For autosizing, use `autoSizeColumn`. 133 void setColumnWidth(int col, int width) 134 { 135 SendMessageW(handle, LVM_SETCOLUMNWIDTH, col, width); 136 } 137 138 /** 139 * Size column `col` to fit its content (`ColumnAutoSize.content`) or its header 140 * text (`ColumnAutoSize.header`) — the equivalents of WinForms' `-1` and `-2` 141 * column widths. 142 * 143 * This is a *one-shot* measurement of the column's current contents, not a 144 * persistent mode: call it **after** the rows are populated, and again if the 145 * data changes. As a special case, `ColumnAutoSize.header` on the last column 146 * stretches that column to fill the list's remaining width, so a common recipe 147 * is fixed/auto widths for the leading columns and `header` on the last. 148 */ 149 void autoSizeColumn(int col, ColumnAutoSize mode = ColumnAutoSize.content) 150 { 151 immutable int sentinel = mode == ColumnAutoSize.header 152 ? LVSCW_AUTOSIZE_USEHEADER : LVSCW_AUTOSIZE; 153 SendMessageW(handle, LVM_SETCOLUMNWIDTH, col, sentinel); 154 } 155 156 /// Get the pixel width of column `col`. 157 int getColumnWidth(int col) 158 { 159 return cast(int) SendMessageW(handle, LVM_GETCOLUMNWIDTH, col, 0); 160 } 161 162 /** 163 * Append a row. `cells[0]` is the main item text; `cells[1 .. $]` fill the 164 * subsequent columns. Returns the new row index, or -1 if `cells` is empty. 165 */ 166 int addItem(string[] cells) 167 { 168 if (cells.length == 0) 169 return -1; 170 171 LVITEMW item; 172 item.mask = LVIF_TEXT; 173 item.iItem = getItemCount(); 174 item.iSubItem = 0; 175 item.pszText = cast(LPWSTR) cells[0].toWStringz; 176 177 int row = cast(int) SendMessageW(handle, LVM_INSERTITEMW, 0, cast(LPARAM)&item); 178 179 foreach (c; 1 .. cells.length) 180 { 181 LVITEMW sub; 182 sub.iSubItem = cast(int) c; 183 sub.pszText = cast(LPWSTR) cells[c].toWStringz; 184 SendMessageW(handle, LVM_SETITEMTEXTW, row, cast(LPARAM)&sub); 185 } 186 187 return row; 188 } 189 190 /// Remove all rows (and release any retained item data; see `setItemData`). 191 void clear() 192 { 193 SendMessageW(handle, LVM_DELETEALLITEMS, 0, 0); 194 retainedData_ = null; 195 } 196 197 /// Return the number of rows. 198 int getItemCount() 199 { 200 return cast(int) SendMessageW(handle, LVM_GETITEMCOUNT, 0, 0); 201 } 202 203 /// Return the index of the selected row, or -1 if nothing is selected. 204 int getSelectedIndex() 205 { 206 return cast(int) SendMessageW(handle, LVM_GETNEXTITEM, cast(WPARAM)-1, LVNI_SELECTED); 207 } 208 209 /// Select (and focus) the row at `index`. 210 void setSelectedIndex(int index) 211 { 212 LVITEMW item; 213 item.state = LVIS_SELECTED | LVIS_FOCUSED; 214 item.stateMask = LVIS_SELECTED | LVIS_FOCUSED; 215 SendMessageW(handle, LVM_SETITEMSTATE, index, cast(LPARAM)&item); 216 } 217 218 /// Scroll the row at `index` into view. 219 void ensureVisible(int index) 220 { 221 SendMessageW(handle, LVM_ENSUREVISIBLE, index, FALSE); 222 } 223 224 /// Return the text of the cell at the given `row` and `col`. 225 string getItemText(int row, int col) 226 { 227 // LVM_GETITEMTEXTW returns the number of characters copied; a result that 228 // fills the buffer (cap-1) may have been truncated, so grow and retry. 229 for (int cap = 256;; cap *= 2) 230 { 231 auto buf = new wchar[cap]; 232 LVITEMW item; 233 item.iSubItem = col; 234 item.pszText = buf.ptr; 235 item.cchTextMax = cap; 236 int got = cast(int) SendMessageW(handle, LVM_GETITEMTEXTW, row, 237 cast(LPARAM)&item); 238 if (got < cap - 1 || cap >= 1 << 16) 239 return fromWString(buf[0 .. got]); 240 } 241 } 242 243 /** 244 * Associate an opaque user pointer with the row at `index`. 245 * 246 * The pointer is stored inside the native control, where the D garbage 247 * collector cannot see it. To keep GC-allocated `data` from being collected 248 * out from under the control, Deft also retains a reference internally for the 249 * control's lifetime; the retained references are released by `clear()`. (The 250 * native control owns the canonical copy returned by `getItemData`.) 251 */ 252 void setItemData(int index, void* data) 253 { 254 if (data !is null) 255 retainedData_ ~= data; 256 LVITEMW item; 257 item.mask = LVIF_PARAM; 258 item.iItem = index; 259 item.lParam = cast(LPARAM) data; 260 SendMessageW(handle, LVM_SETITEM, 0, cast(LPARAM)&item); 261 } 262 263 /// Retrieve the user pointer associated with the row at `index`. 264 void* getItemData(int index) 265 { 266 LVITEMW item; 267 item.mask = LVIF_PARAM; 268 item.iItem = index; 269 SendMessageW(handle, LVM_GETITEM, 0, cast(LPARAM)&item); 270 return cast(void*) item.lParam; 271 } 272 273 /// Set the left-to-right display order of the columns. 274 void setColumnsOrder(int[] order) 275 { 276 SendMessageW(handle, LVM_SETCOLUMNORDERARRAY, order.length, cast(LPARAM) order.ptr); 277 } 278 279 /// Get the current left-to-right display order of the columns. 280 int[] getColumnsOrder() 281 { 282 auto arr = new int[columnCount_]; 283 SendMessageW(handle, LVM_GETCOLUMNORDERARRAY, columnCount_, cast(LPARAM) arr.ptr); 284 return arr; 285 } 286 287 /// Route list-view selection and activation notifications to their events. 288 override bool processNotify(NMHDR* header) 289 { 290 switch (header.code) 291 { 292 case LVN_ITEMCHANGED: 293 auto nm = cast(NMLISTVIEW*) header; 294 if ((nm.uChanged & LVIF_STATE) 295 && (nm.uNewState & LVIS_SELECTED) 296 && !(nm.uOldState & LVIS_SELECTED)) 297 onSelectionChanged.fire(nm.iItem); 298 return true; 299 300 case LVN_ITEMACTIVATE: 301 auto nm = cast(NMITEMACTIVATE*) header; 302 onItemActivated.fire(nm.iItem); 303 return true; 304 305 default: 306 return false; 307 } 308 } 309 310 /** 311 * Turn `WM_CONTEXTMENU` into `onContextMenu`. The message is raised by a 312 * right-click and by the keyboard (Apps key / Shift+F10); the latter arrives 313 * with a position of `(-1, -1)`, in which case the menu is anchored at the 314 * selected row so a keyboard user gets the menu where focus is. 315 */ 316 override bool processSubclassed(UINT msg, WPARAM wParam, LPARAM lParam, 317 ref LRESULT result) 318 { 319 // On keyboard focus with nothing selected, select the first row so a 320 // screen reader announces an item instead of silence. 321 if (msg == WM_SETFOCUS) 322 { 323 if (getSelectedIndex() < 0 && getItemCount() > 0) 324 setSelectedIndex(0); 325 return false; 326 } 327 328 if (msg != WM_CONTEXTMENU) 329 return false; 330 331 immutable short sx = cast(short)(lParam & 0xFFFF); 332 immutable short sy = cast(short)((lParam >> 16) & 0xFFFF); 333 334 int index; 335 int x, y; 336 if (sx == -1 && sy == -1) 337 { 338 // Keyboard: anchor at the selected row (or the control's corner). 339 index = getSelectedIndex(); 340 POINT pt; 341 RECT rc; 342 rc.left = LVIR_LABEL; 343 if (index >= 0 344 && SendMessageW(handle, LVM_GETITEMRECT, index, cast(LPARAM)&rc)) 345 { 346 pt.x = rc.left; 347 pt.y = rc.bottom; 348 } 349 ClientToScreen(handle, &pt); 350 x = pt.x; 351 y = pt.y; 352 } 353 else 354 { 355 // Mouse: hit-test the click point to find the row under the cursor. 356 x = sx; 357 y = sy; 358 POINT pt = POINT(sx, sy); 359 ScreenToClient(handle, &pt); 360 LVHITTESTINFO ht; 361 ht.pt = pt; 362 index = cast(int) SendMessageW(handle, LVM_HITTEST, 0, cast(LPARAM)&ht); 363 } 364 365 onContextMenu.fire(index, MouseEventArgs(x, y, MouseButton.right)); 366 result = 0; 367 return true; 368 } 369 370 /// A sensible default size for a report-mode list. 371 override Size getPreferredSize() 372 { 373 return Size(300, 200); 374 } 375 }