Skip to content

Commit

Permalink
Merge pull request #500 from leanprover/mhuisi/infoview-perf
Browse files Browse the repository at this point in the history
  • Loading branch information
Vtec234 authored Jul 20, 2024
2 parents 4da93b5 + 0dda825 commit 1a6ddf8
Show file tree
Hide file tree
Showing 12 changed files with 577 additions and 374 deletions.
2 changes: 1 addition & 1 deletion lean4-infoview/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@leanprover/infoview",
"version": "0.7.2",
"version": "0.7.3",
"description": "An interactive display for the Lean 4 theorem prover.",
"scripts": {
"watch": "rollup --config --environment NODE_ENV:development --watch",
Expand Down
101 changes: 45 additions & 56 deletions lean4-infoview/src/infoview/goalLocation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ Authors: E.W.Ayers, Wojciech Nawrocki
*/
import { FVarId, MVarId, SubexprPos } from '@leanprover/infoview-api'
import * as React from 'react'
import { DetectHoverSpan, HoverState } from './tooltips'

/**
* A location within a goal. It is either:
Expand Down Expand Up @@ -76,66 +75,56 @@ export interface Locations {

export const LocationsContext = React.createContext<Locations | undefined>(undefined)

type SelectableLocationProps = React.PropsWithoutRef<
React.DetailedHTMLProps<React.HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>
> & {
locs?: Locations
loc?: GoalsLocation
alwaysHighlight: boolean
setHoverState?: React.Dispatch<React.SetStateAction<HoverState>>
export type SelectableLocationSettings =
| {
isSelectable: true
/** The {@link GoalsLocation} that can be selected by interacting with the span. */
loc: GoalsLocation
}
| { isSelectable: false }

export interface SelectableLocation {
className: string
/** Returns whether propagation of the click event within the same handler should be stopped. */
onClick: (e: React.MouseEvent<HTMLSpanElement, MouseEvent>) => boolean
onPointerDown: (e: React.PointerEvent<HTMLSpanElement>) => void
}

/**
* A `<span>` with a corresponding {@link GoalsLocation} which can be (un)selected using shift-click.
* If `locs` or `loc` is `undefined`, selection functionality is turned off. The element is also
* highlighted when hovered over if `alwaysHighlight` is `true` or `locs` and `loc` are both defined.
* `setHoverState` is passed through to {@link DetectHoverSpan}. */
export function SelectableLocation(props_: SelectableLocationProps): JSX.Element {
const { locs, loc, alwaysHighlight, setHoverState: setParentHoverState, ...props } = props_

const shouldHighlight: boolean = alwaysHighlight || (!!locs && !!loc)
const [hoverState, setHoverState] = React.useState<HoverState>('off')
let spanClassName: string = ''
if (shouldHighlight) {
spanClassName += 'highlightable '
if (hoverState !== 'off') spanClassName += 'highlight '
if (props.className) spanClassName += props.className
* Logic for a component that can be selected using Shift-click and is highlighted when selected.
*
* The hook returns a string of CSS classes containing `highlight-selected` when appropriate,
* as well as event handlers which the the caller must attach to the component.
*/
export function useSelectableLocation(settings: SelectableLocationSettings): SelectableLocation {
const locs = React.useContext(LocationsContext)
let className: string = ''
if (settings.isSelectable && locs && locs.isSelected(settings.loc)) {
className += 'highlight-selected '
}

const innerSpanClassName: string =
'highlightable ' + (locs && loc && locs.isSelected(loc) ? 'highlight-selected ' : '')
const onClick = (e: React.MouseEvent<HTMLSpanElement, MouseEvent>) => {
// On shift-click, if we are in a context where selecting subexpressions makes sense,
// (un)select the current subexpression.
if (settings.isSelectable && locs && e.shiftKey) {
locs.setSelected(settings.loc, on => !on)
e.stopPropagation()
return true
}
return false
}

const setHoverStateAll: React.Dispatch<React.SetStateAction<HoverState>> = React.useCallback(
val => {
setHoverState(val)
if (setParentHoverState) setParentHoverState(val)
},
[setParentHoverState],
)
const onPointerDown = (e: React.PointerEvent<HTMLSpanElement>) => {
// We have special handling for shift+click events, so prevent default browser
// events from interfering when shift is held.
if (settings.isSelectable && locs && e.shiftKey) {
e.preventDefault()
}
}

return (
<DetectHoverSpan
{...props}
setHoverState={setHoverStateAll}
className={spanClassName}
onClick={e => {
// On shift-click, if we are in a context where selecting subexpressions makes sense,
// (un)select the current subexpression.
if (e.shiftKey && locs && loc) {
locs.setSelected(loc, on => !on)
e.stopPropagation()
}
if (props.onClick) props.onClick(e)
}}
onPointerDown={e => {
// Since shift-click on this component is a custom selection, when shift is held prevent
// the default action which on text is to start a text selection.
if (e.shiftKey) e.preventDefault()
if (props.onPointerDown) props.onPointerDown(e)
}}
>
{/* Note: we use two spans so that the two `highlight`s don't interfere. */}
<span className={innerSpanClassName}>{props.children}</span>
</DetectHoverSpan>
)
return {
className,
onClick,
onPointerDown,
}
}
74 changes: 61 additions & 13 deletions lean4-infoview/src/infoview/goals.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import {
import * as React from 'react'
import { Details } from './collapsing'
import { ConfigContext, EditorContext } from './contexts'
import { Locations, LocationsContext, SelectableLocation } from './goalLocation'
import { Locations, LocationsContext, SelectableLocationSettings, useSelectableLocation } from './goalLocation'
import { useHoverHighlight } from './hoverHighlight'
import { InteractiveCode } from './interactiveCode'
import { WithTooltipOnHover } from './tooltips'
import { useEvent } from './util'
Expand Down Expand Up @@ -74,6 +75,57 @@ function getFilteredHypotheses(
}, [])
}

interface HypNameProps {
name: string
isInserted: boolean
isRemoved: boolean
mvarId?: string | undefined
fvarId?: string | undefined
}

function HypName({ name, isInserted, isRemoved, mvarId, fvarId }: HypNameProps) {
const ref = React.useRef<HTMLSpanElement>(null)

const locs = React.useContext(LocationsContext)

const hhl = useHoverHighlight({
ref,
highlightOnHover: locs !== undefined && mvarId !== undefined && fvarId !== undefined,
underlineOnModHover: false,
})

let selectableLocationSettings: SelectableLocationSettings
if (mvarId !== undefined && fvarId !== undefined) {
selectableLocationSettings = { isSelectable: true, loc: { mvarId, loc: { hyp: fvarId } } }
} else {
selectableLocationSettings = { isSelectable: false }
}
const sl = useSelectableLocation(selectableLocationSettings)

const namecls: string =
(isInserted ? 'inserted-text ' : '') +
(isRemoved ? 'removed-text ' : '') +
(isInaccessibleName(name) ? 'goal-inaccessible ' : '') +
hhl.className +
sl.className
return (
<>
<span
ref={ref}
className={namecls}
onPointerOver={e => hhl.onPointerOver(e)}
onPointerOut={e => hhl.onPointerOut(e)}
onPointerMove={e => hhl.onPointerMove(e)}
onClick={e => sl.onClick(e)}
onPointerDown={e => sl.onPointerDown(e)}
>
{name}
</span>
&nbsp;
</>
)
}

interface HypProps {
hyp: InteractiveHypothesisBundle
mvarId?: MVarId
Expand All @@ -82,19 +134,15 @@ interface HypProps {
function Hyp({ hyp: h, mvarId }: HypProps) {
const locs = React.useContext(LocationsContext)

const namecls: string = (h.isInserted ? 'inserted-text ' : '') + (h.isRemoved ? 'removed-text ' : '')

const names = InteractiveHypothesisBundle_nonAnonymousNames(h).map((n, i) => (
<span className={namecls + (isInaccessibleName(n) ? 'goal-inaccessible ' : '')} key={i}>
<SelectableLocation
locs={locs}
loc={mvarId && h.fvarIds && h.fvarIds.length > i ? { mvarId, loc: { hyp: h.fvarIds[i] } } : undefined}
alwaysHighlight={false}
>
{n}
</SelectableLocation>
&nbsp;
</span>
<HypName
name={n}
isInserted={!!h.isInserted}
isRemoved={!!h.isRemoved}
mvarId={mvarId}
fvarId={h.fvarIds?.at(i)}
key={i}
></HypName>
))

const typeLocs: Locations | undefined = React.useMemo(
Expand Down
116 changes: 116 additions & 0 deletions lean4-infoview/src/infoview/hoverHighlight.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import React from 'react'
import { HoverState } from './tooltips'

export interface HoverHighlightSettings {
ref: React.RefObject<HTMLSpanElement>
/**
* Whether to return the `highlight` CSS class on hover.
*/
highlightOnHover: boolean
/**
* Whether to return the `underline` CSS class on hover while holding `Ctrl` / `Meta`.
*/
underlineOnModHover: boolean
}

export interface HoverHighlight {
hoverState: HoverState
setHoverState: React.Dispatch<React.SetStateAction<HoverState>>
className: string
onPointerOver: (e: React.PointerEvent<HTMLSpanElement>) => void
onPointerOut: (e: React.PointerEvent<HTMLSpanElement>) => void
onPointerMove: (e: React.PointerEvent<HTMLSpanElement>) => void
}

/**
* Logic for a component that is highlighted/underlined when hovered over.
* The component is passed in `settings.ref`.
*
* The hook returns the current hover state of the component,
* a string of CSS classes containing `highlight` and/or `underline` when appropriate,
* as well as event handlers which the the caller must attach to the component.
*/
export function useHoverHighlight(settings: HoverHighlightSettings): HoverHighlight {
const { ref, highlightOnHover, underlineOnModHover } = settings

const [hoverState, setHoverState] = React.useState<HoverState>('off')
const isHoveredOver = hoverState !== 'off'

let className: string = ''
if (highlightOnHover && isHoveredOver) {
className += 'highlight '
}
if (underlineOnModHover && hoverState === 'ctrlOver') {
className += 'underline '
}

const onPointerEvent = (b: boolean, e: React.PointerEvent<HTMLSpanElement>) => {
// It's more composable to let pointer events bubble up rather than to call `stopPropagation`,
// but we only want to handle hovers in the innermost component. So we record that the
// event was handled with a property.
// The `contains` check ensures that the node hovered over is a child in the DOM
// tree and not just a logical React child (see useLogicalDom and
// https://reactjs.org/docs/portals.html#event-bubbling-through-portals).
if (ref.current && e.target instanceof Node && ref.current.contains(e.target)) {
if ('_DetectHoverSpanSeen' in e) {
return
}
;(e as any)._DetectHoverSpanSeen = {}
if (!b) {
setHoverState('off')
} else if (e.ctrlKey || e.metaKey) {
setHoverState('ctrlOver')
} else {
setHoverState('over')
}
}
}
const onPointerOver = (e: React.PointerEvent<HTMLSpanElement>) => onPointerEvent(true, e)
const onPointerOut = (e: React.PointerEvent<HTMLSpanElement>) => onPointerEvent(false, e)

const onPointerMove = (e: React.PointerEvent<HTMLSpanElement>) => {
if (e.ctrlKey || e.metaKey) {
setHoverState(st => (st === 'over' ? 'ctrlOver' : st))
} else {
setHoverState(st => (st === 'ctrlOver' ? 'over' : st))
}
}

const onKeyDown = React.useCallback((e: KeyboardEvent) => {
if (e.key === 'Control' || e.key === 'Meta') {
setHoverState(st => (st === 'over' ? 'ctrlOver' : st))
}
}, [])

const onKeyUp = React.useCallback((e: KeyboardEvent) => {
if (e.key === 'Control' || e.key === 'Meta') {
setHoverState(st => (st === 'ctrlOver' ? 'over' : st))
}
}, [])

React.useEffect(() => {
if (!isHoveredOver) {
// Avoid adding lots of expensive global event handlers for spans that are not being
// hovered over
return
}

// These event handlers do not fire when the InfoView is not focused.
document.addEventListener('keydown', onKeyDown)
document.addEventListener('keyup', onKeyUp)

return () => {
document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('keyup', onKeyUp)
}
}, [onKeyDown, onKeyUp, isHoveredOver])

return {
hoverState,
setHoverState,
className,
onPointerOver,
onPointerOut,
onPointerMove,
}
}
10 changes: 2 additions & 8 deletions lean4-infoview/src/infoview/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -49,20 +49,14 @@ body {
font-weight: normal;
}

/* Used to denote text highlighted by the pretty-print expression widget. */
.highlightable {
border-radius: 2pt;
transition: background-color 100ms ease-in-out;
.highlight-selected {
background-color: var(--vscode-editorOverviewRuler-rangeHighlightForeground) !important;
}

.highlight {
background-color: var(--vscode-editor-selectionBackground) !important;
}

.highlight-selected {
background-color: var(--vscode-editorOverviewRuler-rangeHighlightForeground);
}

/* Interactive traces */
.trace-line:hover {
background-color: #dbeeff;
Expand Down
Loading

0 comments on commit 1a6ddf8

Please sign in to comment.