krz/orgstar

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

Sources/Orgstar/ModeLine.swift

191ddb506f3a91b93c755ba6e8123a4e8ac4f90d
orgstar/Sources/Orgstar/ModeLine.swift history · blame · raw

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