Commit 58136891cd
Verified · cmc
Layout: unified · split
Sources/OrgEditorAppKit/OrgEditor.swift +11 −67
| @@ -180,7 +180,7 @@ public final class OrgEditor: NSObject { | ||
| 180 | 180 | indented = startup.indented |
| 181 | 181 | hideStars = startup.hideStars |
| 182 | 182 | if startup.folds != FoldState() { setFolds(startup.folds, wrappers: startup.wrappers) } |
| 183 | shrinkAtStartup() | |
| 183 | shrunk = ShrunkColumns.startup(document.tree) | |
| 184 | 184 | if startup.inlineImages { showsImages = true } |
| 185 | 185 | if startup.alignTables { _ = perform(AlignAllTables()) } |
| 186 | 186 | if !indented || !hideStars { restyleOutsideEditing(0..<utf16Length) } |
| @@ -812,18 +812,13 @@ public final class OrgEditor: NSObject { | ||
| 812 | 812 | |
| 813 | 813 | // MARK: - Shrunk table columns |
| 814 | 814 | |
| 815 | /// Narrowed table columns (`org-table-shrink`): by table start, each column's segments. | |
| 816 | /// Recomputed as the text changes; typing into one widens its column again, as editing | |
| 817 | /// org's overlays deletes them. Commands' edits keep columns narrow, as `org-table-align` | |
| 818 | /// shrinks them again. | |
| 819 | private var shrunk: [Int: [Int: [TableShrink.Segment]]] = [:] | |
| 815 | /// Narrowed table columns (`org-table-shrink`). | |
| 816 | private var shrunk = ShrunkColumns() | |
| 820 | 817 | private var commandEditing = false |
| 821 | 818 | |
| 822 | 819 | /// Narrows `columns` of `table` and widens its others. |
| 823 | 820 | private func setShrunk(_ columns: [Int], in table: Range<Int>) { |
| 824 | let text = textView.string | |
| 825 | let segments = Dictionary(uniqueKeysWithValues: columns.map { ($0, TableShrink.segments(table, column: $0, in: text)) }) | |
| 826 | shrunk[table.lowerBound] = segments.isEmpty ? nil : segments | |
| 821 | shrunk.set(columns, in: table, text: textView.string) | |
| 827 | 822 | restyleOutsideEditing(table) |
| 828 | 823 | textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange) |
| 829 | 824 | textView.needsDisplay = true |
| @@ -832,73 +827,22 @@ public final class OrgEditor: NSObject { | ||
| 832 | 827 | /// C-c TAB in a table: `org-table-toggle-column-width`; `shrink` for `org-table-shrink`, |
| 833 | 828 | /// `expand` for `org-table-expand`. |
| 834 | 829 | private func tableColumnWidth(_ id: String) { |
| 835 | let caret = textView.selectedRange().location | |
| 836 | let text = textView.string | |
| 837 | guard let table = TableShrink.table(at: caret, in: text) else { | |
| 838 | onMessage?("Not in a table") | |
| 839 | return | |
| 840 | } | |
| 841 | let current = shrunk[table.lowerBound].map { Array($0.keys) } ?? [] | |
| 842 | switch id { | |
| 843 | case "org.table.shrink": setShrunk(TableShrink.cookieColumns(table, in: text), in: table) | |
| 844 | case "org.table.expand": setShrunk([], in: table) | |
| 845 | default: | |
| 846 | if let column = TableShrink.column(at: caret, in: text) { | |
| 847 | setShrunk(Array(Set(current).symmetricDifference([column])), in: table) | |
| 848 | return | |
| 849 | } | |
| 850 | let line = (text as NSString).lineRange(for: NSRange(location: caret, length: 0)) | |
| 830 | switch shrunk.change(for: id, at: textView.selectedRange().location, in: textView.string) { | |
| 831 | case .notInTable: onMessage?("Not in a table") | |
| 832 | case .set(let columns, let table): setShrunk(columns, in: table) | |
| 833 | case .ask(let table, let current, let max): | |
| 851 | 834 | onPrompt?(Prompt(key: "columns", message: "Column ranges (e.g. 2-4 6-):")) { [weak self] answer in |
| 852 | 835 | guard let self, let answer else { return } |
| 853 | self.setShrunk(Array(Set(current).symmetricDifference(TableShrink.columns(answer, max: line.length / 2))), in: table) | |
| 836 | self.setShrunk(ShrunkColumns.toggled(current, TableShrink.columns(answer, max: max)), in: table) | |
| 854 | 837 | } |
| 855 | 838 | } |
| 856 | 839 | } |
| 857 | 840 | |
| 858 | /// `#+STARTUP: shrink`: every table's columns with width cookies start narrow. | |
| 859 | private func shrinkAtStartup() { | |
| 860 | guard Presentation.startupOptions(document.tree).contains(where: { $0.lowercased() == "shrink" }) else { return } | |
| 861 | let text = textView.string | |
| 862 | var offset = 0 | |
| 863 | let ns = text as NSString | |
| 864 | while offset < ns.length { | |
| 865 | let line = ns.lineRange(for: NSRange(location: offset, length: 0)) | |
| 866 | defer { offset = max(NSMaxRange(line), offset + 1) } | |
| 867 | guard ns.substring(with: line).range(of: "^[ \\t]*\\|", options: .regularExpression) != nil, | |
| 868 | let table = TableShrink.table(at: line.location, in: text) else { continue } | |
| 869 | let columns = TableShrink.cookieColumns(table, in: text) | |
| 870 | if !columns.isEmpty { shrunk[table.lowerBound] = Dictionary(uniqueKeysWithValues: columns.map { ($0, TableShrink.segments(table, column: $0, in: text)) }) } | |
| 871 | offset = table.upperBound | |
| 872 | } | |
| 873 | } | |
| 874 | ||
| 875 | /// Moves shrunk columns through `edit`; returns the tables to restyle. | |
| 876 | private func mapShrunk(through edit: TextEdit) -> [Range<Int>] { | |
| 877 | guard !shrunk.isEmpty else { return [] } | |
| 878 | let text = textView.string | |
| 879 | let delta = edit.replacement.utf16.count - edit.range.count | |
| 880 | var mapped: [Int: [Int: [TableShrink.Segment]]] = [:] | |
| 881 | var tables: [Range<Int>] = [] | |
| 882 | for (start, columns) in shrunk { | |
| 883 | let kept = columns.filter { _, segments in | |
| 884 | commandEditing || !segments.contains { $0.range.lowerBound <= edit.range.upperBound && edit.range.lowerBound <= $0.range.upperBound } | |
| 885 | }.keys | |
| 886 | let touched = columns.values.joined().contains { $0.range.lowerBound <= edit.range.upperBound && edit.range.lowerBound <= $0.range.upperBound } | |
| 887 | let newStart = start < edit.range.lowerBound ? start : start >= edit.range.upperBound ? start + delta : edit.range.lowerBound | |
| 888 | guard let table = TableShrink.table(at: min(newStart, max(0, text.utf16.count - 1)), in: text) else { continue } | |
| 889 | if touched || kept.count != columns.count { tables.append(table) } | |
| 890 | guard !kept.isEmpty else { continue } | |
| 891 | mapped[table.lowerBound] = Dictionary(uniqueKeysWithValues: kept.map { ($0, TableShrink.segments(table, column: $0, in: text)) }) | |
| 892 | } | |
| 893 | shrunk = mapped | |
| 894 | return tables | |
| 895 | } | |
| 896 | ||
| 897 | 841 | /// Hides each shrunk segment's text and draws its display where the text was. |
| 898 | 842 | private func applyShrunk(_ range: Range<Int>, in storage: NSTextStorage) { |
| 899 | 843 | guard !shrunk.isEmpty else { return } |
| 900 | 844 | let cell = ("0" as NSString).size(withAttributes: [.font: theme.font]).width |
| 901 | for segment in shrunk.values.flatMap({ $0.values.joined() }) where segment.range.overlaps(range) && segment.range.upperBound <= storage.length { | |
| 845 | for segment in shrunk.segments where segment.range.overlaps(range) && segment.range.upperBound <= storage.length { | |
| 902 | 846 | let r = segment.range |
| 903 | 847 | let shown = segment.display.count |
| 904 | 848 | let kept = min(shown, r.count) |
| @@ -1233,7 +1177,7 @@ public final class OrgEditor: NSObject { | ||
| 1233 | 1177 | highlights = [] |
| 1234 | 1178 | updateHidden() |
| 1235 | 1179 | restyle(min(oldMapped.lowerBound, newAffected.lowerBound)..<min(max(oldMapped.upperBound, newAffected.upperBound), newText.length), in: storage) |
| 1236 | for table in mapShrunk(through: edit) { restyle(table.lowerBound..<min(table.upperBound, newText.length), in: storage) } | |
| 1180 | for table in shrunk.map(through: edit, in: storage.string, command: commandEditing) { restyle(table.lowerBound..<min(table.upperBound, newText.length), in: storage) } | |
| 1237 | 1181 | for range in oldHighlights where range.upperBound <= newText.length { |
| 1238 | 1182 | let lines = newText.lineRange(for: NSRange(range)) |
| 1239 | 1183 | restyle(lines.location..<NSMaxRange(lines), in: storage) |
Sources/OrgPresentation/ShrunkColumns.swift added +101
| @@ -0,0 +1,101 @@ | ||
| 1 | import Foundation | |
| 2 | import OrgCore | |
| 3 | ||
| 4 | /// Narrowed table columns (`org-table-shrink`), as the Mac and iOS editors keep them: by table | |
| 5 | /// start, each column's segments. Recomputed as the text changes; typing into one widens its | |
| 6 | /// column again, as editing org's overlays deletes them, while commands' edits keep columns | |
| 7 | /// narrow, as `org-table-align` shrinks them again. | |
| 8 | public struct ShrunkColumns: Sendable, Equatable { | |
| 9 | public private(set) var tables: [Int: [Int: [TableShrink.Segment]]] = [:] | |
| 10 | ||
| 11 | public init() {} | |
| 12 | ||
| 13 | public var isEmpty: Bool { tables.isEmpty } | |
| 14 | ||
| 15 | /// Every shrunk field's segments. | |
| 16 | public var segments: [TableShrink.Segment] { tables.values.flatMap { $0.values.joined() } } | |
| 17 | ||
| 18 | /// The shrunk columns of the table starting at `table`. | |
| 19 | public func columns(in table: Range<Int>) -> [Int] { | |
| 20 | tables[table.lowerBound].map { Array($0.keys).sorted() } ?? [] | |
| 21 | } | |
| 22 | ||
| 23 | /// Narrows `columns` of `table` and widens its others. | |
| 24 | public mutating func set(_ columns: [Int], in table: Range<Int>, text: String) { | |
| 25 | let segments = Dictionary(uniqueKeysWithValues: columns.map { ($0, TableShrink.segments(table, column: $0, in: text)) }) | |
| 26 | tables[table.lowerBound] = segments.isEmpty ? nil : segments | |
| 27 | } | |
| 28 | ||
| 29 | /// `#+STARTUP: shrink`: every table's columns with width cookies start narrow. | |
| 30 | public static func startup(_ tree: OrgTree) -> ShrunkColumns { | |
| 31 | var result = ShrunkColumns() | |
| 32 | guard Presentation.startupOptions(tree).contains(where: { $0.lowercased() == "shrink" }) else { return result } | |
| 33 | let text = tree.text | |
| 34 | let ns = text as NSString | |
| 35 | var offset = 0 | |
| 36 | while offset < ns.length { | |
| 37 | let line = ns.lineRange(for: NSRange(location: offset, length: 0)) | |
| 38 | defer { offset = max(NSMaxRange(line), offset + 1) } | |
| 39 | guard ns.substring(with: line).range(of: "^[ \\t]*\\|", options: .regularExpression) != nil, | |
| 40 | let table = TableShrink.table(at: line.location, in: text) else { continue } | |
| 41 | let columns = TableShrink.cookieColumns(table, in: text) | |
| 42 | if !columns.isEmpty { result.set(columns, in: table, text: text) } | |
| 43 | offset = table.upperBound | |
| 44 | } | |
| 45 | return result | |
| 46 | } | |
| 47 | ||
| 48 | /// Moves the columns through `edit`, `text` being the text after it; columns the edit | |
| 49 | /// touched widen unless `command` made it. Returns the tables to restyle. | |
| 50 | public mutating func map(through edit: TextEdit, in text: String, command: Bool) -> [Range<Int>] { | |
| 51 | guard !tables.isEmpty else { return [] } | |
| 52 | let delta = edit.replacement.utf16.count - edit.range.count | |
| 53 | func touches(_ segment: TableShrink.Segment) -> Bool { | |
| 54 | segment.range.lowerBound <= edit.range.upperBound && edit.range.lowerBound <= segment.range.upperBound | |
| 55 | } | |
| 56 | var mapped: [Int: [Int: [TableShrink.Segment]]] = [:] | |
| 57 | var restyle: [Range<Int>] = [] | |
| 58 | for (start, columns) in tables { | |
| 59 | let kept = columns.filter { _, segments in command || !segments.contains(where: touches) }.keys | |
| 60 | let touched = columns.values.joined().contains(where: touches) | |
| 61 | let newStart = start < edit.range.lowerBound ? start : start >= edit.range.upperBound ? start + delta : edit.range.lowerBound | |
| 62 | guard let table = TableShrink.table(at: min(newStart, max(0, text.utf16.count - 1)), in: text) else { continue } | |
| 63 | if touched || kept.count != columns.count { restyle.append(table) } | |
| 64 | guard !kept.isEmpty else { continue } | |
| 65 | mapped[table.lowerBound] = Dictionary(uniqueKeysWithValues: kept.map { ($0, TableShrink.segments(table, column: $0, in: text)) }) | |
| 66 | } | |
| 67 | tables = mapped | |
| 68 | return restyle | |
| 69 | } | |
| 70 | ||
| 71 | /// What a column width command does at `caret`. | |
| 72 | public enum Change: Equatable, Sendable { | |
| 73 | case notInTable | |
| 74 | /// Shrink these columns of the table, expanding the others. | |
| 75 | case set([Int], table: Range<Int>) | |
| 76 | /// Ask for column ranges, then toggle them (`toggled`). | |
| 77 | case ask(table: Range<Int>, current: [Int], max: Int) | |
| 78 | } | |
| 79 | ||
| 80 | /// `org-table-toggle-column-width` (`org.table.toggle-column-width`), `org-table-shrink` | |
| 81 | /// (`org.table.shrink`) and `org-table-expand` (`org.table.expand`) at `caret`. | |
| 82 | public func change(for id: String, at caret: Int, in text: String) -> Change { | |
| 83 | guard let table = TableShrink.table(at: caret, in: text) else { return .notInTable } | |
| 84 | let current = columns(in: table) | |
| 85 | switch id { | |
| 86 | case "org.table.shrink": return .set(TableShrink.cookieColumns(table, in: text), table: table) | |
| 87 | case "org.table.expand": return .set([], table: table) | |
| 88 | default: | |
| 89 | if let column = TableShrink.column(at: caret, in: text) { | |
| 90 | return .set(Self.toggled(current, [column]), table: table) | |
| 91 | } | |
| 92 | let line = (text as NSString).lineRange(for: NSRange(location: caret, length: 0)) | |
| 93 | return .ask(table: table, current: current, max: line.length / 2) | |
| 94 | } | |
| 95 | } | |
| 96 | ||
| 97 | /// `current` with `columns` toggled. | |
| 98 | public static func toggled(_ current: [Int], _ columns: [Int]) -> [Int] { | |
| 99 | Set(current).symmetricDifference(columns).sorted() | |
| 100 | } | |
| 101 | } | |
Tests/OrgPresentationTests/ShrunkColumnsTests.swift added +66
| @@ -0,0 +1,66 @@ | ||
| 1 | import Foundation | |
| 2 | import OrgCore | |
| 3 | import Testing | |
| 4 | @testable import OrgPresentation | |
| 5 | ||
| 6 | struct ShrunkColumnsTests { | |
| 7 | static let table = "| <3> | b |\n|------+---|\n| abcdef | x |\n" | |
| 8 | ||
| 9 | @Test func startupShrinksCookieColumnsOnlyWithTheWord() { | |
| 10 | let plain = OrgParser.parse("intro\n" + Self.table) | |
| 11 | #expect(ShrunkColumns.startup(plain).isEmpty) | |
| 12 | let tree = OrgParser.parse("#+STARTUP: shrink\n" + Self.table + "\n| a | b |\n") | |
| 13 | let shrunk = ShrunkColumns.startup(tree) | |
| 14 | let start = ("#+STARTUP: shrink\n" as NSString).length | |
| 15 | #expect(Array(shrunk.tables.keys) == [start]) | |
| 16 | #expect(shrunk.columns(in: start..<start + 1) == [1]) | |
| 17 | #expect(!shrunk.segments.isEmpty) | |
| 18 | } | |
| 19 | ||
| 20 | @Test func togglesTheColumnAtTheCaret() throws { | |
| 21 | let text = Self.table | |
| 22 | var shrunk = ShrunkColumns() | |
| 23 | let caret = (text as NSString).range(of: "abc").location | |
| 24 | guard case .set(let columns, let table) = shrunk.change(for: "org.table.toggle-column-width", at: caret, in: text) else { | |
| 25 | Issue.record("expected a column") | |
| 26 | return | |
| 27 | } | |
| 28 | #expect(columns == [1]) | |
| 29 | shrunk.set(columns, in: table, text: text) | |
| 30 | #expect(shrunk.change(for: "org.table.toggle-column-width", at: caret, in: text) == .set([], table: table)) | |
| 31 | #expect(shrunk.change(for: "org.table.expand", at: caret, in: text) == .set([], table: table)) | |
| 32 | #expect(shrunk.change(for: "org.table.shrink", at: caret, in: text) == .set([1], table: table)) | |
| 33 | #expect(shrunk.change(for: "org.table.shrink", at: (text as NSString).length, in: text + "after\n") == .notInTable) | |
| 34 | guard case .ask(_, let current, _) = shrunk.change(for: "org.table.toggle-column-width", at: 0, in: text) else { | |
| 35 | Issue.record("expected a prompt before the first column") | |
| 36 | return | |
| 37 | } | |
| 38 | #expect(ShrunkColumns.toggled(current, TableShrink.columns("1-2", max: 4)) == [2]) | |
| 39 | } | |
| 40 | ||
| 41 | @Test func typingInAColumnWidensItButCommandsKeepIt() throws { | |
| 42 | let text = Self.table | |
| 43 | let table = try #require(TableShrink.table(at: 0, in: text)) | |
| 44 | var shrunk = ShrunkColumns() | |
| 45 | shrunk.set([1], in: table, text: text) | |
| 46 | let at = (text as NSString).range(of: "abc").location | |
| 47 | let edit = TextEdit(range: at..<at, replacement: "z") | |
| 48 | let after = (text as NSString).replacingCharacters(in: NSRange(location: at, length: 0), with: "z") | |
| 49 | var byCommand = shrunk | |
| 50 | #expect(byCommand.map(through: edit, in: after, command: true).count == 1) | |
| 51 | #expect(byCommand.columns(in: table) == [1]) | |
| 52 | #expect(shrunk.map(through: edit, in: after, command: false).count == 1) | |
| 53 | #expect(shrunk.isEmpty) | |
| 54 | } | |
| 55 | ||
| 56 | @Test func editsBeforeATableMoveIt() throws { | |
| 57 | let text = "x\n" + Self.table | |
| 58 | let table = try #require(TableShrink.table(at: 2, in: text)) | |
| 59 | var shrunk = ShrunkColumns() | |
| 60 | shrunk.set([1], in: table, text: text) | |
| 61 | let after = "xyz\n" + Self.table | |
| 62 | #expect(shrunk.map(through: TextEdit(range: 1..<1, replacement: "yz"), in: after, command: false).isEmpty) | |
| 63 | #expect(Array(shrunk.tables.keys) == [4]) | |
| 64 | #expect(shrunk.segments == TableShrink.segments(4..<4 + (Self.table as NSString).length, column: 1, in: after)) | |
| 65 | } | |
| 66 | } | |