krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Commit 3605fe1062

3605fe10628204897eb73b0699a574783941cf45

parent: 58136891cd

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-09 01:28 UTC

iOS editor: shrunk table columns and themed selection

#+STARTUP: shrink narrows columns with width cookies at open, and the
column width commands (C-c TAB, shrink, expand) are in the palette and
keymap, drawn as on the Mac. The selection is drawn in the theme's
selection color through the text view's selection display interaction;
the cursor and handles keep the cursor color. Image loading is shared
with the reader.

Layout: unified · split

Sources/OrgstarMobile/EditorScreen.swift +2
@@ -414,6 +414,8 @@ struct CommandPalette: View {
414 ("app.clock.default", "Mark as Default Clock Task"), ("app.clock.resolve", "Resolve Open Clocks…"), 414 ("app.clock.default", "Mark as Default Clock Task"), ("app.clock.resolve", "Resolve Open Clocks…"),
415 ("app.toggle-markup", "Show or Hide Markup"), 415 ("app.toggle-markup", "Show or Hide Markup"),
416 ("org.toggle-inline-images", "Show or Hide Inline Images"), ("editor.toggle-truncate-lines", "Truncate or Wrap Long Lines"), 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 var body: some View { 421 var body: some View {
Sources/OrgstarMobile/MobileEditor.swift +94 −4
@@ -171,6 +171,10 @@ final class MobileEditor: NSObject, DocumentBuffer {
171 private static let imageCache = NSCache<NSString, UIImage>() 171 private static let imageCache = NSCache<NSString, UIImage>()
172 private var loading = false 172 private var loading = false
173 private var restyleScheduled: Range<Int>? 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 init( 179 init(
176 document: DocumentState, editable: Bool, options: EditingOptions = .org, theme spec: ThemeSpec = .default, 180 document: DocumentState, editable: Bool, options: EditingOptions = .org, theme spec: ThemeSpec = .default,
@@ -204,6 +208,7 @@ final class MobileEditor: NSObject, DocumentBuffer {
204 indented = startup.indented 208 indented = startup.indented
205 hideStars = startup.hideStars 209 hideStars = startup.hideStars
206 showsImages = startup.inlineImages 210 showsImages = startup.inlineImages
211 shrunk = ShrunkColumns.startup(document.tree)
207 applyThemeToView() 212 applyThemeToView()
208 load(document.text) 213 load(document.text)
209 setFolds(startup.folds) 214 setFolds(startup.folds)
@@ -226,6 +231,7 @@ final class MobileEditor: NSObject, DocumentBuffer {
226 let result = try change(&document) 231 let result = try change(&document)
227 if document.text != before { 232 if document.text != before {
228 // A save that merged a change from disk, or a revert: the view shows the new text. 233 // A save that merged a change from disk, or a revert: the view shows the new text.
234 shrunk = ShrunkColumns()
229 load(document.text) 235 load(document.text)
230 let length = (document.text as NSString).length 236 let length = (document.text as NSString).length
231 textView.selectedRange = NSRange(location: min(selected.location, length), length: 0) 237 textView.selectedRange = NSRange(location: min(selected.location, length), length: 0)
@@ -249,7 +255,9 @@ final class MobileEditor: NSObject, DocumentBuffer {
249 onMessage?("This file is read-only.") 255 onMessage?("This file is read-only.")
250 return .failed("read-only") 256 return .failed("read-only")
251 } 257 }
258 commandEditing = true
252 applyEdits(result.edits) 259 applyEdits(result.edits)
260 commandEditing = false
253 if let selected = result.selection?.first { 261 if let selected = result.selection?.first {
254 unfold(around: selected.lowerBound) 262 unfold(around: selected.lowerBound)
255 textView.selectedRange = NSRange(location: selected.lowerBound, length: selected.count) 263 textView.selectedRange = NSRange(location: selected.lowerBound, length: selected.count)
@@ -319,6 +327,7 @@ final class MobileEditor: NSObject, DocumentBuffer {
319 onMessage?(truncatesLines ? "Truncate long lines enabled" : "Truncate long lines disabled") 327 onMessage?(truncatesLines ? "Truncate long lines enabled" : "Truncate long lines disabled")
320 case "edit.undo": textView.undoManager?.undo() 328 case "edit.undo": textView.undoManager?.undo()
321 case "edit.redo": textView.undoManager?.redo() 329 case "edit.redo": textView.undoManager?.redo()
330 case "org.table.toggle-column-width", "org.table.shrink", "org.table.expand": tableColumnWidth(id)
322 case _ where id.hasPrefix("app."): 331 case _ where id.hasPrefix("app."):
323 if let onAppCommand { onAppCommand(id) } else { onMessage?("Not available on iOS yet") } 332 if let onAppCommand { onAppCommand(id) } else { onMessage?("Not available on iOS yet") }
324 default: 333 default:
@@ -339,6 +348,8 @@ final class MobileEditor: NSObject, DocumentBuffer {
339 case "app.clock.in", "app.clock.out", "app.clock.cancel", "app.clock.goto", "app.clock.in-recent", "app.clock.in-last", 348 case "app.clock.in", "app.clock.out", "app.clock.cancel", "app.clock.goto", "app.clock.in-recent", "app.clock.in-last",
340 "app.clock.goto-recent", "app.clock.default", "app.clock.resolve", "app.toggle-markup": return onAppCommand != nil 349 "app.clock.goto-recent", "app.clock.default", "app.clock.resolve", "app.toggle-markup": return onAppCommand != nil
341 case "org.link.open": return OpenLinkAtPoint().applies(in: context) 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 default: return false 353 default: return false
343 } 354 }
344 } 355 }
@@ -456,13 +467,16 @@ final class MobileEditor: NSObject, DocumentBuffer {
456 guard !loading else { return } 467 guard !loading else { return }
457 let old = range.location..<(range.location + range.length - delta) 468 let old = range.location..<(range.location + range.length - delta)
458 let replacement = (textView.textStorage.string as NSString).substring(with: range) 469 let replacement = (textView.textStorage.string as NSString).substring(with: range)
470 let edit = TextEdit(range: old, replacement: replacement)
459 do { 471 do {
460 try document.apply([TextEdit(range: old, replacement: replacement)], baseRevision: document.revision) 472 try document.apply([edit], baseRevision: document.revision)
461 } catch { 473 } catch {
462 // Out of step: the view's text wins. 474 // Out of step: the view's text wins.
475 shrunk = ShrunkColumns()
463 load(textView.textStorage.string) 476 load(textView.textStorage.string)
464 return 477 return
465 } 478 }
479 for table in shrunk.map(through: edit, in: textView.textStorage.string, command: commandEditing) { scheduleRestyle(table) }
466 fold = FoldState( 480 fold = FoldState(
467 folds: Self.map(fold.folds, old, delta), bodyFolds: Self.map(fold.bodyFolds, old, delta), 481 folds: Self.map(fold.folds, old, delta), bodyFolds: Self.map(fold.bodyFolds, old, delta),
468 wrapperFolds: Self.map(fold.wrapperFolds, old, delta) 482 wrapperFolds: Self.map(fold.wrapperFolds, old, delta)
@@ -496,6 +510,16 @@ final class MobileEditor: NSObject, DocumentBuffer {
496 textView.tintColor = theme.color("cursor") 510 textView.tintColor = theme.color("cursor")
497 textView.typingAttributes = theme.base 511 textView.typingAttributes = theme.base
498 fragmentStyle.set(blockBackground: theme.color("block-background") ?? .clear, width: textView.bounds.width) 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 /// The band behind blocks spans the view's width, or the widest line's when truncated; 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 if Presentation.excludesScripts(run.role) { scriptExclusions.append(run.range) } 594 if Presentation.excludesScripts(run.role) { scriptExclusions.append(run.range) }
571 } 595 }
572 if !showMarkup, options.prettyEntities { prettify(whole, in: storage, revealed: revealed, scriptExclusions: scriptExclusions) } 596 if !showMarkup, options.prettyEntities { prettify(whole, in: storage, revealed: revealed, scriptExclusions: scriptExclusions) }
597 applyShrunk(whole, in: storage)
573 applyImages(whole, in: storage, revealed: revealed) 598 applyImages(whole, in: storage, revealed: revealed)
574 storage.endEditing() 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 // MARK: - Inline images 692 // MARK: - Inline images
627 693
628 private func toggleImages() { 694 private func toggleImages() {
@@ -634,7 +700,7 @@ final class MobileEditor: NSObject, DocumentBuffer {
634 var offset = 0 700 var offset = 0
635 while offset < ns.length { 701 while offset < ns.length {
636 let line = ns.lineRange(for: NSRange(location: offset, length: 0)) 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 offset = NSMaxRange(line) 704 offset = NSMaxRange(line)
639 } 705 }
640 onMessage?("\(count) images displayed inline") 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 let expanded = (path as NSString).expandingTildeInPath 714 let expanded = (path as NSString).expandingTildeInPath
648 let url: URL 715 let url: URL
649 if expanded.hasPrefix("/") { 716 if expanded.hasPrefix("/") {
@@ -672,7 +739,7 @@ final class MobileEditor: NSObject, DocumentBuffer {
672 while location < range.upperBound, location < text.length { 739 while location < range.upperBound, location < text.length {
673 let line = text.lineRange(for: NSRange(location: location, length: 0)) 740 let line = text.lineRange(for: NSRange(location: location, length: 0))
674 location = NSMaxRange(line) 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 let width = min(image.width.map(CGFloat.init) ?? picture.size.width, maxWidth) 743 let width = min(image.width.map(CGFloat.init) ?? picture.size.width, maxWidth)
677 let height = picture.size.width > 0 ? width * picture.size.height / picture.size.width : 0 744 let height = picture.size.width > 0 ? width * picture.size.height / picture.size.width : 0
678 let content = NSRange(location: line.location, length: line.length - (text.substring(with: line).hasSuffix("\n") ? 1 : 0)) 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.
924final 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/// A line drawn with more than its text, as the Mac editor's fragments draw them: a folded 946/// A line drawn with more than its text, as the Mac editor's fragments draw them: a folded
857/// line's ellipsis, a block's band across the view, an image under an image link, and pretty 947/// line's ellipsis, a block's band across the view, an image under an image link, and pretty
858/// entities' characters where the entities' last (invisible) characters sit. 948/// entities' characters where the entities' last (invisible) characters sit.