Writing an Explore Backend

Explore backends are implemented as an interpreter for each supported input type. Each interpreter implements an Explore* trait for a backend-specific component type. Along with the interpreters for each supported input type, there should be an interpreter for the Layout trait, so that multiple components can be composed. Additionally, the component type must extend the Explorer trait parameterized by the targeted doodle backend's Drawing, Algebra, Canvas, and Frame type. The reference backend implementation is doodle.explore.java2d.Component.scala.

A minimal Java2D backend

Let's write a minimal Java2D backend with an implementation for IntExplore and Layout. We'll start with our Component type. It is easiest expressed as a Scala 3 enum with a case for each type of input. We'll use doodle.java2d.{Drawing, Algebra, Canvas, Frame} as our Doodle backend.

enum Component[A] extends Explorer[A, Drawing, Algebra, Canvas, Frame] {
    ...

    def run: Stream[Pure, A] = ??? // required by the Explorer trait
}

Implementing ExploreInt

To figure out the representation of our int subtype, we can look at what's needed to implement ExploreInt.

enum Component[A] extends Explorer[A, Drawing, Algebra, Canvas, Frame] {
    case IntIR(label: String, bounds: (Int, Int), initial: Int) extends Component[Int]

    def run: Stream[Pure, A] = {
        val frame = JFrame("Explorer")

        val (ui, values) = this match {
            case IntIR(labelText, (start, end), initial) =>
                val panel = JPanel()
                val label = JLabel(labelText)
                val slider = JSlider(start, end, initial)

                panel.add(label, panel.add(slider))
                val values = Stream(initial).repeat.map(_ => slider.getValue)

                (panel, values)
        }

        // displaying the UI
        frame.add(ui)
        frame.setVisible(true)
        frame.pack

        // returning the values stream
        values
    }
}

Next, we have to write the actual ExploreInt implementation, using Component as the internal type:

implicit object IntInterpreter extends ExploreInt[Component] {
  import Component.IntIR

  // Construct a brand new IntIR
  override def int(label: String) = IntIR(label, None, 0)

  // Pattern match to retrieve `generator` as the IntIR subtype, and then
  // update it.
  override def within(generator: Component[Int], start: Int, end: Int) =
    generator match {
      case generator: IntIR =>
        generator.copy(bounds = Some(start, end), initial = (start + end) / 2)
    }

  override def withDefault(generator: Component[Int], newInitial: Int) =
    generator match {
      case generator: IntIR => 
        generator.copy(initial = newInitial)
    }
}

This implementation will work for a single int slider, but we can't compose them until there's an implementation for Layout.

Implementing Layout

Implementing Layout with Java2D requires a slight restructuring. We want our whole GUI to fit in the same window, so we can only create one JFrame. The easiest way to solve this is to extract the UI creation logic into a separate function and let run handle creating the JFrame after the whole UI JComponent has been constructed. We'll call the new function runAndMakeUI. Now our Component looks like this:

enum Component[A] extends Explorer[A, Drawing, Algebra, Canvas, Frame] {
    case IntIR(label: String, bounds: (Int, Int), initial: Int) extends Component[Int]

    def runAndMakeUI: (JComponent, Stream[Pure, A]) = this match {
        case IntIR(labelText, (start, end), initial) =>
            val panel = JPanel()
            val label = JLabel(labelText)
            val slider = JSlider(start, end, initial)

            panel.add(label, panel.add(slider))
            val values = Stream(initial).repeat.map(_ => slider.getValue)

            (panel, values)
    }

    def run: Stream[Pure, A] = {
        val frame = JFrame("Explorer")

        val (ui, values) = runAndMakeUI

        // displaying the UI
        frame.add(ui)
        frame.setVisible(true)
        frame.pack

        // returning the values stream
        values
    }
}

Next, we can add a LayoutIR subtype to our Component. It may use Explore's LayoutDirection type for consistency.

    case LayoutIR[A, B](direction: LayoutDirection, a: Component[A], b: Component[B]) extends Component[(A, B)]

Adding it to runAndMakeUI, we get:

def runAndMakeUI: (JComponent, Stream[Pure, A]) = this match {
    case IntIR(labelText, (start, end), initial) =>
        ...

    case LayoutIR(direction, a, b) =>
      val (aUI, aValues) = a.runAndMakeUI
      val (bUI, bValues) = b.runAndMakeUI

      val directionInt = direction match {
        case LayoutDirection.Horizontal => BoxLayout.X_AXIS
        case LayoutDirection.Vertical   => BoxLayout.Y_AXIS,
      }

      val panel = JPanel()
      panel.setLayout(BoxLayout(panel, direction))
      panel.add(a)
      panel.add(b)

      val zippedValues = aValues.zip(bValues)
      (panel, zippedValues)
}

Next, the explore.Layout implementation:

implicit object LayoutInterpreter extends Layout[Component] {
  import Component.LayoutIR

  def above[A, B](top: Component[A], bottom: Component[B]) =
    LayoutIR(LayoutDirection.Vertical, top, bottom)

  def beside[A, B](left: Component[A], right: Component[B]) =
    LayoutIR(LayoutDirection.Horizontal, left, right)
}

Our final code looks like this:

enum Component[A] extends Explorer[A, Drawing, Algebra, Canvas, Frame] {
    case IntIR(label: String, bounds: (Int, Int), initial: Int) extends Component[Int]
    case LayoutIR[A, B](direction: LayoutDirection, a: Component[A], b: Component[B]) extends Component[(A, B)]

    def runAndMakeUI: (JComponent, Stream[Pure, A]) = this match {
        case IntIR(labelText, (start, end), initial) =>
            val panel = JPanel()
            val label = JLabel(labelText)
            val slider = JSlider(start, end, initial)

            panel.add(label, panel.add(slider))
            val values = Stream(initial).repeat.map(_ => slider.getValue)

            (panel, values)

        case LayoutIR(direction, a, b) =>
          val (aUI, aValues) = a.runAndMakeUI
          val (bUI, bValues) = b.runAndMakeUI

          val directionInt = direction match {
            case LayoutDirection.Horizontal => BoxLayout.X_AXIS
            case LayoutDirection.Vertical   => BoxLayout.Y_AXIS,
          }

          val panel = JPanel()
          panel.setLayout(BoxLayout(panel, direction))
          panel.add(a)
          panel.add(b)

          val zippedValues = aValues.zip(bValues)
          (panel, zippedValues)
    }

    def run: Stream[Pure, A] = {
        val frame = JFrame("Explorer")

        val (ui, values) = runAndMakeUI

        // displaying the UI
        frame.add(ui)
        frame.setVisible(true)
        frame.pack

        // returning the values stream
        values
    }
}

implicit object IntInterpreter extends ExploreInt[Component] {
  import Component.IntIR

  // Construct a brand new IntIR
  override def int(label: String) = IntIR(label, None, 0)

  // Pattern match to retrieve `generator` as the IntIR subtype, and then
  // update it.
  override def within(generator: Component[Int], start: Int, end: Int) =
    generator match {
      case generator: IntIR =>
        generator.copy(bounds = Some(start, end), initial = (start + end) / 2)
    }

  override def withDefault(generator: Component[Int], newInitial: Int) =
    generator match {
      case generator: IntIR => 
        generator.copy(initial = newInitial)
    }
}

implicit object LayoutInterpreter extends Layout[Component] {
  import Component.LayoutIR

  def above[A, B](top: Component[A], bottom: Component[B]) =
    LayoutIR(LayoutDirection.Vertical, top, bottom)

  def beside[A, B](left: Component[A], right: Component[B]) =
    LayoutIR(LayoutDirection.Horizontal, left, right)
}