Commit 3605fe1062
Verified · cmc
Layout: unified · split
Sources/OrgstarMobile/EditorScreen.swift +2
| @@ -414,6 +414,8 @@ struct CommandPalette: View { | ||
| 414 | 414 | ("app.clock.default", "Mark as Default Clock Task"), ("app.clock.resolve", "Resolve Open Clocks…"), |
| 415 | 415 | ("app.toggle-markup", "Show or Hide Markup"), |
| 416 | 416 | ("org.toggle-inline-images", "Show or Hide Inline Images"), ("editor.toggle-truncate-lines", "Truncate or Wrap Long Lines"), |
| 417 | ("org.table.toggle-column-width", "Shrink or Expand Table Column"), ("org.table.shrink", "Shrink Table Columns with Widths"), | |
| 418 | ("org.table.expand", "Expand Table Columns"), | |
| 417 | 419 | ] |
| 418 | 420 | |
| 419 | 421 | var body: some View { |
Sources/OrgstarMobile/MobileEditor.swift +94 −4
| @@ -171,6 +171,10 @@ final class MobileEditor: NSObject, DocumentBuffer { | ||
| 171 | 171 | private static let imageCache = NSCache<NSString, UIImage>() |
| 172 | 172 | private var loading = false |
| 173 | 173 | private var restyleScheduled: Range<Int>? |
| 174 | /// Narrowed table columns (`org-table-shrink`). | |
| 175 | private var shrunk = ShrunkColumns() | |
| 176 | /// A command's edits being applied, which keep shrunk columns narrow. | |
| 177 | private var commandEditing = false | |
| 174 | 178 | |
| 175 | 179 | init( |
| 176 | 180 | document: DocumentState, editable: Bool, options: EditingOptions = .org, theme spec: ThemeSpec = .default, |
| @@ -204,6 +208,7 @@ final class MobileEditor: NSObject, DocumentBuffer { | ||
| 204 | 208 | indented = startup.indented |
| 205 | 209 | hideStars = startup.hideStars |
| 206 | 210 | showsImages = startup.inlineImages |
| 211 | shrunk = ShrunkColumns.startup(document.tree) | |
| 207 | 212 | applyThemeToView() |
| 208 | 213 | load(document.text) |
| 209 | 214 | setFolds(startup.folds) |
| @@ -226,6 +231,7 @@ final class MobileEditor: NSObject, DocumentBuffer { | ||
| 226 | 231 | let result = try change(&document) |
| 227 | 232 | if document.text != before { |
| 228 | 233 | // A save that merged a change from disk, or a revert: the view shows the new text. |
| 234 | shrunk = ShrunkColumns() | |
| 229 | 235 | load(document.text) |
| 230 | 236 | let length = (document.text as NSString).length |
| 231 | 237 | textView.selectedRange = NSRange(location: min(selected.location, length), length: 0) |
| @@ -249,7 +255,9 @@ final class MobileEditor: NSObject, DocumentBuffer { | ||
| 249 | 255 | onMessage?("This file is read-only.") |
| 250 | 256 | return .failed("read-only") |
| 251 | 257 | } |
| 258 | commandEditing = true | |
| 252 | 259 | applyEdits(result.edits) |
| 260 | commandEditing = false | |
| 253 | 261 | if let selected = result.selection?.first { |
| 254 | 262 | unfold(around: selected.lowerBound) |
| 255 | 263 | textView.selectedRange = NSRange(location: selected.lowerBound, length: selected.count) |
| @@ -319,6 +327,7 @@ final class MobileEditor: NSObject, DocumentBuffer { | ||
| 319 | 327 | onMessage?(truncatesLines ? "Truncate long lines enabled" : "Truncate long lines disabled") |
| 320 | 328 | case "edit.undo": textView.undoManager?.undo() |
| 321 | 329 | case "edit.redo": textView.undoManager?.redo() |
| 330 | case "org.table.toggle-column-width", "org.table.shrink", "org.table.expand": tableColumnWidth(id) | |
| 322 | 331 | case _ where id.hasPrefix("app."): |
| 323 | 332 | if let onAppCommand { onAppCommand(id) } else { onMessage?("Not available on iOS yet") } |
| 324 | 333 | default: |
| @@ -339,6 +348,8 @@ final class MobileEditor: NSObject, DocumentBuffer { | ||
| 339 | 348 | case "app.clock.in", "app.clock.out", "app.clock.cancel", "app.clock.goto", "app.clock.in-recent", "app.clock.in-last", |
| 340 | 349 | "app.clock.goto-recent", "app.clock.default", "app.clock.resolve", "app.toggle-markup": return onAppCommand != nil |
| 341 | 350 | case "org.link.open": return OpenLinkAtPoint().applies(in: context) |
| 351 | case "org.table.toggle-column-width", "org.table.shrink", "org.table.expand": | |
| 352 | return TableShrink.table(at: context.caret, in: context.text) != nil | |
| 342 | 353 | default: return false |
| 343 | 354 | } |
| 344 | 355 | } |
| @@ -456,13 +467,16 @@ final class MobileEditor: NSObject, DocumentBuffer { | ||
| 456 | 467 | guard !loading else { return } |
| 457 | 468 | let old = range.location..<(range.location + range.length - delta) |
| 458 | 469 | let replacement = (textView.textStorage.string as NSString).substring(with: range) |
| 470 | let edit = TextEdit(range: old, replacement: replacement) | |
| 459 | 471 | do { |
| 460 | try document.apply([TextEdit(range: old, replacement: replacement)], baseRevision: document.revision) | |
| 472 | try document.apply([edit], baseRevision: document.revision) | |
| 461 | 473 | } catch { |
| 462 | 474 | // Out of step: the view's text wins. |
| 475 | shrunk = ShrunkColumns() | |
| 463 | 476 | load(textView.textStorage.string) |
| 464 | 477 | return |
| 465 | 478 | } |
| 479 | for table in shrunk.map(through: edit, in: textView.textStorage.string, command: commandEditing) { scheduleRestyle(table) } | |
| 466 | 480 | fold = FoldState( |
| 467 | 481 | folds: Self.map(fold.folds, old, delta), bodyFolds: Self.map(fold.bodyFolds, old, delta), |
| 468 | 482 | wrapperFolds: Self.map(fold.wrapperFolds, old, delta) |
| @@ -496,6 +510,16 @@ final class MobileEditor: NSObject, DocumentBuffer { | ||
| 496 | 510 | textView.tintColor = theme.color("cursor") |
| 497 | 511 | textView.typingAttributes = theme.base |
| 498 | 512 | fragmentStyle.set(blockBackground: theme.color("block-background") ?? .clear, width: textView.bounds.width) |
| 513 | themeSelection() | |
| 514 | } | |
| 515 | ||
| 516 | /// The selection in the theme's selection color: the text view's selection display | |
| 517 | /// interaction draws it with our highlight view; the cursor and handles keep the tint. | |
| 518 | private func themeSelection() { | |
| 519 | guard let interaction = textView.interactions.lazy.compactMap({ $0 as? UITextSelectionDisplayInteraction }).first else { return } | |
| 520 | let view = interaction.highlightView as? SelectionHighlightView ?? SelectionHighlightView() | |
| 521 | view.color = theme.color("selection") ?? UIColor.tintColor.withAlphaComponent(0.25) | |
| 522 | if interaction.highlightView !== view { interaction.highlightView = view } | |
| 499 | 523 | } |
| 500 | 524 | |
| 501 | 525 | /// The band behind blocks spans the view's width, or the widest line's when truncated; |
| @@ -570,6 +594,7 @@ final class MobileEditor: NSObject, DocumentBuffer { | ||
| 570 | 594 | if Presentation.excludesScripts(run.role) { scriptExclusions.append(run.range) } |
| 571 | 595 | } |
| 572 | 596 | if !showMarkup, options.prettyEntities { prettify(whole, in: storage, revealed: revealed, scriptExclusions: scriptExclusions) } |
| 597 | applyShrunk(whole, in: storage) | |
| 573 | 598 | applyImages(whole, in: storage, revealed: revealed) |
| 574 | 599 | storage.endEditing() |
| 575 | 600 | } |
| @@ -623,6 +648,47 @@ final class MobileEditor: NSObject, DocumentBuffer { | ||
| 623 | 648 | } |
| 624 | 649 | } |
| 625 | 650 | |
| 651 | // MARK: - Shrunk table columns | |
| 652 | ||
| 653 | /// C-c TAB in a table: `org-table-toggle-column-width`; `shrink` for `org-table-shrink`, | |
| 654 | /// `expand` for `org-table-expand`. | |
| 655 | private func tableColumnWidth(_ id: String) { | |
| 656 | switch shrunk.change(for: id, at: textView.selectedRange.location, in: textView.textStorage.string) { | |
| 657 | case .notInTable: onMessage?("Not in a table") | |
| 658 | case .set(let columns, let table): setShrunk(columns, in: table) | |
| 659 | case .ask(let table, let current, let max): | |
| 660 | onPrompt?(Prompt(key: "columns", message: "Column ranges (e.g. 2-4 6-):")) { [weak self] answer in | |
| 661 | guard let self, let answer else { return } | |
| 662 | self.setShrunk(ShrunkColumns.toggled(current, TableShrink.columns(answer, max: max)), in: table) | |
| 663 | } | |
| 664 | } | |
| 665 | } | |
| 666 | ||
| 667 | /// Narrows `columns` of `table` and widens its others. | |
| 668 | private func setShrunk(_ columns: [Int], in table: Range<Int>) { | |
| 669 | shrunk.set(columns, in: table, text: textView.textStorage.string) | |
| 670 | restyleAll() | |
| 671 | } | |
| 672 | ||
| 673 | /// Hides each shrunk segment's text and draws its display where the text was, as pretty | |
| 674 | /// entities draw theirs. | |
| 675 | private func applyShrunk(_ range: Range<Int>, in storage: NSTextStorage) { | |
| 676 | guard !shrunk.isEmpty else { return } | |
| 677 | let cell = theme.column | |
| 678 | for segment in shrunk.segments where segment.range.overlaps(range) && segment.range.upperBound <= storage.length { | |
| 679 | let r = segment.range | |
| 680 | let shown = segment.display.count | |
| 681 | let kept = min(shown, r.count) | |
| 682 | if r.count > kept { storage.addAttributes(theme.hidden, range: NSRange(location: r.lowerBound, length: r.count - kept)) } | |
| 683 | guard kept > 0 else { continue } | |
| 684 | let visible = NSRange(location: r.upperBound - kept, length: kept) | |
| 685 | let color = storage.attribute(.foregroundColor, at: visible.location, effectiveRange: nil) as? UIColor ?? theme.foreground | |
| 686 | storage.addAttribute(.foregroundColor, value: UIColor.clear, range: visible) | |
| 687 | storage.addAttributes([.orgEntityDisplay: segment.display, .orgEntityColor: color], range: NSRange(location: visible.location, length: 1)) | |
| 688 | if shown > kept { storage.addAttribute(.kern, value: CGFloat(shown - kept) * cell, range: NSRange(location: NSMaxRange(visible) - 1, length: 1)) } | |
| 689 | } | |
| 690 | } | |
| 691 | ||
| 626 | 692 | // MARK: - Inline images |
| 627 | 693 | |
| 628 | 694 | private func toggleImages() { |
| @@ -634,7 +700,7 @@ final class MobileEditor: NSObject, DocumentBuffer { | ||
| 634 | 700 | var offset = 0 |
| 635 | 701 | while offset < ns.length { |
| 636 | 702 | let line = ns.lineRange(for: NSRange(location: offset, length: 0)) |
| 637 | if let image = InlineImages.image(atLine: line.location, in: ns), loadImage(image.path) != nil { count += 1 } | |
| 703 | if let image = InlineImages.image(atLine: line.location, in: ns), Self.loadImage(image.path, relativeTo: baseDirectory) != nil { count += 1 } | |
| 638 | 704 | offset = NSMaxRange(line) |
| 639 | 705 | } |
| 640 | 706 | onMessage?("\(count) images displayed inline") |
| @@ -643,7 +709,8 @@ final class MobileEditor: NSObject, DocumentBuffer { | ||
| 643 | 709 | } |
| 644 | 710 | } |
| 645 | 711 | |
| 646 | private func loadImage(_ path: String) -> UIImage? { | |
| 712 | /// An image link's image, by its path or one relative to `baseDirectory`. | |
| 713 | static func loadImage(_ path: String, relativeTo baseDirectory: URL?) -> UIImage? { | |
| 647 | 714 | let expanded = (path as NSString).expandingTildeInPath |
| 648 | 715 | let url: URL |
| 649 | 716 | if expanded.hasPrefix("/") { |
| @@ -672,7 +739,7 @@ final class MobileEditor: NSObject, DocumentBuffer { | ||
| 672 | 739 | while location < range.upperBound, location < text.length { |
| 673 | 740 | let line = text.lineRange(for: NSRange(location: location, length: 0)) |
| 674 | 741 | location = NSMaxRange(line) |
| 675 | guard let image = InlineImages.image(atLine: line.location, in: text), let picture = loadImage(image.path) else { continue } | |
| 742 | guard let image = InlineImages.image(atLine: line.location, in: text), let picture = Self.loadImage(image.path, relativeTo: baseDirectory) else { continue } | |
| 676 | 743 | let width = min(image.width.map(CGFloat.init) ?? picture.size.width, maxWidth) |
| 677 | 744 | let height = picture.size.width > 0 ? width * picture.size.height / picture.size.width : 0 |
| 678 | 745 | let content = NSRange(location: line.location, length: line.length - (text.substring(with: line).hasSuffix("\n") ? 1 : 0)) |
| @@ -853,6 +920,29 @@ final class InlineImageBox: NSObject, @unchecked Sendable { | ||
| 853 | 920 | } |
| 854 | 921 | } |
| 855 | 922 | |
| 923 | /// Selected text's background, in one color however the rects overlap. | |
| 924 | final class SelectionHighlightView: UIView, UITextSelectionHighlightView { | |
| 925 | var selectionRects: [UITextSelectionRect] = [] { didSet { setNeedsDisplay() } } | |
| 926 | var color: UIColor = .clear { didSet { setNeedsDisplay() } } | |
| 927 | ||
| 928 | override init(frame: CGRect) { | |
| 929 | super.init(frame: frame) | |
| 930 | isOpaque = false | |
| 931 | backgroundColor = .clear | |
| 932 | isUserInteractionEnabled = false | |
| 933 | registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (view: SelectionHighlightView, _) in view.setNeedsDisplay() } | |
| 934 | } | |
| 935 | ||
| 936 | required init?(coder: NSCoder) { fatalError("init(coder:) is not used") } | |
| 937 | ||
| 938 | override func draw(_ rect: CGRect) { | |
| 939 | let path = UIBezierPath() | |
| 940 | for selection in selectionRects where !selection.rect.isEmpty { path.append(UIBezierPath(rect: selection.rect)) } | |
| 941 | color.setFill() | |
| 942 | path.fill() | |
| 943 | } | |
| 944 | } | |
| 945 | ||
| 856 | 946 | /// A line drawn with more than its text, as the Mac editor's fragments draw them: a folded |
| 857 | 947 | /// line's ellipsis, a block's band across the view, an image under an image link, and pretty |
| 858 | 948 | /// entities' characters where the entities' last (invisible) characters sit. |