Hue and purity ribbons

“My creative process balances analytic study, based very much on research, with, in the end, a purely intuitive gesture.”

Maya Lin, Boundaries, 2000

In designing Image Performer as an instrument, I have given a good deal of consideration to its use in performance and improvisation. One goal of its design is to support the kind of purely intuitive gesture whose importance was recognized by artist and architect Maya Lin.

To that end the color model used in Image Performer relies upon concepts that allow artists to work with and think about colors in parsimonious ways, collects individual colors into chords, and minimizes the number of controllers one must handle while performing .

A hue-centered color model

We are start work on color with the hue-saturation-value (HSV) or hue-saturation-brightness (HSB) color model. The main reason for using this, rather than the red-green-blue (RGB), the cyan-magenta-yellow-black (CMYK), or other model, is that hues and their modifications represent the most natural way of thinking about color. Hues provide the familiar names of colors and have received a lot of attention from painters and other artists over the years.

Here is a ribbon that uses the standard HSV model, with the full saturation and brightness (or value) across the hue spectrum.

Hue space as defined in the HSV model

To create a gradient like that above we need more colors than are available in the standard SwiftUI Color set. In his article Creating and Managing Colors in SwiftUI, Brady Murphy describes how to create a set of custom colors. Our set of twelve spectral colors is in Color.xcassets and an extension of Color for managing it is at the top of the newly created file ColorModel.swift.

Our first task is to create a pair of controllers, one for hues, and one that combines saturation and brightness (or value) into the color construct purity. For the moment our swift code will embed these two color constructs in global variables. For each of the constructs we will have two variables that take on values from 0.0 to 1.0; one for the location of the center of a range and the other for its width. Here they are for the hue controller.

class Globals: ObservableObject {
     @Published var metalContext = MetalContext()
     @Published var hueCenter:CGFloat = 0.0
     @Published var hueRange:CGFloat = 0.0

The HueRibbon structure is described in a file of the same name.

import SwiftUI
 struct HueRibbon: View {
     let ribbonWidth:CGFloat
     let ribbonHeight:CGFloat
     @EnvironmentObject var globals: Globals
     var body: some View {
         ZStack {
             // twelve named hues placed according to the where they appear in hsv's hue space
             let gradient = Gradient(stops: [.init(color: .spectralRedOrange, location: spectrum.segments[0].hsvLoc),
                                             .init(color: .spectralOrange, location: spectrum.segments[1].hsvLoc),
                                             .init(color: .spectralOrangeYellow, location: spectrum.segments[2].hsvLoc),
                                             .init(color: .spectralYellow, location: spectrum.segments[3].hsvLoc),
                                             .init(color: .spectralYellowGreen, location: spectrum.segments[4].hsvLoc),
                                             .init(color: .spectralGreen, location: spectrum.segments[5].hsvLoc),
                                             .init(color: .spectralGreenBlue, location: spectrum.segments[6].hsvLoc),
                                             .init(color: .spectralBlue, location: spectrum.segments[7].hsvLoc),
                                             .init(color: .spectralBlueViolet, location: spectrum.segments[8].hsvLoc),
                                             .init(color: .spectralViolet, location: spectrum.segments[9].hsvLoc),
                                             .init(color: .spectralVioletRed, location: spectrum.segments[10].hsvLoc),
                                             .init(color: .spectralRed, location: spectrum.segments[11].hsvLoc)
             Rectangle() // the background
                 .fill(LinearGradient(gradient:gradient, startPoint:.leading, endPoint:.trailing))
                 .frame(width:ribbonWidth, height:ribbonHeight)
             Rectangle() // the thumb or selector
                 .fill(Color( colorLiteral(red: 1, green: 1, blue: 1, alpha: 0.5)))
                 .border(Color(.white), width:1)
                 .frame(width:(4 + globals.hueRange * ribbonWidth), height:ribbonHeight)
                 .offset(x:thumbOffset(center:globals.hueCenter, range:globals.hueRange))
             Rectangle() // the gesture sensor
                 .fill(Color( colorLiteral(red: 1, green: 1, blue: 1, alpha: 0.01)))
                 .frame(width:ribbonWidth, height:ribbonHeight)
                     //print("tap is currently unused")
                 .gesture(DragGesture().onChanged { value in
                     var location = value.location.x
                     if location < 0 { location = 0 }
                     if location > ribbonWidth { location = ribbonWidth }
                     globals.hueCenter = location / ribbonWidth
                 .gesture(MagnificationGesture().onChanged { value in
                     if value > 1 { if globals.hueRange <= 1.0 { increaseRange() } }
                     else { if globals.hueRange > 0.0 { decreaseRange() } }
     func adjustCenter() {
         if globals.hueCenter + (globals.hueRange / 2.0) > 1.0 { globals.hueCenter = 1 - (globals.hueRange / 2.0) }
         if globals.hueCenter - (globals.hueRange / 2.0) < 0.0 { globals.hueCenter = globals.hueRange / 2.0 }
     func increaseRange() {
         globals.hueRange += 0.01
     func decreaseRange() {
         globals.hueRange -= 0.01
     func thumbOffset(center:CGFloat, range:CGFloat) -> CGFloat {
         return (thumbOffsetZeroOne(center:center, range:range) * ribbonWidth) - (ribbonWidth / 2)
     func thumbOffsetZeroOne(center:CGFloat, range:CGFloat) -> CGFloat {
         var theOffset = center
         if center < (range / 2) {
             theOffset = range / 2
         if center > (1 - (range / 2)) {
             theOffset =  1 - (range / 2)
         return theOffset

The body of the struct describes a ZStack with three Rectangle views. The first one, located at the bottom of the ZStack, is the background, where the color spectrum is drawn. To do the drawing, it uses the spectral colors we defined as anchors for SwiftUI’s built in Gradient function. The locations were established empirically, by observing where the colors appear to be located in a zero-one version of the hue space of the hsv-model.

The middle rectangle contains the thumb which moves to reflect the location or range that is selected. The top rectangle, which is transparent, contains the touch sensors. We are using two of SwiftUI’s gesture recognizers. The drag gesture is used for moving the selector (or thumb) along the ribbon. The magnification gesture (pinching in and out) is used to change the size of the thumb, and hence the range of values covered by it.

Code has been added to the MetalView.swift file’s clearColor function.

        private func clearColor() -> MTLClearColor {
             let hueCenter = Double(parent.globals.hueCenter)
             let hueRange = Double(parent.globals.hueRange) 

             var hue = hueCenter
             if hueRange > 0.0 {
                 let hueBegin = hueCenter - (hueRange / 2)
                 hue = (hueBegin + (hueRange * locationWithinPulsedFunction))

Finally, when the code in the ColorPanel file is updated to use the HueRibbon, it becomes possible to choose the hues showing on the canvas.

struct ColorPanel: View {
     var body: some View {
         VStack {
             GeometryReader { geo in
                 HueRibbon(ribbonWidth:geo.size.width * 0.6, ribbonHeight:geo.size.height * 0.35)
                     .offset(x:geo.size.width * 0.2, y:geo.size.height * 0.05)

If you use the pinching motion to increase the size of the slider, the hues will change in response to MetalViews pulse function.

Color purity

To describe a color technically requires at least three dimensions. As suggested by its name, the model we are using specifies hue, saturation and value (or brightness) to describe a color. Specifying saturation can be thought of as choosing the amount of the pigment to use. For example the same hue can define red or pink, the latter by making red less saturated. You can also think of pink as having more white mixed into it. Similarly value, or brightness, can be thought of as mixing black into the base hue. The more black it contains, the lower its brightness; much as reflecting less light would reduce its apparent brightness. While these dimensions describe a color technically, painters and other artists have many other ways of referring to these aspects of color. More than saturation or brightness, you’ll encounter descriptions describing colors as deep, muted, weakened, strong, broken, shaded, tinted, and by scores of other descriptors. This is especially the case in discussions of the relationship of colors to musical concepts, where the language draws upon musical analogies. Following M.E. Chevreul, Karl Gerstner, and others, we use the idea of departure from pure hues in reference to all of these modifications of hues. A hue’s purity diminishes as it departs from the most saturated and brightest version of itself.

A nice example of purity in use is Thomas Cole’s Diagram of Contrasts. Cole was a realistic painter of the American Hudson Valley School. In 1834 he painted this experiment based upon something he had read as a boy about a “music of colors.” In each of its twelve wedges, Cole moves from a low brightness, through a pure hue (high brightness and saturation), to a low saturation.

Thomas Cole’s Diagram of Contrasts, 1834 from the Richard Sharp collection

The code changes required to add the purity controller are very like those we just saw for the hue controller. A structure has been added to ColorModel to assist in dealing with some new complexities that come with this addition.

struct Spectrum {
     struct segment {
         var color: Color
         var hsvLocation: CGFloat
         var hsvBegin: CGFloat
         var hsvEnd: CGFloat
     var segments = [segment]()
     init() {
         // negative value in hsvBegin allows for use of strictly greater test even on first segment
         segments.append(segment(color:.spectralRedOrange, hsvLoc:0.04, hsvBegin:-0.001, hsvEnd:0.08))
         segments.append(segment(color:.spectralOrange, hsvLoc:0.10, hsvBegin:0.08, hsvEnd:0.12))
         segments.append(segment(color:.spectralOrangeYellow, hsvLoc:0.13, hsvBegin:0.12, hsvEnd:0.15))
         segments.append(segment(color:.spectralYellow, hsvLoc:0.17, hsvBegin:0.15, hsvEnd:0.18))
         segments.append(segment(color:.spectralYellowGreen, hsvLoc:0.24, hsvBegin:0.18, hsvEnd:0.30))
         segments.append(segment(color:.spectralGreen, hsvLoc:0.33, hsvBegin:0.30, hsvEnd:0.42))
         segments.append(segment(color:.spectralGreenBlue, hsvLoc:0.50, hsvBegin:0.42, hsvEnd:0.60))
         segments.append(segment(color:.spectralBlue, hsvLoc:0.67, hsvBegin:0.60, hsvEnd:0.71))
         segments.append(segment(color:.spectralBlueViolet, hsvLoc:0.74, hsvBegin:0.71, hsvEnd:0.78))
         segments.append(segment(color:.spectralViolet, hsvLoc:0.80, hsvBegin:0.78, hsvEnd:0.83))
         segments.append(segment(color:.spectralVioletRed, hsvLoc:0.86, hsvBegin:0.83, hsvEnd:0.90))
         segments.append(segment(color:.spectralRed, hsvLoc:0.95, hsvBegin:0.90, hsvEnd:1.00))
     func namedColorFromHsvLocation(_ location:CGFloat) -> Color {
         var theColor:Color = .spectralRed
         let spectrum = Spectrum()
         for i in 0...11 {
             if location > spectrum.segments[i].hsvBegin && location <= spectrum.segments[i].hsvEnd
                 { theColor = spectrum.segments[i].color }
         return theColor

The PurityRibbon calls Spectrum’s namedColorFromHsvLocation to learn which spectral color should serve as the center of the gradient when drawing the display layer of the controller. It does so based on the global hueCenter. We will add other purity ribbons later.

The standard HSV hue ribbon and the white—pure—black purity ribbon

As of February 22, 2021