Something went wrong. Try again.
Monorepo for Aesthetic.Computer aesthetic.computer
Something went wrong. Try again.
11 kB · 265 lines
Swift
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266// AgendaView.swift — the "no day selected" face of the wizard: an ordered,// scrollable list of everything coming up (rolling window from now forward,// see WizardController.upcomingDays). Where WeekView is a spatial hour grid,// this is a chronological agenda — one section per day, each event a row of// TIME ● Title (source)// sorted by start. Aesthetical events are editable (solid chip); feed events// are read-only (dashed chip + source-color dot), matching WeekView's grammar.//// The view is a flipped document view inside an NSScrollView; it sizes its own// height to the content so the scroller appears only when the list overflows.import AppKit
protocol AgendaViewDelegate: AnyObject { func agendaView(_ view: AgendaView, didSelectEditable event: LaidEvent) func agendaView(_ view: AgendaView, didSelectReadOnly event: LaidEvent)}
final class AgendaView: NSView { weak var delegate: AgendaViewDelegate?
// Chronological events to show (already filtered to the upcoming window by // the controller). Re-laid on set. var events: [LaidEvent] = [] { didSet { relayout() } }
// ── layout metrics ──────────────────────────────────────────────── private let topPad: CGFloat = 8 private let sectionH: CGFloat = 28 // day header row private let rowH: CGFloat = 30 private let sectionGap: CGFloat = 4 private let sideInset: CGFloat = 10 private let timeColW: CGFloat = 78 // right edge of the time column
override var isFlipped: Bool { true } // y grows downward — top-to-bottom list.
// One rendered row: either a day header or an event. private enum Item { case header(date: Date) case event(LaidEvent) } private var items: [Item] = []
private struct HitBox { var rect: NSRect; var event: LaidEvent } private var hitBoxes: [HitBox] = []
// Scrim opacity over the living backdrop, matching WeekView so the two read // as one surface. private let scrimAlpha: CGFloat = 0.24
// ── build the item list (headers + rows) ────────────────────────── private func relayout() { let cal = Calendar.current let sorted = events.sorted { $0.start < $1.start } var built: [Item] = [] var lastDay: Date? for ev in sorted { let day = cal.startOfDay(for: ev.start) if lastDay == nil || !cal.isDate(day, inSameDayAs: lastDay!) { built.append(.header(date: day)) lastDay = day } built.append(.event(ev)) } items = built
// Size the document view to fit; the enclosing scroll view reveals a // scroller when this exceeds the visible height. let contentH = topPad + items.reduce(0) { acc, item in switch item { case .header: return acc + sectionH + sectionGap case .event: return acc + rowH } } + topPad let width = enclosingScrollView?.contentSize.width ?? bounds.width setFrameSize(NSSize(width: width, height: max(contentH, enclosingScrollView?.contentSize.height ?? contentH))) needsDisplay = true }
// Keep our width locked to the scroll view's content width on resize so the // rows always span the full pane. override func viewDidMoveToSuperview() { super.viewDidMoveToSuperview() relayout() }
override func setFrameSize(_ newSize: NSSize) { super.setFrameSize(newSize) needsDisplay = true }
// ── drawing ─────────────────────────────────────────────────────── override func draw(_ dirtyRect: NSRect) { NSColor.windowBackgroundColor.withAlphaComponent(scrimAlpha).setFill() bounds.fill()
hitBoxes.removeAll()
if items.isEmpty { drawEmptyState() return }
let cal = Calendar.current var y = topPad for item in items { switch item { case .header(let date): drawHeader(date, atY: y) y += sectionH + sectionGap case .event(let ev): let rect = NSRect(x: sideInset, y: y + 2, width: bounds.width - sideInset * 2, height: rowH - 4) drawRow(ev, in: rect, cal: cal) hitBoxes.append(HitBox(rect: rect, event: ev)) y += rowH } } }
private func drawEmptyState() { let style = NSMutableParagraphStyle(); style.alignment = .center let shadow = NSShadow() shadow.shadowColor = NSColor.white.withAlphaComponent(0.6) shadow.shadowBlurRadius = 1.5 let attrs: [NSAttributedString.Key: Any] = [ .font: NSFont.systemFont(ofSize: 13, weight: .medium), .foregroundColor: NSColor.secondaryLabelColor, .paragraphStyle: style, .shadow: shadow, ] ("Nothing coming up." as NSString).draw( in: NSRect(x: 0, y: 40, width: bounds.width, height: 20), withAttributes: attrs) }
private func drawHeader(_ date: Date, atY y: CGFloat) { let cal = Calendar.current let color = DayPalette.color(for: date)
// A relative word ("Today"/"Tomorrow") plus the date, colored in the // day's ROYGBIV hue so the agenda echoes the strip. let dayDelta = cal.dateComponents([.day], from: cal.startOfDay(for: Date()), to: cal.startOfDay(for: date)).day ?? 0 let fmt = DateFormatter(); fmt.dateFormat = "EEEE, MMM d" let prefix: String switch dayDelta { case 0: prefix = "Today · " case 1: prefix = "Tomorrow · " default: prefix = "" } let text = "\(prefix)\(fmt.string(from: date))"
// A small day-color dot, then the label. let dotD: CGFloat = 8 let dotRect = NSRect(x: sideInset, y: y + (sectionH - dotD) / 2 + 1, width: dotD, height: dotD) color.withAlphaComponent(0.9).setFill() NSBezierPath(ovalIn: dotRect).fill()
let shadow = NSShadow() shadow.shadowColor = NSColor.white.withAlphaComponent(0.5) shadow.shadowBlurRadius = 1.5 let attrs: [NSAttributedString.Key: Any] = [ .font: NSFont.systemFont(ofSize: 12, weight: .heavy), .foregroundColor: color, .shadow: shadow, ] (text as NSString).draw(at: NSPoint(x: dotRect.maxX + 7, y: y + (sectionH - 15) / 2), withAttributes: attrs)
// A hairline under the header. NSColor.separatorColor.withAlphaComponent(0.4).setStroke() let line = NSBezierPath() line.move(to: NSPoint(x: sideInset, y: y + sectionH)) line.line(to: NSPoint(x: bounds.width - sideInset, y: y + sectionH)) line.lineWidth = 0.5 line.stroke() }
private func drawRow(_ ev: LaidEvent, in rect: NSRect, cal: Calendar) { // Time column (left): "all-day", or a compact start time. let timeStr: String if ev.allDay { timeStr = "all-day" } else { let tf = DateFormatter(); tf.dateFormat = "h:mm a" timeStr = tf.string(from: ev.start).lowercased() } let timeStyle = NSMutableParagraphStyle(); timeStyle.alignment = .right let timeShadow = NSShadow() timeShadow.shadowColor = NSColor.white.withAlphaComponent(0.5) timeShadow.shadowBlurRadius = 1.0 let timeAttrs: [NSAttributedString.Key: Any] = [ .font: NSFont.monospacedDigitSystemFont(ofSize: 10, weight: .medium), .foregroundColor: NSColor.labelColor, .paragraphStyle: timeStyle, .shadow: timeShadow, ] (timeStr as NSString).draw( in: NSRect(x: rect.minX, y: rect.midY - 7, width: timeColW - 8, height: 14), withAttributes: timeAttrs)
// Title chip (right of the time column). let chip = NSRect(x: rect.minX + timeColW, y: rect.minY, width: rect.maxX - (rect.minX + timeColW), height: rect.height) let path = NSBezierPath(roundedRect: chip, xRadius: 5, yRadius: 5) let labelColor: NSColor var labelInsetX: CGFloat = 9
if ev.kind == .aesthetical { let fill = (ev.visibility == "public") ? NSColor.systemGreen : NSColor.controlAccentColor fill.withAlphaComponent(0.85).setFill() path.fill() labelColor = .white } else { let c = ev.color ?? NSColor.systemGray c.withAlphaComponent(0.12).setFill() path.fill() c.setStroke() path.lineWidth = 1 path.setLineDash([3, 2], count: 2, phase: 0) path.stroke() // Source dot. let dot = NSBezierPath(ovalIn: NSRect(x: chip.minX + 7, y: chip.midY - 3, width: 6, height: 6)) c.setFill(); dot.fill() labelColor = .labelColor labelInsetX = 20 }
// Title. let tstyle = NSMutableParagraphStyle(); tstyle.lineBreakMode = .byTruncatingTail let tAttrs: [NSAttributedString.Key: Any] = [ .font: NSFont.systemFont(ofSize: 11, weight: .medium), .foregroundColor: labelColor, .paragraphStyle: tstyle, ] // Reserve room on the right for a source label on feed rows. var titleW = chip.width - labelInsetX - 8 if let src = ev.sourceLabel { let sAttrs: [NSAttributedString.Key: Any] = [ .font: NSFont.systemFont(ofSize: 9, weight: .regular), .foregroundColor: NSColor.secondaryLabelColor, ] let sSize = (src as NSString).size(withAttributes: sAttrs) let sX = chip.maxX - sSize.width - 8 (src as NSString).draw(at: NSPoint(x: sX, y: chip.midY - sSize.height / 2), withAttributes: sAttrs) titleW = max(20, sX - (chip.minX + labelInsetX) - 8) } (ev.title as NSString).draw( in: NSRect(x: chip.minX + labelInsetX, y: chip.midY - 8, width: titleW, height: 16), withAttributes: tAttrs) }
// ── interaction ─────────────────────────────────────────────────── override func mouseDown(with event: NSEvent) { let p = convert(event.locationInWindow, from: nil) for box in hitBoxes where box.rect.contains(p) { if box.event.kind == .aesthetical { delegate?.agendaView(self, didSelectEditable: box.event) } else { delegate?.agendaView(self, didSelectReadOnly: box.event) } return } }}