Commit d3bd97c631
Verified · cmc
Layout: unified · split
Sources/OrgCore/Commands/TableShrink.swift added +153
| @@ -0,0 +1,153 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// Narrowed table columns (`org-table-shrink`, `org-table-toggle-column-width`, Org 9.8.7): | |
| 4 | /// where org puts its overlays, as ranges of text shown as other text. The editor hides each | |
| 5 | /// range and draws its display instead. | |
| 6 | public enum TableShrink { | |
| 7 | public struct Segment: Sendable, Equatable { | |
| 8 | public let range: Range<Int> | |
| 9 | /// What shows instead of the range's text. | |
| 10 | public let display: String | |
| 11 | } | |
| 12 | ||
| 13 | static let indicator = "…" | |
| 14 | ||
| 15 | /// The table holding `offset`: its start and end. | |
| 16 | public static func table(at offset: Int, in text: String) -> Range<Int>? { | |
| 17 | let buffer = EmacsBuffer(text, point: offset) | |
| 18 | guard buffer.atTable else { return nil } | |
| 19 | return buffer.tableBegin()..<buffer.tableEnd() | |
| 20 | } | |
| 21 | ||
| 22 | /// The column at `offset`, or nil before the first or after the last. | |
| 23 | public static func column(at offset: Int, in text: String) -> Int? { | |
| 24 | let buffer = EmacsBuffer(text, point: offset) | |
| 25 | let outside = buffer.saveExcursion { () -> Bool in | |
| 26 | buffer.skipBackwardNot([124, 10]) | |
| 27 | return buffer.bolp || buffer.looking("[ \\t]*$", caseInsensitive: false, setsMatch: false) != nil | |
| 28 | } | |
| 29 | return outside ? nil : buffer.tableCurrentColumn() | |
| 30 | } | |
| 31 | ||
| 32 | /// The columns with a width cookie, as `org-table-shrink` finds them. | |
| 33 | public static func cookieColumns(_ table: Range<Int>, in text: String) -> [Int] { | |
| 34 | let buffer = EmacsBuffer(text, point: table.lowerBound) | |
| 35 | var columns: [Int] = [] | |
| 36 | while let match = buffer.searchForward("\\|[ \\t]*<[lrc]?[0-9]+>[ \\t]*(\\||$)", bound: table.upperBound, caseInsensitive: false) { | |
| 37 | buffer.point = match[1]!.lowerBound | |
| 38 | let column = buffer.tableCurrentColumn() | |
| 39 | if !columns.contains(column) { columns.append(column) } | |
| 40 | } | |
| 41 | return columns.sorted() | |
| 42 | } | |
| 43 | ||
| 44 | /// `org-table--read-column-selection`. | |
| 45 | public static func columns(_ selection: String, max: Int) -> [Int] { | |
| 46 | var result: Set<Int> = [] | |
| 47 | for part in selection.split(whereSeparator: \.isWhitespace) { | |
| 48 | if let m = part.wholeMatch(of: /([0-9]+)?-([0-9]+)?/) { | |
| 49 | let low = m.1.flatMap { Int($0) } ?? 1 | |
| 50 | let high = m.2.flatMap { Int($0) } ?? max | |
| 51 | if low <= high { result.formUnion(low...high) } | |
| 52 | } else if let n = Int(part) { | |
| 53 | result.insert(n) | |
| 54 | } | |
| 55 | } | |
| 56 | return result.sorted() | |
| 57 | } | |
| 58 | ||
| 59 | /// `org-table--shrink-columns` for one column. | |
| 60 | public static func segments(_ table: Range<Int>, column c: Int, in text: String) -> [Segment] { | |
| 61 | let buffer = EmacsBuffer(text, point: table.lowerBound) | |
| 62 | var align: String? | |
| 63 | var width: Int? | |
| 64 | var fields: [(start: Int, end: Int, contents: String?)] = [] | |
| 65 | while buffer.point < table.upperBound { | |
| 66 | let lineEnd = buffer.lineEnd(buffer.point) | |
| 67 | let hline = buffer.lookingAt("[ \\t]*\\|-") != nil | |
| 68 | let separator: unichar = hline ? 43 : 124 | |
| 69 | var ok = buffer.searchForward("\\|", bound: lineEnd, caseInsensitive: false) != nil | |
| 70 | var remaining = c - 1 | |
| 71 | while ok, remaining > 0 { | |
| 72 | guard let next = (buffer.point..<lineEnd).first(where: { buffer.character(at: $0) == separator }) else { ok = false; break } | |
| 73 | buffer.point = next + 1 | |
| 74 | remaining -= 1 | |
| 75 | } | |
| 76 | if ok { | |
| 77 | let start = buffer.point | |
| 78 | var end = start | |
| 79 | while end < lineEnd, let ch = buffer.character(at: end), ch != 124, ch != separator { end += 1 } | |
| 80 | let contents: String? = hline ? nil : buffer.substring(start, end).trimmingCharacters(in: CharacterSet(charactersIn: " \t\n\r")) | |
| 81 | fields.append((start, end, contents)) | |
| 82 | if let contents, let m = contents.wholeMatch(of: /<([lrc])?([0-9]+)>/) { | |
| 83 | if align == nil { align = m.1.map(String.init) } | |
| 84 | if width == nil { width = Int(m.2) } | |
| 85 | } | |
| 86 | } | |
| 87 | if buffer.forwardLine(1) != 0 { break } | |
| 88 | } | |
| 89 | return fields.flatMap { shrinkField(width: width ?? 0, align: align ?? "l", start: $0.start, end: $0.end, contents: $0.contents, buffer: buffer) } | |
| 90 | } | |
| 91 | ||
| 92 | /// `org-table--shrink-field`; nil contents for a rule. | |
| 93 | static func shrinkField(width: Int, align: String, start: Int, end: Int, contents: String?, buffer: EmacsBuffer) -> [Segment] { | |
| 94 | func overlay(_ s: Int, _ e: Int, _ display: String, pre: Bool = false) -> Segment { | |
| 95 | Segment(range: s..<e, display: pre ? " " + display : display + indicator) | |
| 96 | } | |
| 97 | func stringWidth(_ s: Int, _ e: Int) -> Int { buffer.cellWidth(buffer.substring(s, e)) } | |
| 98 | guard start != end else { return [] } | |
| 99 | if width == 0 { return [overlay(start, end, "")] } | |
| 100 | guard let contents else { return [overlay(start, end, String(repeating: "-", count: width + 1))] } | |
| 101 | if contents.isEmpty { | |
| 102 | let w = stringWidth(start, end) | |
| 103 | let full = width + 2 | |
| 104 | return w <= full ? [overlay(end - 1, end, String(repeating: " ", count: full - w))] : [overlay(end - (w - full) - 1, end, "")] | |
| 105 | } | |
| 106 | var lead = 0 | |
| 107 | while start + lead < end, buffer.character(at: start + lead) == 32 { lead += 1 } | |
| 108 | var trail = 0 | |
| 109 | while end - trail > start + lead, buffer.character(at: end - trail - 1) == 32 { trail += 1 } | |
| 110 | let contentsWidth = stringWidth(start + lead, end - trail) | |
| 111 | if width <= contentsWidth { | |
| 112 | let pre = lead > 0 ? overlay(start, start + lead, "", pre: true) : nil | |
| 113 | let begin = start + lead | |
| 114 | var lower = begin | |
| 115 | var upper = end - 1 | |
| 116 | let target = lead == 0 ? width + 1 : width | |
| 117 | var cut = upper | |
| 118 | while upper - lower > 1 { | |
| 119 | let mean = (lower >> 1) + (upper >> 1) + (lower & upper & 1) | |
| 120 | let w = stringWidth(begin, mean) | |
| 121 | if w == target { | |
| 122 | cut = mean | |
| 123 | break | |
| 124 | } else if w > target { | |
| 125 | upper = mean | |
| 126 | } else { | |
| 127 | lower = mean | |
| 128 | } | |
| 129 | cut = upper | |
| 130 | } | |
| 131 | return [pre, overlay(cut, end, "")].compactMap { $0 } | |
| 132 | } | |
| 133 | let required = width - contentsWidth | |
| 134 | let before: Int = lead == 0 ? -1 : align == "r" ? required : align == "c" ? required / 2 : 0 | |
| 135 | let after = required - before | |
| 136 | var result: [Segment] = [] | |
| 137 | if lead != 0, lead - 1 != before { | |
| 138 | if lead - 1 > before { | |
| 139 | result.append(overlay(start, start + (lead - before), "", pre: true)) | |
| 140 | } else { | |
| 141 | result.append(overlay(start, start + 1, String(repeating: " ", count: before - (lead - 1)), pre: true)) | |
| 142 | } | |
| 143 | } | |
| 144 | if trail - 1 == after { | |
| 145 | result.append(overlay(end - 1, end, "")) | |
| 146 | } else if trail - 1 > after { | |
| 147 | result.append(overlay(after + (end - trail), end, "")) | |
| 148 | } else { | |
| 149 | result.append(overlay(end - 1, end, String(repeating: " ", count: after - (trail - 1)))) | |
| 150 | } | |
| 151 | return result | |
| 152 | } | |
| 153 | } | |
Sources/OrgCore/Keymap/Presets.swift +4
| @@ -647,6 +647,10 @@ public enum KeymapPreset: String, Sendable, CaseIterable { | ||
| 647 | 647 | keys = "C-c =" |
| 648 | 648 | command = "org.table.column-formula" |
| 649 | 649 | [[bind]] |
| 650 | keys = "C-c TAB" | |
| 651 | command = "org.table.toggle-column-width" | |
| 652 | when = "table" | |
| 653 | [[bind]] | |
| 650 | 654 | keys = "C-c C-x C-r" |
| 651 | 655 | command = "org.clock.report" |
| 652 | 656 | [[bind]] |
Sources/OrgEditorAppKit/EditorCommands.swift +3
| @@ -37,6 +37,9 @@ public enum EditorCommands { | ||
| 37 | 37 | public static let all: [EditorCommand] = Commands.everything.map { EditorCommand(id: $0.id, title: $0.title, action: .text($0)) } + [ |
| 38 | 38 | EditorCommand(id: "org.cycle", title: "Cycle Visibility", action: .view), |
| 39 | 39 | EditorCommand(id: "org.cycle-global", title: "Cycle Global Visibility", action: .view), |
| 40 | EditorCommand(id: "org.table.toggle-column-width", title: "Shrink or Expand Table Column", action: .view), | |
| 41 | EditorCommand(id: "org.table.shrink", title: "Shrink Table Columns with Widths", action: .view), | |
| 42 | EditorCommand(id: "org.table.expand", title: "Expand Table Columns", action: .view), | |
| 40 | 43 | EditorCommand(id: "org.link.insert", title: "Insert Link…", action: .app), |
| 41 | 44 | EditorCommand(id: "org.link.store", title: "Store Link", action: .app), |
| 42 | 45 | EditorCommand(id: "org.id.store-link", title: "Store ID Link", action: .app), |
Sources/OrgEditorAppKit/OrgEditor.swift +112 −1
| @@ -150,6 +150,7 @@ public final class OrgEditor: NSObject { | ||
| 150 | 150 | let wrappers = Presentation.startupWrappers(document.tree, hideDrawers: options.hideDrawerStartup, hideBlocks: options.hideBlockStartup) |
| 151 | 151 | startup.wrapperFolds = Set(wrappers.map(\.start)) |
| 152 | 152 | if startup != FoldState() { setFolds(startup, wrappers: wrappers) } |
| 153 | shrinkAtStartup() | |
| 153 | 154 | } |
| 154 | 155 | } |
| 155 | 156 | |
| @@ -369,6 +370,7 @@ public final class OrgEditor: NSObject { | ||
| 369 | 370 | if !isOrg, id.hasPrefix("org.") { return false } |
| 370 | 371 | return switch EditorCommands.command(id)?.action { |
| 371 | 372 | case .text(let command): command.applies(in: context) |
| 373 | case .view where id.hasPrefix("org.table."): KeyContext.holds("table", in: context) | |
| 372 | 374 | case .view: id != "org.cycle" || Presentation.heading(containing: context.caret, in: context.tree) != nil |
| 373 | 375 | || Wrappers.toggleable(at: context.caret, in: context.tree) != nil |
| 374 | 376 | case .edit, .mark, .app, .find: true |
| @@ -395,7 +397,11 @@ public final class OrgEditor: NSObject { | ||
| 395 | 397 | case .text(let text): |
| 396 | 398 | perform(text) |
| 397 | 399 | case .view: |
| 398 | if id == "org.cycle" { cycleAtCaret() } else { cycleGlobal() } | |
| 400 | switch id { | |
| 401 | case "org.cycle": cycleAtCaret() | |
| 402 | case "org.cycle-global": cycleGlobal() | |
| 403 | default: tableColumnWidth(id) | |
| 404 | } | |
| 399 | 405 | case .edit(let selector, let extending): |
| 400 | 406 | if markActive, let extending { |
| 401 | 407 | keepMark = true |
| @@ -436,7 +442,9 @@ public final class OrgEditor: NSObject { | ||
| 436 | 442 | onMessage?("This file is read-only.") |
| 437 | 443 | return .failed("read-only") |
| 438 | 444 | } |
| 445 | commandEditing = true | |
| 439 | 446 | applyEdits(result.edits) |
| 447 | commandEditing = false | |
| 440 | 448 | if let selection = result.selection?.first { |
| 441 | 449 | setCaret(selection.lowerBound) |
| 442 | 450 | if !selection.isEmpty { textView.setSelectedRange(NSRange(selection)) } |
| @@ -510,6 +518,107 @@ public final class OrgEditor: NSObject { | ||
| 510 | 518 | hidden.set(Presentation.hiddenRanges(document.tree, folds: view.folds, bodyFolds: view.bodyFolds, wrapperRanges: Array(wrapperHidden.values))) |
| 511 | 519 | } |
| 512 | 520 | |
| 521 | // MARK: - Shrunk table columns | |
| 522 | ||
| 523 | /// Narrowed table columns (`org-table-shrink`): by table start, each column's segments. | |
| 524 | /// Recomputed as the text changes; typing into one widens its column again, as editing | |
| 525 | /// org's overlays deletes them. Commands' edits keep columns narrow, as `org-table-align` | |
| 526 | /// shrinks them again. | |
| 527 | private var shrunk: [Int: [Int: [TableShrink.Segment]]] = [:] | |
| 528 | private var commandEditing = false | |
| 529 | ||
| 530 | /// Narrows `columns` of `table` and widens its others. | |
| 531 | private func setShrunk(_ columns: [Int], in table: Range<Int>) { | |
| 532 | let text = textView.string | |
| 533 | let segments = Dictionary(uniqueKeysWithValues: columns.map { ($0, TableShrink.segments(table, column: $0, in: text)) }) | |
| 534 | shrunk[table.lowerBound] = segments.isEmpty ? nil : segments | |
| 535 | restyleOutsideEditing(table) | |
| 536 | textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange) | |
| 537 | textView.needsDisplay = true | |
| 538 | } | |
| 539 | ||
| 540 | /// C-c TAB in a table: `org-table-toggle-column-width`; `shrink` for `org-table-shrink`, | |
| 541 | /// `expand` for `org-table-expand`. | |
| 542 | private func tableColumnWidth(_ id: String) { | |
| 543 | let caret = textView.selectedRange().location | |
| 544 | let text = textView.string | |
| 545 | guard let table = TableShrink.table(at: caret, in: text) else { | |
| 546 | onMessage?("Not in a table") | |
| 547 | return | |
| 548 | } | |
| 549 | let current = shrunk[table.lowerBound].map { Array($0.keys) } ?? [] | |
| 550 | switch id { | |
| 551 | case "org.table.shrink": setShrunk(TableShrink.cookieColumns(table, in: text), in: table) | |
| 552 | case "org.table.expand": setShrunk([], in: table) | |
| 553 | default: | |
| 554 | if let column = TableShrink.column(at: caret, in: text) { | |
| 555 | setShrunk(Array(Set(current).symmetricDifference([column])), in: table) | |
| 556 | return | |
| 557 | } | |
| 558 | let line = (text as NSString).lineRange(for: NSRange(location: caret, length: 0)) | |
| 559 | onPrompt?(Prompt(key: "columns", message: "Column ranges (e.g. 2-4 6-):")) { [weak self] answer in | |
| 560 | guard let self, let answer else { return } | |
| 561 | self.setShrunk(Array(Set(current).symmetricDifference(TableShrink.columns(answer, max: line.length / 2))), in: table) | |
| 562 | } | |
| 563 | } | |
| 564 | } | |
| 565 | ||
| 566 | /// `#+STARTUP: shrink`: every table's columns with width cookies start narrow. | |
| 567 | private func shrinkAtStartup() { | |
| 568 | guard Presentation.startupOptions(document.tree).contains(where: { $0.lowercased() == "shrink" }) else { return } | |
| 569 | let text = textView.string | |
| 570 | var offset = 0 | |
| 571 | let ns = text as NSString | |
| 572 | while offset < ns.length { | |
| 573 | let line = ns.lineRange(for: NSRange(location: offset, length: 0)) | |
| 574 | defer { offset = max(NSMaxRange(line), offset + 1) } | |
| 575 | guard ns.substring(with: line).range(of: "^[ \\t]*\\|", options: .regularExpression) != nil, | |
| 576 | let table = TableShrink.table(at: line.location, in: text) else { continue } | |
| 577 | let columns = TableShrink.cookieColumns(table, in: text) | |
| 578 | if !columns.isEmpty { shrunk[table.lowerBound] = Dictionary(uniqueKeysWithValues: columns.map { ($0, TableShrink.segments(table, column: $0, in: text)) }) } | |
| 579 | offset = table.upperBound | |
| 580 | } | |
| 581 | } | |
| 582 | ||
| 583 | /// Moves shrunk columns through `edit`; returns the tables to restyle. | |
| 584 | private func mapShrunk(through edit: TextEdit) -> [Range<Int>] { | |
| 585 | guard !shrunk.isEmpty else { return [] } | |
| 586 | let text = textView.string | |
| 587 | let delta = edit.replacement.utf16.count - edit.range.count | |
| 588 | var mapped: [Int: [Int: [TableShrink.Segment]]] = [:] | |
| 589 | var tables: [Range<Int>] = [] | |
| 590 | for (start, columns) in shrunk { | |
| 591 | let kept = columns.filter { _, segments in | |
| 592 | commandEditing || !segments.contains { $0.range.lowerBound <= edit.range.upperBound && edit.range.lowerBound <= $0.range.upperBound } | |
| 593 | }.keys | |
| 594 | let touched = columns.values.joined().contains { $0.range.lowerBound <= edit.range.upperBound && edit.range.lowerBound <= $0.range.upperBound } | |
| 595 | let newStart = start < edit.range.lowerBound ? start : start >= edit.range.upperBound ? start + delta : edit.range.lowerBound | |
| 596 | guard let table = TableShrink.table(at: min(newStart, max(0, text.utf16.count - 1)), in: text) else { continue } | |
| 597 | if touched || kept.count != columns.count { tables.append(table) } | |
| 598 | guard !kept.isEmpty else { continue } | |
| 599 | mapped[table.lowerBound] = Dictionary(uniqueKeysWithValues: kept.map { ($0, TableShrink.segments(table, column: $0, in: text)) }) | |
| 600 | } | |
| 601 | shrunk = mapped | |
| 602 | return tables | |
| 603 | } | |
| 604 | ||
| 605 | /// Hides each shrunk segment's text and draws its display where the text was. | |
| 606 | private func applyShrunk(_ range: Range<Int>, in storage: NSTextStorage) { | |
| 607 | guard !shrunk.isEmpty else { return } | |
| 608 | let cell = ("0" as NSString).size(withAttributes: [.font: theme.font]).width | |
| 609 | for segment in shrunk.values.flatMap({ $0.values.joined() }) where segment.range.overlaps(range) && segment.range.upperBound <= storage.length { | |
| 610 | let r = segment.range | |
| 611 | let shown = segment.display.count | |
| 612 | let kept = min(shown, r.count) | |
| 613 | if r.count > kept { storage.addAttributes(theme.hidden, range: NSRange(location: r.lowerBound, length: r.count - kept)) } | |
| 614 | guard kept > 0 else { continue } | |
| 615 | let visible = NSRange(location: r.upperBound - kept, length: kept) | |
| 616 | storage.addAttribute(.foregroundColor, value: NSColor.clear, range: visible) | |
| 617 | storage.addAttribute(.orgEntityDisplay, value: segment.display, range: NSRange(location: visible.location, length: 1)) | |
| 618 | if shown > kept { storage.addAttribute(.kern, value: CGFloat(shown - kept) * cell, range: NSRange(location: NSMaxRange(visible) - 1, length: 1)) } | |
| 619 | } | |
| 620 | } | |
| 621 | ||
| 513 | 622 | /// Moves drawer and block folds through `edit`, checking again only those near it. |
| 514 | 623 | private func mapWrapperFolds(through edit: TextEdit, near affected: Range<Int>) { |
| 515 | 624 | guard !wrapperHidden.isEmpty else { return } |
| @@ -647,6 +756,7 @@ public final class OrgEditor: NSObject { | ||
| 647 | 756 | } |
| 648 | 757 | } |
| 649 | 758 | if !showMarkup, options.prettyEntities { prettify(range, in: storage, revealed: revealed, scriptExclusions: scriptExclusions) } |
| 759 | applyShrunk(range, in: storage) | |
| 650 | 760 | } |
| 651 | 761 | |
| 652 | 762 | /// `org-pretty-entities`: entities drawn as their character over the entity's last |
| @@ -836,6 +946,7 @@ public final class OrgEditor: NSObject { | ||
| 836 | 946 | mapWrapperFolds(through: edit, near: min(oldMapped.lowerBound, newAffected.lowerBound)..<max(oldMapped.upperBound, newAffected.upperBound)) |
| 837 | 947 | updateHidden() |
| 838 | 948 | restyle(min(oldMapped.lowerBound, newAffected.lowerBound)..<min(max(oldMapped.upperBound, newAffected.upperBound), newText.length), in: storage) |
| 949 | for table in mapShrunk(through: edit) { restyle(table.lowerBound..<min(table.upperBound, newText.length), in: storage) } | |
| 839 | 950 | let oldShaded = shaded.all |
| 840 | 951 | if isOrg { |
| 841 | 952 | // Walking the tree for blocks costs more than typing; only an edit on a line that |
Tests/OrgCoreTests/TableShrinkTests.swift added +33
| @@ -0,0 +1,33 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import OrgCore | |
| 4 | ||
| 5 | struct TableShrinkTests { | |
| 6 | static let oracle = ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil | |
| 7 | ||
| 8 | @Test(.enabled(if: oracle)) | |
| 9 | func placesOverlaysLikeOrg() throws { | |
| 10 | let tables = [ | |
| 11 | "| <5> | <r3> | x | <c6> |\n|--------+------+--------+----------|\n| abcdefghij | ab | long text | mid |\n| | abcd | y | abcdefghi |\n| lead | z | | q |\n", | |
| 12 | "| a | b |\n|---+---|\n| hello world | 1 |\n", | |
| 13 | ] | |
| 14 | for text in tables { | |
| 15 | let table = try #require(TableShrink.table(at: 2, in: text)) | |
| 16 | let columns = TableShrink.cookieColumns(table, in: text) | |
| 17 | var ours: [String] = [] | |
| 18 | for c in columns.isEmpty ? [1, 2] : columns { | |
| 19 | ours += TableShrink.segments(table, column: c, in: text).map { "\($0.range.lowerBound + 1) \($0.range.upperBound + 1) \($0.display)" } | |
| 20 | } | |
| 21 | let shrink = columns.isEmpty ? "(org-table--shrink-columns '(1 2) (org-table-begin) (org-table-end))" : "(org-table-shrink)" | |
| 22 | let emacs = try EmacsOracle.evaluate(text, """ | |
| 23 | (with-current-buffer (find-file-noselect "oracle.org") (goto-char 3) \(shrink) | |
| 24 | (mapcar (lambda (o) (format "%d %d %s" (overlay-start o) (overlay-end o) (substring-no-properties (overlay-get o 'display)))) | |
| 25 | (sort (seq-filter (lambda (o) (eq (overlay-get o 'org-overlay-type) 'table-column-hide)) (overlays-in (point-min) (point-max))) | |
| 26 | (lambda (a b) (< (overlay-start a) (overlay-start b)))))) | |
| 27 | """) | |
| 28 | #expect(emacs.count > 4) | |
| 29 | #expect(ours.sorted { Int($0.split(separator: " ")[0])! < Int($1.split(separator: " ")[0])! } == emacs) | |
| 30 | } | |
| 31 | #expect(TableShrink.columns("2-4 6- 1", max: 7) == [1, 2, 3, 4, 6, 7]) | |
| 32 | } | |
| 33 | } | |