| 1 | import 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. |
| 6 | struct 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. |
| 62 | struct 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 | } |