Sources/Orgstar/ModeLine.swift
139 lines · 6046 bytes
5 symbols in this file
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}