{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "components-highlight-text",
  "type": "registry:component",
  "title": "Highlight text",
  "description": "A phrase whose matches a marker wash sweeps across",
  "author": "sudo-kw <https://github.com/sudo-kw>",
  "dependencies": [
    "@gpuix/native@0.11.0",
    "@gpuix/react@0.11.0",
    "effect@4.1.2",
    "react@^19.2.4"
  ],
  "registryDependencies": [
    "https://gpuixui.sudokw.com/gpuix-motion.json",
    "https://gpuixui.sudokw.com/gpuix-tokens.json",
    "https://gpuixui.sudokw.com/gpuix-tw.json"
  ],
  "files": [
    {
      "path": "packages/components/src/highlight-text.tsx",
      "target": "@ui/gpuixui/components/highlight-text.tsx",
      "type": "registry:component",
      "content": "/** @jsxImportSource @gpuix/react */\nimport type { Props } from \"@gpuix/react\";\nimport {\n  mergeStyles,\n  resolveStyle,\n  type StateStyle,\n} from \"@gpuix/react/floating\";\nimport { cn } from \"../tw/index.js\";\nimport * as Arr from \"effect/Array\";\nimport * as Option from \"effect/Option\";\nimport { useEffect, useMemo, useState } from \"react\";\nimport {\n  hostRanges,\n  marksFor,\n  stepOf,\n  sweepLength,\n  sweptRanges,\n  type Range,\n} from \"./highlight-text/logic.js\";\nimport { washFor } from \"./highlight-text/wash.js\";\nimport { useMotionPreference } from \"./internal/presence.js\";\nimport { recipeStyle } from \"./internal/recipe.js\";\nimport { textOf } from \"./internal/text.js\";\nimport { textStyle, useTheme, type TextRole } from \"./internal/theme.js\";\n\nexport interface HighlightTextState {\n  readonly text: string;\n\n  readonly revealed: number;\n\n  readonly total: number;\n  readonly complete: boolean;\n  readonly reducedMotion: boolean;\n}\n\nexport interface HighlightTextProps extends Omit<Props, \"children\" | \"style\"> {\n  readonly text: string;\n\n  readonly query?: string;\n\n  readonly ranges?: ReadonlyArray<Range>;\n\n  readonly caseSensitive?: boolean;\n\n  readonly wholeWord?: boolean;\n\n  readonly stepMs?: number;\n\n  readonly color?: string;\n\n  readonly activeColor?: string;\n\n  readonly activeIndex?: number;\n\n  readonly role?: TextRole;\n\n  readonly className?: string;\n\n  readonly style?: StateStyle<HighlightTextState>;\n}\n\ninterface Progress {\n  readonly run: string;\n  readonly revealed: number;\n}\n\nexport const HighlightText = ({\n  text,\n  query,\n  ranges,\n  caseSensitive = false,\n  wholeWord = false,\n  stepMs: stepMsProp,\n  color,\n  activeColor,\n  activeIndex,\n  role = \"body\",\n  className,\n  style,\n  ...rest\n}: HighlightTextProps) => {\n  const theme = useTheme();\n  const reducedMotion = useMotionPreference() === \"reduced\";\n  const stepMs = stepOf(Option.fromNullishOr(stepMsProp));\n\n  const phrase = textOf(text);\n  const marks = useMemo(\n    () => marksFor(phrase, { query, ranges, caseSensitive, wholeWord }),\n    [caseSensitive, phrase, query, ranges, wholeWord],\n  );\n  const total = sweepLength(phrase, marks);\n  const instant = reducedMotion || stepMs === 0;\n\n  const rangeKey = Arr.map(\n    ranges ?? [],\n    (range) => `${range[0]}:${range[1]}`,\n  ).join(\",\");\n  const run = `${phrase}|${query ?? \"\"}|${rangeKey}|${caseSensitive}|${wholeWord}|${stepMs}`;\n\n  const [progress, setProgress] = useState<Progress>({ run, revealed: 0 });\n  const revealed = instant\n    ? total\n    : progress.run === run\n      ? progress.revealed\n      : 0;\n  const complete = revealed >= total;\n\n  useEffect(() => {\n    if (instant || total === 0) return;\n\n    let shown = 0;\n    let handle: ReturnType<typeof setTimeout> | undefined;\n    const sweep = (): void => {\n      shown += 1;\n      setProgress({ run, revealed: shown });\n      if (shown < total) handle = setTimeout(sweep, stepMs);\n    };\n    handle = setTimeout(sweep, stepMs);\n\n    return () => {\n      if (handle !== undefined) clearTimeout(handle);\n    };\n  }, [instant, run, stepMs, total]);\n\n  const state: HighlightTextState = {\n    text: phrase,\n    revealed,\n    total,\n    complete,\n    reducedMotion,\n  };\n  const painted = sweptRanges(phrase, marks, revealed);\n  const wash = washFor(\n    theme,\n    { color, activeColor, activeIndex },\n    Arr.length(painted),\n  );\n  const marksOnHost = hostRanges(painted);\n\n  return (\n    <div\n      {...rest}\n      highlight={\n        wash === undefined\n          ? { ranges: marksOnHost }\n          : { ranges: marksOnHost, ...wash }\n      }\n      style={mergeStyles(\n        recipeStyle(cn(className), theme),\n        resolveStyle(style, state),\n      )}\n    >\n      <text\n        style={{\n          ...textStyle(theme, role),\n          color: theme.colors.foreground,\n        }}\n      >\n        {phrase}\n      </text>\n    </div>\n  );\n};\n\nexport const highlightTextMeta = {\n  name: \"Highlight text\",\n  tier: 5,\n  status: \"done\",\n  gpuixSupport: \"yes\",\n  limitations: [\n    \"the sweep is a real-time timer, so it is not driven by the renderer's motion clock\",\n    \"no animated highlight: the wash is repainted as a shorter range each step\",\n    \"the query's case folding is the regular-expression engine's; a locale that changes the string length needs the caller's ranges\",\n    \"`activeIndex` counts the marks this phrase paints, not a document's matches; a find bar converts a document-wide index first\",\n  ],\n  docs: \"docs/highlight-text.mdx\",\n  example: \"examples/highlight-text.example.tsx\",\n  testFile: \"src/behaviour/highlight-text.test.tsx\",\n} as const;\n"
    },
    {
      "path": "packages/components/src/highlight-text/logic.ts",
      "target": "@ui/gpuixui/components/highlight-text/logic.ts",
      "type": "registry:lib",
      "content": "import * as Arr from \"effect/Array\";\nimport * as Option from \"effect/Option\";\nimport * as Result from \"effect/Result\";\n\nexport type Range = readonly [number, number];\n\nexport interface MatchOptions {\n  readonly caseSensitive?: boolean;\n\n  readonly wholeWord?: boolean;\n}\n\nconst WORD_CHARACTER = /[\\p{L}\\p{N}\\p{M}_]/u;\n\nconst escape = (value: string): string =>\n  value.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n\nconst codePoints = (\n  text: string,\n  start: number,\n  end: number,\n): ReadonlyArray<string> => Arr.fromIterable(text.slice(start, end));\n\nconst spanOf = (text: string, range: Range): number =>\n  Arr.length(codePoints(text, range[0], range[1]));\n\nconst readRange = (text: string, range: Range): Result.Result<Range, void> => {\n  const [start, end] = range;\n  if (!Number.isFinite(start) || !Number.isFinite(end)) return Result.failVoid;\n  const first = Math.min(Math.max(Math.trunc(start), 0), text.length);\n  const last = Math.min(Math.max(Math.trunc(end), 0), text.length);\n  return last > first\n    ? Result.succeed([first, last] as const)\n    : Result.failVoid;\n};\n\nconst paintableRanges = (\n  text: string,\n  ranges: ReadonlyArray<Range>,\n): ReadonlyArray<Range> =>\n  Arr.filterMap(ranges, (range) => readRange(text, range));\n\nconst isWordCode = (code: number | undefined): boolean =>\n  Option.match(Option.fromNullishOr(code), {\n    onNone: () => false,\n    onSome: (value) => WORD_CHARACTER.test(String.fromCodePoint(value)),\n  });\n\nconst wordBefore = (text: string, index: number): boolean => {\n  if (index <= 0) return false;\n  const low = text.codePointAt(index - 1);\n  const start =\n    low !== undefined && low >= 0xdc00 && low <= 0xdfff ? index - 2 : index - 1;\n  return isWordCode(text.codePointAt(start));\n};\n\nconst wordAfter = (text: string, index: number): boolean =>\n  index < text.length && isWordCode(text.codePointAt(index));\n\nexport const matchRanges = (\n  text: string,\n  query: string,\n  options: MatchOptions = {},\n): ReadonlyArray<Range> => {\n  if (query.length === 0) return [];\n  const pattern = new RegExp(escape(query), options.caseSensitive ? \"g\" : \"gi\");\n\n  return Arr.filterMap(Arr.fromIterable(text.matchAll(pattern)), (match) => {\n    const start = match.index;\n    const end = start + (match[0] ?? \"\").length;\n    const whole =\n      !options.wholeWord || (!wordBefore(text, start) && !wordAfter(text, end));\n    return whole ? Result.succeed([start, end] as const) : Result.failVoid;\n  });\n};\n\nexport const marksFor = (\n  text: string,\n  input: {\n    readonly query?: string | undefined;\n    readonly ranges?: ReadonlyArray<Range> | undefined;\n    readonly caseSensitive?: boolean | undefined;\n    readonly wholeWord?: boolean | undefined;\n  },\n): ReadonlyArray<Range> =>\n  Option.match(Option.fromNullishOr(input.ranges), {\n    onSome: (ranges) => paintableRanges(text, ranges),\n    onNone: () =>\n      Option.match(Option.fromNullishOr(input.query), {\n        onNone: () => [],\n        onSome: (query) =>\n          matchRanges(text, query, {\n            caseSensitive: input.caseSensitive,\n            wholeWord: input.wholeWord,\n          }),\n      }),\n  });\n\ninterface Sweep {\n  readonly budget: number;\n  readonly ranges: ReadonlyArray<Range>;\n}\n\nexport const sweptRanges = (\n  text: string,\n  ranges: ReadonlyArray<Range>,\n  step: number,\n): ReadonlyArray<Range> => {\n  const start: Sweep = { budget: Math.max(0, Math.floor(step)), ranges: [] };\n  const sweep = Arr.reduce(\n    paintableRanges(text, ranges),\n    start,\n    (state, range) => {\n      if (state.budget <= 0) return state;\n      const points = codePoints(text, range[0], range[1]);\n      const visible = Arr.take(points, state.budget);\n      const width = Arr.reduce(visible, 0, (sum, point) => sum + point.length);\n      return {\n        budget: state.budget - visible.length,\n        ranges: [...state.ranges, [range[0], range[0] + width] as const],\n      };\n    },\n  );\n\n  return sweep.ranges;\n};\n\nexport const sweepLength = (\n  text: string,\n  ranges: ReadonlyArray<Range>,\n): number =>\n  Arr.reduce(\n    paintableRanges(text, ranges),\n    0,\n    (total, range) => total + spanOf(text, range),\n  );\n\nconst STEP_MS = 60;\n\nexport const stepOf = (value: Option.Option<number>): number =>\n  Option.getOrElse(\n    Option.filter(value, (ms) => Number.isFinite(ms) && ms >= 0),\n    () => STEP_MS,\n  );\n\nexport const hostRanges = (\n  ranges: ReadonlyArray<Range>,\n): Array<[number, number]> =>\n  Arr.map(ranges, (range): [number, number] => [range[0], range[1]]);\n"
    },
    {
      "path": "packages/components/src/highlight-text/wash.ts",
      "target": "@ui/gpuixui/components/highlight-text/wash.ts",
      "type": "registry:lib",
      "content": "import * as Option from \"effect/Option\";\nimport { findHighlight, type FindHighlightSource } from \"../internal/theme.js\";\n\nexport interface WashInput {\n  readonly color?: string | undefined;\n\n  readonly activeColor?: string | undefined;\n\n  readonly activeIndex?: number | undefined;\n}\n\nexport interface Wash {\n  readonly color: string;\n  readonly activeColor: string;\n  readonly radius: number;\n\n  readonly activeIndex: number | undefined;\n}\n\nexport const activeMarkOf = (\n  index: number | undefined,\n  painted: number,\n): number | undefined =>\n  Option.getOrUndefined(\n    Option.filter(\n      Option.fromNullishOr(index),\n      (value) => Number.isInteger(value) && value >= 0 && value < painted,\n    ),\n  );\n\nexport const washFor = (\n  source: FindHighlightSource,\n  input: WashInput,\n  painted: number,\n): Wash | undefined => {\n  const derived = findHighlight(source);\n  if (derived === undefined) return undefined;\n  return {\n    color: input.color ?? derived.color,\n    activeColor: input.activeColor ?? derived.activeColor,\n    radius: derived.radius,\n    activeIndex: activeMarkOf(input.activeIndex, painted),\n  };\n};\n"
    },
    {
      "path": "packages/components/src/internal/presence.tsx",
      "target": "@ui/gpuixui/components/internal/presence.tsx",
      "type": "registry:component",
      "content": "/** @jsxImportSource @gpuix/react */\nimport type { MotionProps } from \"@gpuix/react\";\nimport {\n  delayedTiming,\n  exitWindowMs,\n  fadeFrom,\n  fadeFromAnchor,\n  fadeFromSide,\n  fadeOnly,\n  presenceMotion as motionOfPresence,\n  presenceStyle,\n  transitionWindowMs,\n  type PresenceFrames,\n  type PresenceHitRule,\n  type PresenceSide,\n  type PresenceTiming,\n  type PresenceTransition,\n} from \"../../motion/presence.js\";\nimport {\n  MotionProvider,\n  useMotionPreference,\n  useSurfacePresence,\n  useSurfaceTiming,\n  type MotionProviderProps,\n  type SurfacePresence,\n} from \"../../motion/react/presence.js\";\nimport {\n  motionTiming,\n  type MotionLevel,\n  type MotionPreference,\n} from \"../../motion/timing.js\";\n\nexport { MotionProvider, useMotionPreference, type MotionProviderProps };\n\nexport type SurfaceFrames = PresenceFrames;\n\nexport type SurfaceTiming = PresenceTiming;\n\nexport type PanelSide = PresenceSide;\n\nexport {\n  delayedTiming,\n  exitWindowMs,\n  fadeFrom,\n  fadeFromAnchor,\n  fadeFromSide,\n  fadeOnly,\n  presenceStyle,\n  transitionWindowMs,\n};\nexport type { PresenceHitRule };\n\nexport { useSurfacePresence, useSurfaceTiming, type SurfacePresence };\n\nexport const moveTiming = (\n  level: MotionLevel,\n  preference: MotionPreference,\n): PresenceTransition => motionTiming(level, { phase: \"standard\", preference });\n\nexport const useMoveTiming = (level: MotionLevel): PresenceTransition =>\n  moveTiming(level, useMotionPreference());\n\nexport const presenceMotion = (\n  frames: SurfaceFrames,\n  presence: SurfacePresence,\n  timing: SurfaceTiming = presence.timing,\n): MotionProps => motionOfPresence(frames, presence.present, timing);\n\nexport const PresenceShield = ({ present }: { readonly present: boolean }) =>\n  present ? null : (\n    <div\n      testId=\"presence-shield\"\n      style={{ position: \"absolute\", top: 0, right: 0, bottom: 0, left: 0 }}\n    />\n  );\n\nexport const presenceTabStop = (\n  present: boolean,\n): { readonly tabIndex?: number } => (present ? {} : { tabIndex: -1 });\n"
    },
    {
      "path": "packages/components/src/internal/recipe.ts",
      "target": "@ui/gpuixui/components/internal/recipe.ts",
      "type": "registry:lib",
      "content": "import type { StyleDesc } from \"@gpuix/react\";\nimport { createRecipeCache } from \"../../tw/index.js\";\nimport type { Theme } from \"./theme.js\";\n\nexport const recipeStyle: (recipe: string, theme: Theme) => StyleDesc =\n  createRecipeCache(512);\n"
    },
    {
      "path": "packages/components/src/internal/text.ts",
      "target": "@ui/gpuixui/components/internal/text.ts",
      "type": "registry:lib",
      "content": "import { pipe } from \"effect\";\nimport * as Option from \"effect/Option\";\nimport * as Str from \"effect/String\";\n\nexport const textOf = (value: unknown): string =>\n  typeof value === \"string\" ? value : \"\";\n\nexport const nameOf = (value: unknown): string | undefined =>\n  pipe(\n    Option.some(textOf(value)),\n    Option.map(Str.trim),\n    Option.filter(Str.isNonEmpty),\n    Option.getOrUndefined,\n  );\n"
    },
    {
      "path": "packages/components/src/internal/theme.ts",
      "target": "@ui/gpuixui/components/internal/theme.ts",
      "type": "registry:lib",
      "content": "import type { StyleDesc } from \"@gpuix/react\";\nimport * as Schema from \"effect/Schema\";\nimport {\n  alpha,\n  avatarPixels,\n  avatarSize,\n  brandTextOn,\n  controlFocusRing,\n  controlHeight,\n  controlPaddingX,\n  currentPlatform,\n  darkTheme,\n  disabledAction,\n  disabledOpacity,\n  elevationShadow,\n  filledHover,\n  filledPressed,\n  findHighlight,\n  focusRing,\n  keyboardFocusRing,\n  lightTheme,\n  nativeTextTheme,\n  pointerWash,\n  selectionOn,\n  statusTone,\n  syntaxColors,\n  targetSize,\n  textStyle,\n  type BoxShadow,\n  type ElevationLevel,\n  type FindHighlightSource,\n  type NativeTextFill,\n  type PlatformName,\n  type PointerWashSurface,\n  type StatusTone,\n  type SyntaxColors,\n  type SyntaxRole,\n  type TextRole,\n  type Theme,\n  type ThemeColors,\n} from \"../../tokens/index.js\";\n\nexport {\n  alpha,\n  avatarPixels,\n  avatarSize,\n  controlFocusRing,\n  controlHeight,\n  controlPaddingX,\n  currentPlatform,\n  darkTheme,\n  disabledAction,\n  disabledOpacity,\n  elevationShadow,\n  filledHover,\n  filledPressed,\n  findHighlight,\n  focusRing,\n  keyboardFocusRing,\n  lightTheme,\n  nativeTextTheme,\n  statusTone,\n  syntaxColors,\n  targetSize,\n  textStyle,\n};\nexport type {\n  BoxShadow,\n  ElevationLevel,\n  FindHighlightSource,\n  NativeTextFill,\n  PlatformName,\n  StatusTone,\n  SyntaxColors,\n  SyntaxRole,\n  TextRole,\n  Theme,\n};\nexport { ThemeProvider, useTheme } from \"../../tokens/react.js\";\n\nexport const keyboardRing = (theme: Theme, invalid = false): StyleDesc =>\n  controlFocusRing(theme, { invalid }) ?? {};\n\nexport interface ControlRingState {\n  readonly disabled?: boolean;\n\n  readonly invalid?: boolean;\n}\n\nexport const controlRing = (\n  theme: Theme,\n  state: ControlRingState = {},\n): StyleDesc => controlFocusRing(theme, state) ?? {};\n\nexport const pressWash = (theme: Theme, replaced: PointerWashSurface): string =>\n  pointerWash(theme, \"active\", replaced) ?? theme.colors.active;\n\nexport const selectionWash = (theme: Theme): StyleDesc => ({\n  selectionColor: theme.colors.selection,\n});\n\nexport const keepContent: StyleDesc = { keyboardFocusDim: false };\n\nexport const disabledFade = (\n  disabled: boolean,\n): { readonly opacity: number } | undefined =>\n  disabled ? { opacity: disabledOpacity } : undefined;\n\nconst Tones = Schema.Literals([\"info\", \"success\", \"warning\", \"danger\"]);\n\nexport type Tone = typeof Tones.Type;\n\nexport const toneOf = (value: unknown): Tone =>\n  Schema.is(Tones)(value) ? value : \"info\";\n\nexport interface ToneStyle {\n  readonly fg: string;\n  readonly bg: string;\n  readonly border: string;\n}\n\nconst TOKEN_TONE: Readonly<Record<Tone, StatusTone>> = {\n  info: \"info\",\n  success: \"success\",\n  warning: \"warning\",\n  danger: \"destructive\",\n};\n\nexport const tone = (theme: Theme, value: Tone): ToneStyle => {\n  const { background, border, foreground } = statusTone(\n    theme,\n    TOKEN_TONE[value],\n  );\n  return { bg: background, border, fg: foreground };\n};\n\nconst softRoles: Readonly<Record<Tone, keyof ThemeColors>> = {\n  info: \"infoSoft\",\n  success: \"successSoft\",\n  warning: \"warningSoft\",\n  danger: \"destructiveSoft\",\n};\n\nexport const messageLink = (theme: Theme, value: Tone): string =>\n  brandTextOn(theme, softRoles[value]);\n\nexport const messageSelection = (theme: Theme, value: Tone): string =>\n  selectionOn(theme, softRoles[value]);\n\nexport const toneSurface = (theme: Theme, value: Tone): StyleDesc => {\n  const { bg, border, fg } = tone(theme, value);\n  return {\n    backgroundColor: bg,\n    color: fg,\n    borderWidth: theme.borderWidth.thin,\n    borderColor: border,\n  };\n};\n"
    }
  ],
  "docs": "[Highlight text](https://gpuixui.sudokw.com/components/highlight-text) — These are GPUix native components: the files render through GPUix's reconciler against `@gpuix/react`, not the DOM, and need the per-platform `@gpuix/native` binding. The CLI installs the `@gpuixui/*` runtime the components import as registry items, so the copied tree resolves on its own; wrap it in `ThemeProvider` before it renders.",
  "meta": {
    "url": "/components/highlight-text",
    "kind": "component",
    "runtime": "gpuix",
    "registry": "gpuixui"
  }
}