diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b8ef9b64..902bdd430 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- In the CSV editor, right-click a column header to rename, insert, delete, or change the type of that column. (#1913) + ### Fixed - SSH tunnels using the None auth method now work on iPhone and iPad, not just the Mac. A connection synced from the Mac with None auth no longer fails to connect. (#1912) diff --git a/TablePro/Views/Inspector/InspectorColumnMenuBuilder.swift b/TablePro/Views/Inspector/InspectorColumnMenuBuilder.swift new file mode 100644 index 000000000..f95511d3c --- /dev/null +++ b/TablePro/Views/Inspector/InspectorColumnMenuBuilder.swift @@ -0,0 +1,86 @@ +// +// InspectorColumnMenuBuilder.swift +// TablePro +// + +import AppKit +import TableProPluginKit + +@MainActor +enum InspectorColumnMenuBuilder { + static func structureItems(forColumn index: Int, currentType: InspectorColumnType) -> [NSMenuItem] { + let rename = actionItem( + title: String(localized: "Rename Column…"), + action: #selector(InspectorViewController.inspectorRenameColumn(_:)), + column: index + ) + let insertBefore = actionItem( + title: String(localized: "Insert Column Before"), + action: #selector(InspectorViewController.inspectorInsertColumnBefore(_:)), + column: index + ) + let insertAfter = actionItem( + title: String(localized: "Insert Column After"), + action: #selector(InspectorViewController.inspectorInsertColumnAfter(_:)), + column: index + ) + let changeType = NSMenuItem(title: String(localized: "Change Type"), action: nil, keyEquivalent: "") + changeType.submenu = typeSubmenu(forColumn: index, currentType: currentType) + let delete = actionItem( + title: String(localized: "Delete Column"), + action: #selector(InspectorViewController.inspectorDeleteColumn(_:)), + column: index + ) + return [rename, insertBefore, insertAfter, changeType, .separator(), delete] + } + + static func typeSubmenu(forColumn index: Int, currentType: InspectorColumnType) -> NSMenu { + let submenu = NSMenu() + for type in InspectorColumnType.allCases { + let item = actionItem( + title: typeLabel(type), + action: #selector(InspectorViewController.inspectorSetColumnType(_:)), + column: index + ) + item.representedObject = ColumnTypeAssignment(column: index, type: type) + item.state = (type == currentType) ? .on : .off + submenu.addItem(item) + } + submenu.addItem(.separator()) + let reset = actionItem( + title: String(localized: "Reset to Inferred"), + action: #selector(InspectorViewController.inspectorSetColumnType(_:)), + column: index + ) + reset.representedObject = ColumnTypeAssignment(column: index, type: nil) + submenu.addItem(reset) + return submenu + } + + static func typeSymbol(_ type: InspectorColumnType) -> String { + switch type { + case .text: return "textformat" + case .integer: return "number" + case .real: return "number.square" + case .boolean: return "checkmark.square" + case .date: return "calendar" + } + } + + static func typeLabel(_ type: InspectorColumnType) -> String { + switch type { + case .text: return String(localized: "Text") + case .integer: return String(localized: "Integer") + case .real: return String(localized: "Real") + case .boolean: return String(localized: "Boolean") + case .date: return String(localized: "Date") + } + } + + private static func actionItem(title: String, action: Selector, column: Int) -> NSMenuItem { + let item = NSMenuItem(title: title, action: action, keyEquivalent: "") + item.tag = column + item.target = nil + return item + } +} diff --git a/TablePro/Views/Inspector/InspectorViewController.swift b/TablePro/Views/Inspector/InspectorViewController.swift index 1945bdea6..25f4d9d97 100644 --- a/TablePro/Views/Inspector/InspectorViewController.swift +++ b/TablePro/Views/Inspector/InspectorViewController.swift @@ -270,6 +270,14 @@ final class InspectorViewController: NSViewController, NSUserInterfaceValidation inspectorDocument?.setTypeOverride(assignment.type, forColumn: assignment.column) } + fileprivate func columnStructureMenuItems(forColumn index: Int) -> [NSMenuItem] { + guard let inspector = inspectorDocument, index >= 0, index < inspector.columnNames.count else { return [] } + return InspectorColumnMenuBuilder.structureItems( + forColumn: index, + currentType: inspector.displayedType(forColumn: index) + ) + } + private func renameLayoutKey(from oldName: String, to newName: String) { if state.columnLayout.columnOrder != nil { state.columnLayout.columnOrder = state.columnLayout.columnOrder?.map { $0 == oldName ? newName : $0 } @@ -689,6 +697,10 @@ private final class InspectorGridDelegate: DataGridViewDelegate { owner?.handleSortChanged(state) } + func dataGridColumnStructureMenuItems(forColumn dataColumnIndex: Int) -> [NSMenuItem] { + owner?.columnStructureMenuItems(forColumn: dataColumnIndex) ?? [] + } + func dataGridUndo() { owner?.handleUndo() } diff --git a/TablePro/Views/Inspector/InspectorWindowController.swift b/TablePro/Views/Inspector/InspectorWindowController.swift index 6a2ca4dfc..2c057fb89 100644 --- a/TablePro/Views/Inspector/InspectorWindowController.swift +++ b/TablePro/Views/Inspector/InspectorWindowController.swift @@ -144,99 +144,23 @@ final class InspectorWindowController: NSWindowController, NSWindowDelegate, NST for (index, name) in columns.enumerated() { let item = NSMenuItem(title: name, action: nil, keyEquivalent: "") let type = inspector.displayedType(forColumn: index) - item.image = NSImage(systemSymbolName: Self.typeSymbol(type), accessibilityDescription: nil) - item.submenu = makeColumnSubmenu(columnIndex: index, currentType: type) + item.image = NSImage( + systemSymbolName: InspectorColumnMenuBuilder.typeSymbol(type), + accessibilityDescription: nil + ) + item.submenu = columnSubmenu(forColumn: index, currentType: type) menu.addItem(item) } } - private func makeColumnSubmenu(columnIndex: Int, currentType: InspectorColumnType) -> NSMenu { - let submenu = NSMenu() - let rename = NSMenuItem( - title: String(localized: "Rename…"), - action: #selector(InspectorViewController.inspectorRenameColumn(_:)), - keyEquivalent: "" - ) - rename.tag = columnIndex - submenu.addItem(rename) - - let insertBefore = NSMenuItem( - title: String(localized: "Insert Column Before"), - action: #selector(InspectorViewController.inspectorInsertColumnBefore(_:)), - keyEquivalent: "" - ) - insertBefore.tag = columnIndex - submenu.addItem(insertBefore) - - let insertAfter = NSMenuItem( - title: String(localized: "Insert Column After"), - action: #selector(InspectorViewController.inspectorInsertColumnAfter(_:)), - keyEquivalent: "" - ) - insertAfter.tag = columnIndex - submenu.addItem(insertAfter) - - submenu.addItem(.separator()) - - let typeItem = NSMenuItem(title: String(localized: "Type"), action: nil, keyEquivalent: "") - typeItem.submenu = makeTypeSubmenu(columnIndex: columnIndex, currentType: currentType) - submenu.addItem(typeItem) - - submenu.addItem(.separator()) - - let delete = NSMenuItem( - title: String(localized: "Delete"), - action: #selector(InspectorViewController.inspectorDeleteColumn(_:)), - keyEquivalent: "" - ) - delete.tag = columnIndex - submenu.addItem(delete) - return submenu - } - - private func makeTypeSubmenu(columnIndex: Int, currentType: InspectorColumnType) -> NSMenu { + private func columnSubmenu(forColumn index: Int, currentType: InspectorColumnType) -> NSMenu { let submenu = NSMenu() - for type in InspectorColumnType.allCases { - let item = NSMenuItem( - title: Self.typeLabel(type), - action: #selector(InspectorViewController.inspectorSetColumnType(_:)), - keyEquivalent: "" - ) - item.representedObject = ColumnTypeAssignment(column: columnIndex, type: type) - item.state = (type == currentType) ? .on : .off + for item in InspectorColumnMenuBuilder.structureItems(forColumn: index, currentType: currentType) { submenu.addItem(item) } - submenu.addItem(.separator()) - let reset = NSMenuItem( - title: String(localized: "Reset to Inferred"), - action: #selector(InspectorViewController.inspectorSetColumnType(_:)), - keyEquivalent: "" - ) - reset.representedObject = ColumnTypeAssignment(column: columnIndex, type: nil) - submenu.addItem(reset) return submenu } - private static func typeSymbol(_ type: InspectorColumnType) -> String { - switch type { - case .text: return "textformat" - case .integer: return "number" - case .real: return "number.square" - case .boolean: return "checkmark.square" - case .date: return "calendar" - } - } - - private static func typeLabel(_ type: InspectorColumnType) -> String { - switch type { - case .text: return String(localized: "Text") - case .integer: return String(localized: "Integer") - case .real: return String(localized: "Real") - case .boolean: return String(localized: "Boolean") - case .date: return String(localized: "Date") - } - } - private func makeItem( identifier: NSToolbarItem.Identifier, label: String, diff --git a/TablePro/Views/Results/DataGridViewDelegate.swift b/TablePro/Views/Results/DataGridViewDelegate.swift index 536d653f3..5be7bf038 100644 --- a/TablePro/Views/Results/DataGridViewDelegate.swift +++ b/TablePro/Views/Results/DataGridViewDelegate.swift @@ -27,6 +27,7 @@ protocol DataGridViewDelegate: AnyObject { func dataGridCanClearResults() -> Bool func dataGridHideColumn(_ columnName: String) func dataGridShowAllColumns() + func dataGridColumnStructureMenuItems(forColumn dataColumnIndex: Int) -> [NSMenuItem] func dataGridVisualState(forRow row: Int) -> RowVisualState? func dataGridRowView(for tableView: NSTableView, row: Int, coordinator: TableViewCoordinator) -> NSTableRowView? func dataGridEmptySpaceMenu() -> NSMenu? @@ -55,6 +56,7 @@ extension DataGridViewDelegate { func dataGridCanClearResults() -> Bool { false } func dataGridHideColumn(_ columnName: String) {} func dataGridShowAllColumns() {} + func dataGridColumnStructureMenuItems(forColumn dataColumnIndex: Int) -> [NSMenuItem] { [] } func dataGridVisualState(forRow row: Int) -> RowVisualState? { nil } func dataGridRowView(for tableView: NSTableView, row: Int, coordinator: TableViewCoordinator) -> NSTableRowView? { nil } func dataGridEmptySpaceMenu() -> NSMenu? { nil } diff --git a/TablePro/Views/Results/Extensions/DataGridView+Sort.swift b/TablePro/Views/Results/Extensions/DataGridView+Sort.swift index bbc488d03..783448985 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Sort.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Sort.swift @@ -223,6 +223,18 @@ extension TableViewCoordinator { showAllItem.target = self menu.addItem(showAllItem) } + + appendColumnStructureItems(to: menu, forColumnIdentifier: column.identifier) + } + + private func appendColumnStructureItems(to menu: NSMenu, forColumnIdentifier identifier: NSUserInterfaceItemIdentifier) { + guard let dataColumnIndex = dataColumnIndex(from: identifier), + let structureItems = delegate?.dataGridColumnStructureMenuItems(forColumn: dataColumnIndex), + !structureItems.isEmpty else { return } + menu.addItem(NSMenuItem.separator()) + for item in structureItems { + menu.addItem(item) + } } @objc func sortAscending(_ sender: NSMenuItem) { diff --git a/TableProTests/Views/InspectorColumnMenuBuilderTests.swift b/TableProTests/Views/InspectorColumnMenuBuilderTests.swift new file mode 100644 index 000000000..f64f519d0 --- /dev/null +++ b/TableProTests/Views/InspectorColumnMenuBuilderTests.swift @@ -0,0 +1,58 @@ +// +// InspectorColumnMenuBuilderTests.swift +// TableProTests +// + +import AppKit +@testable import TablePro +import TableProPluginKit +import Testing + +@MainActor +@Suite("InspectorColumnMenuBuilder") +struct InspectorColumnMenuBuilderTests { + @Test("Structure items route each action to the clicked column") + func structureItemsWiring() { + let items = InspectorColumnMenuBuilder.structureItems(forColumn: 3, currentType: .text) + + #expect(items.count == 6) + #expect(items[0].action == #selector(InspectorViewController.inspectorRenameColumn(_:))) + #expect(items[1].action == #selector(InspectorViewController.inspectorInsertColumnBefore(_:))) + #expect(items[2].action == #selector(InspectorViewController.inspectorInsertColumnAfter(_:))) + #expect(items[3].submenu != nil) + #expect(items[4].isSeparatorItem) + #expect(items[5].action == #selector(InspectorViewController.inspectorDeleteColumn(_:))) + + for index in [0, 1, 2, 5] { + #expect(items[index].tag == 3) + #expect(items[index].target == nil) + } + } + + @Test("Delete Column is the last item, in its own trailing group") + func deleteIsLastAfterSeparator() { + let items = InspectorColumnMenuBuilder.structureItems(forColumn: 0, currentType: .integer) + + #expect(items.last?.action == #selector(InspectorViewController.inspectorDeleteColumn(_:))) + #expect(items[items.count - 2].isSeparatorItem) + } + + @Test("Type submenu checks the current type and offers Reset to Inferred") + func typeSubmenuState() { + let submenu = InspectorColumnMenuBuilder.typeSubmenu(forColumn: 1, currentType: .integer) + + let typeItems = submenu.items.filter { !$0.isSeparatorItem } + #expect(typeItems.count == InspectorColumnType.allCases.count + 1) + + let checked = submenu.items.filter { $0.state == .on } + #expect(checked.count == 1) + let checkedAssignment = checked.first?.representedObject as? ColumnTypeAssignment + #expect(checkedAssignment?.type == .integer) + + let reset = submenu.items.last + #expect(reset?.action == #selector(InspectorViewController.inspectorSetColumnType(_:))) + let resetAssignment = reset?.representedObject as? ColumnTypeAssignment + #expect(resetAssignment != nil) + #expect(resetAssignment?.type == nil) + } +} diff --git a/docs/features/csv-inspector.mdx b/docs/features/csv-inspector.mdx index 3bc450c21..a54c82403 100644 --- a/docs/features/csv-inspector.mdx +++ b/docs/features/csv-inspector.mdx @@ -34,7 +34,7 @@ Rows load in pages. The page size comes from the Default page size setting in [S - Double-click a cell (or press Return on it) to edit. Return commits and closes the editor. Tab commits and moves to the next cell, wrapping to the next row. - The toolbar `Add Row` button appends a new row, scrolls to it, and selects it. -- Select rows and press `Delete` (or the toolbar `Delete` button) to remove them. The next row takes selection so arrow keys keep working from where you were. +- Select rows and press `Delete` (the toolbar `Delete` button, or right-click a row and choose Delete) to remove them. The next row takes selection so arrow keys keep working from where you were. - Cmd+Z and Cmd+Shift+Z undo and redo every change. A bulk delete or a paste is a single undo step. ## Column operations @@ -44,12 +44,14 @@ Rows load in pages. The page size comes from the Default page size setting in [S CSV Inspector Columns Menu -The toolbar `Columns` button opens a menu. **Add Column…** at the top appends a new column at the end. Below it, every column is listed with its current type, each with a submenu: +Right-click a column header, or open the toolbar `Columns` menu, to reach the same per-column actions: -- **Rename…** prompts for a new name; the column stays in place. +- **Rename Column…** prompts for a new name; the column stays in place. - **Insert Column Before / After** inserts a new column at the chosen position with a name you supply. -- **Type ▸** overrides the inferred type as Text, Integer, Real, Boolean, or Date. **Reset to Inferred** drops the override. -- **Delete** removes the column (undoable). +- **Change Type ▸** overrides the inferred type as Text, Integer, Real, Boolean, or Date. **Reset to Inferred** drops the override. +- **Delete Column** removes the column. It sits last in the menu and is undoable with Cmd+Z. + +In the toolbar `Columns` menu, **Add Column…** at the top appends a new column at the end, and every column is listed below it with its current type and the same submenu. ## Filter and sort