Sources/Orgstar/ModeLine.swift
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}