{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "core-dot-matrix-display",
  "type": "registry:ui",
  "title": "Dot Matrix Display",
  "description": "The character screen bolted into the front of a rack unit — five columns by seven or eight rows per cell, several lines, and the unlit dots present the whole time. Square dark ink on a yellow-green backlit pane is the module every reverb and sampler shipped; round lit dots on black glass is the LED marquee, optionally scrolling on a clock that cannot drift. A bitmap prop drives the dots directly for meters, waveforms and logos, and a blinking block cursor marks the field being edited. The unlit grid is one tiled pattern and each line of ink is one path, so a 20x2 screen is a handful of nodes rather than 1,600.",
  "dependencies": [
    "maruaudio@>=0.0.3 <0.1.0"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "components/ui/dot-matrix-display.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/dot-matrix-display\n * Copyright © [Maru](https://maru.audio)\n *\n * Maru UI Kit — https://maru.audio/ui-kit\n *\n * Installed from the Maru UI registry. This file is yours now: edit it\n * freely, it will not be overwritten unless you re-run the add command.\n *\n * Keep this item’s companion files and CSS imports together. Dependencies\n * declared by the registry are installed with the source.\n */\n/**\n * DotMatrixDisplay — the character screen bolted into the front of a rack unit.\n *\n * The other half of the panel from `segment-display`. A segment readout can say\n * a number; this is what says `C:Small Plate` on the top line and\n * `Mix=99 Volume=99` on the bottom, and it does it the way the hardware does:\n * a fixed field of dots, five columns by seven or eight rows per character,\n * with the ink moving across dots that never move.\n *\n * The dots that are **off** are the whole effect, exactly as with the segments.\n * They are drawn once, as a tiled `<pattern>` across the pane — one element for\n * the entire grid — and the lit ones go on top as a single `<path>` built by\n * `lib/dot-matrix-font`. A 20x2 screen is 1,600 dot positions; as elements that\n * is a layout thrash on every frame of a marquee, and as one path plus one\n * pattern it is two nodes that never change count.\n *\n * ## Emissive and reflective\n *\n * `preset` decides which kind of screen this is, and they are genuinely\n * different objects (see `lib/display-chrome`). An LED marquee makes light at\n * the dot: dark pane, bright round dots, a halo. A backlit character LCD lights\n * the *pane* and the dots are shadows on it: pale yellow-green glass, dark\n * square ink, no halo at all, and the spill onto the panel comes from the\n * backlight — so it does not dim when the screen has less to say. Passing\n * `preset=\"lcd-green\"` with `shape=\"square\"` is the module every reverb and\n * sampler of the era shipped; `preset=\"led-red\"` with `shape=\"round\"` is the\n * marquee on the front of a scrolling sign.\n *\n * ## Using it\n *\n *   <DotMatrixDisplay preset=\"lcd-green\" columns={16} text={[\n *     \"C:Small Plate\",\n *     \"Mix=99 Volume=99\",\n *   ]} />\n *\n * `text` takes a string or a line per array entry. `columns` is how many\n * character cells the panel physically has — text wider than that either sits\n * cut off, as a real module does, or scrolls if `scroll` is set. `bitmap`\n * bypasses the font entirely and drives the dots directly, which is how you get\n * a level meter, a waveform, or a boot logo out of the same screen.\n *\n * `cursor` is the block a module leaves under the field being edited, and it\n * blinks the way one does. `rows={7}` is a 5x7 module and clips descenders;\n * `rows={8}` is a 5x8 one and draws them.\n *\n * The marquee's position is a function of elapsed time rather than an\n * accumulated per-frame delta, so a dropped frame or a backgrounded tab cannot\n * make it drift; it stops entirely under `prefers-reduced-motion`.\n *\n * Colour comes from the skin, with the same three escape hatches the segment\n * display has: `color` for one screen, `--uikit-display-ink` /\n * `--uikit-display-spill` for every display under a wrapper, or a whole\n * `DisplayPreset` object for a panel nobody has built yet.\n */\n\nimport { useEffect, useId, useMemo, useRef, useState } from \"react\"\nimport type { CSSProperties } from \"react\"\nimport { token } from \"maruaudio/ui/shared/theme\"\nimport {\n  type DisplayPreset,\n  type DisplayPresetId,\n  bezelStyle,\n  bloomRadius,\n  displayPreset,\n  ghostColor,\n  glassSheen,\n  inkColor,\n  paneStyle,\n} from \"maruaudio/ui/shared/display-chrome\"\nimport {\n  MATRIX_ADVANCE,\n  MATRIX_FONT_COLUMNS,\n  MATRIX_TRACKING,\n  type DotShape,\n  dotPath,\n  marqueeCycle,\n  marqueeOffset,\n  matrixColumns,\n  matrixLitRatio,\n  matrixWindow,\n} from \"maruaudio/ui/shared/dot-matrix-font\"\n\n/** The block a module leaves where you are typing. */\nexport interface DotMatrixCursor {\n  /** Which line, from zero. */\n  line?: number\n  /** Which character cell, from zero. */\n  column: number\n  /** A filled cell, or a bar under it. */\n  style?: \"block\" | \"underline\"\n  /** Blink it, as a real one does. On by default. */\n  blink?: boolean\n}\n\nexport interface DotMatrixDisplayProps {\n  /** A line, or one per array entry. Newlines split too. */\n  text?: string | readonly string[]\n  /**\n   * Drive the dots directly, `[row][column]`, ignoring the font. For meters,\n   * waveforms, logos — anything the character generator cannot say.\n   */\n  bitmap?: readonly (readonly boolean[])[]\n\n  /** Character cells across. Defaults to the widest line. */\n  columns?: number\n  /** Dot rows per cell: 7 for a 5x7 module, 8 to draw descenders. */\n  rows?: 7 | 8\n\n  /** Panel skin — see `DISPLAY_PRESETS`. A whole preset object also works. */\n  preset?: DisplayPresetId | DisplayPreset\n  /** Override the ink colour for this instance. The unlit dots follow it. */\n  color?: string\n  /** Square, as on a character module, or round, as on an LED marquee. */\n  shape?: DotShape\n\n  /** Dot pitch in pixels — centre to centre. The display's whole scale. */\n  pitch?: number\n  /** Dot size as a fraction of the pitch. Under 1 is what leaves a grid. */\n  fill?: number\n  /** How visible the unlit dots are, 0..1. Defaults to the skin's. */\n  ghost?: number\n  /** Halo around the lit dots, 0..1. Defaults to the skin's. */\n  bloom?: number\n  /** How far light escapes the window, 0..1. Defaults to the skin's. */\n  spill?: number\n  /** Overall brightness of the ink, 0..1. */\n  brightness?: number\n\n  /** Scroll lines that do not fit. Off leaves them cut off, as hardware does. */\n  scroll?: boolean\n  /** Marquee speed in dot columns per second. */\n  speed?: number\n  /** Clear columns between the end of a scrolling message and its start. */\n  scrollGap?: number\n\n  cursor?: DotMatrixCursor\n\n  /** Silkscreen caption above the display. */\n  label?: string\n  /** Draw the panel around the pane. Off leaves the bare window. */\n  frame?: boolean\n\n  className?: string\n  style?: CSSProperties\n  \"aria-label\"?: string\n}\n\nexport function DotMatrixDisplay({\n  text = \"\",\n  bitmap,\n  columns,\n  rows = 7,\n  preset: presetProp,\n  color,\n  shape,\n  pitch = 4,\n  fill = 0.74,\n  ghost,\n  bloom,\n  spill,\n  brightness = 1,\n  scroll = false,\n  speed = 18,\n  scrollGap = MATRIX_ADVANCE * 3,\n  cursor,\n  label,\n  frame = true,\n  className,\n  style,\n  \"aria-label\": ariaLabel,\n}: DotMatrixDisplayProps) {\n  const uid = useId().replace(/[^a-zA-Z0-9-]/g, \"\")\n  const preset = displayPreset(presetProp)\n  const ink = inkColor(preset, color)\n  const dark = ghostColor(preset, color, ghost)\n  // Square ink is the character module; round is the marquee. Reflective\n  // screens are always the former, so that is the default they get.\n  const dotShape: DotShape = shape ?? (preset.emission === \"reflective\" ? \"square\" : \"round\")\n\n  /* ----------------------------------------------------------- the message */\n  const lines = useMemo(() => {\n    if (bitmap) return []\n    const list = Array.isArray(text) ? (text as readonly string[]) : String(text ?? \"\").split(\"\\n\")\n    return list.map((line) => matrixColumns(String(line ?? \"\")))\n  }, [text, bitmap])\n\n  const bitmapColumns = useMemo(() => {\n    if (!bitmap) return null\n    const width = bitmap.reduce((widest, row) => Math.max(widest, row.length), 0)\n    const masks: number[] = []\n    for (let column = 0; column < width; column++) {\n      let mask = 0\n      for (let row = 0; row < bitmap.length; row++) {\n        if (bitmap[row]?.[column]) mask |= 1 << row\n      }\n      masks.push(mask)\n    }\n    return masks\n  }, [bitmap])\n\n  const runs = bitmapColumns ? [bitmapColumns] : lines\n  const dotRows = bitmap ? Math.max(1, bitmap.length) : rows\n\n  // A module's physical width: `columns` cells with a gutter between them, and\n  // no gutter hanging off the end.\n  const cells =\n    columns ??\n    (bitmapColumns\n      ? Math.ceil(bitmapColumns.length / MATRIX_ADVANCE)\n      : Math.max(1, ...runs.map((run) => Math.ceil(run.length / MATRIX_ADVANCE))))\n  const screenColumns = bitmapColumns\n    ? columns\n      ? cells * MATRIX_ADVANCE - MATRIX_TRACKING\n      : bitmapColumns.length\n    : cells * MATRIX_ADVANCE - MATRIX_TRACKING\n\n  /* -------------------------------------------------------------- scrolling */\n  const cycle = useMemo(() => {\n    if (!scroll) return 0\n    const widest = Math.max(0, ...runs.map((run) => run.length))\n    return marqueeCycle(widest, screenColumns, scrollGap)\n  }, [scroll, runs, screenColumns, scrollGap])\n\n  const [elapsed, setElapsed] = useState(0)\n  const startedAt = useRef(0)\n  useEffect(() => {\n    if (cycle <= 0 || speed <= 0) {\n      setElapsed(0)\n      return\n    }\n    if (typeof window === \"undefined\") return\n    if (window.matchMedia?.(\"(prefers-reduced-motion: reduce)\").matches) return\n\n    let frame = 0\n    startedAt.current = performance.now()\n    const step = (now: number) => {\n      setElapsed(now - startedAt.current)\n      frame = requestAnimationFrame(step)\n    }\n    frame = requestAnimationFrame(step)\n    return () => cancelAnimationFrame(frame)\n  }, [cycle, speed])\n\n  const offset = marqueeOffset(elapsed, speed, cycle)\n\n  /* --------------------------------------------------------------- drawing */\n  const windows = useMemo(\n    () =>\n      runs.map((run) => {\n        const scrolling = cycle > 0 && run.length > screenColumns\n        return matrixWindow(run, screenColumns, {\n          offset: scrolling ? offset : 0,\n          wrap: scrolling,\n        })\n      }),\n    [runs, screenColumns, cycle, offset],\n  )\n\n  const glow = useMemo(() => {\n    if (windows.length === 0) return 0\n    // Reflective screens glow from the backlight, which does not care what is\n    // written on them; emissive ones glow from the ink, which does.\n    if (preset.emission === \"reflective\") return 1\n    return (\n      windows.reduce((sum, window) => sum + matrixLitRatio(window, dotRows), 0) / windows.length\n    )\n  }, [windows, dotRows, preset.emission])\n\n  const dotSize = pitch * Math.max(0.05, Math.min(1, fill))\n  const lineHeight = dotRows * pitch\n  const lineGap = Math.round(pitch * 1.5)\n  const width = screenColumns * pitch\n  const height = windows.length * lineHeight + Math.max(0, windows.length - 1) * lineGap\n  const blur = bloomRadius(preset, dotSize, bloom)\n\n  const radius = Math.round(pitch * 1.2)\n  const padX = Math.round(pitch * 2)\n  const padY = Math.round(pitch * 1.8)\n  const spillPreset = spill === undefined ? preset : { ...preset, spillStrength: spill }\n\n  /* ---------------------------------------------------------------- cursor */\n  const [cursorOn, setCursorOn] = useState(true)\n  const cursorBlink = cursor ? cursor.blink !== false : false\n  useEffect(() => {\n    if (!cursorBlink) {\n      setCursorOn(true)\n      return\n    }\n    const timer = setInterval(() => setCursorOn((on) => !on), 530)\n    return () => clearInterval(timer)\n  }, [cursorBlink])\n\n  const cursorRect = useMemo(() => {\n    if (!cursor || !cursorOn) return null\n    const line = cursor.line ?? 0\n    const x = cursor.column * MATRIX_ADVANCE * pitch\n    const y = line * (lineHeight + lineGap)\n    const cellWidth = MATRIX_FONT_COLUMNS * pitch\n    return cursor.style === \"underline\"\n      ? { x, y: y + lineHeight - pitch, width: cellWidth, height: pitch }\n      : { x, y, width: cellWidth, height: lineHeight }\n  }, [cursor, cursorOn, pitch, lineHeight, lineGap])\n\n  const readout = Array.isArray(text)\n    ? (text as readonly string[]).join(\" — \")\n    : String(text ?? \"\").split(\"\\n\").join(\" — \")\n\n  return (\n    <div\n      role=\"img\"\n      aria-label={ariaLabel ?? (label ? `${label}: ${readout}` : readout || \"Dot matrix display\")}\n      className={className}\n      style={{\n        display: \"inline-block\",\n        padding: frame ? Math.round(pitch * 2.4) : 0,\n        ...(frame ? bezelStyle(preset, Math.round(pitch * 1.8)) : null),\n        ...style,\n      }}\n    >\n      {label && (\n        <div\n          style={{\n            fontFamily: token.font,\n            fontSize: Math.max(8, Math.round(pitch * 2.2)),\n            letterSpacing: \"0.08em\",\n            color: preset.caption,\n            marginBottom: Math.round(pitch * 0.8),\n          }}\n        >\n          {label}\n        </div>\n      )}\n\n      <div\n        style={{\n          ...paneStyle(spillPreset, { glow, radius, scale: Math.max(0.6, pitch / 4) }),\n          padding: `${padY}px ${padX}px`,\n          lineHeight: 0,\n        }}\n      >\n        <svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} aria-hidden>\n          <defs>\n            {/* The dots that are always there. One tile, repeated — the panel\n                is a grid of them whether or not anything is driving it. */}\n            <pattern\n              id={`${uid}-grid`}\n              width={pitch}\n              height={pitch}\n              patternUnits=\"userSpaceOnUse\"\n            >\n              {dotShape === \"round\" ? (\n                <circle cx={pitch / 2} cy={pitch / 2} r={dotSize / 2} fill={dark} />\n              ) : (\n                <rect\n                  x={(pitch - dotSize) / 2}\n                  y={(pitch - dotSize) / 2}\n                  width={dotSize}\n                  height={dotSize}\n                  fill={dark}\n                />\n              )}\n            </pattern>\n\n            {blur > 0 && (\n              <filter\n                id={`${uid}-bloom`}\n                x=\"-30%\"\n                y=\"-30%\"\n                width=\"160%\"\n                height=\"160%\"\n                colorInterpolationFilters=\"sRGB\"\n              >\n                <feGaussianBlur in=\"SourceGraphic\" stdDeviation={blur} result=\"halo\" />\n                <feMerge>\n                  <feMergeNode in=\"halo\" />\n                  <feMergeNode in=\"halo\" />\n                  <feMergeNode in=\"SourceGraphic\" />\n                </feMerge>\n              </filter>\n            )}\n          </defs>\n\n          {windows.map((_, index) => (\n            <rect\n              key={`grid-${index}`}\n              x={0}\n              y={index * (lineHeight + lineGap)}\n              width={width}\n              height={lineHeight}\n              fill={`url(#${uid}-grid)`}\n            />\n          ))}\n\n          {/* The cursor is under the ink: a block cursor has the character\n              standing on it, which is how a module draws one. */}\n          {cursorRect && (\n            <rect\n              x={cursorRect.x}\n              y={cursorRect.y}\n              width={cursorRect.width}\n              height={cursorRect.height}\n              fill={ink}\n              opacity={0.42 * Math.max(0, Math.min(1, brightness))}\n            />\n          )}\n\n          <g\n            fill={ink}\n            opacity={Math.max(0, Math.min(1, brightness))}\n            filter={blur > 0 ? `url(#${uid}-bloom)` : undefined}\n          >\n            {windows.map((window, index) => (\n              <path\n                key={`line-${index}`}\n                d={dotPath(window, {\n                  rows: dotRows,\n                  pitch,\n                  size: dotSize,\n                  shape: dotShape,\n                  y: index * (lineHeight + lineGap),\n                })}\n              />\n            ))}\n          </g>\n        </svg>\n\n        <div\n          aria-hidden\n          style={{\n            position: \"absolute\",\n            inset: 0,\n            borderRadius: radius,\n            background: glassSheen(preset),\n            pointerEvents: \"none\",\n          }}\n        />\n      </div>\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "@ui/dot-matrix-display.tsx"
    }
  ]
}
