Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: shift-click to unselect #451

Merged
merged 7 commits into from
Jan 13, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions lean4-infoview-api/src/infoviewApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,18 @@ export type InfoviewActionKind =

export type InfoviewAction = { kind: InfoviewActionKind }

export type ContextMenuEntry = 'goToDefinition' | 'select' | 'unselect' | 'unselectAll'

export interface ContextMenuAction {
entry: ContextMenuEntry
/**
* An entry-specific identifier extracted from `data-vscode-context`.
* For example, for `goToDefinition` it is the value of `interactiveCodeTagId`.
* See https://code.visualstudio.com/api/extension-guides/webview#context-menus.
*/
id: string
}

/** Interface the hosting editor uses to talk to the InfoView WebView. */
export interface InfoviewApi {
/** Must be called exactly once on initialization with the current cursor position. */
Expand Down Expand Up @@ -147,11 +159,9 @@ export interface InfoviewApi {
requestedAction(action: InfoviewAction): Promise<void>

/**
* Must fire whenever the user requests to go to a definition using the infoview context menu.
* `interactiveCodeTagId` is obtainable from the `data-vscode-context` field in
* `InteractiveCodeTag`s in the InfoView. See https://code.visualstudio.com/api/extension-guides/webview#context-menus.
*/
goToDefinition(interactiveCodeTagId: string): Promise<void>
* Must fire whenever the user clicks an infoview-specific context menu entry.
**/
clickedContextMenu(action: ContextMenuAction): Promise<void>
Vtec234 marked this conversation as resolved.
Show resolved Hide resolved

/**
* Execute the given JavaScript code inside the infoview. Must not be used
Expand Down
10 changes: 8 additions & 2 deletions lean4-infoview/src/infoview/editorConnection.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Location, ShowDocumentParams } from 'vscode-languageserver-protocol'

import {
ContextMenuAction,
EditorApi,
InfoviewAction,
InfoviewActionKind,
Expand All @@ -12,9 +13,14 @@ import {
import { EventEmitter, Eventify } from './event'
import { DocumentPosition } from './util'

export type EditorEvents = Omit<Eventify<InfoviewApi>, 'requestedAction' | 'goToDefinition'> & {
export type EditorEvents = Omit<Eventify<InfoviewApi>, 'requestedAction' | 'clickedContextMenu'> & {
requestedAction: EventEmitter<InfoviewAction, InfoviewActionKind>
goToDefinition: EventEmitter<string, string>
/**
* Must fire whenever the user clicks an infoview-specific context menu entry.
*
* Keys for this event are of the form `` `${action.entry}:${action.id}` ``.
*/
clickedContextMenu: EventEmitter<ContextMenuAction, string>
}

/** Provides higher-level wrappers around functionality provided by the editor,
Expand Down
60 changes: 52 additions & 8 deletions lean4-infoview/src/infoview/goalLocation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ Authors: E.W.Ayers, Wojciech Nawrocki
*/
import { FVarId, MVarId, SubexprPos } from '@leanprover/infoview-api'
import * as React from 'react'
import { EditorContext } from './contexts'
import { useEvent } from './util'

/**
* A location within a goal. It is either:
Expand Down Expand Up @@ -60,16 +62,19 @@ export namespace GoalsLocation {
}

/**
* An interface available through a React context in components where selecting subexpressions
* makes sense. Currently this is only the goal state display. There, {@link GoalLocation}s can be
* selected. */
* An interface available through a React context in components
* where selecting subexpressions makes sense.
* Currently, subexpressions can only be selected in the tactic state.
* We use {@link GoalsLocation} to represent subexpression positions in a goal.
*/
export interface Locations {
isSelected: (l: GoalsLocation) => boolean
setSelected: (l: GoalsLocation, fn: React.SetStateAction<boolean>) => void
/**
* A template for the location of the current component. It is defined if and only if the current
* component is a subexpression of a selectable expression. We use
* {@link GoalsLocation.withSubexprPos} to map this template to a complete location. */
* A template for the location of the current component.
* It is defined if and only if the current component is a subexpression of a selectable expression.
* We use {@link GoalsLocation.withSubexprPos} to map this template to a complete location.
*/
subexprTemplate?: GoalsLocation
}

Expand All @@ -88,16 +93,22 @@ export interface SelectableLocation {
/** 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
dataVscodeContext: any
Vtec234 marked this conversation as resolved.
Show resolved Hide resolved
}

/**
* 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.
* The hook returns
* - a string of CSS classes containing `highlight-selected` when appropriate; and
* - event handlers which the the caller must attach to the component; and
* - an object to append to `data-vscode-context`
* in order to display context menu entries to (un)select this location in VSCode.
*/
export function useSelectableLocation(settings: SelectableLocationSettings): SelectableLocation {
const locs = React.useContext(LocationsContext)
const ec = React.useContext(EditorContext)

let className: string = ''
if (settings.isSelectable && locs && locs.isSelected(settings.loc)) {
className += 'highlight-selected '
Expand All @@ -123,9 +134,42 @@ export function useSelectableLocation(settings: SelectableLocationSettings): Sel
}
}

const dataVscodeContext: any = {
// We set both IDs to an invalid value by default
// in order to clear ancestors' locations in this span
// (`data-vscode-context` fields are overridden by child components).
// The value can be anything that will not be returned from `useId`.
locationSelectId: '',
locationUnselectId: '',
}
const id = React.useId()
if (settings.isSelectable && locs) {
if (locs.isSelected(settings.loc)) dataVscodeContext.locationUnselectId = id
else dataVscodeContext.locationSelectId = id
}
useEvent(
ec.events.clickedContextMenu,
_ => {
if (!settings.isSelectable || !locs) return
locs.setSelected(settings.loc, true)
},
[locs, settings],
`select:${id}`,
)
useEvent(
ec.events.clickedContextMenu,
_ => {
if (!settings.isSelectable || !locs) return
locs.setSelected(settings.loc, false)
},
[locs, settings],
`unselect:${id}`,
)

return {
className,
onClick,
onPointerDown,
dataVscodeContext,
}
}
1 change: 1 addition & 0 deletions lean4-infoview/src/infoview/goals.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ function HypName({ name, isInserted, isRemoved, mvarId, fvarId }: HypNameProps)
<span
ref={ref}
className={namecls}
data-vscode-context={JSON.stringify(sl.dataVscodeContext)}
onPointerOver={e => hhl.onPointerOver(e)}
onPointerOut={e => hhl.onPointerOut(e)}
onPointerMove={e => hhl.onPointerMove(e)}
Expand Down
14 changes: 13 additions & 1 deletion lean4-infoview/src/infoview/info.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,10 +147,20 @@ interface GoalInfoDisplayProps {

function GoalInfoDisplay(props: GoalInfoDisplayProps) {
const { pos, goals, termGoal, userWidgets } = props
const ec = React.useContext(EditorContext)

const config = React.useContext(ConfigContext)

const [selectedLocs, setSelectedLocs] = React.useState<GoalsLocation[]>([])
const selectedLocationsId = React.useId()
useEvent(
ec.events.clickedContextMenu,
_ => {
setSelectedLocs([])
},
[setSelectedLocs],
`unselectAll:${selectedLocationsId}`,
)

// https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes
const [prevPos, setPrevPos] = React.useState<DocumentPosition>(pos)
Expand Down Expand Up @@ -180,7 +190,9 @@ function GoalInfoDisplay(props: GoalInfoDisplayProps) {
return (
<>
<LocationsContext.Provider value={locs}>
<FilteredGoals key="goals" headerChildren="Tactic state" initiallyOpen goals={goals} displayCount />
<span data-vscode-context={JSON.stringify({ selectedLocationsId })}>
mhuisi marked this conversation as resolved.
Show resolved Hide resolved
<FilteredGoals key="goals" headerChildren="Tactic state" initiallyOpen goals={goals} displayCount />
</span>
</LocationsContext.Provider>
<FilteredGoals
key="term-goal"
Expand Down
10 changes: 7 additions & 3 deletions lean4-infoview/src/infoview/interactiveCode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -255,8 +255,12 @@ function InteractiveCodeTag({ tag: ct, fmt }: InteractiveTagProps<SubexprInfo>)
// parameterized with `data-vscode-context`. We then use this context to execute the
// command in the context of the correct interactive code tag in the InfoView.
const interactiveCodeTagId = React.useId()
const vscodeContext = { interactiveCodeTagId }
useEvent(ec.events.goToDefinition, async _ => void execGoToLoc(true), [execGoToLoc], interactiveCodeTagId)
useEvent(
ec.events.clickedContextMenu,
async _ => void execGoToLoc(true),
[execGoToLoc],
`goToDefinition:${interactiveCodeTagId}`,
)

const ht = useHoverTooltip(ref, <TypePopupContents info={ct} />, (e, cont) => {
// On ctrl-click or ⌘-click, if location is known, go to it in the editor
Expand Down Expand Up @@ -284,7 +288,7 @@ function InteractiveCodeTag({ tag: ct, fmt }: InteractiveTagProps<SubexprInfo>)
<span
ref={ref}
className={className}
data-vscode-context={JSON.stringify(vscodeContext)}
data-vscode-context={JSON.stringify({ ...sl.dataVscodeContext, interactiveCodeTagId })}
data-has-tooltip-on-hover
onClick={e => {
const stopClick = sl.onClick(e)
Expand Down
5 changes: 3 additions & 2 deletions lean4-infoview/src/infoview/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ export function renderInfoview(editorApi: EditorApi, uiElement: HTMLElement): In
changedInfoviewConfig: new EventEmitter(),
runTestScript: new EventEmitter(),
requestedAction: new EventEmitter(),
goToDefinition: new EventEmitter(),
clickedContextMenu: new EventEmitter(),
}

// Challenge: write a type-correct fn from `Eventify<T>` to `T` without using `any`
Expand All @@ -147,7 +147,8 @@ export function renderInfoview(editorApi: EditorApi, uiElement: HTMLElement): In
changedCursorLocation: async loc => editorEvents.changedCursorLocation.fire(loc),
changedInfoviewConfig: async conf => editorEvents.changedInfoviewConfig.fire(conf),
requestedAction: async action => editorEvents.requestedAction.fire(action, action.kind),
goToDefinition: async id => editorEvents.goToDefinition.fire(id, id),
clickedContextMenu: async action =>
editorEvents.clickedContextMenu.fire(action, `${action.entry}:${action.id}`),
// See https://rollupjs.org/guide/en/#avoiding-eval
// eslint-disable-next-line @typescript-eslint/no-implied-eval
runTestScript: async script => new Function(script)(),
Expand Down
30 changes: 30 additions & 0 deletions vscode-lean4/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,24 @@
"title": "Go to Definition",
"description": "This command is an implementation detail of the 'Go to Definition' context menu option in the infoview."
},
{
"command": "lean4.infoview.select",
"category": "Lean 4",
"title": "Select",
"description": "This command is an implementation detail of the 'Select' context menu option in the infoview."
},
{
"command": "lean4.infoview.unselect",
"category": "Lean 4",
"title": "Unselect",
"description": "This command is an implementation detail of the 'Unselect' context menu option in the infoview."
},
{
"command": "lean4.infoview.unselectAll",
"category": "Lean 4",
"title": "Unselect All",
"description": "This command is an implementation detail of the 'Unselect All' context menu option in the infoview."
},
Vtec234 marked this conversation as resolved.
Show resolved Hide resolved
{
"command": "lean4.loogle.search",
"category": "Lean 4",
Expand Down Expand Up @@ -876,6 +894,18 @@
{
"command": "lean4.infoview.goToDefinition",
"when": "webviewId == 'lean4_infoview' && interactiveCodeTagId"
},
{
"command": "lean4.infoview.select",
"when": "webviewId == 'lean4_infoview' && locationSelectId"
Vtec234 marked this conversation as resolved.
Show resolved Hide resolved
},
{
"command": "lean4.infoview.unselect",
"when": "webviewId == 'lean4_infoview' && locationUnselectId"
Vtec234 marked this conversation as resolved.
Show resolved Hide resolved
},
{
"command": "lean4.infoview.unselectAll",
"when": "webviewId == 'lean4_infoview' && selectedLocationsId"
mhuisi marked this conversation as resolved.
Show resolved Hide resolved
}
]
},
Expand Down
11 changes: 10 additions & 1 deletion vscode-lean4/src/infoview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,16 @@ export class InfoProvider implements Disposable {
this.webviewPanel?.api.requestedAction({ kind: 'togglePin' }),
),
commands.registerCommand('lean4.infoview.goToDefinition', args =>
this.webviewPanel?.api.goToDefinition(args.interactiveCodeTagId),
this.webviewPanel?.api.clickedContextMenu({ entry: 'goToDefinition', id: args.interactiveCodeTagId }),
),
commands.registerCommand('lean4.infoview.select', args =>
this.webviewPanel?.api.clickedContextMenu({ entry: 'select', id: args.locationSelectId }),
),
commands.registerCommand('lean4.infoview.unselect', args =>
this.webviewPanel?.api.clickedContextMenu({ entry: 'unselect', id: args.locationUnselectId }),
),
commands.registerCommand('lean4.infoview.unselectAll', args =>
this.webviewPanel?.api.clickedContextMenu({ entry: 'unselectAll', id: args.selectedLocationsId }),
),
)
}
Expand Down
Loading