From 06a4f148ae1419589ecb736743a39e674dec4b4c Mon Sep 17 00:00:00 2001 From: Ricardo Date: Wed, 11 Mar 2026 20:21:08 +0000 Subject: [PATCH 1/3] Extract Layout enum to top level and add DefaultNoteView Move the nested PianoRoll.Layout enum to a top-level PianoRollLayout type so it can be referenced without specifying generic parameters. Extract the hardcoded note visuals into a reusable DefaultNoteView. --- Sources/PianoRoll/DefaultNoteView.swift | 47 +++++++++++++++++++++++++ Sources/PianoRoll/PianoRollGrid.swift | 2 +- 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 Sources/PianoRoll/DefaultNoteView.swift diff --git a/Sources/PianoRoll/DefaultNoteView.swift b/Sources/PianoRoll/DefaultNoteView.swift new file mode 100644 index 0000000..973364d --- /dev/null +++ b/Sources/PianoRoll/DefaultNoteView.swift @@ -0,0 +1,47 @@ +// Copyright AudioKit. All Rights Reserved. Revision History at http://github.com/AudioKit/PianoRoll/ + +import SwiftUI + +/// The default note appearance used by PianoRoll when no custom noteContent closure is provided. +public struct DefaultNoteView: View { + var note: PianoRollNote + var color: Color + var isActive: Bool + var lineOpacity: Double + var layout: PianoRollLayout + + public var body: some View { + switch layout { + case .horizontal: + ZStack(alignment: .trailing) { + ZStack(alignment: .leading) { + Rectangle() + .foregroundColor((note.color ?? color).opacity(isActive ? 1.0 : 0.8)) + Text(note.text ?? "") + .opacity(note.text == nil ? 0 : 1) + .padding(.leading, 5) + } + Rectangle() + .foregroundColor(.black) + .padding(4) + .frame(width: 10) + .opacity(lineOpacity) + } + case .vertical: + ZStack(alignment: .bottom) { + ZStack(alignment: .bottom) { + Rectangle() + .foregroundColor((note.color ?? color).opacity(isActive ? 1.0 : 0.8)) + Text(note.text ?? "") + .opacity(note.text == nil ? 0 : 1) + .padding(.bottom, 5) + } + Rectangle() + .foregroundColor(.black) + .padding(4) + .frame(height: 10) + .opacity(lineOpacity) + } + } + } +} diff --git a/Sources/PianoRoll/PianoRollGrid.swift b/Sources/PianoRoll/PianoRollGrid.swift index 9d49719..9d151b2 100644 --- a/Sources/PianoRoll/PianoRollGrid.swift +++ b/Sources/PianoRoll/PianoRollGrid.swift @@ -10,7 +10,7 @@ struct PianoRollGrid: Shape { var gridSize: CGSize var length: Int var height: Int - var layout: PianoRoll.Layout + var layout: PianoRollLayout func path(in rect: CGRect) -> Path { let size = rect.size From 7af0890b6f160ff8e46926e338d112dbb086ab08 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Wed, 11 Mar 2026 20:21:20 +0000 Subject: [PATCH 2/3] Allow custom note views via noteContent closure Make PianoRoll, PianoRollNoteView, and VerticalPianoRollNoteView generic over NoteContent. Replace hardcoded note visuals with a noteContent closure. Add a backward-compatible constrained init that uses DefaultNoteView so existing call sites compile unchanged. --- Sources/PianoRoll/PianoRoll.swift | 59 +++++++++++++++---- Sources/PianoRoll/PianoRollNoteView.swift | 23 +++----- .../PianoRoll/VerticalPianoRollNoteView.swift | 23 +++----- 3 files changed, 63 insertions(+), 42 deletions(-) diff --git a/Sources/PianoRoll/PianoRoll.swift b/Sources/PianoRoll/PianoRoll.swift index d5635c2..71c17dc 100644 --- a/Sources/PianoRoll/PianoRoll.swift +++ b/Sources/PianoRoll/PianoRoll.swift @@ -2,14 +2,17 @@ import SwiftUI +/// Layout orientation for the piano roll. +public enum PianoRollLayout: Sendable { + case horizontal + case vertical +} + /// Touch-oriented piano roll. /// /// Note: Requires macOS 12 / iOS 15 due to SwiftUI bug (crashes in SwiftUI when deleting notes). -public struct PianoRoll: View { - public enum Layout: Sendable { - case horizontal - case vertical - } +public struct PianoRoll: View { + public typealias Layout = PianoRollLayout @Binding var model: PianoRollModel var editable: Bool @@ -17,9 +20,10 @@ public struct PianoRoll: View { var gridSize: CGSize var noteColor: Color var noteLineOpacity: Double - var layout: Layout + var layout: PianoRollLayout + var noteContent: (PianoRollNote, Bool) -> NoteContent - /// Initialize PianoRoll with a binding to a model and a color + /// Initialize PianoRoll with a binding to a model, a color, and a custom note view builder /// - Parameters: /// - editable: Disable edition of any note in piano roll /// - model: PianoRoll data @@ -27,6 +31,8 @@ public struct PianoRoll: View { /// - noteLineOpacity: Opacity of the note view vertical black line /// - gridColor: Color of grid /// - gridSize: Size of a grid cell + /// - layout: Horizontal or vertical layout + /// - noteContent: Custom view builder for note appearance. Receives the note and whether it is active (hovering/dragging). public init( editable: Bool = true, model: Binding, @@ -34,7 +40,8 @@ public struct PianoRoll: View { noteLineOpacity: Double = 1, gridColor: Color = Color(red: 15.0 / 255.0, green: 17.0 / 255.0, blue: 16.0 / 255.0), gridSize: CGSize = CGSize(width: 80, height: 40), - layout: Layout = .horizontal + layout: PianoRollLayout = .horizontal, + @ViewBuilder noteContent: @escaping (PianoRollNote, Bool) -> NoteContent ) { _model = model self.noteColor = noteColor @@ -43,6 +50,7 @@ public struct PianoRoll: View { self.gridColor = gridColor self.editable = editable self.layout = layout + self.noteContent = noteContent } private var width: CGFloat { @@ -92,7 +100,7 @@ public struct PianoRoll: View { sequenceHeight: model.height, isContinuous: true, editable: editable, - lineOpacity: noteLineOpacity + noteContent: noteContent ).onTapGesture { guard editable else { return } model.notes.removeAll(where: { $0 == note }) @@ -107,7 +115,7 @@ public struct PianoRoll: View { sequenceHeight: model.height, isContinuous: true, editable: editable, - lineOpacity: noteLineOpacity + noteContent: noteContent ).onTapGesture { guard editable else { return } model.notes.removeAll(where: { $0 == note }) @@ -119,6 +127,37 @@ public struct PianoRoll: View { } } +/// Backward-compatible initializer that uses the default note appearance. +extension PianoRoll where NoteContent == DefaultNoteView { + public init( + editable: Bool = true, + model: Binding, + noteColor: Color = .accentColor, + noteLineOpacity: Double = 1, + gridColor: Color = Color(red: 15.0 / 255.0, green: 17.0 / 255.0, blue: 16.0 / 255.0), + gridSize: CGSize = CGSize(width: 80, height: 40), + layout: PianoRollLayout = .horizontal + ) { + self.init( + editable: editable, + model: model, + noteColor: noteColor, + noteLineOpacity: noteLineOpacity, + gridColor: gridColor, + gridSize: gridSize, + layout: layout + ) { note, isActive in + DefaultNoteView( + note: note, + color: noteColor, + isActive: isActive, + lineOpacity: editable ? noteLineOpacity : 0, + layout: layout + ) + } + } +} + struct PianoRollPreview: View { init() {} diff --git a/Sources/PianoRoll/PianoRollNoteView.swift b/Sources/PianoRoll/PianoRollNoteView.swift index 903d35f..8f83e22 100644 --- a/Sources/PianoRoll/PianoRollNoteView.swift +++ b/Sources/PianoRoll/PianoRollNoteView.swift @@ -8,7 +8,7 @@ import SwiftUI /// /// With each note as a separate view this might not be suitable for very large sequences, but /// it makes it easier to implement. -struct PianoRollNoteView: View { +struct PianoRollNoteView: View { @Binding var note: PianoRollNote var gridSize: CGSize var color: Color @@ -28,7 +28,11 @@ struct PianoRollNoteView: View { var sequenceHeight: Int var isContinuous = false var editable: Bool = false - var lineOpacity: Double = 1 + var noteContent: (PianoRollNote, Bool) -> NoteContent + + var isActive: Bool { + hovering || offset != .zero || lengthOffset != 0 + } var noteColor: Color { note.color ?? color @@ -104,20 +108,7 @@ struct PianoRollNoteView: View { } // Main note body. - ZStack(alignment: .trailing) { - ZStack(alignment: .leading) { - Rectangle() - .foregroundColor(noteColor.opacity((hovering || offset != .zero || lengthOffset != 0) ? 1.0 : 0.8)) - Text(note.text ?? "") - .opacity(note.text == nil ? 0 : 1) - .padding(.leading, 5) - } - Rectangle() - .foregroundColor(.black) - .padding(4) - .frame(width: 10) - .opacity(editable ? lineOpacity : 0) - } + noteContent(note, isActive) .onHover { over in hovering = over } .padding(1) // so we can see consecutive notes .frame(width: max(gridSize.width, gridSize.width * CGFloat(note.length) + lengthOffset), diff --git a/Sources/PianoRoll/VerticalPianoRollNoteView.swift b/Sources/PianoRoll/VerticalPianoRollNoteView.swift index 3c3624e..5fb2229 100644 --- a/Sources/PianoRoll/VerticalPianoRollNoteView.swift +++ b/Sources/PianoRoll/VerticalPianoRollNoteView.swift @@ -8,7 +8,7 @@ import SwiftUI /// /// With each note as a separate view this might not be suitable for very large sequences, but /// it makes it easier to implement. -struct VerticalPianoRollNoteView: View { +struct VerticalPianoRollNoteView: View { @Binding var note: PianoRollNote var gridSize: CGSize var color: Color @@ -28,7 +28,11 @@ struct VerticalPianoRollNoteView: View { var sequenceHeight: Int var isContinuous = false var editable: Bool = false - var lineOpacity: Double = 1 + var noteContent: (PianoRollNote, Bool) -> NoteContent + + var isActive: Bool { + hovering || offset != .zero || heightOffset != 0 + } var noteColor: Color { note.color ?? color @@ -108,20 +112,7 @@ struct VerticalPianoRollNoteView: View { } // Main note body. - ZStack(alignment: .bottom) { - ZStack(alignment: .bottom) { - Rectangle() - .foregroundColor(noteColor.opacity((hovering || offset != .zero || heightOffset != 0) ? 1.0 : 0.8)) - Text(note.text ?? "") - .opacity(note.text == nil ? 0 : 1) - .padding(.bottom, 5) - } - Rectangle() - .foregroundColor(.black) - .padding(4) - .frame(height: 10) - .opacity(editable ? lineOpacity : 0) - } + noteContent(note, isActive) .onHover { over in hovering = over } .padding(1) // so we can see consecutive notes .frame(width: gridSize.height, From 7d323485aae47db4a513b5206999d917376f7b0b Mon Sep 17 00:00:00 2001 From: Ricardo Date: Wed, 11 Mar 2026 20:21:49 +0000 Subject: [PATCH 3/3] Add custom neon pill notes demo Add a second PianoRoll example with per-pitch rainbow colors, gradient fills, glowing shadows, and scale-on-hover animation. Set a minimum window size for the macOS demo. --- Demo/Shared/PianoRollDemoApp.swift | 1 + Demo/Shared/PianoRollDemoView.swift | 58 +++++++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/Demo/Shared/PianoRollDemoApp.swift b/Demo/Shared/PianoRollDemoApp.swift index 6e369d0..c6ed125 100644 --- a/Demo/Shared/PianoRollDemoApp.swift +++ b/Demo/Shared/PianoRollDemoApp.swift @@ -7,6 +7,7 @@ struct PianoRollDemoApp: App { var body: some Scene { WindowGroup { PianoRollDemoView() + .frame(minWidth: 600, minHeight: 500) } } } diff --git a/Demo/Shared/PianoRollDemoView.swift b/Demo/Shared/PianoRollDemoView.swift index d0ba9c9..053b692 100644 --- a/Demo/Shared/PianoRollDemoView.swift +++ b/Demo/Shared/PianoRollDemoView.swift @@ -9,12 +9,62 @@ public struct PianoRollDemoView: View { @State var model = PianoRollModel(notes: [ PianoRollNote(start: 1, length: 2, pitch: 3), PianoRollNote(start: 5, length: 1, pitch: 4), - ], length: 128, height: 128) + ], length: 16, height: 16) + + static let pitchColors: [Color] = [ + .red, .orange, .yellow, .green, .mint, .cyan, + .blue, .indigo, .purple, .pink, .red, .orange, + .yellow, .green, .mint, .cyan, + ] + + @State var customModel = PianoRollModel(notes: [ + PianoRollNote(start: 0, length: 2, pitch: 3, text: "C"), + PianoRollNote(start: 2, length: 2, pitch: 5, text: "E"), + PianoRollNote(start: 4, length: 3, pitch: 7, text: "G"), + PianoRollNote(start: 4, length: 1, pitch: 10, text: "B♭"), + PianoRollNote(start: 7, length: 1, pitch: 8, text: "A♭"), + PianoRollNote(start: 8, length: 4, pitch: 5, text: "E"), + PianoRollNote(start: 8, length: 2, pitch: 12, text: "D"), + PianoRollNote(start: 10, length: 1, pitch: 9, text: "A"), + PianoRollNote(start: 12, length: 3, pitch: 6, text: "F"), + PianoRollNote(start: 12, length: 1, pitch: 14, text: "F"), + PianoRollNote(start: 13, length: 2, pitch: 11, text: "C♯"), + ], length: 16, height: 16) public var body: some View { - ScrollView([.horizontal, .vertical], showsIndicators: true) { - PianoRoll(model: $model, noteColor: .cyan, layout: .vertical) - }.background(Color(white: 0.1)) + VStack(spacing: 20) { + Text("Default Note Style") + ScrollView([.horizontal, .vertical], showsIndicators: true) { + PianoRoll(model: $model, noteColor: .cyan, layout: .horizontal) + }.background(Color(white: 0.4)) + + Text("Custom Neon Pill Notes") + ScrollView([.horizontal, .vertical], showsIndicators: true) { + PianoRoll(model: $customModel, noteColor: .cyan, layout: .horizontal) { note, isActive in + let color = Self.pitchColors[(note.pitch - 1) % Self.pitchColors.count] + ZStack { + RoundedRectangle(cornerRadius: 12) + .fill( + LinearGradient( + colors: [color, color.opacity(0.6)], + startPoint: .top, + endPoint: .bottom + ) + ) + .opacity(isActive ? 1.0 : 0.85) + .shadow(color: color.opacity(isActive ? 0.9 : 0.5), radius: isActive ? 8 : 4) + RoundedRectangle(cornerRadius: 12) + .strokeBorder(.white.opacity(0.4), lineWidth: 1) + Text(note.text ?? "") + .font(.system(.caption, design: .rounded).bold()) + .foregroundColor(.white) + .shadow(color: .black.opacity(0.5), radius: 2, x: 0, y: 1) + } + .scaleEffect(isActive ? 1.05 : 1.0) + .animation(.easeOut(duration: 0.15), value: isActive) + } + }.background(Color(white: 0.12)) + } } }