diff --git a/EasyTables.xcodeproj/project.pbxproj b/EasyTables.xcodeproj/project.pbxproj index 58b83f1..a50f14d 100644 --- a/EasyTables.xcodeproj/project.pbxproj +++ b/EasyTables.xcodeproj/project.pbxproj @@ -17,6 +17,7 @@ 5415A6BD1F3F2396000FF9BB /* NSTableView+Easy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5415A6BC1F3F2396000FF9BB /* NSTableView+Easy.swift */; }; 542578E81F6B1BD700FE6DA8 /* Cartography.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 542578E61F6B1BD300FE6DA8 /* Cartography.framework */; }; 542578E91F6B1CA500FE6DA8 /* Cartography.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 542578E61F6B1BD300FE6DA8 /* Cartography.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 5450685F203C02A600855939 /* SelectedObjects.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5450685E203C02A600855939 /* SelectedObjects.swift */; }; 54CEC1D01F21D4DE00FA3BC6 /* EasyTables.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 54CEC1C61F21D4DE00FA3BC6 /* EasyTables.framework */; }; 54CEC1D51F21D4DE00FA3BC6 /* EasyTablesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54CEC1D41F21D4DE00FA3BC6 /* EasyTablesTests.swift */; }; 54CEC1D71F21D4DE00FA3BC6 /* EasyTables.h in Headers */ = {isa = PBXBuildFile; fileRef = 54CEC1C91F21D4DE00FA3BC6 /* EasyTables.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -31,6 +32,7 @@ 54DBCE9220363BF0001D86DF /* 3.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 54DBCE8E20363BF0001D86DF /* 3.jpg */; }; 54DBCE9420363C25001D86DF /* RandomImageDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54DBCE9320363C25001D86DF /* RandomImageDirectory.swift */; }; 54DBCE962036468A001D86DF /* ObjectOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54DBCE952036468A001D86DF /* ObjectOperation.swift */; }; + 54DBCE98203646F7001D86DF /* SelectionModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54DBCE97203646F7001D86DF /* SelectionModel.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -66,6 +68,9 @@ 542578E21F6B18D800FE6DA8 /* Cartfile */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = Cartfile; sourceTree = ""; }; 542578E31F6B18D800FE6DA8 /* Cartfile.resolved */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = Cartfile.resolved; sourceTree = ""; }; 542578E61F6B1BD300FE6DA8 /* Cartography.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Cartography.framework; path = Carthage/Build/Mac/Cartography.framework; sourceTree = ""; }; + 5450685E203C02A600855939 /* SelectedObjects.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectedObjects.swift; sourceTree = ""; }; + 5457D1722036BD01006028A0 /* LICENSE */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = LICENSE; sourceTree = ""; }; + 5457D1732036BD01006028A0 /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; 54CEC1C61F21D4DE00FA3BC6 /* EasyTables.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = EasyTables.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 54CEC1C91F21D4DE00FA3BC6 /* EasyTables.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = EasyTables.h; sourceTree = ""; }; 54CEC1CA1F21D4DE00FA3BC6 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -86,6 +91,7 @@ 54DBCE8E20363BF0001D86DF /* 3.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = 3.jpg; sourceTree = ""; }; 54DBCE9320363C25001D86DF /* RandomImageDirectory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RandomImageDirectory.swift; sourceTree = ""; }; 54DBCE952036468A001D86DF /* ObjectOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObjectOperation.swift; sourceTree = ""; }; + 54DBCE97203646F7001D86DF /* SelectionModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectionModel.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -130,6 +136,8 @@ 54CEC1BC1F21D4DE00FA3BC6 = { isa = PBXGroup; children = ( + 5457D1722036BD01006028A0 /* LICENSE */, + 5457D1732036BD01006028A0 /* README.md */, 542578E21F6B18D800FE6DA8 /* Cartfile */, 542578E31F6B18D800FE6DA8 /* Cartfile.resolved */, 5415A6A91F3F0020000FF9BB /* Frameworks */, @@ -156,6 +164,8 @@ 54CEC1C91F21D4DE00FA3BC6 /* EasyTables.h */, 54CEC1CA1F21D4DE00FA3BC6 /* Info.plist */, 54CEC20D1F21D6D900FA3BC6 /* EasyTableSource.swift */, + 54DBCE97203646F7001D86DF /* SelectionModel.swift */, + 5450685E203C02A600855939 /* SelectedObjects.swift */, 54DBCE952036468A001D86DF /* ObjectOperation.swift */, 540403391F67B669000A7C79 /* ColumnDefinition.swift */, 54CEC20F1F21D85D00FA3BC6 /* GenericTableDataSource.swift */, @@ -350,10 +360,12 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 5450685F203C02A600855939 /* SelectedObjects.swift in Sources */, 54DBCE962036468A001D86DF /* ObjectOperation.swift in Sources */, 5404033A1F67B669000A7C79 /* ColumnDefinition.swift in Sources */, 5415A6BD1F3F2396000FF9BB /* NSTableView+Easy.swift in Sources */, 54CEC2101F21D85D00FA3BC6 /* GenericTableDataSource.swift in Sources */, + 54DBCE98203646F7001D86DF /* SelectionModel.swift in Sources */, 5415A6B81F3F0FB5000FF9BB /* EasyTableSource.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/EasyTables/ColumnDefinition.swift b/EasyTables/ColumnDefinition.swift index beb35dd..411c39f 100644 --- a/EasyTables/ColumnDefinition.swift +++ b/EasyTables/ColumnDefinition.swift @@ -50,16 +50,19 @@ public enum ColumnWidth { public struct ColumnDefinition { /// Name of the column - public var name: String + public let name: String /// Derive the value to display from the object - public var value: (Object)->(Any) + public let value: (Object)->(Any) /// Comparison operator - public var comparison: (Object, Object)->ComparisonResult + public let comparison: (Object, Object)->ComparisonResult /// Witdh of the column - public var width: ColumnWidth + public let width: ColumnWidth /// Alignment - public var alignment: NSTextAlignment - + public let alignment: NSTextAlignment + /// Internal ID + private let generatedID: UUID = UUID() + /// Column identifier + let identifier: String public init(name: String, width: ColumnWidth = .M, @@ -67,6 +70,7 @@ public struct ColumnDefinition { comparison: ((Object, Object)->ComparisonResult)? = nil, value: @escaping (Object)->(Any) ) { + self.identifier = (name + "_" + self.generatedID.uuidString).lowercased() self.name = name self.value = value self.width = width diff --git a/EasyTables/EasyTableSource.swift b/EasyTables/EasyTableSource.swift index c5b34e7..7265563 100644 --- a/EasyTables/EasyTableSource.swift +++ b/EasyTables/EasyTableSource.swift @@ -30,9 +30,9 @@ let TextCellViewIdentifier = "EasyDialogs_TextCellViewIdentifier" /// At initialization, it is associated with a specific instance of NSTableView /// and will create the necessary columns on that NSTableView public class EasyTableSource { - + /// Internal data source associated with the table - public let dataSource: GenericTableDataSource + private(set) public var dataSource: GenericTableDataSource! = nil public var table: NSTableView { return self.dataSource.table @@ -48,34 +48,31 @@ public class EasyTableSource { /// when right-clicking on a table row /// - parameter table: the table to apply this configuration to. If not specified, /// will create a new one - /// - parameter allowMultipleSelection: whether multiple rows can be selected in the table + /// - parameter selectionModel: whether multiple rows can be selected in the table + /// - parameter selectionCallback: callback invoked when the selection changes public init(initialObjects: Objects, columns: [ColumnDefinition], contextMenuOperations: [ObjectOperation], table: NSTableView? = nil, - allowMultipleSelection: Bool, - selectionCallback: @escaping ([Object])->(Void)) + selectionModel: SelectionModel = .singleNative, + selectionCallback: (([Object])->(Void))?) where Objects.Iterator.Element == Object { + let columns = (selectionModel.requiresCheckboxColumn ? [self.checkboxColumn] : []) + columns let table = table ?? NSTableView() self.dataSource = GenericTableDataSource( initialObjects: Array(initialObjects), columns: columns, contextMenuOperations: contextMenuOperations, table: table, - selectionCallback: selectionCallback + selectionModel: selectionModel, + selectionCallback: selectionCallback ?? { _ in } ) table.dataSource = self.dataSource table.delegate = self.dataSource - - var columnsLookup: [String: ColumnDefinition] = [:] - columns.forEach { - columnsLookup[$0.name.lowercased()] = $0 - } - - self.setupTable(columns: columns, multiSelection: allowMultipleSelection) + self.setupTable(columns: columns, selectionModel: selectionModel) self.setupMenu(operations: contextMenuOperations) } @@ -95,21 +92,21 @@ extension EasyTableSource { /// Sets up table columns and selection methods fileprivate func setupTable( columns: [ColumnDefinition], - multiSelection: Bool) + selectionModel: SelectionModel) { let preColumns = self.table.tableColumns preColumns.forEach { self.table.removeTableColumn($0) } - + columns.forEach { cdef in let column = NSTableColumn() column.title = cdef.name - column.identifier = NSUserInterfaceItemIdentifier(rawValue: cdef.name.lowercased()) + column.identifier = NSUserInterfaceItemIdentifier(rawValue: cdef.identifier) column.isEditable = false column.minWidth = cdef.width.width column.maxWidth = cdef.width.width * 2 - column.sortDescriptorPrototype = NSSortDescriptor(key: column.title, ascending: false) { + column.sortDescriptorPrototype = NSSortDescriptor(key: cdef.identifier, ascending: false) { let value1 = cdef.value($0 as! Object) let value2 = cdef.value($1 as! Object) return "\(value1)".compare("\(value2)") @@ -117,10 +114,7 @@ extension EasyTableSource { table.addTableColumn(column) } - self.table.allowsEmptySelection = true - self.table.allowsColumnSelection = false - self.table.allowsTypeSelect = true - self.table.allowsMultipleSelection = multiSelection + selectionModel.configureTableSelectionProperties(self.table) } /// Sets up the contextual menu for the table @@ -145,18 +139,34 @@ extension EasyTableSource { self.table.menu = menu } + private var checkboxColumn: ColumnDefinition { + return ColumnDefinition(name: "✅", + width: .custom(10), + alignment: .center) + { obj in + let checkbox = ClosureButton() + checkbox.closure = { [weak self, weak checkbox] _ in + guard let `self` = self, let checkbox = checkbox else { return } + let isSelected = checkbox.state == NSControl.StateValue.on + self.dataSource.checkboxSelected[obj] = isSelected + } + checkbox.setButtonType(.switch) + checkbox.title = "" + checkbox.state = self.dataSource.isSelected(obj) ? NSControl.StateValue.on : NSControl.StateValue.off + return checkbox + } + } + /// Objects that should be affected by a contextual operation fileprivate var targetObjectsForContextualOperation: [Object] { - let selectedIndex = self.dataSource.table.selectedRowIndexes - let clickedIndex = self.dataSource.table.clickedRow + let selectedObjects = self.dataSource.selectedItems // TODO change to objs + let clickedObject = self.dataSource.value(row: self.dataSource.table.clickedRow) - let indexesToUse: [Int] - if clickedIndex != -1 && !selectedIndex.contains(clickedIndex) { - indexesToUse = [clickedIndex] + if let clicked = clickedObject, !selectedObjects.contains(clicked) { + return [clicked] } else { - indexesToUse = selectedIndex.map { $0 } + return selectedObjects } - return indexesToUse.flatMap(self.dataSource.value(row:)) } } diff --git a/EasyTables/GenericTableDataSource.swift b/EasyTables/GenericTableDataSource.swift index d562518..5597f15 100644 --- a/EasyTables/GenericTableDataSource.swift +++ b/EasyTables/GenericTableDataSource.swift @@ -46,22 +46,36 @@ public class GenericTableDataSource: NSObject, NSTableViewDel } } + /// List of objects currently selected with checkbox + var checkboxSelected = SelectedObjects() + + /// Checkbox selection change listener + private var checkboxSelectionListenerToken: Any? = nil + + /// Selection model for the table + let selectionModel: SelectionModel + init(initialObjects: [Object], columns: [ColumnDefinition], contextMenuOperations: [ObjectOperation] = [], table: NSTableView, + selectionModel: SelectionModel, selectionCallback: @escaping ([Object])->(Void) = { _ in } ) { self.filter = nil self.table = table - var columnsLookup: [String: ColumnDefinition] = [:] - columns.forEach { - columnsLookup[$0.name.lowercased()] = $0 - } - self.columns = columnsLookup + self.selectionModel = selectionModel + self.columns = Dictionary( + columns.map { ($0.identifier, $0) }, + uniquingKeysWith: { a, b in a }) self.selectionCallback = selectionCallback super.init() - self.update(newObjects: initialObjects) + self.checkboxSelectionListenerToken = self.checkboxSelected.addObserver { + [weak self] _ in + guard let `self` = self else { return } + self.selectionCallback(self.selectedItems) + } + self.update(newObjects: initialObjects, invokeSelectionCallback: false) } public func numberOfRows(in tableView: NSTableView) -> Int { @@ -127,11 +141,17 @@ public class GenericTableDataSource: NSObject, NSTableViewDel self.selectionCallback(self.selectedItems) } + public func selectionShouldChange(in tableView: NSTableView) -> Bool { + return !self.selectionModel.blocksNativeSelection + } + /// Update the objects, re-apply filter and sorting - func update(newObjects: [Object]) { + func update(newObjects: [Object], invokeSelectionCallback: Bool = true) { self.originalObjects = newObjects self.recalculateSource() - self.selectionCallback(self.selectedItems) + if invokeSelectionCallback { + self.selectionCallback(self.selectedItems) + } } /// Refilter original objects then sort them @@ -198,14 +218,33 @@ public class GenericTableDataSource: NSObject, NSTableViewDel public func select(items: SEQUENCE, extendSelection: Bool = false) where SEQUENCE.Iterator.Element == Object { - let indexes = items.flatMap { item in self.sortedObjects.index(where: { $0 == item }) } - self.table.selectRowIndexes(IndexSet(indexes), byExtendingSelection: extendSelection) + if self.selectionModel.requiresManualSelectionTracking { + if !extendSelection { + self.checkboxSelected.setSelection(items) + } else { + self.checkboxSelected.select(items) + } + self.table.reloadData() + } else { + let indexes = items.flatMap { item in self.sortedObjects.index(where: { $0 == item }) } + self.table.selectRowIndexes(IndexSet(indexes), byExtendingSelection: extendSelection) + } } /// The items currently selected in the table public var selectedItems: [Object] { - return self.table.selectedRowIndexes.map { - self.sortedObjects[$0] + if self.selectionModel.requiresManualSelectionTracking { + return self.checkboxSelected.selectedObjects + } else { + return self.table.selectedRowIndexes.map { + self.sortedObjects[$0] + } } } + + /// Whether an object is selected + /// - note: this will cause two linear scans of the elements + func isSelected(_ object: Object) -> Bool { + return self.selectedItems.contains(object) + } } diff --git a/EasyTables/SelectedObjects.swift b/EasyTables/SelectedObjects.swift new file mode 100644 index 0000000..eafe7dc --- /dev/null +++ b/EasyTables/SelectedObjects.swift @@ -0,0 +1,108 @@ +// +// Copyright (c) 2018 Marco Conti +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Foundation + +private let contentChangeNotificationName = Notification.Name(rawValue: "SelectedObjectsContentChange") + +/// List of selected objects +class SelectedObjects: Sequence { + + func makeIterator() -> IndexingIterator<[Object]> { + return self.selectedObjects.makeIterator() + } + + /// List of selected objects + private(set) var selectedObjects: [Object] = [] { + didSet { + if oldValue != self.selectedObjects { + NotificationCenter.default.post( + name: contentChangeNotificationName, + object: self) + } + } + } + + /// The selection status of an object + subscript(object: Object) -> Bool { + get { + return self.isSelected(object) + } + set { + if newValue { + self.select(object) + } else { + self.deselect(object) + } + } + } + + /// Checks whether the given object is selected + func isSelected(_ object: Object) -> Bool { + // linear scan! can it be made faster without hashable? + return self.selectedObjects.index(of: object) != nil + } + + /// Adds one object to the selection + func select(_ object: Object) { + guard !isSelected(object) else { return } + self.selectedObjects.append(object) + } + + /// Adds object to the selection + func select(_ objects: T) where T.Element == Object { + objects.forEach { self.select($0) } + } + + /// Remove one object from the selection + func deselect(_ object: Object) { + guard let index = self.selectedObjects.index(of: object) else { return } + self.selectedObjects.remove(at: index) + } + + /// Deselect all objects + func deselectAll() { + self.selectedObjects = [] + } + + /// Set the selected objects to the given collection + func setSelection(_ objects: T) where T.Element == Object { + self.selectedObjects = Array(objects) + } + + /// Add observer for selection change + func addObserver(block: @escaping (SelectedObjects)->()) -> Any { + return NotificationCenter.default.addObserver( + forName: contentChangeNotificationName, + object: self, + queue: nil, + using: { note in + guard let object = note.object as? SelectedObjects else { return } + block(object) + }) + } + + /// Remove observer for selection change + func removeObserver(_ observer: Any) { + NotificationCenter.default.removeObserver(observer) + } +} diff --git a/EasyTables/SelectionModel.swift b/EasyTables/SelectionModel.swift new file mode 100644 index 0000000..d3a307e --- /dev/null +++ b/EasyTables/SelectionModel.swift @@ -0,0 +1,68 @@ +// +// Copyright (c) 2017 Marco Conti +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Foundation + +public enum SelectionModel { + /// Allow selection of one row, using native table selection mechanism + case singleNative + /// Allow selection of multiple rows, using native table selection mechanism + case multipleNative + /// Allow selection of multiple rows, using checkbox input + case multipleCheckbox + /// Do not allow selection + case none +} + +// MARK: - Table configuration +extension SelectionModel { + /// Sets the corresponding selection mode on a table view + func configureTableSelectionProperties(_ table: NSTableView) { + table.allowsEmptySelection = true + table.allowsColumnSelection = false + table.allowsTypeSelect = !self.blocksNativeSelection + table.allowsMultipleSelection = self == .multipleNative + } + + /// Whether this mode requires an extra checkbox column + var requiresCheckboxColumn: Bool { + return self == .multipleCheckbox + } + + /// Whether this mode requires manual tracking of selection + var requiresManualSelectionTracking: Bool { + return self == .multipleCheckbox + } + + /// Whether this mode should inhibit the native selection mechanism + var blocksNativeSelection: Bool { + switch self { + case .none: + return true + case .multipleCheckbox: + return true + default: + return false + } + } +} + diff --git a/EasyTablesExample/TableViewController.swift b/EasyTablesExample/TableViewController.swift index caabf4c..9383076 100644 --- a/EasyTablesExample/TableViewController.swift +++ b/EasyTablesExample/TableViewController.swift @@ -94,7 +94,7 @@ class TableViewController: NSViewController { }) ], table: table, - allowMultipleSelection: true, + selectionModel: .multipleCheckbox, selectionCallback: { /// Just print out something when selected print("Selection changed:") diff --git a/README.md b/README.md index 096b375..01611d4 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,18 @@ class ViewController: NSViewController { The result of this setup is shown in the following screenshot: ![Screenshot of table](https://github.com/marcoconti83/EasyTables/blob/master/docs/table-example.png?raw=true) -## Credits +### Other cell values +Tables can display formatted strings (`NSAttributedString`), controls and images. + +### Selection models +Tables have 4 selection modes: +- no selection +- single row selection +- multiple row selection +- use checkboxes for multiple row selection instead of the standard Cocoa selection model of click or `Shift`/`Cmd`-click + +![Screenshot of table](https://github.com/marcoconti83/EasyTables/blob/master/docs/table-example2.png?raw=true) + +### Credits The images used in the example are from [Unsplash](https://unsplash.com). diff --git a/docs/table-example2.png b/docs/table-example2.png new file mode 100644 index 0000000..47b292f Binary files /dev/null and b/docs/table-example2.png differ