1import SwiftUI
2
3/// A fully resolved colour set for one appearance (light or dark), styled after
4/// pitchreader.com: white/near-black staff, blue natural lines, a coral active
5/// band, and a heavy dark (or light) pitch trace.
6struct Palette {
7 var background: Color
8 var cLine: Color
9 var cLabel: Color
10 var naturalLine: Color
11 var naturalLabel: Color
12 var accidentalLine: Color
13 var band: Color
14 var pill: Color
15 var pillText: Color
16 var trace: Color
17 var dot: Color
18 var noteBar: Color // committed "intentional" note
19 var notePending: Color // the note being sung right now
20 var noteBarEdge: Color // crisp outline so bars read under the trace
21
22 static func make(_ scheme: ColorScheme) -> Palette {
23 scheme == .dark ? .dark : .light
24 }
25
26 static let light = Palette(
27 background: Color.white,
28 cLine: Color(red: 0.11, green: 0.12, blue: 0.14).opacity(0.9),
29 cLabel: Color(red: 0.10, green: 0.11, blue: 0.13),
30 naturalLine: Color(red: 0.42, green: 0.58, blue: 0.93).opacity(0.42),
31 naturalLabel: Color(red: 0.33, green: 0.50, blue: 0.90),
32 accidentalLine: Color(red: 0.55, green: 0.58, blue: 0.66).opacity(0.20),
33 band: Color(red: 0.95, green: 0.55, blue: 0.50).opacity(0.50),
34 pill: Color(red: 0.93, green: 0.49, blue: 0.45),
35 pillText: Color.white,
36 trace: Color(red: 0.20, green: 0.24, blue: 0.31),
37 dot: Color(red: 0.20, green: 0.24, blue: 0.31),
38 noteBar: Color(red: 0.36, green: 0.52, blue: 0.90).opacity(0.28),
39 notePending: Color(red: 0.36, green: 0.52, blue: 0.90).opacity(0.44),
40 noteBarEdge: Color(red: 0.28, green: 0.44, blue: 0.85).opacity(0.65))
41
42 static let dark = Palette(
43 background: Color(red: 0.09, green: 0.10, blue: 0.12),
44 cLine: Color.white.opacity(0.85),
45 cLabel: Color.white.opacity(0.95),
46 naturalLine: Color(red: 0.46, green: 0.61, blue: 0.96).opacity(0.42),
47 naturalLabel: Color(red: 0.56, green: 0.69, blue: 0.99),
48 accidentalLine: Color.white.opacity(0.07),
49 band: Color(red: 0.90, green: 0.46, blue: 0.43).opacity(0.36),
50 pill: Color(red: 0.90, green: 0.47, blue: 0.44),
51 pillText: Color.white,
52 trace: Color(red: 0.93, green: 0.95, blue: 0.99),
53 dot: Color(red: 0.93, green: 0.95, blue: 0.99),
54 noteBar: Color(red: 0.55, green: 0.68, blue: 0.99).opacity(0.26),
55 notePending: Color(red: 0.55, green: 0.68, blue: 0.99).opacity(0.44),
56 noteBarEdge: Color(red: 0.62, green: 0.74, blue: 1.0).opacity(0.7))
57}
58
59/// Shared plot geometry so the static grid layer and the animated trace layer
60/// map pitch → screen identically. Vertical axis is uniform per semitone —
61/// every semitone (naturals and sharps alike) gets an evenly-spaced row.
62struct PitchLayout {
63 let size: CGSize
64 let top: Double // MIDI at the top edge
65 let bot: Double // MIDI at the bottom edge
66 let labelWidth: CGFloat
67
68 init(size: CGSize, range: PitchRange, labelWidth: CGFloat = 44) {
69 self.size = size
70 self.top = Double(range.high) + 0.7
71 self.bot = Double(range.low) - 0.7
72 self.labelWidth = labelWidth
73 }
74
75 var plotWidth: CGFloat { size.width - labelWidth }
76
77 func y(_ midi: Double) -> CGFloat {
78 CGFloat((top - midi) / (top - bot)) * size.height
79 }
80
81 /// Screen x for a timestamp, with `now` at the right edge of the plot area.
82 func x(_ t: Double, now: Double, visible: Double) -> CGFloat {
83 plotWidth * CGFloat(1 - (now - t) / visible)
84 }
85
86 /// Height of one semitone row, in points.
87 var rowHeight: CGFloat { abs(y(0) - y(1)) }
88}