@@ -3,12 +3,16 @@ import type { Dispatch, JSX, ReactNode, SetStateAction } from "react";
33import { createContext , useContext , useMemo , useState } from "react" ;
44import type { DecorationItem } from "shiki" ;
55import type {
6- SelectionPlainObject ,
7- SerializedMarks ,
6+ Position ,
7+ Selection ,
88 TestCaseSnapshot ,
99} from "@cursorless/lib-common" ;
10- import { plainObjectToSelection , splitKey } from "@cursorless/lib-common" ;
10+ import {
11+ plainObjectToRange ,
12+ plainObjectToSelection ,
13+ } from "@cursorless/lib-common" ;
1114import { Code } from "./Code" ;
15+ import { highlightColors } from "./highlightColors" ;
1216import type { RecordedTest } from "./types" ;
1317
1418interface RecordedTestVisualizerContextValue {
@@ -98,12 +102,17 @@ export function RecordedTestVisualizer({
98102 url : `https://github.com/cursorless-dev/cursorless/blob/main/resources/fixtures/recorded/docs/${ path } ` ,
99103 } ;
100104
105+ const renderHats =
106+ fixture . initialState . marks != null &&
107+ Object . keys ( fixture . initialState . marks ) . length > 0 ;
108+
101109 return (
102110 < div className = "row" >
103111 < div className = "col" >
104112 Input
105113 < CodeState
106114 renderWhitespace = { renderWhitespace }
115+ renderHats = { renderHats }
107116 languageId = { languageId }
108117 link = { link }
109118 state = { initialState }
@@ -113,6 +122,7 @@ export function RecordedTestVisualizer({
113122 Output
114123 < CodeState
115124 renderWhitespace = { renderWhitespace }
125+ renderHats = { renderHats }
116126 languageId = { languageId }
117127 link = { link }
118128 state = { finalState }
@@ -124,6 +134,7 @@ export function RecordedTestVisualizer({
124134
125135interface CodeStateProps {
126136 renderWhitespace : boolean ;
137+ renderHats : boolean ;
127138 languageId : string ;
128139 link : {
129140 name : string ;
@@ -134,16 +145,14 @@ interface CodeStateProps {
134145
135146function CodeState ( {
136147 renderWhitespace,
148+ renderHats,
137149 languageId,
138150 link,
139151 state,
140152} : CodeStateProps ) : JSX . Element {
141153 const decorations = useMemo (
142- ( ) => [
143- ...state . selections . map ( toDecoration ) ,
144- ...toMarkDecorations ( state . marks , state . documentContents ) ,
145- ] ,
146- [ state . selections , state . marks , state . documentContents ] ,
154+ ( ) => toDecorations ( state , renderHats ) ,
155+ [ state , renderHats ] ,
147156 ) ;
148157 return (
149158 < >
@@ -166,9 +175,7 @@ function CodeState({
166175 ) ;
167176}
168177
169- function toDecoration ( plainSelection : SelectionPlainObject ) : DecorationItem {
170- const selection = plainObjectToSelection ( plainSelection ) ;
171-
178+ function toDecoration ( selection : Selection ) : DecorationItem {
172179 const cursorClassName = selection . isReversed
173180 ? "code-cursor-before"
174181 : "code-cursor-after" ;
@@ -187,50 +194,81 @@ function toDecoration(plainSelection: SelectionPlainObject): DecorationItem {
187194 } ;
188195}
189196
190- /**
191- * Converts serialized token marks to Shiki decorations around the first
192- * occurrence of each mark's decorated character within its token.
193- */
194- function toMarkDecorations (
195- marks : SerializedMarks | undefined ,
196- documentContents : string ,
197+ function toDecorations (
198+ state : TestCaseSnapshot ,
199+ renderHats : boolean ,
200+ ) : DecorationItem [ ] {
201+ const selections = state . selections . map ( plainObjectToSelection ) ;
202+ const hatRanges = renderHats
203+ ? ( state . hatTokenMap ?? [ ] ) . map ( ( { hatRange } ) =>
204+ plainObjectToRange ( hatRange ) ,
205+ )
206+ : [ ] ;
207+
208+ // Shiki rejects intersecting decorations. A zero-width cursor at the end of
209+ // a hat range intersects the hat decoration, so render both on one wrapper.
210+ // We only merge at the end because code-cursor-after recreates that exact
211+ // boundary without competing with the hat's ::before pseudo-element.
212+ const mergedCursorPositions = selections
213+ . filter (
214+ ( selection ) =>
215+ selection . isEmpty &&
216+ hatRanges . some ( ( { end } ) => end . isEqual ( selection . active ) ) ,
217+ )
218+ . map ( ( { active } ) => active ) ;
219+
220+ return [
221+ // Omit cursors that the corresponding hat decoration will render instead.
222+ ...selections
223+ . filter (
224+ ( selection ) =>
225+ ! selection . isEmpty ||
226+ ! mergedCursorPositions . some ( ( position ) =>
227+ position . isEqual ( selection . active ) ,
228+ ) ,
229+ )
230+ . map ( toDecoration ) ,
231+ ...toHatDecorations ( state , renderHats , mergedCursorPositions ) ,
232+ ] ;
233+ }
234+
235+ function toHatDecorations (
236+ state : TestCaseSnapshot ,
237+ renderHats : boolean ,
238+ mergedCursorPositions : readonly Position [ ] ,
197239) : DecorationItem [ ] {
198- if ( marks == null ) {
240+ if ( ! renderHats || state . hatTokenMap == null ) {
199241 return [ ] ;
200242 }
201243
202- const lines = documentContents . split ( / \r ? \n / u ) ;
244+ const markRanges = Object . values ( state . marks ?? { } ) . map ( plainObjectToRange ) ;
203245
204- return Object . entries ( marks ) . map ( ( [ key , range ] ) => {
205- if ( range . start . line !== range . end . line ) {
206- throw new Error ( `Mark ${ key } spans multiple lines` ) ;
207- }
246+ return state . hatTokenMap . map ( ( { hatStyle, hatRange } ) => {
247+ const range = plainObjectToRange ( hatRange ) ;
248+ const properties : DecorationItem [ "properties" ] = {
249+ className : [ "code-hat" , `code-hat-${ hatStyle } ` ] ,
250+ } ;
208251
209- const line = lines [ range . start . line ] ;
252+ const isReferenced = markRanges . some ( ( markRange ) =>
253+ markRange . contains ( range ) ,
254+ ) ;
210255
211- if ( line == null ) {
212- throw new Error ( `Mark ${ key } is outside the document` ) ;
256+ if ( isReferenced ) {
257+ properties . className ?. push ( "code-hat-referenced" ) ;
258+ properties . style = `--code-hat-referenced-color: ${ highlightColors . content . background } ;` ;
213259 }
214260
215- const { hatStyle, character } = splitKey ( key ) ;
216- const tokenText = line . slice ( range . start . character , range . end . character ) ;
217- const characterOffset = tokenText . indexOf ( character ) ;
218- const characterStart = range . start . character + characterOffset ;
219-
220- if ( characterOffset === - 1 ) {
221- throw new Error ( `Mark ${ key } does not occur in its token` ) ;
261+ if ( mergedCursorPositions . some ( ( position ) => position . isEqual ( range . end ) ) ) {
262+ // The hat uses ::before and the cursor uses ::after, allowing both
263+ // visuals to share this wrapper without overlapping Shiki decorations.
264+ properties . className ?. push ( "code-cursor-after" ) ;
222265 }
223266
224267 return {
225- start : { line : range . start . line , character : characterStart } ,
226- end : {
227- line : range . start . line ,
228- character : characterStart + character . length ,
229- } ,
268+ start : hatRange . start ,
269+ end : hatRange . end ,
230270 alwaysWrap : true ,
231- properties : {
232- className : [ "code-hat" , `code-hat-${ hatStyle } ` ] ,
233- } ,
271+ properties,
234272 } ;
235273 } ) ;
236274}
0 commit comments