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