Web Inspector: Network's columns shake when scrolling at non-default zoom level
[WebKit-https.git] / Source / WebInspectorUI / UserInterface / Views / Table.js
1 /*
2  * Copyright (C) 2008-2017 Apple Inc. All Rights Reserved.
3  *
4  * Redistribution and use in source and binary forms, with or without
5  * modification, are permitted provided that the following conditions
6  * are met:
7  * 1. Redistributions of source code must retain the above copyright
8  *    notice, this list of conditions and the following disclaimer.
9  * 2. Redistributions in binary form must reproduce the above copyright
10  *    notice, this list of conditions and the following disclaimer in the
11  *    documentation and/or other materials provided with the distribution.
12  *
13  * THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS''
14  * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
15  * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
16  * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS
17  * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
18  * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
19  * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
20  * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
21  * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
22  * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
23  * THE POSSIBILITY OF SUCH DAMAGE.
24  */
25
26 WI.Table = class Table extends WI.View
27 {
28     constructor(identifier, dataSource, delegate, rowHeight)
29     {
30         super();
31
32         console.assert(typeof identifier === "string");
33         console.assert(dataSource);
34         console.assert(delegate);
35         console.assert(rowHeight > 0);
36
37         this._identifier = identifier;
38         this._dataSource = dataSource;
39         this._delegate = delegate;
40         this._rowHeight = rowHeight;
41
42         // FIXME: Should be able to horizontally scroll non-locked table contents.
43         // To do this smoothly (without tearing) will require synchronous scroll events, or
44         // synchronized scrolling between multiple elements, or making `position: sticky`
45         // respect different vertical / horizontal scroll containers.
46
47         this.element.classList.add("table", identifier);
48         this.element.tabIndex = 0;
49         this.element.addEventListener("keydown", this._handleKeyDown.bind(this));
50
51         this._headerElement = this.element.appendChild(document.createElement("div"));
52         this._headerElement.className = "header";
53
54         let scrollHandler = this._handleScroll.bind(this);
55         this._scrollContainerElement = this.element.appendChild(document.createElement("div"));
56         this._scrollContainerElement.className = "data-container";
57         this._scrollContainerElement.addEventListener("scroll", scrollHandler);
58         this._scrollContainerElement.addEventListener("mousewheel", scrollHandler);
59         if (this._delegate.tableCellMouseDown)
60             this._scrollContainerElement.addEventListener("mousedown", this._handleMouseDown.bind(this));
61         if (this._delegate.tableCellContextMenuClicked)
62             this._scrollContainerElement.addEventListener("contextmenu", this._handleContextMenu.bind(this));
63
64         this._topSpacerElement = this._scrollContainerElement.appendChild(document.createElement("div"));
65         this._topSpacerElement.className = "spacer";
66
67         this._listElement = this._scrollContainerElement.appendChild(document.createElement("ul"));
68         this._listElement.className = "data-list";
69
70         this._bottomSpacerElement = this._scrollContainerElement.appendChild(document.createElement("div"));
71         this._bottomSpacerElement.className = "spacer";
72
73         this._fillerRow = this._listElement.appendChild(document.createElement("li"));
74         this._fillerRow.className = "filler";
75
76         this._resizersElement = this._element.appendChild(document.createElement("div"));
77         this._resizersElement.className = "resizers";
78
79         this._cachedRows = new Map;
80
81         this._columnSpecs = new Map;
82         this._columnOrder = [];
83         this._visibleColumns = [];
84         this._hiddenColumns = [];
85
86         this._widthGeneration = 1;
87         this._columnWidths = null; // Calculated in _resizeColumnsAndFiller.
88         this._fillerHeight = 0; // Calculated in _resizeColumnsAndFiller.
89
90         this._selectedRowIndex = NaN;
91
92         this._resizers = [];
93         this._currentResizer = null;
94         this._resizeLeftColumns = null;
95         this._resizeRightColumns = null;
96         this._resizeOriginalColumnWidths = null;
97         this._lastColumnIndexToAcceptRemainderPixel = 0;
98
99         this._sortOrderSetting = new WI.Setting(this._identifier + "-sort-order", WI.Table.SortOrder.Indeterminate);
100         this._sortColumnIdentifierSetting = new WI.Setting(this._identifier + "-sort", null);
101         this._columnVisibilitySetting = new WI.Setting(this._identifier + "-column-visibility", {});
102
103         this._sortOrder = this._sortOrderSetting.value;
104         this._sortColumnIdentifier = this._sortColumnIdentifierSetting.value;
105
106         this._cachedWidth = NaN;
107         this._cachedHeight = NaN;
108         this._cachedScrollTop = NaN;
109         this._cachedScrollableHeight = NaN;
110         this._previousRevealedRowCount = NaN;
111         this._topSpacerHeight = NaN;
112         this._bottomSpacerHeight = NaN;
113         this._visibleRowIndexStart = NaN;
114         this._visibleRowIndexEnd = NaN;
115
116         console.assert(this._delegate.tablePopulateCell, "Table delegate must implement tablePopulateCell.");
117     }
118
119     // Public
120
121     get identifier() { return this._identifier; }
122     get dataSource() { return this._dataSource; }
123     get delegate() { return this._delegate; }
124     get rowHeight() { return this._rowHeight; }
125     get selectedRow() { return this._selectedRowIndex; }
126     get scrollContainer() { return this._scrollContainerElement; }
127
128     get sortOrder()
129     {
130         return this._sortOrder;
131     }
132
133     set sortOrder(sortOrder)
134     {
135         if (sortOrder === this._sortOrder && this.didInitialLayout)
136             return;
137
138         console.assert(sortOrder === WI.Table.SortOrder.Indeterminate || sortOrder === WI.Table.SortOrder.Ascending || sortOrder === WI.Table.SortOrder.Descending);
139
140         this._sortOrder = sortOrder;
141         this._sortOrderSetting.value = sortOrder;
142
143         if (this._sortColumnIdentifier) {
144             let column = this._columnSpecs.get(this._sortColumnIdentifier);
145             let columnIndex = this._visibleColumns.indexOf(column);
146             if (columnIndex !== -1) {
147                 let headerCell = this._headerElement.children[columnIndex];
148                 headerCell.classList.toggle("sort-ascending", this._sortOrder === WI.Table.SortOrder.Ascending);
149                 headerCell.classList.toggle("sort-descending", this._sortOrder === WI.Table.SortOrder.Descending);
150             }
151
152             if (this._dataSource.tableSortChanged)
153                 this._dataSource.tableSortChanged(this);
154         }
155     }
156
157     get sortColumnIdentifier()
158     {
159         return this._sortColumnIdentifier;
160     }
161
162     set sortColumnIdentifier(columnIdentifier)
163     {
164         if (columnIdentifier === this._sortColumnIdentifier && this.didInitialLayout)
165             return;
166
167         let column = this._columnSpecs.get(columnIdentifier);
168
169         console.assert(column, "Column not found.", columnIdentifier);
170         if (!column)
171             return;
172
173         console.assert(column.sortable, "Column is not sortable.", columnIdentifier);
174         if (!column.sortable)
175             return;
176
177         let oldSortColumnIdentifier = this._sortColumnIdentifier;
178         this._sortColumnIdentifier = columnIdentifier;
179         this._sortColumnIdentifierSetting.value = columnIdentifier;
180
181         if (oldSortColumnIdentifier) {
182             let oldColumn = this._columnSpecs.get(oldSortColumnIdentifier);
183             let oldColumnIndex = this._visibleColumns.indexOf(oldColumn);
184             if (oldColumnIndex !== -1) {
185                 let headerCell = this._headerElement.children[oldColumnIndex];
186                 headerCell.classList.remove("sort-ascending", "sort-descending");
187             }
188         }
189
190         if (this._sortColumnIdentifier) {
191             let newColumnIndex = this._visibleColumns.indexOf(column);
192             if (newColumnIndex !== -1) {
193                 let headerCell = this._headerElement.children[newColumnIndex];
194                 headerCell.classList.toggle("sort-ascending", this._sortOrder === WI.Table.SortOrder.Ascending);
195                 headerCell.classList.toggle("sort-descending", this._sortOrder === WI.Table.SortOrder.Descending);
196             } else
197                 this._sortColumnIdentifier = null;
198         }
199
200         if (this._dataSource.tableSortChanged)
201             this._dataSource.tableSortChanged(this);
202     }
203
204     resize()
205     {
206         this._cachedWidth = NaN;
207         this._cachedHeight = NaN;
208
209         this._resizeColumnsAndFiller();
210     }
211
212     reloadData()
213     {
214         this._cachedRows.clear();
215
216         this._previousRevealedRowCount = NaN;
217         this.needsLayout();
218     }
219
220     reloadDataAddedToEndOnly()
221     {
222         this._previousRevealedRowCount = NaN;
223         this.needsLayout();
224     }
225
226     reloadRow(rowIndex)
227     {
228         // Visible row, repopulate the cell.
229         if (this._isRowVisible(rowIndex)) {
230             let row = this._cachedRows.get(rowIndex);
231             if (!row)
232                 return;
233             this._populateRow(row);
234             return;
235         }
236
237         // Non-visible row, will populate when it becomes visible.
238         this._cachedRows.delete(rowIndex);
239     }
240
241     reloadVisibleColumnCells(column)
242     {
243         let columnIndex = this._visibleColumns.indexOf(column);
244         if (columnIndex === -1)
245             return;
246
247         for (let rowIndex = this._visibleRowIndexStart; rowIndex < this._visibleRowIndexEnd; ++rowIndex) {
248             let row = this._cachedRows.get(rowIndex);
249             if (!row)
250                 continue;
251             let cell = row.children[columnIndex];
252             if (!cell)
253                 continue;
254             this._delegate.tablePopulateCell(this, cell, column, rowIndex);
255         }
256     }
257
258     reloadCell(rowIndex, columnIdentifier)
259     {
260         let column = this._columnSpecs.get(columnIdentifier);
261         let columnIndex = this._visibleColumns.indexOf(column);
262         if (columnIndex === -1)
263             return;
264
265         // Visible row, repopulate the cell.
266         if (this._isRowVisible(rowIndex)) {
267             let row = this._cachedRows.get(rowIndex);
268             if (!row)
269                 return;
270             let cell = row.children[columnIndex];
271             if (!cell)
272                 return;
273             this._delegate.tablePopulateCell(this, cell, column, rowIndex);
274             return;
275         }
276
277         // Non-visible row, will populate when it becomes visible.
278         this._cachedRows.delete(rowIndex);
279     }
280
281     selectRow(rowIndex)
282     {
283         if (this._selectedRowIndex === rowIndex)
284             return;
285
286         let oldSelectedRow = this._cachedRows.get(this._selectedRowIndex);
287         if (oldSelectedRow)
288             oldSelectedRow.classList.remove("selected");
289
290         this._selectedRowIndex = rowIndex;
291
292         let newSelectedRow = this._cachedRows.get(this._selectedRowIndex);
293         if (newSelectedRow)
294             newSelectedRow.classList.add("selected");
295
296         if (this._delegate.tableSelectedRowChanged)
297             this._delegate.tableSelectedRowChanged(this, this._selectedRowIndex);
298     }
299
300     clearSelectedRow()
301     {
302         if (isNaN(this._selectedRowIndex))
303             return;
304
305         let oldSelectedRow = this._cachedRows.get(this._selectedRowIndex);
306         if (oldSelectedRow)
307             oldSelectedRow.classList.remove("selected");
308
309         this._selectedRowIndex = NaN;
310     }
311
312     columnWithIdentifier(identifier)
313     {
314         return this._columnSpecs.get(identifier);
315     }
316
317     cellForRowAndColumn(rowIndex, column)
318     {
319         if (!this._isRowVisible(rowIndex))
320             return null;
321
322         let row = this._cachedRows.get(rowIndex);
323         if (!row)
324             return null;
325
326         let columnIndex = this._visibleColumns.indexOf(column);
327         if (columnIndex === -1)
328             return null;
329
330         return row.children[columnIndex];
331     }
332
333     addColumn(column)
334     {
335         this._columnSpecs.set(column.identifier, column);
336         this._columnOrder.push(column.identifier);
337
338         if (column.hidden) {
339             this._hiddenColumns.push(column);
340             column.width = NaN;
341         } else {
342             this._visibleColumns.push(column);
343             this._headerElement.appendChild(this._createHeaderCell(column));
344             this._fillerRow.appendChild(this._createFillerCell(column));
345             if (column.headerView)
346                 this.addSubview(column.headerView);
347         }
348
349         // Restore saved user-specified column visibility.
350         let savedColumnVisibility = this._columnVisibilitySetting.value;
351         if (column.identifier in savedColumnVisibility) {
352             let visible = savedColumnVisibility[column.identifier];
353             if (visible)
354                 this.showColumn(column);
355             else
356                 this.hideColumn(column);
357         }
358
359         this.reloadData();
360     }
361
362     showColumn(column)
363     {
364         console.assert(this._columnSpecs.get(column.identifier) === column, "Column not in this table.");
365         console.assert(!column.locked, "Locked columns should always be shown.");
366         if (column.locked)
367             return;
368
369         if (!column.hidden)
370             return;
371
372         column.hidden = false;
373
374         let columnIndex = this._hiddenColumns.indexOf(column);
375         this._hiddenColumns.splice(columnIndex, 1);
376
377         let newColumnIndex = this._indexToInsertColumn(column);
378         this._visibleColumns.insertAtIndex(column, newColumnIndex);
379
380         // Save user preference for this column to be visible.
381         let savedColumnVisibility = this._columnVisibilitySetting.value;
382         if (savedColumnVisibility[column.identifier] !== true) {
383             let copy = Object.shallowCopy(savedColumnVisibility);
384             if (column.defaultHidden)
385                 copy[column.identifier] = true;
386             else
387                 delete copy[column.identifier];
388             this._columnVisibilitySetting.value = copy;
389         }
390
391         this._headerElement.insertBefore(this._createHeaderCell(column), this._headerElement.children[newColumnIndex]);
392         this._fillerRow.insertBefore(this._createFillerCell(column), this._fillerRow.children[newColumnIndex]);
393
394         if (column.headerView)
395             this.addSubview(column.headerView);
396
397         // We haven't yet done any layout, nothing to do.
398         if (!this._columnWidths)
399             return;
400
401         // To avoid recreating all the cells in the row we create empty cells,
402         // size them, and then populate them. We always populate a cell after
403         // it has been sized.
404         let cellsToPopulate = [];
405         for (let row of this._listElement.children) {
406             if (row !== this._fillerRow) {
407                 let unpopulatedCell = this._createCell(column, newColumnIndex);
408                 cellsToPopulate.push(unpopulatedCell);
409                 row.insertBefore(unpopulatedCell, row.children[newColumnIndex]);
410             }
411         }
412
413         // Re-layout all columns to make space.
414         this._columnWidths = null;
415         this.resize();
416
417         // Now populate only the new cells for this column.
418         for (let cell of cellsToPopulate)
419             this._delegate.tablePopulateCell(this, cell, column, cell.parentElement.__index);
420     }
421
422     hideColumn(column)
423     {
424         console.assert(this._columnSpecs.get(column.identifier) === column, "Column not in this table.");
425         console.assert(!column.locked, "Locked columns should always be shown.");
426         if (column.locked)
427             return;
428
429         console.assert(column.hideable, "Column is not hideable so should always be shown.");
430         if (!column.hideable)
431             return;
432
433         if (column.hidden)
434             return;
435
436         column.hidden = true;
437
438         this._hiddenColumns.push(column);
439
440         let columnIndex = this._visibleColumns.indexOf(column);
441         this._visibleColumns.splice(columnIndex, 1);
442
443         // Save user preference for this column to be hidden.
444         let savedColumnVisibility = this._columnVisibilitySetting.value;
445         if (savedColumnVisibility[column.identifier] !== false) {
446             let copy = Object.shallowCopy(savedColumnVisibility);
447             if (column.defaultHidden)
448                 delete copy[column.identifier];
449             else
450                 copy[column.identifier] = false;
451             this._columnVisibilitySetting.value = copy;
452         }
453
454         this._headerElement.removeChild(this._headerElement.children[columnIndex]);
455         this._fillerRow.removeChild(this._fillerRow.children[columnIndex]);
456
457         if (column.headerView)
458             this.removeSubview(column.headerView);
459
460         // We haven't yet done any layout, nothing to do.
461         if (!this._columnWidths)
462             return;
463
464         this._columnWidths.splice(columnIndex, 1);
465
466         for (let row of this._listElement.children) {
467             if (row !== this._fillerRow)
468                 row.removeChild(row.children[columnIndex]);
469         }
470
471         this.needsLayout();
472     }
473
474     restoreScrollPosition()
475     {
476         if (this._cachedScrollTop && !this._scrollContainerElement.scrollTop)
477             this._scrollContainerElement.scrollTop = this._cachedScrollTop;
478     }
479
480     // Protected
481
482     initialLayout()
483     {
484         this.sortOrder = this._sortOrderSetting.value;
485
486         let restoreSortColumnIdentifier = this._sortColumnIdentifierSetting.value;
487         if (!this._columnSpecs.has(restoreSortColumnIdentifier))
488             this._sortColumnIdentifierSetting.value = null;
489         else
490             this.sortColumnIdentifier = restoreSortColumnIdentifier;
491     }
492
493     layout()
494     {
495         this._updateVisibleRows();
496
497         if (this.layoutReason === WI.View.LayoutReason.Resize)
498             this.resize();
499         else
500             this._resizeColumnsAndFiller();
501     }
502
503     // Resizer delegate
504
505     resizerDragStarted(resizer)
506     {
507         console.assert(!this._currentResizer, resizer, this._currentResizer);
508
509         let resizerIndex = this._resizers.indexOf(resizer);
510
511         this._currentResizer = resizer;
512         this._resizeLeftColumns = this._visibleColumns.slice(0, resizerIndex + 1).reverse(); // Reversed to simplify iteration.
513         this._resizeRightColumns = this._visibleColumns.slice(resizerIndex + 1);
514         this._resizeOriginalColumnWidths = [].concat(this._columnWidths);
515     }
516
517     resizerDragging(resizer, positionDelta)
518     {
519         console.assert(resizer === this._currentResizer, resizer, this._currentResizer);
520         if (resizer !== this._currentResizer)
521             return;
522
523         if (WI.resolvedLayoutDirection() === WI.LayoutDirection.RTL)
524             positionDelta = -positionDelta;
525
526         // Completely recalculate columns from the original sizes based on the new mouse position.
527         this._columnWidths = [].concat(this._resizeOriginalColumnWidths);
528
529         if (!positionDelta) {
530             this._applyColumnWidths();
531             return;
532         }
533
534         let delta = Math.abs(positionDelta);
535         let leftDirection = positionDelta > 0;
536         let rightDirection = !leftDirection;
537
538         let columnWidths = this._columnWidths;
539         let visibleColumns = this._visibleColumns;
540
541         function growableSize(column) {
542             let width = columnWidths[visibleColumns.indexOf(column)];
543             if (column.maxWidth)
544                 return column.maxWidth - width;
545             return Infinity;
546         }
547
548         function shrinkableSize(column) {
549             let width = columnWidths[visibleColumns.indexOf(column)];
550             if (column.minWidth)
551                 return width - column.minWidth;
552             return width;
553         }
554
555         function canGrow(column) {
556             return growableSize(column) > 0;
557         }
558
559         function canShrink(column) {
560             return shrinkableSize(column) > 0;
561         }
562
563         function columnToResize(columns, isShrinking) {
564             // First find a flexible column we can resize.
565             for (let column of columns) {
566                 if (!column.flexible)
567                     continue;
568                 if (isShrinking ? canShrink(column) : canGrow(column))
569                     return column;
570             }
571
572             // Failing that see if we can resize the immediately neighbor.
573             let immediateColumn = columns[0];
574             if ((isShrinking && canShrink(immediateColumn)) || (!isShrinking && canGrow(immediateColumn)))
575                 return immediateColumn;
576
577             // Bail. There isn't anything obvious in the table that can resize.
578             return null;
579         }
580
581         while (delta > 0) {
582             let leftColumn = columnToResize(this._resizeLeftColumns, leftDirection);
583             let rightColumn = columnToResize(this._resizeRightColumns, rightDirection);
584             if (!leftColumn || !rightColumn) {
585                 // No more left or right column to grow or shrink.
586                 break;
587             }
588
589             let incrementalDelta = Math.min(delta,
590                 leftDirection ? shrinkableSize(leftColumn) : shrinkableSize(rightColumn),
591                 leftDirection ? growableSize(rightColumn) : growableSize(leftColumn));
592
593             let leftIndex = this._visibleColumns.indexOf(leftColumn);
594             let rightIndex = this._visibleColumns.indexOf(rightColumn);
595
596             if (leftDirection) {
597                 this._columnWidths[leftIndex] -= incrementalDelta;
598                 this._columnWidths[rightIndex] += incrementalDelta;
599             } else {
600                 this._columnWidths[leftIndex] += incrementalDelta;
601                 this._columnWidths[rightIndex] -= incrementalDelta;
602             }
603
604             delta -= incrementalDelta;
605         }
606
607         // We have new column widths.
608         this._widthGeneration++;
609
610         this._applyColumnWidths();
611         this._positionHeaderViews();
612     }
613
614     resizerDragEnded(resizer)
615     {
616         console.assert(resizer === this._currentResizer, resizer, this._currentResizer);
617         if (resizer !== this._currentResizer)
618             return;
619
620         this._currentResizer = null;
621         this._resizeLeftColumns = null;
622         this._resizeRightColumns = null;
623         this._resizeOriginalColumnWidths = null;
624
625         this._positionResizerElements();
626         this._positionHeaderViews();
627     }
628
629     // Private
630
631     _createHeaderCell(column)
632     {
633         let cell = document.createElement("span");
634         cell.classList.add("cell", column.identifier);
635         cell.textContent = column.name;
636
637         if (column.align)
638             cell.classList.add("align-" + column.align);
639         if (column.sortable) {
640             cell.classList.add("sortable");
641             cell.addEventListener("click", this._handleHeaderCellClicked.bind(this, column));
642         }
643
644         cell.addEventListener("contextmenu", this._handleHeaderContextMenu.bind(this, column));
645
646         return cell;
647     }
648
649     _createFillerCell(column)
650     {
651         let cell = document.createElement("span");
652         cell.classList.add("cell", column.identifier);
653         return cell;
654     }
655
656     _createCell(column, columnIndex)
657     {
658         let cell = document.createElement("span");
659         cell.classList.add("cell", column.identifier);
660         if (column.align)
661             cell.classList.add("align-" + column.align);
662         if (this._columnWidths)
663             cell.style.width = this._columnWidths[columnIndex] + "px";
664         return cell;
665     }
666
667     _getOrCreateRow(rowIndex)
668     {
669         let cachedRow = this._cachedRows.get(rowIndex);
670         if (cachedRow)
671             return cachedRow;
672
673         let row = document.createElement("li");
674         row.__index = rowIndex;
675         row.__widthGeneration = 0;
676         if (rowIndex === this._selectedRowIndex)
677             row.classList.add("selected");
678
679         this._cachedRows.set(rowIndex, row);
680         return row;
681     }
682
683     _populatedCellForColumnAndRow(column, columnIndex, rowIndex)
684     {
685         console.assert(rowIndex !== undefined, "Tried to populate a row that did not know its index. Is this the filler row?");
686
687         let cell = this._createCell(column, columnIndex);
688         this._delegate.tablePopulateCell(this, cell, column, rowIndex);
689         return cell;
690     }
691
692     _populateRow(row)
693     {
694         row.removeChildren();
695
696         let rowIndex = row.__index;
697         for (let i = 0; i < this._visibleColumns.length; ++i) {
698             let column = this._visibleColumns[i];
699             let cell = this._populatedCellForColumnAndRow(column, i, rowIndex);
700             row.appendChild(cell);
701         }
702     }
703
704     _resizeColumnsAndFiller()
705     {
706         let oldWidth = this._cachedWidth;
707         let oldHeight = this._cachedHeight;
708         let oldNumberOfRows = this._cachedNumberOfRows;
709
710         if (isNaN(this._cachedWidth)) {
711             let boundingClientRect = this._scrollContainerElement.getBoundingClientRect();
712             this._cachedWidth = Math.floor(boundingClientRect.width);
713             this._cachedHeight = Math.floor(boundingClientRect.height);
714         }
715
716         // Not visible yet.
717         if (!this._cachedWidth)
718             return;
719
720         let availableWidth = this._cachedWidth;
721         let availableHeight = this._cachedHeight;
722
723         let numberOfRows = this._dataSource.tableNumberOfRows(this);
724         this._cachedNumberOfRows = numberOfRows;
725
726         let contentHeight = numberOfRows * this._rowHeight;
727         this._fillerHeight = Math.max(availableHeight - contentHeight, 0);
728
729         // No change to layout metrics so no resizing is needed.
730         if (this._columnWidths && availableWidth === oldWidth && availableWidth === oldHeight && numberOfRows === oldNumberOfRows) {
731             this._updateFillerRowWithNewHeight();
732             this._applyColumnWidthsToColumnsIfNeeded();
733             return;
734         }
735
736         let lockedWidth = 0;
737         let lockedColumnCount = 0;
738         let totalMinimumWidth = 0;
739
740         for (let column of this._visibleColumns) {
741             if (column.locked) {
742                 lockedWidth += column.width;
743                 lockedColumnCount++;
744                 totalMinimumWidth += column.width;
745             } else if (column.minWidth)
746                 totalMinimumWidth += column.minWidth;
747         }
748
749         let flexibleWidth = availableWidth - lockedWidth;
750         let flexibleColumnCount = this._visibleColumns.length - lockedColumnCount;
751
752         // NOTE: We will often distribute pixels evenly across flexible columns in the table.
753         // If `availableWidth < totalMinimumWidth` than the table is too small for the minimum
754         // sizes of all the columns and we will start crunching the table (removing pixels from
755         // all flexible columns). This would be the appropriate time to introduce horizontal
756         // scrolling. For now we just remove pixels evenly.
757         //
758         // When distributing pixels, always start from the last column to accept remainder
759         // pixels so we don't always add from one side / to one column.
760         function distributeRemainingPixels(remainder, shrinking) {
761             // No pixels to distribute.
762             if (!remainder)
763                 return;
764
765             let indexToStartAddingRemainderPixels = (this._lastColumnIndexToAcceptRemainderPixel + 1) % this._visibleColumns.length;
766
767             // Handle tables that are too small or too large. If the size constraints
768             // cause the columns to be too small or large. A second pass will do the
769             // expanding or crunching ignoring constraints.
770             let ignoreConstraints = false;
771
772             while (remainder > 0) {
773                 let initialRemainder = remainder;
774
775                 for (let i = indexToStartAddingRemainderPixels; i < this._columnWidths.length; ++i) {
776                     let column = this._visibleColumns[i];
777                     if (column.locked)
778                         continue;
779
780                     if (shrinking) {
781                         if (ignoreConstraints || (column.minWidth && this._columnWidths[i] > column.minWidth)) {
782                             this._columnWidths[i]--;
783                             remainder--;
784                         }
785                     } else {
786                         if (ignoreConstraints || (column.maxWidth && this._columnWidths[i] < column.maxWidth)) {
787                             this._columnWidths[i]++;
788                             remainder--;
789                         } else if (!column.maxWidth) {
790                             this._columnWidths[i]++;
791                             remainder--;
792                         }
793                     }
794
795                     if (!remainder) {
796                         this._lastColumnIndexToAcceptRemainderPixel = i;
797                         break;
798                     }
799                 }
800
801                 if (remainder === initialRemainder && !indexToStartAddingRemainderPixels) {
802                     // We have remaining pixels. Start crunching if we need to.
803                     if (ignoreConstraints)
804                         break;
805                     ignoreConstraints = true;
806                 }
807
808                 indexToStartAddingRemainderPixels = 0;
809             }
810
811             console.assert(!remainder, "Should not have undistributed pixels.");
812         }
813
814         // Two kinds of layouts. Autosize or Resize.
815         if (!this._columnWidths) {
816             // Autosize: Flex all the flexes evenly and trickle out any remaining pixels.
817             this._columnWidths = [];
818             this._lastColumnIndexToAcceptRemainderPixel = 0;
819
820             let bestFitWidth = 0;
821             let bestFitColumnCount = 0;
822
823             function bestFit(callback) {
824                 while (true) {
825                     let remainingFlexibleColumnCount = flexibleColumnCount - bestFitColumnCount;
826                     if (!remainingFlexibleColumnCount)
827                         return;
828
829                     // Fair size to give each flexible column.
830                     let remainingFlexibleWidth = flexibleWidth - bestFitWidth;
831                     let flexWidth = Math.floor(remainingFlexibleWidth / remainingFlexibleColumnCount);
832
833                     let didPerformBestFit = false;
834                     for (let i = 0; i < this._visibleColumns.length; ++i) {
835                         // Already best fit this column.
836                         if (this._columnWidths[i])
837                             continue;
838
839                         let column = this._visibleColumns[i];
840                         console.assert(column.flexible, "Non-flexible columns should have been sized earlier", column);
841
842                         // Attempt best fit.
843                         let bestWidth = callback(column, flexWidth);
844                         if (bestWidth === -1)
845                             continue;
846
847                         this._columnWidths[i] = bestWidth;
848                         bestFitWidth += bestWidth;
849                         bestFitColumnCount++;
850                         didPerformBestFit = true;
851                     }
852                     if (!didPerformBestFit)
853                         return;
854
855                     // Repeat with a new flex size now that we have fewer flexible columns.
856                 }
857             }
858
859             // Fit the locked columns.
860             for (let i = 0; i < this._visibleColumns.length; ++i) {
861                 let column = this._visibleColumns[i];
862                 if (column.locked)
863                     this._columnWidths[i] = column.width;
864             }
865
866             // Best fit with the preferred initial width for flexible columns.
867             bestFit.call(this, (column, width) => {
868                 if (!column.preferredInitialWidth || width <= column.preferredInitialWidth)
869                     return -1;
870                 return column.preferredInitialWidth;
871             });
872
873             // Best fit max size flexible columns. May make more pixels available for other columns.
874             bestFit.call(this, (column, width) => {
875                 if (!column.maxWidth || width <= column.maxWidth)
876                     return -1;
877                 return column.maxWidth;
878             });
879
880             // Best fit min size flexible columns. May make less pixels available for other columns.
881             bestFit.call(this, (column, width) => {
882                 if (!column.minWidth || width >= column.minWidth)
883                     return -1;
884                 return column.minWidth;
885             });
886
887             // Best fit the remaining flexible columns with the fair remaining size.
888             bestFit.call(this, (column, width) => width);
889
890             // Distribute any remaining pixels evenly.
891             let remainder = availableWidth - (lockedWidth + bestFitWidth);
892             let shrinking = remainder < 0;
893             distributeRemainingPixels.call(this, Math.abs(remainder), shrinking);
894         } else {
895             // Resize: Distribute pixels evenly across flex columns.
896             console.assert(this._columnWidths.length === this._visibleColumns.length, "Number of columns should not change in a resize.");
897
898             let originalTotalColumnWidth = 0;
899             for (let width of this._columnWidths)
900                 originalTotalColumnWidth += width;
901
902             let remainder = Math.abs(availableWidth - originalTotalColumnWidth);
903             let shrinking = availableWidth < originalTotalColumnWidth;
904             distributeRemainingPixels.call(this, remainder, shrinking);
905         }
906
907         // We have new column widths.
908         this._widthGeneration++;
909
910         // Apply widths.
911
912         this._updateFillerRowWithNewHeight();
913         this._applyColumnWidths();
914         this._positionResizerElements();
915         this._positionHeaderViews();
916     }
917
918     _updateVisibleRows()
919     {
920         let rowHeight = this._rowHeight;
921         let updateOffsetThreshold = rowHeight * 10;
922         let overflowPadding = updateOffsetThreshold * 3;
923
924         if (isNaN(this._cachedScrollTop))
925             this._cachedScrollTop = this._scrollContainerElement.scrollTop;
926
927         if (isNaN(this._cachedScrollableHeight) || !this._cachedScrollableHeight)
928             this._cachedScrollableHeight = this._scrollContainerElement.getBoundingClientRect().height;
929
930         let scrollTop = this._cachedScrollTop;
931         let scrollableOffsetHeight = this._cachedScrollableHeight;
932
933         let visibleRowCount = Math.ceil((scrollableOffsetHeight + (overflowPadding * 2)) / rowHeight);
934         let currentTopMargin = this._topSpacerHeight;
935         let currentBottomMargin = this._bottomSpacerHeight;
936         let currentTableBottom = currentTopMargin + (visibleRowCount * rowHeight);
937
938         let belowTopThreshold = !currentTopMargin || scrollTop > currentTopMargin + updateOffsetThreshold;
939         let aboveBottomThreshold = !currentBottomMargin || scrollTop + scrollableOffsetHeight < currentTableBottom - updateOffsetThreshold;
940
941         if (belowTopThreshold && aboveBottomThreshold && !isNaN(this._previousRevealedRowCount))
942             return;
943
944         let numberOfRows = this._dataSource.tableNumberOfRows(this);
945         this._previousRevealedRowCount = numberOfRows;
946
947         // Scroll back up if the number of rows was reduced such that the existing
948         // scroll top value is larger than it could otherwise have been. We only
949         // need to do this adjustment if there are more rows than would fit on screen,
950         // because when the filler row activates it will reset our scroll.
951         if (scrollTop) {
952             let rowsThatCanFitOnScreen = Math.ceil(scrollableOffsetHeight / rowHeight);
953             if (numberOfRows >= rowsThatCanFitOnScreen) {
954                 let maximumScrollTop = Math.max(0, (numberOfRows * rowHeight) - scrollableOffsetHeight);
955                 if (scrollTop > maximumScrollTop) {
956                     this._scrollContainerElement.scrollTop = maximumScrollTop;
957                     this._cachedScrollTop = maximumScrollTop;
958                 }
959             }
960         }
961
962         let topHiddenRowCount = Math.max(0, Math.floor((scrollTop - overflowPadding) / rowHeight));
963         let bottomHiddenRowCount = Math.max(0, this._previousRevealedRowCount - topHiddenRowCount - visibleRowCount);
964
965         let marginTop = topHiddenRowCount * rowHeight;
966         let marginBottom = bottomHiddenRowCount * rowHeight;
967
968         if (this._topSpacerHeight !== marginTop) {
969             this._topSpacerHeight = marginTop;
970             this._topSpacerElement.style.height = marginTop + "px";
971         }
972
973         if (this._bottomDataTableMarginElement !== marginBottom) {
974             this._bottomSpacerHeight = marginBottom;
975             this._bottomSpacerElement.style.height = marginBottom + "px";
976         }
977
978         this._visibleRowIndexStart = topHiddenRowCount;
979         this._visibleRowIndexEnd = this._visibleRowIndexStart + visibleRowCount;
980
981         // Completely remove all rows and add new ones.
982         this._listElement.removeChildren();
983         this._listElement.classList.toggle("odd-first-zebra-stripe", !!(topHiddenRowCount % 2));
984
985         for (let i = this._visibleRowIndexStart; i < this._visibleRowIndexEnd && i < numberOfRows; ++i) {
986             let row = this._getOrCreateRow(i);
987             this._listElement.appendChild(row);
988         }
989
990         this._listElement.appendChild(this._fillerRow);
991     }
992
993     _updateFillerRowWithNewHeight()
994     {
995         if (!this._fillerHeight) {
996             this._scrollContainerElement.classList.remove("not-scrollable");
997             this._fillerRow.remove();
998             return;
999         }
1000
1001         this._scrollContainerElement.classList.add("not-scrollable");
1002
1003         // In the event that we just made the table not scrollable then the number
1004         // of rows can fit on screen. Reset the scroll top.
1005         if (this._cachedScrollTop) {
1006             this._scrollContainerElement.scrollTop = 0;
1007             this._cachedScrollTop = 0;
1008         }
1009
1010         // Extend past edge some reasonable amount. At least 200px.
1011         const paddingPastTheEdge = 200;
1012         this._fillerHeight += paddingPastTheEdge;
1013
1014         for (let cell of this._fillerRow.children)
1015             cell.style.height = this._fillerHeight + "px";
1016
1017         if (!this._fillerRow.parentElement)
1018             this._listElement.appendChild(this._fillerRow);
1019     }
1020
1021     _applyColumnWidths()
1022     {
1023         for (let i = 0; i < this._headerElement.children.length; ++i)
1024             this._headerElement.children[i].style.width = this._columnWidths[i] + "px";
1025
1026         for (let row of this._listElement.children) {
1027             for (let i = 0; i < row.children.length; ++i)
1028                 row.children[i].style.width = this._columnWidths[i] + "px";
1029             row.__widthGeneration = this._widthGeneration;
1030         }
1031
1032         // Update Table Columns after cells since events may respond to this.
1033         for (let i = 0; i < this._visibleColumns.length; ++i)
1034             this._visibleColumns[i].width = this._columnWidths[i];
1035
1036         // Create missing cells after we've sized.
1037         for (let row of this._listElement.children) {
1038             if (row !== this._fillerRow) {
1039                 if (row.children.length !== this._visibleColumns.length)
1040                     this._populateRow(row);
1041             }
1042         }
1043     }
1044
1045     _applyColumnWidthsToColumnsIfNeeded()
1046     {
1047         // Apply and create missing cells only if row needs a width update.
1048         for (let row of this._listElement.children) {
1049             if (row.__widthGeneration !== this._widthGeneration && row !== this._fillerRow) {
1050                 for (let i = 0; i < row.children.length; ++i)
1051                     row.children[i].style.width = this._columnWidths[i] + "px";
1052                 if (row.children.length !== this._visibleColumns.length)
1053                     this._populateRow(row);
1054                 row.__widthGeneration = this._widthGeneration;
1055             }
1056         }
1057     }
1058
1059     _positionResizerElements()
1060     {
1061         console.assert(this._visibleColumns.length === this._columnWidths.length);
1062
1063         // Create the appropriate number of resizers.
1064         let resizersNeededCount = this._visibleColumns.length - 1;
1065         if (this._resizers.length !== resizersNeededCount) {
1066             if (this._resizers.length < resizersNeededCount) {
1067                 do {
1068                     let resizer = new WI.Resizer(WI.Resizer.RuleOrientation.Vertical, this);
1069                     this._resizers.push(resizer);
1070                     this._resizersElement.appendChild(resizer.element);
1071                 } while (this._resizers.length < resizersNeededCount);
1072             } else {
1073                 do {
1074                     let resizer = this._resizers.pop();
1075                     this._resizersElement.removeChild(resizer.element);
1076                 } while (this._resizers.length > resizersNeededCount);
1077             }
1078         }
1079
1080         // Position them.
1081         const columnResizerAdjustment = 3;
1082         let positionAttribute = WI.resolvedLayoutDirection() === WI.LayoutDirection.RTL ? "right" : "left";
1083         let totalWidth = 0;
1084         for (let i = 0; i < resizersNeededCount; ++i) {
1085             totalWidth += this._columnWidths[i];
1086             this._resizers[i].element.style[positionAttribute] = (totalWidth - columnResizerAdjustment) + "px";
1087         }
1088     }
1089
1090     _positionHeaderViews()
1091     {
1092         if (!this.subviews.length)
1093             return;
1094
1095         let offset = 0;
1096         let updates = [];
1097         for (let i = 0; i < this._visibleColumns.length; ++i) {
1098             let column = this._visibleColumns[i];
1099             let width = this._columnWidths[i];
1100             if (column.headerView)
1101                 updates.push({headerView: column.headerView, offset, width});
1102             offset += width;
1103         }
1104
1105         let styleProperty = WI.resolvedLayoutDirection() === WI.LayoutDirection.RTL ? "right" : "left";
1106         for (let {headerView, offset, width} of updates) {
1107             headerView.element.style.setProperty(styleProperty, offset + "px");
1108             headerView.element.style.width = width + "px";
1109             headerView.updateLayout(WI.View.LayoutReason.Resize);
1110         }
1111     }
1112
1113     _isRowVisible(rowIndex)
1114     {
1115         if (!this._previousRevealedRowCount)
1116             return false;
1117
1118         return rowIndex >= this._visibleRowIndexStart && rowIndex <= this._visibleRowIndexEnd;
1119     }
1120
1121     _indexToInsertColumn(column)
1122     {
1123         let currentVisibleColumnIndex = 0;
1124
1125         for (let columnIdentifier of this._columnOrder) {
1126             if (columnIdentifier === column.identifier)
1127                 return currentVisibleColumnIndex;
1128             if (columnIdentifier === this._visibleColumns[currentVisibleColumnIndex].identifier) {
1129                 currentVisibleColumnIndex++;
1130                 if (currentVisibleColumnIndex >= this._visibleColumns.length)
1131                     break;
1132             }
1133         }
1134
1135         return currentVisibleColumnIndex;
1136     }
1137
1138     _handleScroll(event)
1139     {
1140         if (event.type === "mousewheel" && !event.wheelDeltaY)
1141             return;
1142
1143         this._cachedScrollTop = NaN;
1144         this.needsLayout();
1145     }
1146
1147     _handleKeyDown(event)
1148     {
1149         if (!this._isRowVisible(this._selectedRowIndex))
1150             return;
1151
1152         if (event.shiftKey || event.metaKey || event.ctrlKey)
1153             return;
1154
1155         let rowToSelect = NaN;
1156
1157         if (event.keyIdentifier === "Up") {
1158             if (this._selectedRowIndex > 0)
1159                 rowToSelect = this._selectedRowIndex - 1;
1160         } else if (event.keyIdentifier === "Down") {
1161             let numberOfRows = this._dataSource.tableNumberOfRows(this);
1162             if (this._selectedRowIndex < (numberOfRows - 1))
1163                 rowToSelect = this._selectedRowIndex + 1;
1164         }
1165
1166         if (!isNaN(rowToSelect)) {
1167             this.selectRow(rowToSelect);
1168
1169             let row = this._cachedRows.get(this._selectedRowIndex);
1170             console.assert(row, "Moving up or down by one should always find a cached row since it is within the overflow bounds.");
1171             row.scrollIntoViewIfNeeded();
1172
1173             // Force our own scroll update because we may have scrolled.
1174             this._cachedScrollTop = NaN;
1175             this.needsLayout();
1176
1177             event.preventDefault();
1178             event.stopPropagation();
1179         }
1180     }
1181
1182     _handleMouseDown(event)
1183     {
1184         if (event.button !== 0 || event.ctrlKey)
1185             return;
1186
1187         let cell = event.target.enclosingNodeOrSelfWithClass("cell");
1188         if (!cell)
1189             return;
1190
1191         let row = cell.parentElement;
1192         if (row === this._fillerRow)
1193             return;
1194
1195         let columnIndex = Array.from(row.children).indexOf(cell);
1196         let column = this._visibleColumns[columnIndex];
1197         let rowIndex = row.__index;
1198
1199         this._delegate.tableCellMouseDown(this, cell, column, rowIndex, event);
1200     }
1201
1202     _handleContextMenu(event)
1203     {
1204         let cell = event.target.enclosingNodeOrSelfWithClass("cell");
1205         if (!cell)
1206             return;
1207
1208         let row = cell.parentElement;
1209         if (row === this._fillerRow)
1210             return;
1211
1212         let columnIndex = Array.from(row.children).indexOf(cell);
1213         let column = this._visibleColumns[columnIndex];
1214         let rowIndex = row.__index;
1215
1216         this._delegate.tableCellContextMenuClicked(this, cell, column, rowIndex, event);
1217     }
1218
1219     _handleHeaderCellClicked(column, event)
1220     {
1221         let sortOrder = this._sortOrder;
1222         if (sortOrder === WI.Table.SortOrder.Indeterminate)
1223             sortOrder = WI.Table.SortOrder.Descending;
1224         else if (this._sortColumnIdentifier === column.identifier)
1225             sortOrder = sortOrder === WI.Table.SortOrder.Ascending ? WI.Table.SortOrder.Descending : WI.Table.SortOrder.Ascending;
1226
1227         this.sortColumnIdentifier = column.identifier;
1228         this.sortOrder = sortOrder;
1229     }
1230
1231     _handleHeaderContextMenu(column, event)
1232     {
1233         let contextMenu = WI.ContextMenu.createFromEvent(event);
1234
1235         if (column.sortable) {
1236             if (this.sortColumnIdentifier !== column.identifier || this.sortOrder !== WI.Table.SortOrder.Ascending) {
1237                 contextMenu.appendItem(WI.UIString("Sort Ascending"), () => {
1238                     this.sortColumnIdentifier = column.identifier;
1239                     this.sortOrder = WI.Table.SortOrder.Ascending;
1240                 });
1241             }
1242
1243             if (this.sortColumnIdentifier !== column.identifier || this.sortOrder !== WI.Table.SortOrder.Descending) {
1244                 contextMenu.appendItem(WI.UIString("Sort Descending"), () => {
1245                     this.sortColumnIdentifier = column.identifier;
1246                     this.sortOrder = WI.Table.SortOrder.Descending;
1247                 });
1248             }
1249         }
1250
1251         contextMenu.appendSeparator();
1252
1253         const disabled = true;
1254         contextMenu.appendItem(WI.UIString("Displayed Columns"), () => {}, disabled);
1255
1256         for (let [columnIdentifier, column] of this._columnSpecs) {
1257             if (column.locked)
1258                 continue;
1259             if (!column.hideable)
1260                 continue;
1261
1262             let checked = !column.hidden;
1263             contextMenu.appendCheckboxItem(column.name, () => {
1264                 if (column.hidden)
1265                     this.showColumn(column);
1266                 else
1267                     this.hideColumn(column);
1268             }, checked);
1269         }
1270     }
1271 };
1272
1273 WI.Table.SortOrder = {
1274     Indeterminate: "table-sort-order-indeterminate",
1275     Ascending: "table-sort-order-ascending",
1276     Descending: "table-sort-order-descending",
1277 };