Learn · 07 of 09

Gestures and Canvas.

Canvas is the escape hatch for drawings that do not fit the shape protocol: sparklines, plots, editors, pixel grids, and arbitrary curves. Gestures attach to ordinary views, including canvases, and receive pointer data in terminal cell space.

The terminal remains keyboard-first. Pointer support is additive: use it for direct manipulation and inspection, but keep the main workflow available from focus and commands.

A draggable trend plot.

The drawing is a small value type that conforms to CanvasDrawing. The view attaches a DragGesture to append samples and an onPointerHover handler to show inspection state.

TrendEditor.swift swift
import TerminalUI
import TerminalUICLI

@main
struct TrendApp: App {
  var body: some Scene {
    WindowGroup("Trend") {
      TrendEditor()
    }
  }
}

struct TrendEditor: View {
  @State private var values = [2.0, 4.0, 3.0, 8.0, 6.0, 10.0, 9.0]
  @State private var hover: Point?
  @GestureState private var drag: DragGesture.Value?

  var body: some View {
    VStack(alignment: .leading, spacing: 1) {
      Canvas(TrendLine(values: values))
        .frame(width: 48, height: 10)
        .foregroundStyle(.success)
        .border(.separator)
        .gesture(
          DragGesture(minimumDistance: 0)
            .updating($drag) { value, state, _ in
              state = value
            }
            .onEnded { value in
              appendSample(from: value.location, height: 10)
            }
        )
        .onPointerHover { phase in
          switch phase {
          case .entered(let point), .moved(let point):
            hover = point
          case .exited:
            hover = nil
          }
        }

      if let drag {
        Text("drag \(drag.translation.x), \(drag.translation.y)")
          .foregroundStyle(.muted)
      } else if let hover {
        Text("hover \(hover.x), \(hover.y)")
          .foregroundStyle(.muted)
      } else {
        Text("Drag inside the plot to append a sample.")
          .foregroundStyle(.muted)
      }
    }
    .padding(.init(horizontal: 1, vertical: 0))
  }

  func appendSample(from point: Point, height: Int) {
    let clampedY = min(max(point.y, 0), Double(max(1, height - 1)))
    let normalized = 1 - (clampedY / Double(max(1, height - 1)))
    values.append((normalized * 10).rounded())
    values = Array(values.suffix(24))
  }
}

struct TrendLine: CanvasDrawing, Equatable {
  var values: [Double]

  func draw(into context: inout CanvasContext) {
    guard values.count > 1 else { return }
    let low = values.min() ?? 0
    let high = values.max() ?? low
    let spread = max(1, high - low)
    let width = max(1, context.size.width - 1)
    let height = max(1, context.size.height - 1)

    func point(_ index: Int, _ value: Double) -> Point {
      let x = Double(index) * Double(width) / Double(values.count - 1)
      let normalized = (value - low) / spread
      let y = (1 - normalized) * Double(height)
      return Point(x: x, y: y)
    }

    var previous = point(0, values[0])
    for index in values.indices.dropFirst() {
      let next = point(index, values[index])
      context.line(from: previous, to: next)
      previous = next
    }
  }
}

Canvas coordinates

CanvasContext exposes continuous terminal-cell coordinates. A point at x: 2.5 is halfway through the third terminal cell. The active CanvasGrid decides how those fractional samples are packed back into glyphs; the default grid is Braille 2×4.

For dense grid work, Canvas(pixelGridWidth:height:pixels:mode:) lets you write pre-resolved colors as full-cell or vertical half-block pixels. For curves and plots, implement CanvasDrawing.draw(into:) and use methods like line(from:to:), fillRect, and setPixel.

Gesture shape

TerminalUI includes the SwiftUI-shaped primitives: TapGesture, SpatialTapGesture, LongPressGesture, and DragGesture. Attach them with .gesture(...), or use convenience modifiers like .onTapGesture and .onLongPressGesture.

DragGesture.Value carries current location, start location, translation, velocity, predicted end, pointer precision, and the sampled path. Values are in cell-space coordinates, so they compose directly with layout and canvas sizes.

@GestureState

@GestureState stores transient gesture state and resets automatically when the gesture ends or the view leaves the tree. Use it for "current drag" or "is pressing" state. Use @State for committed model changes, like the appended samples in the example.

Where to go next