krz/orgstar

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

Sources/Orgstar/ModeLine.swift

b8d091bdcbf2889c220b12b2d0640d50cea17c24
orgstar/Sources/Orgstar/ModeLine.swift history · blame · raw

104 lines · 4141 bytes

  1import OrgApp
  2import OrgCore
  3import OrgPresentation
  4import SwiftUI
  5
  6/// The line under the editor: evil state, unsaved mark and the headings around the caret on
  7/// the left; the selection's counts, line and column, the file's words and anything unusual
  8/// about its encoding on the right.
  9struct ModeLine: View {
 10    let session: DocumentSession
 11    let commands: AppCommands
 12    @State private var words: Int?
 13    @State private var crlf = false
 14
 15    var body: some View {
 16        let text = commands.editor?.document.text ?? ""
 17        let selection = commands.selection
 18        HStack(spacing: 10) {
 19            if let state = commands.editorState { StateTag(state: state) }
 20            if session.isDirty {
 21                Image(systemName: "circle.fill")
 22                    .font(.system(size: 6))
 23                    .foregroundStyle(.secondary)
 24                    .help("Unsaved changes")
 25                    .accessibilityLabel("Unsaved changes")
 26            }
 27            breadcrumb(at: selection.location)
 28            Spacer(minLength: 12)
 29            if selection.length > 0, let region = region(text, selection) {
 30                Text("\(region.lines) \(region.lines == 1 ? "line" : "lines"), \(region.words) \(region.words == 1 ? "word" : "words"), \(region.characters) chars")
 31                    .help("Selection (count-words-region)")
 32            }
 33            let position = TextStats.position(in: text, at: selection.location + selection.length)
 34            Text("\(position.line):\(position.column)")
 35                .help("Line \(position.line), column \(position.column)")
 36            if let words {
 37                Text("\(words.formatted()) \(words == 1 ? "word" : "words")")
 38                    .help("Words in the file (count-words)")
 39            }
 40            ForEach(encodingNotes, id: \.self) { note in
 41                Text(note).foregroundStyle(.orange)
 42            }
 43        }
 44        .font(.system(.caption, design: .monospaced))
 45        .monospacedDigit()
 46        .lineLimit(1)
 47        .foregroundStyle(.secondary)
 48        .padding(.horizontal, 10)
 49        .frame(height: 22)
 50        .background(.bar)
 51        .overlay(alignment: .top) { Divider() }
 52        .task(id: WordsKey(path: session.path, version: session.editVersion)) {
 53            guard let text = commands.editor?.document.text ?? session.document?.text else {
 54                words = nil
 55                return
 56            }
 57            (words, crlf) = await Task.detached(priority: .utility) { (TextStats.words(in: text), text.contains("\r\n")) }.value
 58        }
 59    }
 60
 61    private struct WordsKey: Hashable {
 62        var path: String?
 63        var version: Int
 64    }
 65
 66    /// The outline path of the heading containing `offset`, outermost first.
 67    @ViewBuilder
 68    private func breadcrumb(at offset: Int) -> some View {
 69        let path = outlinePath(at: offset)
 70        if !path.isEmpty {
 71            Text(path.joined(separator: " › "))
 72                .truncationMode(.head)
 73                .foregroundStyle(.primary)
 74                .help(path.joined(separator: " › "))
 75        }
 76    }
 77
 78    private func outlinePath(at offset: Int) -> [String] {
 79        let outline = session.outline
 80        var path: [String] = []
 81        var current = outline.lastIndex { $0.start <= offset }
 82        while let i = current {
 83            path.insert(outline[i].title.isEmpty ? "(untitled)" : outline[i].title, at: 0)
 84            current = outline[i].parent
 85        }
 86        return path
 87    }
 88
 89    private func region(_ text: String, _ range: NSRange) -> (lines: Int, words: Int, characters: Int)? {
 90        let ns = text as NSString
 91        guard NSMaxRange(range) <= ns.length else { return nil }
 92        return TextStats.region(ns.substring(with: range))
 93    }
 94
 95    /// Only what differs from UTF-8 with LF endings.
 96    private var encodingNotes: [String] {
 97        guard let document = session.document else { return [] }
 98        var notes: [String] = []
 99        if session.isReadOnly { notes.append("Read-only") }
100        if document.source.hasBOM { notes.append("BOM") }
101        if crlf { notes.append("CRLF") }
102        return notes
103    }
104}