krz/orgstar

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

Commit f951456c71

f951456c7144adab99f9f4d30051c24aa6c5aab5

parent: ed6e31776e

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-07 00:03 UTC

iOS capture: the template's questions

Before the text, iOS capture asks a template's questions as the Mac does:
text with its choices, tags offering the target file's and the workspace's,
dates with a calendar or Org's date syntax, and property values. Templates
with immediate-finish file at once.

Layout: unified · split

Sources/OrgstarMobile/CaptureScreen.swift +107 −16
@@ -3,7 +3,8 @@ import OrgApp
3import OrgCore 3import OrgCore
4import SwiftUI 4import SwiftUI
5 5
6/// Capture with a template: its text to edit, filed where the template says. 6/// Capture with a template: its questions (`%^{…}`, `%^g`, `%^t`…), then its text to edit,
7/// filed where the template says, as `org-capture` does.
7struct CaptureScreen: View { 8struct CaptureScreen: View {
8 let workspace: WorkspaceModel 9 let workspace: WorkspaceModel
9 let session: DocumentSession 10 let session: DocumentSession
@@ -11,6 +12,10 @@ struct CaptureScreen: View {
11 @Environment(\.dismiss) private var dismiss 12 @Environment(\.dismiss) private var dismiss
12 @State private var templates: [CaptureTemplate] = [] 13 @State private var templates: [CaptureTemplate] = []
13 @State private var key: String? 14 @State private var key: String?
15 @State private var context = CaptureContext()
16 @State private var questions: [Capture.Question] = []
17 @State private var answers: [String] = []
18 @State private var editing = false
14 @State private var text = "" 19 @State private var text = ""
15 @State private var selection: TextSelection? 20 @State private var selection: TextSelection?
16 @State private var problem: String? 21 @State private var problem: String?
@@ -24,12 +29,19 @@ struct CaptureScreen: View {
24 Picker("Template", selection: $key) { 29 Picker("Template", selection: $key) {
25 ForEach(templates) { Text($0.name).tag(Optional($0.key)) } 30 ForEach(templates) { Text($0.name).tag(Optional($0.key)) }
26 } 31 }
27 Section { 32 if editing {
28 TextEditor(text: $text, selection: $selection) 33 Section {
29 .font(.body.monospaced()) 34 TextEditor(text: $text, selection: $selection)
30 .frame(minHeight: 200) 35 .font(.body.monospaced())
31 .focused($focused) 36 .frame(minHeight: 200)
32 } footer: { 37 .focused($focused)
38 } footer: {
39 if let problem { Text(problem).foregroundStyle(.red) }
40 }
41 } else {
42 ForEach(questions.indices, id: \.self) { index in
43 QuestionField(question: questions[index], answer: $answers[index])
44 }
33 if let problem { Text(problem).foregroundStyle(.red) } 45 if let problem { Text(problem).foregroundStyle(.red) }
34 } 46 }
35 } 47 }
@@ -37,7 +49,13 @@ struct CaptureScreen: View {
37 .navigationBarTitleDisplayMode(.inline) 49 .navigationBarTitleDisplayMode(.inline)
38 .toolbar { 50 .toolbar {
39 ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } 51 ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } }
40 ToolbarItem(placement: .confirmationAction) { Button("File", action: file).disabled(template == nil) } 52 ToolbarItem(placement: .confirmationAction) {
53 if editing {
54 Button("File", action: file).disabled(template == nil)
55 } else {
56 Button("Continue", action: fill)
57 }
58 }
41 } 59 }
42 } 60 }
43 .onAppear { 61 .onAppear {
@@ -45,26 +63,46 @@ struct CaptureScreen: View {
45 templates = loaded.templates 63 templates = loaded.templates
46 problem = loaded.problems.first 64 problem = loaded.problems.first
47 key = request.template.flatMap { wanted in templates.first { $0.key == wanted }?.key } ?? templates.first?.key 65 key = request.template.flatMap { wanted in templates.first { $0.key == wanted }?.key } ?? templates.first?.key
48 focused = true
49 } 66 }
50 .onChange(of: key, initial: true) { fill() } 67 .onChange(of: key, initial: true) { choose() }
51 } 68 }
52 69
53 /// The template expanded, its prompts taking their defaults. 70 /// The template's questions, asked before its text; none, and the text shows at once.
54 private func fill() { 71 private func choose() {
55 guard let template else { return } 72 guard let template else { return }
56 var context = CaptureContext( 73 context = CaptureContext(
57 initial: request.properties["initial"] ?? "", annotation: request.properties["annotation"] ?? "", 74 initial: request.properties["initial"] ?? "", annotation: request.properties["annotation"] ?? "",
58 allTags: (try? workspace.index.tags()) ?? [] 75 allTags: (try? workspace.index.tags()) ?? []
59 ) 76 )
60 context.linkProperties = request.properties 77 context.linkProperties = request.properties
61 let answers = Capture.questions(template.template, context: context).map { _ in "" } 78 // `%^g` offers the target file's tags.
79 let path = DocumentSession.resolve(template.target.file, root: workspace.rootPaths.first)
80 if !template.target.file.isEmpty,
81 let source = session.openTexts[path] ?? (try? String(contentsOfFile: path, encoding: .utf8)) {
82 var tags: [String] = []
83 for heading in DocumentModel(tree: OrgParser.parse(source)).headings { for tag in heading.localTags where !tags.contains(tag) { tags.append(tag) } }
84 context.fileTags = tags
85 }
86 questions = Capture.questions(template.template, context: context)
87 answers = questions.map { _ in "" }
88 editing = false
89 if questions.isEmpty { fill() }
90 }
91
92 /// The template expanded with the answers; the caret where `%?` was.
93 private func fill() {
94 guard let template else { return }
95 context.now = Date()
62 switch Capture.fill(template.template, context: context, answers: answers) { 96 switch Capture.fill(template.template, context: context, answers: answers) {
63 case .success(let filled): 97 case .success(let filled):
64 text = filled.text 98 text = filled.text
65 // Where the template's `%?` was.
66 selection = TextSelection(insertionPoint: String.Index(utf16Offset: filled.cursor, in: filled.text)) 99 selection = TextSelection(insertionPoint: String.Index(utf16Offset: filled.cursor, in: filled.text))
67 case .failure(.message(let message)): problem = message 100 problem = nil
101 editing = true
102 focused = true
103 if template.options.immediateFinish { file() }
104 case .failure(.message(let message)):
105 problem = message
68 } 106 }
69 } 107 }
70 108
@@ -90,4 +128,57 @@ struct CaptureScreen: View {
90 } 128 }
91 } 129 }
92} 130}
131
132/// One of a template's questions: text with its choices, tags, or a date.
133struct QuestionField: View {
134 let question: Capture.Question
135 @Binding var answer: String
136 @State private var date = Date()
137
138 var body: some View {
139 Section {
140 TextField(placeholder, text: $answer)
141 .autocorrectionDisabled()
142 .textInputAutocapitalization(.never)
143 if case .date(let withTime, _) = question.kind {
144 DatePicker("Date", selection: $date, displayedComponents: withTime ? [.date, .hourAndMinute] : [.date])
145 .onChange(of: date) { answer = Self.stamp(date, withTime: withTime) }
146 }
147 if !question.choices.isEmpty {
148 ScrollView(.horizontal, showsIndicators: false) {
149 HStack {
150 ForEach(question.choices.prefix(30), id: \.self) { choice in
151 Button(choice) { pick(choice) }.buttonStyle(.bordered)
152 }
153 }
154 }
155 }
156 } header: {
157 Text(question.prompt)
158 }
159 }
160
161 /// What an empty answer means.
162 private var placeholder: String {
163 switch question.kind {
164 case .text, .property, .clipboard: question.defaultValue
165 case .tags: "tag:tag"
166 case .date(let withTime, _): withTime ? "now; or +2d, fri 14:00" : "today; or +2d, fri"
167 }
168 }
169
170 /// Tags add to what's typed; other choices replace it.
171 private func pick(_ choice: String) {
172 guard question.kind == .tags else { return answer = choice }
173 let tags = answer.split(separator: ":").map(String.init)
174 if !tags.contains(choice) { answer = (tags + [choice]).joined(separator: ":") }
175 }
176
177 static func stamp(_ date: Date, withTime: Bool) -> String {
178 let formatter = DateFormatter()
179 formatter.locale = Locale(identifier: "en_US_POSIX")
180 formatter.dateFormat = withTime ? "yyyy-MM-dd HH:mm" : "yyyy-MM-dd"
181 return formatter.string(from: date)
182 }
183}
93#endif 184#endif