harn-stdlib 0.10.53

Embedded Harn standard library source catalog
Documentation
/** Shared UI contracts for interactive Harn applications. */
pub type UiElementKind = "column" \
  | "row" \
  | "heading" \
  | "text" \
  | "button" \
  | "text_area" \
  | "field" \
  | "select" \
  | "status" \
  | "canvas" \
  | "image" \
  | "divider"

pub type UiPoint = {x: float, y: float}

pub type UiStroke = {id: string, points: list<UiPoint>, color: string, width: float}

pub type UiOption = {value: string, label: string}

/**
 * A flat element graph keeps the wire format simple. `parent` names an
 * earlier container element; omitted parents attach to the document root.
 */
pub type UiElement = {
  id: string,
  kind: UiElementKind,
  parent?: string,
  label?: string,
  text?: string,
  level?: int,
  value?: string,
  options?: list<UiOption>,
  disabled?: bool,
  hidden?: bool,
  variant?: string,
  placeholder?: string,
  rows?: int,
  width?: int,
  height?: int,
  strokes?: list<UiStroke>,
  image_src?: string,
  image_alt?: string,
}

pub type UiDocument = {
  schema: "harn.ui_document.v1",
  title: string,
  revision: int,
  elements: list<UiElement>,
}

pub type UiEventKind = "ready" | "click" | "input" | "canvas.stroke" | "canvas.snapshot"

pub type UiEvent = {
  schema: "harn.ui_event.v1",
  kind: UiEventKind,
  target: string,
  value?: string,
  points?: list<UiPoint>,
}

pub type UiEffectKind = "send_event" | "capture_canvas" | "download"

pub type UiEffect = {
  kind: UiEffectKind,
  after_ms?: int,
  event?: UiEvent,
  target?: string,
  event_target?: string,
  name?: string,
  mime_type?: string,
  data_base64?: string,
}

pub type UiUpdate = {schema: "harn.ui_update.v1", document: UiDocument, effects: list<UiEffect>}

pub type UiAppOptions = {
  description?: string,
  version?: string,
  prefers_border?: bool,
  validation?: dict,
}?

const UI_DOCUMENT_SCHEMA = "harn.ui_document.v1"

const UI_EVENT_SCHEMA = "harn.ui_event.v1"

const UI_UPDATE_SCHEMA = "harn.ui_update.v1"

const UI_MAX_CANVAS_SIDE = 8192

const UI_MAX_STROKE_POINTS = 4096

const UI_MAX_DOCUMENT_POINTS = 65536

fn __ui_app_text(value) -> string {
  return trim(to_string(value ?? ""))
}

fn __ui_element_kind(kind: string) -> UiElementKind {
  if kind == "column" {
    return "column"
  }
  if kind == "row" {
    return "row"
  }
  if kind == "heading" {
    return "heading"
  }
  if kind == "text" {
    return "text"
  }
  if kind == "button" {
    return "button"
  }
  if kind == "text_area" {
    return "text_area"
  }
  if kind == "field" {
    return "field"
  }
  if kind == "select" {
    return "select"
  }
  if kind == "status" {
    return "status"
  }
  if kind == "canvas" {
    return "canvas"
  }
  if kind == "image" {
    return "image"
  }
  if kind == "divider" {
    return "divider"
  }
  throw "std/ui: unsupported element kind " + kind
}

fn __ui_effect_kind(kind: string) -> UiEffectKind {
  if kind == "send_event" {
    return "send_event"
  }
  if kind == "capture_canvas" {
    return "capture_canvas"
  }
  if kind == "download" {
    return "download"
  }
  throw "std/ui: unsupported effect kind " + kind
}

fn __ui_point(raw) -> UiPoint {
  const x = to_float(raw?.x)
  const y = to_float(raw?.y)
  if x == nil
    || y == nil
    || is_nan(x)
    || is_nan(y)
    || is_infinite(x)
    || is_infinite(y)
    || x < 0.0
    || x > 1.0
    || y < 0.0
    || y > 1.0 {
    throw "std/ui: canvas coordinates must be finite and between 0.0 and 1.0"
  }
  return {x: x, y: y}
}

fn __ui_strokes(raw) -> list<UiStroke> {
  let checked: list<UiStroke> = []
  let point_count = 0
  let ids = []
  for stroke in raw ?? [] {
    const id = __ui_app_text(stroke?.id)
    const points = stroke?.points ?? []
    const width = to_float(stroke?.width)
    if id == "" || ids.contains(id) {
      throw "std/ui: canvas stroke ids must be unique and non-empty"
    }
    if len(points) < 2 || len(points) > UI_MAX_STROKE_POINTS {
      throw "std/ui: canvas stroke must contain 2 to 4096 points"
    }
    if width == nil || is_nan(width) || is_infinite(width) || width <= 0.0 || width > 512.0 {
      throw "std/ui: canvas stroke width must be finite and between 0 and 512"
    }
    point_count = point_count + len(points)
    if point_count > UI_MAX_DOCUMENT_POINTS {
      throw "std/ui: canvas strokes may contain at most 65,536 points in total"
    }
    let checked_points: list<UiPoint> = []
    for point in points {
      checked_points = checked_points + [__ui_point(point)]
    }
    checked = checked
      + [
      {id: id, points: checked_points, color: to_string(stroke?.color ?? ""), width: width},
    ]
    ids = ids + [id]
  }
  return checked
}

fn __ui_element(raw) -> UiElement {
  return {
    id: __ui_app_text(raw?.id),
    kind: __ui_element_kind(__ui_app_text(raw?.kind)),
    parent: raw?.parent,
    label: raw?.label,
    text: raw?.text,
    level: raw?.level,
    value: raw?.value,
    options: raw?.options,
    disabled: raw?.disabled,
    hidden: raw?.hidden,
    variant: raw?.variant,
    placeholder: raw?.placeholder,
    rows: raw?.rows,
    width: raw?.width,
    height: raw?.height,
    strokes: raw?.strokes,
    image_src: raw?.image_src,
    image_alt: raw?.image_alt,
  }
}

/**
 * Check one document and copy its supported fields at the Harn boundary.
 *
 * @effects: []
 * @errors: [invalid_argument]
 */
pub fn document(title: string, revision: int, elements) -> UiDocument {
  if __ui_app_text(title) == "" {
    throw "std/ui: document title is required"
  }
  if revision < 0 {
    throw "std/ui: document revision must be non-negative"
  }
  let ids = []
  let containers = []
  let checked: list<UiElement> = []
  for raw in elements ?? [] {
    let element = __ui_element(raw)
    const id = __ui_app_text(element.id)
    if id == "" {
      throw "std/ui: every element needs an id"
    }
    if ids.contains(id) {
      throw "std/ui: duplicate element id " + id
    }
    if element.parent != nil && !ids.contains(element.parent) {
      throw "std/ui: parent must appear before child: " + element.parent
    }
    if element.parent != nil && !containers.contains(element.parent) {
      throw "std/ui: parent must be a row or column: " + element.parent
    }
    if element.kind == "canvas" && (element.width ?? 0) <= 0 {
      throw "std/ui: canvas width must be positive"
    }
    if element.kind == "canvas" && (element.height ?? 0) <= 0 {
      throw "std/ui: canvas height must be positive"
    }
    if element.kind == "canvas" && (element.width ?? 0) > UI_MAX_CANVAS_SIDE {
      throw "std/ui: canvas width may not exceed 8,192 pixels"
    }
    if element.kind == "canvas" && (element.height ?? 0) > UI_MAX_CANVAS_SIDE {
      throw "std/ui: canvas height may not exceed 8,192 pixels"
    }
    if element.kind == "canvas" {
      element.strokes = __ui_strokes(element.strokes)
    }
    if element.kind == "heading" && (element.level ?? 2) < 1 {
      throw "std/ui: heading level must be between 1 and 6"
    }
    if element.kind == "heading" && (element.level ?? 2) > 6 {
      throw "std/ui: heading level must be between 1 and 6"
    }
    ids = ids + [id]
    if ["column", "row"].contains(element.kind) {
      containers = containers + [id]
    }
    checked = checked + [element]
  }
  return {schema: UI_DOCUMENT_SCHEMA, title: title, revision: revision, elements: checked}
}

/**
 * Check an untrusted browser event before application policy sees it.
 *
 * @effects: []
 * @errors: [invalid_argument]
 */
pub fn event(raw: dict) -> UiEvent {
  if raw?.schema != UI_EVENT_SCHEMA {
    throw "std/ui: unsupported event schema"
  }
  const kind = raw?.kind
  if !["ready", "click", "input", "canvas.stroke", "canvas.snapshot"].contains(kind) {
    throw "std/ui: unsupported event kind " + to_string(kind)
  }
  const target = __ui_app_text(raw?.target)
  if target == "" {
    throw "std/ui: event target is required"
  }
  let event: UiEvent = {schema: UI_EVENT_SCHEMA, kind: kind, target: target}
  if raw?.value != nil {
    event.value = to_string(raw.value)
  }
  if kind == "canvas.stroke" {
    const points = raw?.points ?? []
    if len(points) < 2 || len(points) > 4096 {
      throw "std/ui: canvas stroke must contain 2 to 4096 points"
    }
    let checked_points: list<UiPoint> = []
    for point in points {
      checked_points = checked_points + [__ui_point(point)]
    }
    event.points = checked_points
  }
  return event
}

/**
 * Build the one structured result consumed by every Harn UI renderer.
 *
 * @effects: []
 * @errors: [invalid_argument]
 */
pub fn update(document: UiDocument, effects = []) -> UiUpdate {
  let checked_effects: list<UiEffect> = []
  for raw in effects ?? [] {
    let effect: UiEffect = {kind: __ui_effect_kind(__ui_app_text(raw?.kind))}
    if raw?.after_ms != nil {
      effect.after_ms = to_int(raw.after_ms)
      if effect.after_ms == nil || effect.after_ms < 0 {
        throw "std/ui: effect delay must be a non-negative integer"
      }
    }
    if raw?.event != nil {
      effect.event = event(raw.event)
    }
    if raw?.target != nil {
      effect.target = to_string(raw.target)
    }
    if raw?.event_target != nil {
      effect.event_target = to_string(raw.event_target)
    }
    if raw?.name != nil {
      effect.name = to_string(raw.name)
    }
    if raw?.mime_type != nil {
      effect.mime_type = to_string(raw.mime_type)
    }
    if raw?.data_base64 != nil {
      effect.data_base64 = to_string(raw.data_base64)
    }
    if effect.kind == "send_event" && effect.event == nil {
      throw "std/ui: send_event effect requires an event"
    }
    if effect.kind == "capture_canvas" && __ui_app_text(effect.target) == "" {
      throw "std/ui: capture_canvas effect requires a target"
    }
    if effect.kind == "download"
      && (__ui_app_text(effect.name) == ""
      || __ui_app_text(effect.data_base64)
      == "") {
      throw "std/ui: download effect requires a name and data"
    }
    checked_effects = checked_effects + [effect]
  }
  return {schema: UI_UPDATE_SCHEMA, document: document, effects: checked_effects}
}