krz/orgstar

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

Sources/Orgstar/DatePickerView.swift

15f6b0709d88971fb62ed432c3e5b8032643670a
orgstar/Sources/Orgstar/DatePickerView.swift history · blame · raw

113 lines · 5520 bytes

  1import OrgCore
  2import SwiftUI
  3
  4/// What `org-read-date` shows beside its prompt: the date the answer reads as, and a month
  5/// calendar to pick one by clicking.
  6struct DatePickerView: View {
  7    let answer: String
  8    let read: Prompt.DateRead
  9    let pick: (String) -> Void
 10    @State private var month: Date?
 11    private let calendar = Calendar.current
 12
 13    var body: some View {
 14        let result = Self.result(answer, read)
 15        let selected = calendar.date(from: result.components(calendar))!
 16        let shown = month ?? selected
 17        VStack(alignment: .leading, spacing: 6) {
 18            HStack {
 19                Button { month = calendar.date(byAdding: .month, value: -1, to: shown) } label: { Image(systemName: "chevron.left") }
 20                    .accessibilityLabel("Previous month")
 21                Text(shown.formatted(.dateTime.month(.wide).year())).frame(minWidth: 140)
 22                Button { month = calendar.date(byAdding: .month, value: 1, to: shown) } label: { Image(systemName: "chevron.right") }
 23                    .accessibilityLabel("Next month")
 24                Spacer()
 25                Text("=> " + Self.stamp(result, calendar, inactive: read.inactive)).font(.system(.callout, design: .monospaced))
 26            }
 27            .buttonStyle(.plain)
 28            Grid(horizontalSpacing: 4, verticalSpacing: 2) {
 29                GridRow {
 30                    ForEach(weekdaySymbols.indices, id: \.self) { Text(weekdaySymbols[$0]).foregroundStyle(.secondary) }
 31                }
 32                ForEach(weeks(of: shown), id: \.self) { week in
 33                    GridRow {
 34                        ForEach(week.indices, id: \.self) { i in
 35                            if let day = week[i] {
 36                                dayButton(day, selected: calendar.isDate(day, inSameDayAs: selected), result: result)
 37                            } else {
 38                                Color.clear.frame(width: 1, height: 1)
 39                            }
 40                        }
 41                    }
 42                }
 43            }
 44            .font(.system(.callout, design: .monospaced))
 45        }
 46        .padding(10)
 47        .onChange(of: selected) { month = nil }
 48    }
 49
 50    private func dayButton(_ day: Date, selected: Bool, result: ReadDate.Answer) -> some View {
 51        Button {
 52            pick(Self.answer(for: day, keeping: result, calendar))
 53        } label: {
 54            Text("\(calendar.component(.day, from: day))")
 55                .frame(width: 26, height: 20)
 56                .background(selected ? Color.accentColor.opacity(0.35) : .clear, in: RoundedRectangle(cornerRadius: 4))
 57                .overlay {
 58                    if calendar.isDateInToday(day) { RoundedRectangle(cornerRadius: 4).stroke(Color.accentColor) }
 59                }
 60        }
 61        .buttonStyle(.plain)
 62        .accessibilityLabel(day.formatted(date: .complete, time: .omitted))
 63    }
 64
 65    private var weekdaySymbols: [String] {
 66        let symbols = calendar.veryShortWeekdaySymbols
 67        let first = calendar.firstWeekday - 1
 68        return Array(symbols[first...] + symbols[..<first])
 69    }
 70
 71    /// The month's days in weeks, nil before the first and after the last.
 72    private func weeks(of date: Date) -> [[Date?]] {
 73        let start = calendar.date(from: calendar.dateComponents([.year, .month], from: date))!
 74        let count = calendar.range(of: .day, in: .month, for: start)!.count
 75        let lead = (calendar.component(.weekday, from: start) - calendar.firstWeekday + 7) % 7
 76        var days: [Date?] = Array(repeating: nil, count: lead)
 77        days += (0..<count).map { calendar.date(byAdding: .day, value: $0, to: start) }
 78        days += Array(repeating: nil, count: (7 - days.count % 7) % 7)
 79        return stride(from: 0, to: days.count, by: 7).map { Array(days[$0..<$0 + 7]) }
 80    }
 81
 82    static func result(_ answer: String, _ read: Prompt.DateRead) -> ReadDate.Answer {
 83        ReadDate.read(answer, default: read.defaultDate, now: Date(), calendar: .current)
 84    }
 85
 86    /// The answer for `day`, with the time `result` had.
 87    static func answer(for day: Date, keeping result: ReadDate.Answer, _ calendar: Calendar) -> String {
 88        let c = calendar.dateComponents([.year, .month, .day], from: day)
 89        var text = String(format: "%04d-%02d-%02d", c.year!, c.month!, c.day!)
 90        if result.timeGiven {
 91            text += String(format: " %02d:%02d", result.hour, result.minute)
 92            if let end = result.endTime { text += "-" + end }
 93        }
 94        return text
 95    }
 96
 97    /// The answer moved by `days`, as S-arrows move `org-read-date`'s calendar.
 98    static func shifted(_ answer: String, _ read: Prompt.DateRead, by days: Int) -> String {
 99        let calendar = Calendar.current
100        let result = result(answer, read)
101        let day = calendar.date(byAdding: .day, value: days, to: calendar.date(from: result.components(calendar))!)!
102        return Self.answer(for: day, keeping: result, calendar)
103    }
104
105    static func stamp(_ result: ReadDate.Answer, _ calendar: Calendar, inactive: Bool) -> String {
106        let formatter = DateFormatter()
107        formatter.locale = Locale(identifier: "en_US_POSIX")
108        formatter.dateFormat = result.timeGiven ? "yyyy-MM-dd EEE HH:mm" : "yyyy-MM-dd EEE"
109        let end = result.timeGiven ? result.endTime.map { "-" + $0 } ?? "" : ""
110        let stamp = formatter.string(from: calendar.date(from: result.components(calendar))!) + end
111        return inactive ? "[" + stamp + "]" : "<" + stamp + ">"
112    }
113}