| @@ -150,6 +150,7 @@ public final class OrgEditor: NSObject { |
| 150 | let wrappers = Presentation.startupWrappers(document.tree, hideDrawers: options.hideDrawerStartup, hideBlocks: options.hideBlockStartup) |
150 | let wrappers = Presentation.startupWrappers(document.tree, hideDrawers: options.hideDrawerStartup, hideBlocks: options.hideBlockStartup) |
| 151 | startup.wrapperFolds = Set(wrappers.map(\.start)) |
151 | startup.wrapperFolds = Set(wrappers.map(\.start)) |
| 152 | if startup != FoldState() { setFolds(startup, wrappers: wrappers) } |
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 | if !isOrg, id.hasPrefix("org.") { return false } |
370 | if !isOrg, id.hasPrefix("org.") { return false } |
| 370 | return switch EditorCommands.command(id)?.action { |
371 | return switch EditorCommands.command(id)?.action { |
| 371 | case .text(let command): command.applies(in: context) |
372 | case .text(let command): command.applies(in: context) |
| |
373 | case .view where id.hasPrefix("org.table."): KeyContext.holds("table", in: context) |
| 372 | case .view: id != "org.cycle" || Presentation.heading(containing: context.caret, in: context.tree) != nil |
374 | case .view: id != "org.cycle" || Presentation.heading(containing: context.caret, in: context.tree) != nil |
| 373 | || Wrappers.toggleable(at: context.caret, in: context.tree) != nil |
375 | || Wrappers.toggleable(at: context.caret, in: context.tree) != nil |
| 374 | case .edit, .mark, .app, .find: true |
376 | case .edit, .mark, .app, .find: true |
| @@ -395,7 +397,11 @@ public final class OrgEditor: NSObject { |
| 395 | case .text(let text): |
397 | case .text(let text): |
| 396 | perform(text) |
398 | perform(text) |
| 397 | case .view: |
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 | case .edit(let selector, let extending): |
405 | case .edit(let selector, let extending): |
| 400 | if markActive, let extending { |
406 | if markActive, let extending { |
| 401 | keepMark = true |
407 | keepMark = true |
| @@ -436,7 +442,9 @@ public final class OrgEditor: NSObject { |
| 436 | onMessage?("This file is read-only.") |
442 | onMessage?("This file is read-only.") |
| 437 | return .failed("read-only") |
443 | return .failed("read-only") |
| 438 | } |
444 | } |
| |
445 | commandEditing = true |
| 439 | applyEdits(result.edits) |
446 | applyEdits(result.edits) |
| |
447 | commandEditing = false |
| 440 | if let selection = result.selection?.first { |
448 | if let selection = result.selection?.first { |
| 441 | setCaret(selection.lowerBound) |
449 | setCaret(selection.lowerBound) |
| 442 | if !selection.isEmpty { textView.setSelectedRange(NSRange(selection)) } |
450 | if !selection.isEmpty { textView.setSelectedRange(NSRange(selection)) } |
| @@ -510,6 +518,107 @@ public final class OrgEditor: NSObject { |
| 510 | hidden.set(Presentation.hiddenRanges(document.tree, folds: view.folds, bodyFolds: view.bodyFolds, wrapperRanges: Array(wrapperHidden.values))) |
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 | /// Moves drawer and block folds through `edit`, checking again only those near it. |
622 | /// Moves drawer and block folds through `edit`, checking again only those near it. |
| 514 | private func mapWrapperFolds(through edit: TextEdit, near affected: Range<Int>) { |
623 | private func mapWrapperFolds(through edit: TextEdit, near affected: Range<Int>) { |
| 515 | guard !wrapperHidden.isEmpty else { return } |
624 | guard !wrapperHidden.isEmpty else { return } |
| @@ -647,6 +756,7 @@ public final class OrgEditor: NSObject { |
| 647 | } |
756 | } |
| 648 | } |
757 | } |
| 649 | if !showMarkup, options.prettyEntities { prettify(range, in: storage, revealed: revealed, scriptExclusions: scriptExclusions) } |
758 | if !showMarkup, options.prettyEntities { prettify(range, in: storage, revealed: revealed, scriptExclusions: scriptExclusions) } |
| |
759 | applyShrunk(range, in: storage) |
| 650 | } |
760 | } |
| 651 | |
761 | |
| 652 | /// `org-pretty-entities`: entities drawn as their character over the entity's last |
762 | /// `org-pretty-entities`: entities drawn as their character over the entity's last |
| @@ -836,6 +946,7 @@ public final class OrgEditor: NSObject { |
| 836 | mapWrapperFolds(through: edit, near: min(oldMapped.lowerBound, newAffected.lowerBound)..<max(oldMapped.upperBound, newAffected.upperBound)) |
946 | mapWrapperFolds(through: edit, near: min(oldMapped.lowerBound, newAffected.lowerBound)..<max(oldMapped.upperBound, newAffected.upperBound)) |
| 837 | updateHidden() |
947 | updateHidden() |
| 838 | restyle(min(oldMapped.lowerBound, newAffected.lowerBound)..<min(max(oldMapped.upperBound, newAffected.upperBound), newText.length), in: storage) |
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 | let oldShaded = shaded.all |
950 | let oldShaded = shaded.all |
| 840 | if isOrg { |
951 | if isOrg { |
| 841 | // Walking the tree for blocks costs more than typing; only an edit on a line that |
952 | // Walking the tree for blocks costs more than typing; only an edit on a line that |