krz/orgstar

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

Sources/Orgstar/ModeLine.swift

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

138 lines · 5803 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 running clock, the selection's counts, line and column, the file's words,
  8/// anything unusual about its encoding and the git branch on the right.
  9struct ModeLine: View {
 10    let session: DocumentSession
 11    let commands: AppCommands
 12    let clock: ClockModel
 13    /// Runs an app command (clock out, go to the clocked entry).
 14    let perform: (String) -> Void
 15    @State private var words: Int?
 16    @State private var crlf = false
 17    @State private var branch: String?
 18
 19    var body: some View {
 20        let text = commands.editor?.document.text ?? ""
 21        let selection = commands.selection
 22        HStack(spacing: 10) {
 23            if let state = commands.editorState { StateTag(state: state) }
 24            if session.isDirty {
 25                Image(systemName: "circle.fill")
 26                    .font(.system(size: 6))
 27                    .foregroundStyle(.secondary)
 28                    .help("Unsaved changes")
 29                    .accessibilityLabel("Unsaved changes")
 30            }
 31            breadcrumb(at: selection.location)
 32            Spacer(minLength: 12)
 33            if let running = clock.running {
 34                Menu {
 35                    Button("Clock Out") { perform("app.clock.out") }
 36                    Button("Cancel Clock") { perform("app.clock.cancel") }
 37                    Button("Go to Clocked Entry") { perform("app.clock.goto") }
 38                } label: {
 39                    TimelineView(.periodic(from: .now, by: 30)) { context in
 40                        Text("⏱ \(clockElapsed(running, now: context.date)) \(running.heading)")
 41                            .font(.system(.caption, design: .monospaced))
 42                    }
 43                }
 44                .menuStyle(.borderlessButton)
 45                .menuIndicator(.hidden)
 46                .fixedSize()
 47                .foregroundStyle(.primary)
 48                .help("Clocked in since \(running.startedAt.formatted(date: .omitted, time: .shortened))")
 49            }
 50            if selection.length > 0, let region = region(text, selection) {
 51                Text("\(region.lines) \(region.lines == 1 ? "line" : "lines"), \(region.words) \(region.words == 1 ? "word" : "words"), \(region.characters) chars")
 52                    .help("Selection (count-words-region)")
 53            }
 54            let position = TextStats.position(in: text, at: selection.location + selection.length)
 55            Text("\(position.line):\(position.column)")
 56                .help("Line \(position.line), column \(position.column)")
 57            if let words {
 58                Text("\(words.formatted()) \(words == 1 ? "word" : "words")")
 59                    .help("Words in the file (count-words)")
 60            }
 61            ForEach(encodingNotes, id: \.self) { note in
 62                Text(note).foregroundStyle(.orange)
 63            }
 64            if let branch {
 65                Label(branch, systemImage: "arrow.triangle.branch")
 66                    .labelStyle(.titleAndIcon)
 67                    .help("Git branch")
 68            }
 69        }
 70        .font(.system(.caption, design: .monospaced))
 71        .monospacedDigit()
 72        .lineLimit(1)
 73        .foregroundStyle(.secondary)
 74        .padding(.horizontal, 10)
 75        .frame(height: 22)
 76        .background(.bar)
 77        .overlay(alignment: .top) { Divider() }
 78        .task(id: session.path) {
 79            // HEAD changes outside the app, so look again every few seconds.
 80            while !Task.isCancelled {
 81                let path = session.path
 82                branch = await Task.detached(priority: .utility) { path.flatMap(GitBranch.current) }.value
 83                try? await Task.sleep(for: .seconds(5))
 84            }
 85        }
 86        .task(id: WordsKey(path: session.path, version: session.editVersion)) {
 87            guard let text = commands.editor?.document.text ?? session.document?.text else {
 88                words = nil
 89                return
 90            }
 91            (words, crlf) = await Task.detached(priority: .utility) { (TextStats.words(in: text), text.contains("\r\n")) }.value
 92        }
 93    }
 94
 95    private struct WordsKey: Hashable {
 96        var path: String?
 97        var version: Int
 98    }
 99
100    /// The outline path of the heading containing `offset`, outermost first.
101    @ViewBuilder
102    private func breadcrumb(at offset: Int) -> some View {
103        let path = outlinePath(at: offset)
104        if !path.isEmpty {
105            Text(path.joined(separator: " › "))
106                .truncationMode(.head)
107                .foregroundStyle(.primary)
108                .help(path.joined(separator: " › "))
109        }
110    }
111
112    private func outlinePath(at offset: Int) -> [String] {
113        let outline = session.outline
114        var path: [String] = []
115        var current = outline.lastIndex { $0.start <= offset }
116        while let i = current {
117            path.insert(outline[i].title.isEmpty ? "(untitled)" : outline[i].title, at: 0)
118            current = outline[i].parent
119        }
120        return path
121    }
122
123    private func region(_ text: String, _ range: NSRange) -> (lines: Int, words: Int, characters: Int)? {
124        let ns = text as NSString
125        guard NSMaxRange(range) <= ns.length else { return nil }
126        return TextStats.region(ns.substring(with: range))
127    }
128
129    /// Only what differs from UTF-8 with LF endings.
130    private var encodingNotes: [String] {
131        guard let document = session.document else { return [] }
132        var notes: [String] = []
133        if session.isReadOnly { notes.append("Read-only") }
134        if document.source.hasBOM { notes.append("BOM") }
135        if crlf { notes.append("CRLF") }
136        return notes
137    }
138}