Merge branch 'dev'
This commit is contained in:
@@ -183,6 +183,16 @@ The installation instructions are not yet tested on Mac/Windows. Comments very w
|
||||
After editing some files in VSCode, open VSCode's terminal (View > Terminal) and run `lake build`.
|
||||
Now you can reload your browser to see the changes.
|
||||
|
||||
### Errors
|
||||
|
||||
* If you don't get the pop-up, you might have disabled them and you can reenable it by
|
||||
running the `remote-containers.showReopenInContainerNotificationReset` command in vscode.
|
||||
* If the starting the container fails, in particular with a message `Error: network xyz not found`,
|
||||
you might have deleted stuff from docker via your shell. Try deleting the container and image
|
||||
explicitely in VSCode (left side, "Docker" icon). Then reopen vscode and let it rebuild the
|
||||
container. (this will again take some time)
|
||||
|
||||
|
||||
## Without Dev Containers
|
||||
Install `nvm`:
|
||||
```
|
||||
|
||||
@@ -21,6 +21,16 @@
|
||||
<i>You need to enable JavaScript to use the Lean Game Server, as it is built
|
||||
using React.</i>
|
||||
</p>
|
||||
<p>
|
||||
<strong>Impressum:</strong><br />
|
||||
Jon Eugster<br />
|
||||
Mathematisches Institut der Heinrich-Heine-Universität Düsseldorf<br />
|
||||
Universitätsstr. 1<br />
|
||||
40225 Düsseldorf<br />
|
||||
Germany<br />
|
||||
+49 211 81-12173<br />
|
||||
<a href="https://www.math.hhu.de/en/lehrstuehle-/-personen-/-ansprechpartner/innen/lehrstuehle-des-mathematischen-instituts/lehrstuhl-fuer-algebraische-geometrie/team/jon-eugster">Contact Details</a>
|
||||
</p>
|
||||
</div>
|
||||
</noscript>
|
||||
<script src="bundle.js"></script>
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Outlet, useParams } from "react-router-dom";
|
||||
|
||||
import '@fontsource/roboto/300.css';
|
||||
import '@fontsource/roboto/400.css';
|
||||
import '@fontsource/roboto/500.css';
|
||||
import '@fontsource/roboto/700.css';
|
||||
|
||||
import './reset.css';
|
||||
import './app.css';
|
||||
|
||||
export const GameIdContext = React.createContext<string>(undefined);
|
||||
|
||||
function App() {
|
||||
const params = useParams();
|
||||
return (
|
||||
<div className="app">
|
||||
<GameIdContext.Provider value={"g/" + params.owner + "/" + params.repo}>
|
||||
<Outlet />
|
||||
</GameIdContext.Provider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
+9
-2
@@ -2,6 +2,7 @@
|
||||
|
||||
:root {
|
||||
--clr-primary: #1976d2;
|
||||
--clr-primary-dark: #104a83;
|
||||
--clr-light-gray: #ddd;
|
||||
--clr-dark-gray: #aaa;
|
||||
--clr-darker-gray: #555;
|
||||
@@ -73,6 +74,10 @@ body {
|
||||
background: white;
|
||||
}
|
||||
|
||||
.btn-inverted:hover {
|
||||
color: var(--clr-primary-dark);
|
||||
}
|
||||
|
||||
.btn-disabled, .btn[disabled] {
|
||||
color: var(--clr-dark-gray);
|
||||
background: var(--clr-light-gray);
|
||||
@@ -106,7 +111,8 @@ em {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
padding: 1.1em;
|
||||
filter: drop-shadow(0 0 5px rgba(0,0,0,0.5))
|
||||
filter: drop-shadow(0 0 5px rgba(0,0,0,0.5));
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.app-bar-title, .app-bar-subtitle {
|
||||
@@ -114,7 +120,8 @@ em {
|
||||
font-weight: 500;
|
||||
font-size: 1.3rem;
|
||||
display: inline-block;
|
||||
margin: 0 1em;
|
||||
margin: 0;
|
||||
/* margin: 0 1em; */
|
||||
}
|
||||
|
||||
.app-content {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import App from './App';
|
||||
import App from './app';
|
||||
|
||||
test('renders learn react link', () => {
|
||||
render(<App />);
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as React from 'react';
|
||||
import { Outlet, useParams } from "react-router-dom";
|
||||
|
||||
import '@fontsource/roboto/300.css';
|
||||
import '@fontsource/roboto/400.css';
|
||||
import '@fontsource/roboto/500.css';
|
||||
import '@fontsource/roboto/700.css';
|
||||
|
||||
import './reset.css';
|
||||
import './app.css';
|
||||
import { MobileContext } from './components/infoview/context';
|
||||
import { useWindowDimensions } from './window_width';
|
||||
|
||||
export const GameIdContext = React.createContext<string>(undefined);
|
||||
|
||||
function App() {
|
||||
const params = useParams()
|
||||
const gameId = "g/" + params.owner + "/" + params.repo
|
||||
|
||||
// TODO: Make mobileLayout be changeable in settings
|
||||
// TODO: Handle resize Events
|
||||
const {width, height} = useWindowDimensions()
|
||||
const [mobile, setMobile] = React.useState(width < 800)
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<GameIdContext.Provider value={gameId}>
|
||||
<MobileContext.Provider value={{mobile, setMobile}}>
|
||||
<Outlet />
|
||||
</MobileContext.Provider>
|
||||
</GameIdContext.Provider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -1,15 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { Link, LinkProps } from "react-router-dom";
|
||||
|
||||
export interface ButtonProps extends LinkProps {
|
||||
disabled?: boolean
|
||||
inverted?: boolean
|
||||
}
|
||||
|
||||
export function Button(props: ButtonProps) {
|
||||
if (props.disabled) {
|
||||
return <span className={`btn btn-disabled ${props.inverted ? 'btn-inverted' : ''}`} {...props}>{props.children}</span>
|
||||
} else {
|
||||
return <Link className={`btn ${props.inverted ? 'btn-inverted' : ''}`} {...props}>{props.children}</Link>
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import { Button } from './Button'
|
||||
import { GameIdContext } from '../App';
|
||||
import { useStore } from 'react-redux';
|
||||
import { useAppDispatch, useAppSelector } from '../hooks';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faDownload, faUpload, faEraser } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
import { deleteProgress, selectProgress, loadProgress, GameProgressState } from '../state/progress';
|
||||
|
||||
const downloadFile = ({ data, fileName, fileType }) => {
|
||||
const blob = new Blob([data], { type: fileType })
|
||||
const a = document.createElement('a')
|
||||
a.download = fileName
|
||||
a.href = window.URL.createObjectURL(blob)
|
||||
const clickEvt = new MouseEvent('click', {
|
||||
view: window,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
a.dispatchEvent(clickEvt)
|
||||
a.remove()
|
||||
}
|
||||
|
||||
function GameMenu() {
|
||||
|
||||
const [file, setFile] = React.useState<File>();
|
||||
|
||||
const gameId = React.useContext(GameIdContext)
|
||||
const store = useStore()
|
||||
|
||||
const [eraseMenu, setEraseMenu] = React.useState(false);
|
||||
const openEraseMenu = () => setEraseMenu(true);
|
||||
const closeEraseMenu = () => setEraseMenu(false);
|
||||
|
||||
const [uploadMenu, setUploadMenu] = React.useState(false);
|
||||
const openUploadMenu = () => setUploadMenu(true);
|
||||
const closeUploadMenu = () => setUploadMenu(false);
|
||||
|
||||
const gameProgress = useSelector(selectProgress(gameId))
|
||||
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const downloadProgress = (e) => {
|
||||
e.preventDefault()
|
||||
downloadFile({
|
||||
data: JSON.stringify(gameProgress),
|
||||
fileName: `lean4game-${gameId}-${new Date().toLocaleDateString()}.json`,
|
||||
fileType: 'text/json',
|
||||
})
|
||||
};
|
||||
|
||||
const handleFileChange = (e) => {
|
||||
if (e.target.files) {
|
||||
setFile(e.target.files[0]);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
const uploadProgress = (e) => {
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fileReader = new FileReader();
|
||||
fileReader.readAsText(file, "UTF-8");
|
||||
fileReader.onload = (e) => {
|
||||
const data = JSON.parse(e.target.result.toString()) as GameProgressState;
|
||||
console.debug("Json Data", data);
|
||||
|
||||
dispatch(loadProgress({game: gameId, data: data}))
|
||||
}
|
||||
|
||||
closeUploadMenu()
|
||||
}
|
||||
|
||||
const eraseProgress = () => {
|
||||
dispatch(deleteProgress({game: gameId}))
|
||||
closeEraseMenu()
|
||||
}
|
||||
|
||||
const downloadAndErase = (e) => {
|
||||
downloadProgress(e)
|
||||
eraseProgress()
|
||||
}
|
||||
|
||||
return <nav className="game-menu">
|
||||
<Button onClick={downloadProgress} title="Download game progress" to=""><FontAwesomeIcon icon={faDownload} /></Button>
|
||||
<Button title="Load game progress from JSON" onClick={openUploadMenu} to=""><FontAwesomeIcon icon={faUpload} /></Button>
|
||||
<Button title="Clear game progress" to="" onClick={openEraseMenu}><FontAwesomeIcon icon={faEraser} /></Button>
|
||||
|
||||
{eraseMenu?
|
||||
<div className="modal-wrapper">
|
||||
<div className="modal-backdrop" onClick={closeEraseMenu} />
|
||||
<div className="modal">
|
||||
<div className="codicon codicon-close modal-close" onClick={closeEraseMenu}></div>
|
||||
<h2>Delete Progress?</h2>
|
||||
|
||||
<p>Do you want to delete your saved progress irreversibly?</p>
|
||||
<p>(This only affects your saved proofs, no levels are ever locked.
|
||||
Saves from other games are not deleted.)</p>
|
||||
|
||||
<Button onClick={eraseProgress} to="">Delete</Button>
|
||||
<Button onClick={downloadAndErase} to="">Download & Delete</Button>
|
||||
<Button onClick={closeEraseMenu} to="">Cancel</Button>
|
||||
</div>
|
||||
</div> : null}
|
||||
|
||||
{uploadMenu ?
|
||||
<div className="modal-wrapper">
|
||||
<div className="modal-backdrop" onClick={closeUploadMenu} />
|
||||
<div className="modal">
|
||||
<div className="codicon codicon-close modal-close" onClick={closeUploadMenu}></div>
|
||||
<h2>Upload Saved Progress</h2>
|
||||
|
||||
<p>Select a JSON file with the saved game progress to load your progress.</p>
|
||||
|
||||
<p><b>Warning:</b> This will delete your current game progress!
|
||||
Consider <a className="download-link" onClick={downloadProgress} >downloading your current progress</a> first!</p>
|
||||
|
||||
<input type="file" onChange={handleFileChange}/>
|
||||
|
||||
<Button to="" onClick={uploadProgress}>Load selected file</Button>
|
||||
</div>
|
||||
</div> : null}
|
||||
</nav>
|
||||
}
|
||||
|
||||
export default GameMenu
|
||||
@@ -1,111 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import './inventory.css'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faLock, faLockOpen, faBook, faHammer, faBan } from '@fortawesome/free-solid-svg-icons'
|
||||
import Markdown from './Markdown';
|
||||
import { useLoadDocQuery, InventoryTile, LevelInfo } from '../state/api';
|
||||
import { GameIdContext } from '../App';
|
||||
|
||||
export function Inventory({levelInfo, setInventoryDoc } :
|
||||
{
|
||||
levelInfo: LevelInfo,
|
||||
setInventoryDoc: (inventoryDoc: {name: string, type: string}) => void,
|
||||
}) {
|
||||
|
||||
// TODO: This seems like a useless wrapper to me
|
||||
function openDoc(name, type) {
|
||||
setInventoryDoc({name, type})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="inventory">
|
||||
{/* TODO: Click on Tactic: show info
|
||||
TODO: click on paste icon -> paste into command line */}
|
||||
<h2>Tactics</h2>
|
||||
<InventoryList items={levelInfo?.tactics} docType="Tactic" openDoc={openDoc} />
|
||||
|
||||
<h2>Definitions</h2>
|
||||
<InventoryList items={levelInfo?.definitions} docType="Definition" openDoc={openDoc} />
|
||||
|
||||
<h2>Lemmas</h2>
|
||||
<InventoryList items={levelInfo?.lemmas} docType="Lemma" openDoc={openDoc}
|
||||
defaultTab={levelInfo?.lemmaTab} level={levelInfo}/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InventoryList({items, docType, openDoc, defaultTab=null, level=undefined} :
|
||||
{
|
||||
items: InventoryTile[],
|
||||
docType: string,
|
||||
openDoc(name: string, type: string): void,
|
||||
defaultTab? : string,
|
||||
level? : LevelInfo,
|
||||
}) {
|
||||
// TODO: `level` is only used in the `useEffect` below to check if a new level has
|
||||
// been loaded. Is there a better way to observe this?
|
||||
|
||||
const categorySet = new Set<string>()
|
||||
for (let item of items) {
|
||||
categorySet.add(item.category)
|
||||
}
|
||||
const categories = Array.from(categorySet).sort()
|
||||
|
||||
const [tab, setTab] = useState(defaultTab);
|
||||
|
||||
useEffect(() => {
|
||||
// If the level specifies `LemmaTab "Nat"`, we switch to this tab on loading.
|
||||
// `defaultTab` is `null` or `undefined` otherwise, in which case we don't want to switch.
|
||||
if (defaultTab) {
|
||||
setTab(defaultTab)
|
||||
}}, [level])
|
||||
|
||||
return <>
|
||||
{categories.length > 1 &&
|
||||
<div className="tab-bar">
|
||||
{categories.map((cat) =>
|
||||
<div className={`tab ${cat == (tab ?? categories[0]) ? "active": ""}`} onClick={() => { setTab(cat) }}>{cat}</div>)}
|
||||
</div>}
|
||||
<div className="inventory-list">
|
||||
{ [...items].sort(
|
||||
// Sort entries `available > disabled > locked`.
|
||||
(x, y) => +x.locked - +y.locked || +x.disabled - +y.disabled
|
||||
).map(item => {
|
||||
if ((tab ?? categories[0]) == item.category) {
|
||||
return <InventoryItem key={item.name} showDoc={() => {openDoc(item.name, docType)}}
|
||||
name={item.name} displayName={item.displayName} locked={item.locked}
|
||||
disabled={item.disabled} newly={item.new}/>
|
||||
}
|
||||
}) }
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
|
||||
function InventoryItem({name, displayName, locked, disabled, newly, showDoc}) {
|
||||
const icon = locked ? <FontAwesomeIcon icon={faLock} /> :
|
||||
disabled ? <FontAwesomeIcon icon={faBan} /> : ""
|
||||
const className = locked ? "locked" : disabled ? "disabled" : newly ? "new" : ""
|
||||
const title = locked ? "Not unlocked yet" :
|
||||
disabled ? "Not available in this level" : ""
|
||||
|
||||
const handleClick = () => {
|
||||
if (!locked && !disabled) {
|
||||
showDoc()
|
||||
}
|
||||
}
|
||||
|
||||
return <div className={`item ${className}`} onClick={handleClick} title={title}>{icon} {displayName}</div>
|
||||
}
|
||||
|
||||
export function Documentation({name, type}) {
|
||||
const gameId = React.useContext(GameIdContext)
|
||||
const doc = useLoadDocQuery({game: gameId, type: type, name: name})
|
||||
|
||||
return <>
|
||||
<h2 className="doc">{doc.data?.displayName}</h2>
|
||||
<p><code>{doc.data?.statement}</code></p>
|
||||
{/* <code>docstring: {doc.data?.docstring}</code> */}
|
||||
<Markdown>{doc.data?.content}</Markdown>
|
||||
</>
|
||||
}
|
||||
@@ -1,429 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import '@fontsource/roboto/300.css';
|
||||
import '@fontsource/roboto/400.css';
|
||||
import '@fontsource/roboto/500.css';
|
||||
import '@fontsource/roboto/700.css';
|
||||
import { InfoviewApi } from '@leanprover/infoview'
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { Box, CircularProgress, FormControlLabel, FormGroup, Switch, IconButton } from '@mui/material';
|
||||
import MuiDrawer from '@mui/material/Drawer';
|
||||
import Grid from '@mui/material/Unstable_Grid2';
|
||||
import {Inventory, Documentation} from './Inventory';
|
||||
import { LeanTaskGutter } from 'lean4web/client/src/editor/taskgutter';
|
||||
import { AbbreviationProvider } from 'lean4web/client/src/editor/abbreviation/AbbreviationProvider';
|
||||
import 'lean4web/client/src/editor/vscode.css';
|
||||
import 'lean4web/client/src/editor/infoview.css';
|
||||
import { AbbreviationRewriter } from 'lean4web/client/src/editor/abbreviation/rewriter/AbbreviationRewriter';
|
||||
import { InfoProvider } from 'lean4web/client/src/editor/infoview';
|
||||
import 'lean4web/client/src/editor/infoview.css'
|
||||
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'
|
||||
import './level.css'
|
||||
import { Button } from './Button'
|
||||
import { ConnectionContext, useLeanClient } from '../connection';
|
||||
import { useGetGameInfoQuery, useLoadLevelQuery } from '../state/api';
|
||||
import { changedSelection, codeEdited, selectCode, selectSelections, progressSlice, selectCompleted } from '../state/progress';
|
||||
import { useAppDispatch, useAppSelector } from '../hooks';
|
||||
import { useStore } from 'react-redux';
|
||||
|
||||
import { EditorContext, ConfigContext, ProgressContext, VersionContext } from '../../../node_modules/lean4-infoview/src/infoview/contexts';
|
||||
import { EditorConnection, EditorEvents } from '../../../node_modules/lean4-infoview/src/infoview/editorConnection';
|
||||
import { EventEmitter } from '../../../node_modules/lean4-infoview/src/infoview/event';
|
||||
import { Main } from './infoview/main'
|
||||
import type { Location } from 'vscode-languageserver-protocol';
|
||||
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faHome, faArrowRight, faArrowLeft, faRotateLeft } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
import { styled, useTheme, Theme, CSSObject } from '@mui/material/styles';
|
||||
import Markdown from './Markdown';
|
||||
|
||||
import Split from 'react-split'
|
||||
import { Alert } from '@mui/material';
|
||||
import { GameIdContext } from '../App';
|
||||
|
||||
export const MonacoEditorContext = React.createContext<monaco.editor.IStandaloneCodeEditor>(null as any);
|
||||
|
||||
export const InputModeContext = React.createContext<{
|
||||
commandLineMode: boolean,
|
||||
setCommandLineMode: React.Dispatch<React.SetStateAction<boolean>>,
|
||||
commandLineInput: string,
|
||||
setCommandLineInput: React.Dispatch<React.SetStateAction<string>>
|
||||
}>({
|
||||
commandLineMode: true,
|
||||
setCommandLineMode: () => {},
|
||||
commandLineInput: "",
|
||||
setCommandLineInput: () => {},
|
||||
});
|
||||
|
||||
function Level() {
|
||||
|
||||
const params = useParams();
|
||||
const levelId = parseInt(params.levelId)
|
||||
const worldId = params.worldId
|
||||
|
||||
useLoadWorldFiles(worldId)
|
||||
|
||||
if (levelId == 0) {
|
||||
return <Introduction worldId={worldId} />
|
||||
} else {
|
||||
return <PlayableLevel worldId={worldId} levelId={levelId} />
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function PlayableLevel({worldId, levelId}) {
|
||||
const codeviewRef = useRef<HTMLDivElement>(null)
|
||||
const introductionPanelRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const gameId = React.useContext(GameIdContext)
|
||||
const initialCode = useAppSelector(selectCode(gameId, worldId, levelId))
|
||||
const initialSelections = useAppSelector(selectSelections(gameId, worldId, levelId))
|
||||
|
||||
const [commandLineMode, setCommandLineMode] = useState(true)
|
||||
const [commandLineInput, setCommandLineInput] = useState("")
|
||||
const [canUndo, setCanUndo] = useState(initialCode.trim() !== "")
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
// Scroll to top when loading a new level
|
||||
introductionPanelRef.current!.scrollTo(0,0)
|
||||
// Reset command line input when loading a new level
|
||||
setCommandLineInput("")
|
||||
}, [levelId])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!commandLineMode) {
|
||||
// Delete last input attempt from command line
|
||||
editor.executeEdits("command-line", [{
|
||||
range: editor.getSelection(),
|
||||
text: "",
|
||||
forceMoveMarkers: false
|
||||
}]);
|
||||
editor.focus()
|
||||
}
|
||||
}, [commandLineMode])
|
||||
|
||||
const handleUndo = () => {
|
||||
const endPos = editor.getModel().getFullModelRange().getEndPosition()
|
||||
let range
|
||||
console.log(endPos.column)
|
||||
if (endPos.column === 1) {
|
||||
range = monaco.Selection.fromPositions(
|
||||
new monaco.Position(endPos.lineNumber - 1, 1),
|
||||
endPos
|
||||
)
|
||||
} else {
|
||||
range = monaco.Selection.fromPositions(
|
||||
new monaco.Position(endPos.lineNumber, 1),
|
||||
endPos
|
||||
)
|
||||
}
|
||||
editor.executeEdits("undo-button", [{
|
||||
range,
|
||||
text: "",
|
||||
forceMoveMarkers: false
|
||||
}]);
|
||||
}
|
||||
|
||||
const gameInfo = useGetGameInfoQuery({game: gameId})
|
||||
const level = useLoadLevelQuery({game: gameId, world: worldId, level: levelId})
|
||||
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const onDidChangeContent = (code) => {
|
||||
dispatch(codeEdited({game: gameId, world: worldId, level: levelId, code}))
|
||||
|
||||
setCanUndo(code.trim() !== "")
|
||||
}
|
||||
|
||||
const onDidChangeSelection = (monacoSelections) => {
|
||||
const selections = monacoSelections.map(
|
||||
({selectionStartLineNumber, selectionStartColumn, positionLineNumber, positionColumn}) =>
|
||||
{return {selectionStartLineNumber, selectionStartColumn, positionLineNumber, positionColumn}})
|
||||
dispatch(changedSelection({game: gameId, world: worldId, level: levelId, selections}))
|
||||
}
|
||||
|
||||
const completed = useAppSelector(selectCompleted(gameId, worldId, levelId))
|
||||
|
||||
const {editor, infoProvider, editorConnection} =
|
||||
useLevelEditor(worldId, levelId, codeviewRef, initialCode, initialSelections, onDidChangeContent, onDidChangeSelection)
|
||||
|
||||
// Effect when command line mode gets enabled
|
||||
useEffect(() => {
|
||||
if (editor && commandLineMode) {
|
||||
let endPos = editor.getModel().getFullModelRange().getEndPosition()
|
||||
if (editor.getModel().getLineContent(endPos.lineNumber).trim() !== "") {
|
||||
editor.executeEdits("command-line", [{
|
||||
range: monaco.Selection.fromPositions(endPos, endPos),
|
||||
text: "\n",
|
||||
forceMoveMarkers: true
|
||||
}]);
|
||||
}
|
||||
endPos = editor.getModel().getFullModelRange().getEndPosition()
|
||||
let currPos = editor.getPosition()
|
||||
if (currPos.column != 1 || (currPos.lineNumber != endPos.lineNumber && currPos.lineNumber != endPos.lineNumber - 1)) {
|
||||
// This is not a position that would naturally occur from CommandLine, reset:
|
||||
editor.setSelection(monaco.Selection.fromPositions(endPos, endPos))
|
||||
}
|
||||
}
|
||||
}, [editor, commandLineMode])
|
||||
|
||||
// if this is set to a pair `(name, type)` then the according doc will be open.
|
||||
const [inventoryDoc, setInventoryDoc] = useState<{name: string, type: string}>(null)
|
||||
|
||||
const levelTitle = <>{levelId && `Level ${levelId}`}{level?.data?.title && `: ${level?.data?.title}`}</>
|
||||
|
||||
return <>
|
||||
<div style={level.isLoading ? null : {display: "none"}} className="app-content loading"><CircularProgress /></div>
|
||||
<LevelAppBar isLoading={level.isLoading} levelTitle={levelTitle} worldId={worldId} levelId={levelId} />
|
||||
<Split minSize={0} snapOffset={200} sizes={[50, 25, 25]} className={`app-content level ${level.isLoading ? 'hidden' : ''}`}>
|
||||
<div className="exercise-panel">
|
||||
<div ref={introductionPanelRef} className="introduction-panel">
|
||||
{level?.data?.introduction &&
|
||||
<div className="message info">
|
||||
<Markdown>{level?.data?.introduction}</Markdown>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div className="exercise">
|
||||
<Markdown>
|
||||
{(level?.data?.statementName ?
|
||||
`**Theorem** \`${level?.data?.statementName}\`: `
|
||||
:
|
||||
level?.data?.descrText && "**Exercise**: ")
|
||||
+ `${level?.data?.descrText}`
|
||||
}
|
||||
</Markdown>
|
||||
<div className={`statement ${commandLineMode ? 'hidden' : ''}`}><code>{level?.data?.descrFormat}</code></div>
|
||||
<div ref={codeviewRef} className={`codeview ${commandLineMode ? 'hidden' : ''}`}></div>
|
||||
</div>
|
||||
<div className="input-mode-switch">
|
||||
{commandLineMode && <button className="btn" onClick={handleUndo} disabled={!canUndo}><FontAwesomeIcon icon={faRotateLeft} /> Undo</button>}
|
||||
<FormGroup>
|
||||
<FormControlLabel control={<Switch onChange={(ev) => { setCommandLineMode(!commandLineMode) }} />} label="Editor mode" />
|
||||
</FormGroup>
|
||||
</div>
|
||||
|
||||
<EditorContext.Provider value={editorConnection}>
|
||||
<MonacoEditorContext.Provider value={editor}>
|
||||
<InputModeContext.Provider value={{commandLineMode, setCommandLineMode, commandLineInput, setCommandLineInput}}>
|
||||
{editorConnection && <Main key={`${worldId}/${levelId}`} world={worldId} level={levelId} />}
|
||||
</InputModeContext.Provider>
|
||||
</MonacoEditorContext.Provider>
|
||||
</EditorContext.Provider>
|
||||
|
||||
{completed && <div className="conclusion">
|
||||
{level?.data?.conclusion?.trim() &&
|
||||
<div className="message info">
|
||||
<Markdown>{level?.data?.conclusion}</Markdown>
|
||||
</div>
|
||||
}
|
||||
{levelId >= gameInfo.data?.worldSize[worldId] ?
|
||||
<Button to={`/${gameId}`}><FontAwesomeIcon icon={faHome} /></Button> :
|
||||
<Button to={`/${gameId}/world/${worldId}/level/${levelId + 1}`}>
|
||||
Next <FontAwesomeIcon icon={faArrowRight} /></Button>}
|
||||
|
||||
</div>}
|
||||
</div>
|
||||
<div className="inventory-panel">
|
||||
{!level.isLoading &&
|
||||
<Inventory levelInfo={level?.data} setInventoryDoc={setInventoryDoc} />}
|
||||
</div>
|
||||
<div className="doc-panel">
|
||||
{inventoryDoc && <Documentation name={inventoryDoc.name} type={inventoryDoc.type} />}
|
||||
</div>
|
||||
</Split>
|
||||
</>
|
||||
}
|
||||
|
||||
export default Level
|
||||
|
||||
function Introduction({worldId}) {
|
||||
const gameId = React.useContext(GameIdContext)
|
||||
const gameInfo = useGetGameInfoQuery({game: gameId})
|
||||
|
||||
return <>
|
||||
<div style={gameInfo.isLoading ? null : {display: "none"}} className="app-content loading"><CircularProgress /></div>
|
||||
<LevelAppBar isLoading={gameInfo.isLoading} levelTitle="Einführung" worldId={worldId} levelId={0} />
|
||||
<div style={gameInfo.isLoading ? {display: "none"} : null} className="exercise-panel">
|
||||
<div className="introduction-panel">
|
||||
<div className="message info">
|
||||
<Markdown>
|
||||
{gameInfo.data?.worlds.nodes[worldId].introduction}
|
||||
</Markdown>
|
||||
</div>
|
||||
</div>
|
||||
<div className="conclusion">
|
||||
{0 == gameInfo.data?.worldSize[worldId] ?
|
||||
<Button to={`/${gameId}`}><FontAwesomeIcon icon={faHome} /></Button> :
|
||||
<Button to={`/${gameId}/world/${worldId}/level/1`}>
|
||||
Start <FontAwesomeIcon icon={faArrowRight} />
|
||||
</Button>}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
|
||||
function LevelAppBar({isLoading, levelId, worldId, levelTitle}) {
|
||||
const gameId = React.useContext(GameIdContext)
|
||||
const gameInfo = useGetGameInfoQuery({game: gameId})
|
||||
|
||||
return <div className="app-bar" style={isLoading ? {display: "none"} : null} >
|
||||
<div>
|
||||
<Button to={`/${gameId}`}><FontAwesomeIcon icon={faHome} /></Button>
|
||||
<span className="app-bar-title">
|
||||
{gameInfo.data?.worlds.nodes[worldId].title && `World: ${gameInfo.data?.worlds.nodes[worldId].title}`}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="app-bar-title">
|
||||
{levelTitle}
|
||||
</span>
|
||||
<Button disabled={levelId <= 0} inverted={true}
|
||||
to={`/${gameId}/world/${worldId}/level/${levelId - 1}`}
|
||||
><FontAwesomeIcon icon={faArrowLeft} /> Previous</Button>
|
||||
<Button disabled={levelId >= gameInfo.data?.worldSize[worldId]} inverted={true}
|
||||
to={`/${gameId}/world/${worldId}/level/${levelId + 1}`}
|
||||
>Next <FontAwesomeIcon icon={faArrowRight} /></Button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
}
|
||||
|
||||
function useLevelEditor(worldId: string, levelId: number, codeviewRef, initialCode, initialSelections, onDidChangeContent, onDidChangeSelection) {
|
||||
|
||||
const connection = React.useContext(ConnectionContext)
|
||||
const gameId = React.useContext(GameIdContext)
|
||||
|
||||
const [editor, setEditor] = useState<monaco.editor.IStandaloneCodeEditor|null>(null)
|
||||
const [infoProvider, setInfoProvider] = useState<null|InfoProvider>(null)
|
||||
const [infoviewApi, setInfoviewApi] = useState<null|InfoviewApi>(null)
|
||||
const [editorConnection, setEditorConnection] = useState<null|EditorConnection>(null)
|
||||
|
||||
// Create Editor
|
||||
useEffect(() => {
|
||||
const editor = monaco.editor.create(codeviewRef.current!, {
|
||||
glyphMargin: true,
|
||||
quickSuggestions: false,
|
||||
lightbulb: {
|
||||
enabled: true
|
||||
},
|
||||
unicodeHighlight: {
|
||||
ambiguousCharacters: false,
|
||||
},
|
||||
automaticLayout: true,
|
||||
minimap: {
|
||||
enabled: false
|
||||
},
|
||||
lineNumbersMinChars: 3,
|
||||
'semanticHighlighting.enabled': true,
|
||||
theme: 'vs-code-theme-converted'
|
||||
})
|
||||
|
||||
const infoProvider = new InfoProvider(connection.getLeanClient(gameId))
|
||||
|
||||
const editorApi = infoProvider.getApi()
|
||||
|
||||
const editorEvents: EditorEvents = {
|
||||
initialize: new EventEmitter(),
|
||||
gotServerNotification: new EventEmitter(),
|
||||
sentClientNotification: new EventEmitter(),
|
||||
serverRestarted: new EventEmitter(),
|
||||
serverStopped: new EventEmitter(),
|
||||
changedCursorLocation: new EventEmitter(),
|
||||
changedInfoviewConfig: new EventEmitter(),
|
||||
runTestScript: new EventEmitter(),
|
||||
requestedAction: new EventEmitter(),
|
||||
};
|
||||
|
||||
// Challenge: write a type-correct fn from `Eventify<T>` to `T` without using `any`
|
||||
const infoviewApi: InfoviewApi = {
|
||||
initialize: async l => editorEvents.initialize.fire(l),
|
||||
gotServerNotification: async (method, params) => {
|
||||
editorEvents.gotServerNotification.fire([method, params]);
|
||||
},
|
||||
sentClientNotification: async (method, params) => {
|
||||
editorEvents.sentClientNotification.fire([method, params]);
|
||||
},
|
||||
serverRestarted: async r => editorEvents.serverRestarted.fire(r),
|
||||
serverStopped: async serverStoppedReason => {
|
||||
editorEvents.serverStopped.fire(serverStoppedReason)
|
||||
},
|
||||
changedCursorLocation: async loc => editorEvents.changedCursorLocation.fire(loc),
|
||||
changedInfoviewConfig: async conf => editorEvents.changedInfoviewConfig.fire(conf),
|
||||
requestedAction: async action => editorEvents.requestedAction.fire(action),
|
||||
// See https://rollupjs.org/guide/en/#avoiding-eval
|
||||
// eslint-disable-next-line @typescript-eslint/no-implied-eval
|
||||
runTestScript: async script => new Function(script)(),
|
||||
getInfoviewHtml: async () => document.body.innerHTML,
|
||||
};
|
||||
|
||||
const ec = new EditorConnection(editorApi, editorEvents);
|
||||
setEditorConnection(ec)
|
||||
|
||||
editorEvents.initialize.on((loc: Location) => ec.events.changedCursorLocation.fire(loc))
|
||||
|
||||
setEditor(editor)
|
||||
setInfoProvider(infoProvider)
|
||||
setInfoviewApi(infoviewApi)
|
||||
|
||||
return () => { infoProvider.dispose(); editor.dispose() }
|
||||
}, [])
|
||||
|
||||
const {leanClient, leanClientStarted} = useLeanClient(gameId)
|
||||
|
||||
// Create model when level changes
|
||||
useEffect(() => {
|
||||
if (editor && leanClientStarted) {
|
||||
|
||||
const uri = monaco.Uri.parse(`file:///${worldId}/${levelId}`)
|
||||
let model = monaco.editor.getModel(uri)
|
||||
if (!model) {
|
||||
model = monaco.editor.createModel(initialCode, 'lean4', uri)
|
||||
}
|
||||
model.onDidChangeContent(() => onDidChangeContent(model.getValue()))
|
||||
editor.onDidChangeCursorSelection(() => onDidChangeSelection(editor.getSelections()))
|
||||
editor.setModel(model)
|
||||
if (initialSelections) {
|
||||
editor.setSelections(initialSelections)
|
||||
}
|
||||
|
||||
infoviewApi.serverRestarted(leanClient.initializeResult)
|
||||
infoProvider.openPreview(editor, infoviewApi)
|
||||
|
||||
const taskGutter = new LeanTaskGutter(infoProvider.client, editor)
|
||||
const abbrevRewriter = new AbbreviationRewriter(new AbbreviationProvider(), model, editor)
|
||||
|
||||
return () => { abbrevRewriter.dispose(); taskGutter.dispose(); }
|
||||
}
|
||||
}, [editor, levelId, connection, leanClientStarted])
|
||||
|
||||
return {editor, infoProvider, editorConnection}
|
||||
}
|
||||
|
||||
/** Open all files in this world on the server so that they will load faster when accessed */
|
||||
function useLoadWorldFiles(worldId) {
|
||||
const gameId = React.useContext(GameIdContext)
|
||||
const gameInfo = useGetGameInfoQuery({game: gameId})
|
||||
const store = useStore()
|
||||
|
||||
useEffect(() => {
|
||||
if (gameInfo.data) {
|
||||
const models = []
|
||||
for (let levelId = 1; levelId <= gameInfo.data.worldSize[worldId]; levelId++) {
|
||||
const uri = monaco.Uri.parse(`file:///${worldId}/${levelId}`)
|
||||
let model = monaco.editor.getModel(uri)
|
||||
if (model) {
|
||||
models.push(model)
|
||||
} else {
|
||||
const code = selectCode(gameId, worldId, levelId)(store.getState())
|
||||
models.push(monaco.editor.createModel(code, 'lean4', uri))
|
||||
}
|
||||
}
|
||||
return () => { for (let model of models) { model.dispose() } }
|
||||
}
|
||||
}, [gameInfo.data, worldId])
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { faShield } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import * as React from 'react'
|
||||
|
||||
function PrivacyPolicyPopup ({handleClose}) {
|
||||
return <div className="modal-wrapper">
|
||||
<div className="modal-backdrop" onClick={handleClose} />
|
||||
<div className="modal">
|
||||
<div className="codicon codicon-close modal-close" onClick={handleClose}></div>
|
||||
<h2>Privacy Policy & Impressum</h2>
|
||||
|
||||
<p>Our server collects metadata (such as IP address, browser, operating system)
|
||||
and the data that the user enters into the editor. The data is used to
|
||||
compute the Lean output and display it to the user. The information will be stored
|
||||
as long as the user stays on our website and will be deleted immediately afterwards.
|
||||
We keep logs to improve our software, but the contained data is anonymized.</p>
|
||||
|
||||
<p>We do not use cookies, but your game progress is stored in the browser
|
||||
as site data. Your game progress is not saved on the server; if you delete
|
||||
your browser storage, it is completely gone.
|
||||
</p>
|
||||
|
||||
<p>Our server is located in Germany.</p>
|
||||
|
||||
<p><strong>Contact information:</strong><br />
|
||||
Jon Eugster<br />
|
||||
Mathematisches Institut der Heinrich-Heine-Universität Düsseldorf<br />
|
||||
Universitätsstr. 1<br />
|
||||
40225 Düsseldorf<br />
|
||||
Germany<br />
|
||||
<a href="https://www.math.hhu.de/en/lehrstuehle-/-personen-/-ansprechpartner/innen/lehrstuehle-des-mathematischen-instituts/lehrstuhl-fuer-algebraische-geometrie/team/jon-eugster">Contact Details</a>
|
||||
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
const PrivacyPolicy: React.FC = () => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const handleOpen = () => setOpen(true);
|
||||
const handleClose = () => setOpen(false);
|
||||
|
||||
return (
|
||||
<span>
|
||||
<div className="privacy" onClick={handleOpen} title="Privacy Policy & Impressum">
|
||||
<FontAwesomeIcon icon={faShield} />
|
||||
<p className="p1">legal</p>
|
||||
<p className="p2">notes</p>
|
||||
</div>
|
||||
{open?
|
||||
<PrivacyPolicyPopup handleClose={handleClose} />: null}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// export default PrivacyPolicy
|
||||
|
||||
export {
|
||||
PrivacyPolicy,
|
||||
PrivacyPolicyPopup,
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import './welcome.css'
|
||||
import cytoscape, { LayoutOptions } from 'cytoscape'
|
||||
import klay from 'cytoscape-klay';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useSelector } from 'react-redux';
|
||||
import Split from 'react-split'
|
||||
|
||||
import GameMenu from './GameMenu';
|
||||
import {PrivacyPolicy} from './PrivacyPolicy';
|
||||
|
||||
cytoscape.use( klay );
|
||||
|
||||
import { Box, Typography, CircularProgress } from '@mui/material';
|
||||
import { useGetGameInfoQuery } from '../state/api';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Markdown from './Markdown';
|
||||
import { selectCompleted } from '../state/progress';
|
||||
import { GameIdContext } from '../App';
|
||||
import { Button } from './Button';
|
||||
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faDownload, faUpload, faEraser } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
|
||||
const N = 24 // max number of levels per world
|
||||
const R = 800 // radius of a world
|
||||
const r = 110 // radius of a level
|
||||
const s = 100 // global scale
|
||||
const padding = 2000 // padding of the graphic (on a different scale)
|
||||
|
||||
function LevelIcon({ worldId, levelId, position }) {
|
||||
const gameId = React.useContext(GameIdContext)
|
||||
const completed = useSelector(selectCompleted(gameId, worldId,levelId))
|
||||
|
||||
const x = s * position.x + Math.sin(levelId * 2 * Math.PI / N) * (R + 1.2*r + 2*Math.floor((levelId - 1)/N))
|
||||
const y = s * position.y - Math.cos(levelId * 2 * Math.PI / N) * (R + 1.2*r + 2*Math.floor((levelId - 1)/N))
|
||||
|
||||
// TODO: relative positioning?
|
||||
return (
|
||||
<Link to={`/${gameId}/world/${worldId}/level/${levelId}`}>
|
||||
<circle fill={completed ? "green" :"#999"} cx={x} cy={y} r={r} />
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function Welcome() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const gameId = React.useContext(GameIdContext)
|
||||
const gameInfo = useGetGameInfoQuery({game: gameId})
|
||||
|
||||
const { nodes, bounds }: any = gameInfo.data ? computeWorldLayout(gameInfo.data?.worlds) : {nodes: []}
|
||||
|
||||
useEffect(() => {
|
||||
if (gameInfo.data?.title) {
|
||||
window.document.title = gameInfo.data.title
|
||||
}
|
||||
}, [gameInfo.data?.title])
|
||||
|
||||
const svgElements = []
|
||||
|
||||
if (gameInfo.data) {
|
||||
for (let i in gameInfo.data.worlds.edges) {
|
||||
const edge = gameInfo.data.worlds.edges[i]
|
||||
svgElements.push(
|
||||
<line key={`pathway${i}`} x1={s*nodes[edge[0]].position.x} y1={s*nodes[edge[0]].position.y}
|
||||
x2={s*nodes[edge[1]].position.x} y2={s*nodes[edge[1]].position.y} stroke="#1976d2" strokeWidth={s}/>
|
||||
)
|
||||
}
|
||||
|
||||
for (let id in nodes) {
|
||||
let position: cytoscape.Position = nodes[id].position
|
||||
|
||||
for (let i = 1; i <= gameInfo.data.worldSize[id]; i++) {
|
||||
svgElements.push(
|
||||
<LevelIcon
|
||||
key={`/${gameId}/world/${id}/level/${i}`}
|
||||
position={position} worldId={id} levelId={i} />
|
||||
)
|
||||
}
|
||||
|
||||
svgElements.push(
|
||||
<Link key={`world${id}`} to={`/${gameId}/world/${id}/level/0`}>
|
||||
<circle className="world-circle" cx={s*position.x} cy={s*position.y} r={R}
|
||||
fill="#1976d2"/>
|
||||
<foreignObject className="world-title-wrapper" x={s*position.x} y={s*position.y}
|
||||
width={1.42*R} height={1.42*R} transform={"translate("+ -.71*R +","+ -.71*R +")"}>
|
||||
<div>
|
||||
<p className="world-title" style={{fontSize: Math.floor(R/4) + "px"}}>
|
||||
{nodes[id].data.title ? nodes[id].data.title : id}
|
||||
</p>
|
||||
</div>
|
||||
</foreignObject>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return <div className="app-content ">
|
||||
{ gameInfo.isLoading?
|
||||
<Box display="flex" alignItems="center" justifyContent="center" sx={{ height: "calc(100vh - 64px)" }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
:
|
||||
<Split className="welcome" minSize={200} sizes={[70, 30]}>
|
||||
<div className="column">
|
||||
<Typography variant="body1" component="div" className="welcome-text">
|
||||
<Markdown>{gameInfo.data?.introduction}</Markdown>
|
||||
</Typography>
|
||||
</div>
|
||||
<div className="column">
|
||||
<GameMenu />
|
||||
<Box textAlign='center' sx={{ m: 5 }}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
viewBox={bounds ? `${s*bounds.x1 - padding} ${s*bounds.y1 - padding} ${s*bounds.x2 - s*bounds.x1 + 2 * padding} ${s*bounds.y2 - s*bounds.y1 + 2 * padding}` : ''}>
|
||||
{svgElements}
|
||||
</svg>
|
||||
</Box>
|
||||
</div>
|
||||
</Split>
|
||||
}
|
||||
<PrivacyPolicy />
|
||||
</div>
|
||||
}
|
||||
|
||||
export default Welcome
|
||||
|
||||
function computeWorldLayout(worlds) {
|
||||
|
||||
let elements = []
|
||||
for (let id in worlds.nodes) {
|
||||
elements.push({ data: { id: id, title: worlds.nodes[id].title } })
|
||||
}
|
||||
for (let edge of worlds.edges) {
|
||||
elements.push({
|
||||
data: {
|
||||
id: edge[0] + " --edge-to--> " + edge[1],
|
||||
source: edge[0],
|
||||
target: edge[1]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const cy = cytoscape({
|
||||
container: null,
|
||||
elements,
|
||||
headless: true,
|
||||
styleEnabled: false
|
||||
})
|
||||
|
||||
const layout = cy.layout({name: "klay", klay: {direction: "DOWN", nodePlacement: "LINEAR_SEGMENTS"}} as LayoutOptions).run()
|
||||
let nodes = {}
|
||||
cy.nodes().forEach((node, id) => {
|
||||
nodes[node.id()] = {
|
||||
position: node.position(),
|
||||
data: node.data()
|
||||
}
|
||||
})
|
||||
const bounds = cy.nodes().boundingBox()
|
||||
return { nodes, bounds }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import * as React from 'react';
|
||||
import { Link, LinkProps } from "react-router-dom";
|
||||
|
||||
export interface ButtonProps extends LinkProps {
|
||||
disabled?: boolean
|
||||
inverted?: string // Apparently "inverted" in DOM cannot be `boolean` but must be `inverted`
|
||||
}
|
||||
|
||||
export function Button(props: ButtonProps) {
|
||||
if (props.disabled) {
|
||||
return <span className={`btn btn-disabled ${props.inverted === "true" ? 'btn-inverted' : ''}`} {...props}>{props.children}</span>
|
||||
} else {
|
||||
return <Link className={`btn ${props.inverted === "true" ? 'btn-inverted' : ''}`} {...props}>{props.children}</Link>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { GameHint } from "./infoview/rpc_api";
|
||||
import * as React from 'react';
|
||||
import Markdown from './markdown';
|
||||
|
||||
export function Hint({hint, step, selected, toggleSelection} : {hint: GameHint, step: number, selected: number, toggleSelection: any}) {
|
||||
return <div className={`message information step-${step}` + (step == selected ? ' selected' : '')} onClick={toggleSelection}>
|
||||
<Markdown>{hint.text}</Markdown>
|
||||
</div>
|
||||
}
|
||||
|
||||
export function HiddenHint({hint, step, selected, toggleSelection} : {hint: GameHint, step: number, selected: number, toggleSelection: any}) {
|
||||
return <div className={`message warning step-${step}` + (step == selected ? ' selected' : '')} onClick={toggleSelection}>
|
||||
<Markdown>{hint.text}</Markdown>
|
||||
</div>
|
||||
}
|
||||
|
||||
export function Hints({hints, showHidden, step, selected, toggleSelection} : {hints: GameHint[], showHidden: boolean, step: number, selected: number, toggleSelection: any}) {
|
||||
|
||||
const openHints = hints.filter(hint => !hint.hidden)
|
||||
const hiddenHints = hints.filter(hint => hint.hidden)
|
||||
|
||||
// TODO: Should not use index as key.
|
||||
return <>
|
||||
{openHints.map((hint, j) => <Hint key={`hint-${step}-${j}`} hint={hint} step={step} selected={selected} toggleSelection={toggleSelection}/>)}
|
||||
{showHidden && hiddenHints.map((hint, j) => <HiddenHint key={`hidden-hint-${step}-${j}`} hint={hint} step={step} selected={selected} toggleSelection={toggleSelection}/>)}
|
||||
</>
|
||||
}
|
||||
|
||||
export function DeletedHint({hint} : {hint: GameHint}) {
|
||||
return <div className="message information deleted-hint">
|
||||
<Markdown>{hint.text}</Markdown>
|
||||
</div>
|
||||
}
|
||||
|
||||
export function DeletedHints({hints} : {hints: GameHint[]}) {
|
||||
|
||||
const openHints = hints.filter(hint => !hint.hidden)
|
||||
const hiddenHints = hints.filter(hint => hint.hidden)
|
||||
|
||||
// TODO: Should not use index as key.
|
||||
return <>
|
||||
{openHints.map((hint, i) => <DeletedHint key={`deleted-hint-${i}`} hint={hint} />)}
|
||||
{hiddenHints.map((hint, i) => <DeletedHint key={`deleted-hidden-hint-${i}`} hint={hint}/>)}
|
||||
</>
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import { useRef, useState, useEffect } from 'react'
|
||||
import { LspDiagnosticsContext } from '../../../../node_modules/lean4-infoview/src/infoview/contexts';
|
||||
import { useServerNotificationEffect } from '../../../../node_modules/lean4-infoview/src/infoview/util';
|
||||
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'
|
||||
import { DiagnosticSeverity, PublishDiagnosticsParams } from 'vscode-languageserver-protocol';
|
||||
import { InputModeContext, MonacoEditorContext } from '../Level'
|
||||
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faWandMagicSparkles } from '@fortawesome/free-solid-svg-icons'
|
||||
import { AbbreviationRewriter } from 'lean4web/client/src/editor/abbreviation/rewriter/AbbreviationRewriter';
|
||||
import { AbbreviationProvider } from 'lean4web/client/src/editor/abbreviation/AbbreviationProvider';
|
||||
|
||||
import { Registry } from 'monaco-textmate' // peer dependency
|
||||
import { wireTmGrammars } from 'monaco-editor-textmate'
|
||||
import * as lightPlusTheme from 'lean4web/client/src/lightPlus.json'
|
||||
import * as leanSyntax from 'lean4web/client/src/syntaxes/lean.json'
|
||||
import * as leanMarkdownSyntax from 'lean4web/client/src/syntaxes/lean-markdown.json'
|
||||
import * as codeblockSyntax from 'lean4web/client/src/syntaxes/codeblock.json'
|
||||
import languageConfig from 'lean4/language-configuration.json';
|
||||
|
||||
|
||||
/* We register a new language `leancmd` that looks like lean4, but does not use the lsp server. */
|
||||
|
||||
// register Monaco languages
|
||||
monaco.languages.register({
|
||||
id: 'lean4cmd',
|
||||
extensions: ['.leancmd']
|
||||
})
|
||||
|
||||
// map of monaco "language id's" to TextMate scopeNames
|
||||
const grammars = new Map()
|
||||
grammars.set('lean4', 'source.lean')
|
||||
grammars.set('lean4cmd', 'source.lean')
|
||||
|
||||
const registry = new Registry({
|
||||
getGrammarDefinition: async (scopeName) => {
|
||||
if (scopeName === 'source.lean') {
|
||||
return {
|
||||
format: 'json',
|
||||
content: JSON.stringify(leanSyntax)
|
||||
}
|
||||
} else if (scopeName === 'source.lean.markdown') {
|
||||
return {
|
||||
format: 'json',
|
||||
content: JSON.stringify(leanMarkdownSyntax)
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
format: 'json',
|
||||
content: JSON.stringify(codeblockSyntax)
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
wireTmGrammars(monaco, registry, grammars)
|
||||
|
||||
let config: any = { ...languageConfig }
|
||||
config.autoClosingPairs = config.autoClosingPairs.map(
|
||||
pair => { return {'open': pair[0], 'close': pair[1]} }
|
||||
)
|
||||
monaco.languages.setLanguageConfiguration('lean4cmd', config);
|
||||
|
||||
export function CommandLine() {
|
||||
|
||||
/** Reference to the hidden multi-line editor */
|
||||
const editor = React.useContext(MonacoEditorContext)
|
||||
const [oneLineEditor, setOneLineEditor] = useState<monaco.editor.IStandaloneCodeEditor>(null)
|
||||
const [processing, setProcessing] = useState(false)
|
||||
|
||||
const { commandLineMode, commandLineInput, setCommandLineInput } = React.useContext(InputModeContext)
|
||||
|
||||
const inputRef = useRef()
|
||||
|
||||
// Run the command
|
||||
const runCommand = React.useCallback(() => {
|
||||
if (processing) return;
|
||||
const pos = editor.getPosition()
|
||||
editor.executeEdits("command-line", [{
|
||||
range: monaco.Selection.fromPositions(
|
||||
pos,
|
||||
editor.getModel().getFullModelRange().getEndPosition()
|
||||
),
|
||||
text: commandLineInput + "\n",
|
||||
forceMoveMarkers: false
|
||||
}]);
|
||||
editor.setPosition(pos)
|
||||
setProcessing(true)
|
||||
}, [commandLineInput, editor])
|
||||
|
||||
useEffect(() => {
|
||||
if (oneLineEditor && oneLineEditor.getValue() !== commandLineInput) {
|
||||
oneLineEditor.setValue(commandLineInput)
|
||||
}
|
||||
}, [commandLineInput])
|
||||
|
||||
// React when answer from the server comes back
|
||||
useServerNotificationEffect('textDocument/publishDiagnostics', (params: PublishDiagnosticsParams) => {
|
||||
if (params.uri == editor.getModel().uri.toString()) {
|
||||
setProcessing(false)
|
||||
if (!hasErrorsOrWarnings(params.diagnostics)) {
|
||||
setCommandLineInput("")
|
||||
editor.setPosition(editor.getModel().getFullModelRange().getEndPosition())
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const myEditor = monaco.editor.create(inputRef.current!, {
|
||||
value: commandLineInput,
|
||||
language: "lean4cmd",
|
||||
quickSuggestions: false,
|
||||
lightbulb: {
|
||||
enabled: true
|
||||
},
|
||||
unicodeHighlight: {
|
||||
ambiguousCharacters: false,
|
||||
},
|
||||
automaticLayout: true,
|
||||
minimap: {
|
||||
enabled: false
|
||||
},
|
||||
lineNumbers: 'off',
|
||||
glyphMargin: false,
|
||||
folding: false,
|
||||
lineDecorationsWidth: 0,
|
||||
lineNumbersMinChars: 0,
|
||||
'semanticHighlighting.enabled': true,
|
||||
overviewRulerLanes: 0,
|
||||
hideCursorInOverviewRuler: true,
|
||||
scrollbar: {
|
||||
vertical: 'hidden',
|
||||
horizontalScrollbarSize: 3
|
||||
},
|
||||
overviewRulerBorder: false,
|
||||
theme: 'vs-code-theme-converted',
|
||||
contextmenu: false
|
||||
})
|
||||
|
||||
setOneLineEditor(myEditor)
|
||||
|
||||
const abbrevRewriter = new AbbreviationRewriter(new AbbreviationProvider(), myEditor.getModel(), myEditor)
|
||||
|
||||
return () => {abbrevRewriter.dispose(); myEditor.dispose()}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!oneLineEditor) return
|
||||
// Ensure that our one-line editor can only have a single line
|
||||
const l = oneLineEditor.getModel().onDidChangeContent((e) => {
|
||||
const value = oneLineEditor.getValue()
|
||||
setCommandLineInput(value)
|
||||
const newValue = value.replace(/[\n\r]/g, '')
|
||||
if (value != newValue) {
|
||||
oneLineEditor.setValue(newValue)
|
||||
}
|
||||
})
|
||||
return () => { l.dispose() }
|
||||
}, [oneLineEditor, setCommandLineInput])
|
||||
|
||||
useEffect(() => {
|
||||
if (!oneLineEditor) return
|
||||
// Run command when pressing enter
|
||||
const l = oneLineEditor.onKeyUp((ev) => {
|
||||
if (ev.code === "Enter") {
|
||||
runCommand()
|
||||
}
|
||||
})
|
||||
return () => { l.dispose() }
|
||||
}, [oneLineEditor, runCommand])
|
||||
|
||||
const handleSubmit : React.FormEventHandler<HTMLFormElement> = (ev) => {
|
||||
ev.preventDefault()
|
||||
runCommand()
|
||||
}
|
||||
|
||||
return <div className="command-line">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="command-line-input-wrapper">
|
||||
<div ref={inputRef} className="command-line-input" />
|
||||
</div>
|
||||
<button type="submit" disabled={processing} className="btn btn-inverted"><FontAwesomeIcon icon={faWandMagicSparkles} /> Execute</button>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
|
||||
/** Checks whether the diagnostics contain any errors or warnings to check whether the level has
|
||||
been completed.*/
|
||||
function hasErrorsOrWarnings(diags) {
|
||||
return diags.some(
|
||||
(d) =>
|
||||
!d.message.startsWith("unsolved goals") &&
|
||||
(d.severity == DiagnosticSeverity.Error || d.severity == DiagnosticSeverity.Warning)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
import * as React from 'react'
|
||||
import { useRef, useState, useEffect } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faWandMagicSparkles } from '@fortawesome/free-solid-svg-icons'
|
||||
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'
|
||||
import { Registry } from 'monaco-textmate' // peer dependency
|
||||
import { wireTmGrammars } from 'monaco-editor-textmate'
|
||||
import { DiagnosticSeverity, PublishDiagnosticsParams } from 'vscode-languageserver-protocol';
|
||||
import { useServerNotificationEffect } from '../../../../node_modules/lean4-infoview/src/infoview/util';
|
||||
import { AbbreviationRewriter } from 'lean4web/client/src/editor/abbreviation/rewriter/AbbreviationRewriter';
|
||||
import { AbbreviationProvider } from 'lean4web/client/src/editor/abbreviation/AbbreviationProvider';
|
||||
import * as leanSyntax from 'lean4web/client/src/syntaxes/lean.json'
|
||||
import * as leanMarkdownSyntax from 'lean4web/client/src/syntaxes/lean-markdown.json'
|
||||
import * as codeblockSyntax from 'lean4web/client/src/syntaxes/codeblock.json'
|
||||
import languageConfig from 'lean4/language-configuration.json';
|
||||
import { InteractiveDiagnostic, getInteractiveDiagnostics } from '@leanprover/infoview-api';
|
||||
import { Diagnostic } from 'vscode-languageserver-types';
|
||||
import { DocumentPosition } from '../../../../node_modules/lean4-infoview/src/infoview/util';
|
||||
import { useRpcSessionAtPos } from '../../../../node_modules/lean4-infoview/src/infoview/rpcSessions';
|
||||
import { DeletedChatContext, InputModeContext, MonacoEditorContext, ProofContext, ProofStep } from './context'
|
||||
import { goalsToString } from './goals'
|
||||
import { GameHint, InteractiveGoals } from './rpc_api'
|
||||
|
||||
/* We register a new language `leancmd` that looks like lean4, but does not use the lsp server. */
|
||||
|
||||
// register Monaco languages
|
||||
monaco.languages.register({
|
||||
id: 'lean4cmd',
|
||||
extensions: ['.leancmd']
|
||||
})
|
||||
|
||||
// map of monaco "language id's" to TextMate scopeNames
|
||||
const grammars = new Map()
|
||||
grammars.set('lean4', 'source.lean')
|
||||
grammars.set('lean4cmd', 'source.lean')
|
||||
|
||||
const registry = new Registry({
|
||||
getGrammarDefinition: async (scopeName) => {
|
||||
if (scopeName === 'source.lean') {
|
||||
return {
|
||||
format: 'json',
|
||||
content: JSON.stringify(leanSyntax)
|
||||
}
|
||||
} else if (scopeName === 'source.lean.markdown') {
|
||||
return {
|
||||
format: 'json',
|
||||
content: JSON.stringify(leanMarkdownSyntax)
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
format: 'json',
|
||||
content: JSON.stringify(codeblockSyntax)
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
wireTmGrammars(monaco, registry, grammars)
|
||||
|
||||
let config: any = { ...languageConfig }
|
||||
config.autoClosingPairs = config.autoClosingPairs.map(
|
||||
pair => { return {'open': pair[0], 'close': pair[1]} }
|
||||
)
|
||||
monaco.languages.setLanguageConfiguration('lean4cmd', config);
|
||||
|
||||
/** The input field */
|
||||
export function CommandLine({proofPanelRef}: {proofPanelRef: React.MutableRefObject<HTMLDivElement>}) {
|
||||
|
||||
/** Reference to the hidden multi-line editor */
|
||||
const editor = React.useContext(MonacoEditorContext)
|
||||
const model = editor.getModel()
|
||||
const uri = model.uri.toString()
|
||||
|
||||
const [oneLineEditor, setOneLineEditor] = useState<monaco.editor.IStandaloneCodeEditor>(null)
|
||||
const [processing, setProcessing] = useState(false)
|
||||
|
||||
const {commandLineInput, setCommandLineInput} = React.useContext(InputModeContext)
|
||||
|
||||
const inputRef = useRef()
|
||||
|
||||
// The context storing all information about the current proof
|
||||
const {proof, setProof} = React.useContext(ProofContext)
|
||||
|
||||
// state to store the last batch of deleted messages
|
||||
const {setDeletedChat} = React.useContext(DeletedChatContext)
|
||||
|
||||
// TODO: does the position matter at all?
|
||||
const rpcSess = useRpcSessionAtPos({uri: uri, line: 1, character: 1})
|
||||
|
||||
/** Load all goals an messages of the current proof (line-by-line) and save
|
||||
* the retrieved information into context (`ProofContext`)
|
||||
*/
|
||||
const loadAllGoals = React.useCallback(() => {
|
||||
|
||||
let goalCalls = []
|
||||
let msgCalls = []
|
||||
|
||||
// For each line of code ask the server for the goals and the messages on this line
|
||||
for (let i = 0; i < model.getLineCount(); i++) {
|
||||
goalCalls.push(
|
||||
rpcSess.call('Game.getInteractiveGoals', DocumentPosition.toTdpp({line: i, character: 0, uri: uri}))
|
||||
)
|
||||
msgCalls.push(
|
||||
getInteractiveDiagnostics(rpcSess, {start: i, end: i+1})
|
||||
)
|
||||
}
|
||||
|
||||
// Wait for all these requests to be processed before saving the results
|
||||
Promise.all(goalCalls).then((steps : InteractiveGoals[]) => {
|
||||
Promise.all(msgCalls).then((diagnostics : [InteractiveDiagnostic[]]) => {
|
||||
let tmpProof : ProofStep[] = []
|
||||
|
||||
let goalCount = 0
|
||||
|
||||
steps.map((goals, i) => {
|
||||
// The first step has an empty command and therefore also no error messages
|
||||
// Usually there is a newline at the end of the editors content, so we need to
|
||||
// display diagnostics from potentally two lines in the last step.
|
||||
let messages = i ? (i == steps.length - 1 ? diagnostics.slice(i-1).flat() : diagnostics[i-1]) : []
|
||||
|
||||
// Filter out the 'unsolved goals' message
|
||||
messages = messages.filter((msg) => {
|
||||
return !("append" in msg.message &&
|
||||
"text" in msg.message.append[0] &&
|
||||
msg.message.append[0].text === "unsolved goals")
|
||||
})
|
||||
|
||||
if (typeof goals == 'undefined') {
|
||||
tmpProof.push({
|
||||
command: i ? model.getLineContent(i) : '',
|
||||
goals: [],
|
||||
hints: [],
|
||||
errors: messages
|
||||
} as ProofStep)
|
||||
console.debug('goals is undefined')
|
||||
return
|
||||
}
|
||||
|
||||
// If the number of goals reduce, show a message
|
||||
if (goals.goals.length && goalCount > goals.goals.length) {
|
||||
messages.unshift({
|
||||
range: {
|
||||
start: {
|
||||
line: i-1,
|
||||
character: 0,
|
||||
},
|
||||
end: {
|
||||
line: i-1,
|
||||
character: 0,
|
||||
}},
|
||||
severity: DiagnosticSeverity.Information,
|
||||
message: {
|
||||
text: 'intermediate goal solved 🎉'
|
||||
}
|
||||
})
|
||||
}
|
||||
goalCount = goals.goals.length
|
||||
|
||||
// with no goals there will be no hints.
|
||||
let hints : GameHint[] = goals.goals.length ? goals.goals[0].hints : []
|
||||
|
||||
console.debug(`Command (${i}): `, i ? model.getLineContent(i) : '')
|
||||
console.debug(`Goals: (${i}): `, goalsToString(goals)) //
|
||||
console.debug(`Hints: (${i}): `, hints)
|
||||
console.debug(`Errors: (${i}): `, messages)
|
||||
|
||||
tmpProof.push({
|
||||
// the command of the line above. Note that `getLineContent` starts counting
|
||||
// at `1` instead of `zero`. The first ProofStep will have an empty command.
|
||||
command: i ? model.getLineContent(i) : '',
|
||||
// TODO: store correct data
|
||||
goals: goals.goals,
|
||||
// only need the hints of the active goals in chat
|
||||
hints: hints,
|
||||
// errors and messages from the server
|
||||
errors: messages
|
||||
} as ProofStep)
|
||||
|
||||
})
|
||||
// Save the proof to the context
|
||||
setProof(tmpProof)
|
||||
console.debug('updated proof')
|
||||
proofPanelRef.current?.lastElementChild?.scrollIntoView()
|
||||
})
|
||||
})
|
||||
}, [editor, rpcSess, uri, model, proofPanelRef])
|
||||
|
||||
// Run the command
|
||||
const runCommand = React.useCallback(() => {
|
||||
if (processing) {return}
|
||||
|
||||
// TODO: Desired logic is to only reset this after a new *error-free* command has been entered
|
||||
setDeletedChat([])
|
||||
|
||||
const pos = editor.getPosition()
|
||||
if (commandLineInput) {
|
||||
setProcessing(true)
|
||||
editor.executeEdits("command-line", [{
|
||||
range: monaco.Selection.fromPositions(
|
||||
pos,
|
||||
editor.getModel().getFullModelRange().getEndPosition()
|
||||
),
|
||||
text: commandLineInput.trim() + "\n",
|
||||
forceMoveMarkers: false
|
||||
}])
|
||||
}
|
||||
|
||||
editor.setPosition(pos)
|
||||
}, [commandLineInput, editor])
|
||||
|
||||
useEffect(() => {
|
||||
if (oneLineEditor && oneLineEditor.getValue() !== commandLineInput) {
|
||||
oneLineEditor.setValue(commandLineInput)
|
||||
}
|
||||
}, [commandLineInput])
|
||||
|
||||
// React when answer from the server comes back
|
||||
useServerNotificationEffect('textDocument/publishDiagnostics', (params: PublishDiagnosticsParams) => {
|
||||
if (params.uri == uri) {
|
||||
setProcessing(false)
|
||||
loadAllGoals()
|
||||
if (!hasErrors(params.diagnostics)) {
|
||||
setCommandLineInput("")
|
||||
editor.setPosition(editor.getModel().getFullModelRange().getEndPosition())
|
||||
}
|
||||
} else {
|
||||
// console.debug(`expected uri: ${uri}, got: ${params.uri}`)
|
||||
// console.debug(params)
|
||||
}
|
||||
// TODO: This is the wrong place apparently. Where do wee need to load them?
|
||||
// TODO: instead of loading all goals every time, we could only load the last one
|
||||
// loadAllGoals()
|
||||
}, [uri]);
|
||||
|
||||
useEffect(() => {
|
||||
const myEditor = monaco.editor.create(inputRef.current!, {
|
||||
value: commandLineInput,
|
||||
language: "lean4cmd",
|
||||
quickSuggestions: false,
|
||||
lightbulb: {
|
||||
enabled: true
|
||||
},
|
||||
unicodeHighlight: {
|
||||
ambiguousCharacters: false,
|
||||
},
|
||||
automaticLayout: true,
|
||||
minimap: {
|
||||
enabled: false
|
||||
},
|
||||
lineNumbers: 'off',
|
||||
tabSize: 2,
|
||||
glyphMargin: false,
|
||||
folding: false,
|
||||
lineDecorationsWidth: 0,
|
||||
lineNumbersMinChars: 0,
|
||||
'semanticHighlighting.enabled': true,
|
||||
overviewRulerLanes: 0,
|
||||
hideCursorInOverviewRuler: true,
|
||||
scrollbar: {
|
||||
vertical: 'hidden',
|
||||
horizontalScrollbarSize: 3
|
||||
},
|
||||
overviewRulerBorder: false,
|
||||
theme: 'vs-code-theme-converted',
|
||||
contextmenu: false
|
||||
})
|
||||
|
||||
setOneLineEditor(myEditor)
|
||||
|
||||
const abbrevRewriter = new AbbreviationRewriter(new AbbreviationProvider(), myEditor.getModel(), myEditor)
|
||||
|
||||
return () => {abbrevRewriter.dispose(); myEditor.dispose()}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!oneLineEditor) return
|
||||
// Ensure that our one-line editor can only have a single line
|
||||
const l = oneLineEditor.getModel().onDidChangeContent((e) => {
|
||||
const value = oneLineEditor.getValue()
|
||||
setCommandLineInput(value)
|
||||
const newValue = value.replace(/[\n\r]/g, '')
|
||||
if (value != newValue) {
|
||||
oneLineEditor.setValue(newValue)
|
||||
}
|
||||
})
|
||||
return () => { l.dispose() }
|
||||
}, [oneLineEditor, setCommandLineInput])
|
||||
|
||||
useEffect(() => {
|
||||
if (!oneLineEditor) return
|
||||
// Run command when pressing enter
|
||||
const l = oneLineEditor.onKeyUp((ev) => {
|
||||
if (ev.code === "Enter") {
|
||||
runCommand()
|
||||
}
|
||||
})
|
||||
return () => { l.dispose() }
|
||||
}, [oneLineEditor, runCommand])
|
||||
|
||||
// BUG: Causes `file closed` error
|
||||
//TODO: Intention is to run once when loading, does that work?
|
||||
useEffect(() => {
|
||||
console.debug(`time to update: ${uri} \n ${rpcSess}`)
|
||||
console.debug(rpcSess)
|
||||
loadAllGoals()
|
||||
}, [rpcSess])
|
||||
|
||||
/** Process the entered command */
|
||||
const handleSubmit : React.FormEventHandler<HTMLFormElement> = (ev) => {
|
||||
ev.preventDefault()
|
||||
runCommand()
|
||||
}
|
||||
|
||||
return <div className="command-line">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="command-line-input-wrapper">
|
||||
<div ref={inputRef} className="command-line-input" />
|
||||
</div>
|
||||
<button type="submit" disabled={processing} className="btn btn-inverted">
|
||||
<FontAwesomeIcon icon={faWandMagicSparkles} /> Execute
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
|
||||
/** Checks whether the diagnostics contain any errors or warnings to check whether the level has
|
||||
been completed.*/
|
||||
export function hasErrors(diags: Diagnostic[]) {
|
||||
return diags.some(
|
||||
(d) =>
|
||||
!d.message.startsWith("unsolved goals") &&
|
||||
(d.severity == DiagnosticSeverity.Error ) // || d.severity == DiagnosticSeverity.Warning
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: Didn't manage to unify this with the one above
|
||||
export function hasInteractiveErrors (diags: InteractiveDiagnostic[]) {
|
||||
return (typeof diags !== 'undefined') && diags.some(
|
||||
(d) => (d.severity == DiagnosticSeverity.Error ) // || d.severity == DiagnosticSeverity.Warning
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* @fileOverview This file contains the the react contexts used in the project.
|
||||
*/
|
||||
import * as React from 'react';
|
||||
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'
|
||||
import { InteractiveDiagnostic, InteractiveTermGoal } from '@leanprover/infoview-api';
|
||||
import { GameHint, InteractiveGoal, InteractiveGoals } from './rpc_api';
|
||||
|
||||
export const MonacoEditorContext = React.createContext<monaco.editor.IStandaloneCodeEditor>(
|
||||
null as any)
|
||||
|
||||
export type InfoStatus = 'updating' | 'error' | 'ready';
|
||||
|
||||
/** One step of the proof */
|
||||
export type ProofStep = {
|
||||
/** The command in this step */
|
||||
command : string
|
||||
/** List of goals *after* this command */
|
||||
goals: InteractiveGoal[] // TODO: Add correct type
|
||||
/** Story relevant messages */
|
||||
hints: GameHint[] // TODO: Add correct type
|
||||
/** Errors and warnings */
|
||||
errors: InteractiveDiagnostic[] // TODO: Add correct type
|
||||
}
|
||||
|
||||
/** The context storing the proof step-by-step for the command line mode */
|
||||
export const ProofContext = React.createContext<{
|
||||
/** The proof consists of multiple steps that are processed one after the other.
|
||||
* In particular multi-line terms like `match`-statements will not be supported.
|
||||
*
|
||||
* Note that the first step will always have `null` as command
|
||||
*/
|
||||
proof: ProofStep[],
|
||||
setProof: React.Dispatch<React.SetStateAction<Array<ProofStep>>>
|
||||
}>({
|
||||
proof: [],
|
||||
setProof: () => {} // TODO: implement me
|
||||
})
|
||||
|
||||
export interface ProofStateProps {
|
||||
// pos: DocumentPosition;
|
||||
status: InfoStatus;
|
||||
messages: InteractiveDiagnostic[];
|
||||
goals?: InteractiveGoals;
|
||||
termGoal?: InteractiveTermGoal;
|
||||
error?: string;
|
||||
// userWidgets: UserWidgetInstance[];
|
||||
// rpcSess: RpcSessionAtPos;
|
||||
// triggerUpdate: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const ProofStateContext = React.createContext<{
|
||||
proofState : ProofStateProps,
|
||||
setProofState: React.Dispatch<React.SetStateAction<ProofStateProps>>
|
||||
}>({
|
||||
proofState : {
|
||||
status: 'updating',
|
||||
messages: [],
|
||||
goals: undefined,
|
||||
termGoal: undefined,
|
||||
error: undefined},
|
||||
setProofState: () => {},
|
||||
})
|
||||
|
||||
export const MobileContext = React.createContext<{
|
||||
mobile : boolean,
|
||||
setMobile: React.Dispatch<React.SetStateAction<Boolean>>
|
||||
}>({
|
||||
mobile : false,
|
||||
setMobile: () => {},
|
||||
})
|
||||
|
||||
export const WorldLevelIdContext = React.createContext<{
|
||||
worldId : string,
|
||||
levelId: number
|
||||
}>({
|
||||
worldId : null,
|
||||
levelId: 0,
|
||||
})
|
||||
|
||||
/** Context to keep highlight selected proof step and corresponding chat messages. */
|
||||
export const SelectionContext = React.createContext<{
|
||||
selectedStep : number,
|
||||
setSelectedStep: React.Dispatch<React.SetStateAction<number>>
|
||||
}>({
|
||||
selectedStep : undefined,
|
||||
setSelectedStep: () => {}
|
||||
})
|
||||
|
||||
/** Context for deleted Hints that are visible just a bit after they've been deleted */
|
||||
export const DeletedChatContext = React.createContext<{
|
||||
deletedChat : GameHint[],
|
||||
setDeletedChat: React.Dispatch<React.SetStateAction<Array<GameHint>>>
|
||||
showHelp : Set<number>,
|
||||
setShowHelp: React.Dispatch<React.SetStateAction<Set<number>>>
|
||||
}>({
|
||||
deletedChat: undefined,
|
||||
setDeletedChat: () => {},
|
||||
showHelp: undefined,
|
||||
setShowHelp: () => {}
|
||||
})
|
||||
|
||||
export const InputModeContext = React.createContext<{
|
||||
commandLineMode: boolean,
|
||||
setCommandLineMode: React.Dispatch<React.SetStateAction<boolean>>,
|
||||
commandLineInput: string,
|
||||
setCommandLineInput: React.Dispatch<React.SetStateAction<string>>
|
||||
}>({
|
||||
commandLineMode: true,
|
||||
setCommandLineMode: () => {},
|
||||
commandLineInput: "",
|
||||
setCommandLineInput: () => {},
|
||||
});
|
||||
@@ -1,15 +1,16 @@
|
||||
/* Mostly copied from https://github.com/leanprover/vscode-lean4/blob/master/lean4-infoview/src/infoview/goals.tsx */
|
||||
|
||||
/**
|
||||
* @fileOverview
|
||||
*
|
||||
* Mostly copied from https://github.com/leanprover/vscode-lean4/blob/master/lean4-infoview/src/infoview/goals.tsx
|
||||
*/
|
||||
import * as React from 'react'
|
||||
import { InteractiveCode } from '../../../../node_modules/lean4-infoview/src/infoview/interactiveCode'
|
||||
import { InteractiveHypothesisBundle_nonAnonymousNames, MVarId, TaggedText_stripTags } from '@leanprover/infoview-api'
|
||||
import { WithTooltipOnHover } from '../../../../node_modules/lean4-infoview/src/infoview/tooltips';
|
||||
import { EditorContext } from '../../../../node_modules/lean4-infoview/src/infoview/contexts';
|
||||
import { Locations, LocationsContext, SelectableLocation } from '../../../../node_modules/lean4-infoview/src/infoview/goalLocation';
|
||||
import { InteractiveGoal, InteractiveGoals, InteractiveHypothesisBundle } from './rpcApi';
|
||||
import { Hints } from './hints';
|
||||
import { CommandLine } from './CommandLine';
|
||||
import { InputModeContext } from '../Level';
|
||||
import { InteractiveCode } from '../../../../node_modules/lean4-infoview/src/infoview/interactiveCode'
|
||||
import { WithTooltipOnHover } from '../../../../node_modules/lean4-infoview/src/infoview/tooltips';
|
||||
import { InputModeContext } from './context';
|
||||
import { InteractiveGoal, InteractiveGoals, InteractiveHypothesisBundle } from './rpc_api';
|
||||
|
||||
/** Returns true if `h` is inaccessible according to Lean's default name rendering. */
|
||||
function isInaccessibleName(h: string): boolean {
|
||||
@@ -137,6 +138,9 @@ interface ProofDisplayProps {
|
||||
export const Goal = React.memo((props: GoalProps) => {
|
||||
const { goal, filter, showHints, commandLine } = props
|
||||
|
||||
// TODO: Apparently `goal` can be `undefined`
|
||||
if (!goal) {return <></>}
|
||||
|
||||
const filteredList = getFilteredHypotheses(goal.hyps, filter);
|
||||
const hyps = filter.reverse ? filteredList.slice().reverse() : filteredList;
|
||||
const locs = React.useContext(LocationsContext)
|
||||
@@ -156,7 +160,7 @@ export const Goal = React.memo((props: GoalProps) => {
|
||||
// if (props.goal.isInserted) cn += 'b--inserted '
|
||||
// if (props.goal.isRemoved) cn += 'b--removed '
|
||||
|
||||
const hints = <Hints hints={goal.hints} key={goal.mvarId} />
|
||||
// const hints = <Hints hints={goal.hints} key={goal.mvarId} />
|
||||
const objectHyps = hyps.filter(hyp => !hyp.isAssumption)
|
||||
const assumptionHyps = hyps.filter(hyp => hyp.isAssumption)
|
||||
const {commandLineMode} = React.useContext(InputModeContext)
|
||||
@@ -170,9 +174,9 @@ export const Goal = React.memo((props: GoalProps) => {
|
||||
{!commandLine && assumptionHyps.length > 0 &&
|
||||
<div className="hyp-group"><div className="hyp-group-title">Assumptions:</div>
|
||||
{assumptionHyps.map((h, i) => <Hyp hyp={h} mvarId={goal.mvarId} key={i} />)}</div> }
|
||||
{commandLine && commandLineMode && <CommandLine />}
|
||||
{/* {commandLine && commandLineMode && <CommandLine />} */}
|
||||
{!filter.reverse && goalLi}
|
||||
{showHints && hints}
|
||||
{/* {showHints && hints} */}
|
||||
</div>
|
||||
})
|
||||
|
||||
@@ -231,6 +235,7 @@ export const OtherGoals = React.memo((props: GoalProps2) => {
|
||||
</>
|
||||
})
|
||||
|
||||
// TODO: deprecated
|
||||
export const ProofDisplay = React.memo((props : ProofDisplayProps) => {
|
||||
const { proof } = props
|
||||
const steps = proof.match(/.+/g)
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { GameHint } from "./rpcApi";
|
||||
import * as React from 'react';
|
||||
import { Alert, FormControlLabel, Switch } from '@mui/material';
|
||||
import Markdown from '../Markdown';
|
||||
|
||||
function Hint({hint} : {hint: GameHint}) {
|
||||
return <div className="message info"><Markdown>{hint.text}</Markdown></div>
|
||||
}
|
||||
|
||||
export function Hints({hints} : {hints: GameHint[]}) {
|
||||
|
||||
|
||||
const [showHints, setShowHints] = React.useState(false);
|
||||
|
||||
const openHints = hints.filter(hint => !hint.hidden)
|
||||
const hiddenHints = hints.filter(hint => hint.hidden)
|
||||
|
||||
return <>
|
||||
{openHints.map(hint => <Hint hint={hint} />)}
|
||||
{hiddenHints.length > 0 &&
|
||||
<FormControlLabel
|
||||
control={<Switch checked={showHints} onChange={() => setShowHints((prev) => !prev)} />}
|
||||
label="I need help!"
|
||||
/>}
|
||||
{showHints && hiddenHints.map(hint => <Hint hint={hint} />)}
|
||||
</>
|
||||
}
|
||||
@@ -1,53 +1,50 @@
|
||||
/* Mostly copied from https://github.com/leanprover/vscode-lean4/blob/master/lean4-infoview/src/infoview/info.tsx */
|
||||
|
||||
import * as React from 'react';
|
||||
import type { Location, Diagnostic } from 'vscode-languageserver-protocol';
|
||||
|
||||
import { goalsToString, Goal, MainAssumptions, OtherGoals, FilteredGoals, ProofDisplay } from './goals'
|
||||
import * as React from 'react'
|
||||
import { CircularProgress } from '@mui/material'
|
||||
import type { Location, Diagnostic } from 'vscode-languageserver-protocol'
|
||||
import { getInteractiveTermGoal, InteractiveDiagnostic, UserWidgetInstance, Widget_getWidgets, RpcSessionAtPos, isRpcError,
|
||||
RpcErrorCode, getInteractiveDiagnostics, InteractiveTermGoal } from '@leanprover/infoview-api'
|
||||
import { basename, DocumentPosition, RangeHelpers, useEvent, usePausableState, discardMethodNotFound,
|
||||
mapRpcError, useAsyncWithTrigger, PausableProps } from '../../../../node_modules/lean4-infoview/src/infoview/util';
|
||||
import { ConfigContext, EditorContext, LspDiagnosticsContext, ProgressContext } from '../../../../node_modules/lean4-infoview/src/infoview/contexts';
|
||||
import { AllMessages, lspDiagToInteractive, MessagesList } from './messages';
|
||||
import { getInteractiveTermGoal, InteractiveDiagnostic,
|
||||
UserWidgetInstance, Widget_getWidgets, RpcSessionAtPos, isRpcError,
|
||||
RpcErrorCode, getInteractiveDiagnostics, InteractiveTermGoal } from '@leanprover/infoview-api';
|
||||
import { InteractiveGoal, InteractiveGoals } from './rpcApi';
|
||||
mapRpcError, useAsyncWithTrigger, PausableProps } from '../../../../node_modules/lean4-infoview/src/infoview/util'
|
||||
import { ConfigContext, EditorContext, LspDiagnosticsContext, ProgressContext } from '../../../../node_modules/lean4-infoview/src/infoview/contexts'
|
||||
import { PanelWidgetDisplay } from '../../../../node_modules/lean4-infoview/src/infoview/userWidget'
|
||||
import { RpcContext, useRpcSessionAtPos } from '../../../../node_modules/lean4-infoview/src/infoview/rpcSessions';
|
||||
import { GoalsLocation, Locations, LocationsContext } from '../../../../node_modules/lean4-infoview/src/infoview/goalLocation';
|
||||
import { InteractiveCode } from '../../../../node_modules/lean4-infoview/src/infoview/interactiveCode'
|
||||
import { CircularProgress } from '@mui/material';
|
||||
import { InputModeContext, MonacoEditorContext } from '../Level'
|
||||
import { RpcContext, useRpcSessionAtPos } from '../../../../node_modules/lean4-infoview/src/infoview/rpcSessions'
|
||||
import { GoalsLocation, Locations, LocationsContext } from '../../../../node_modules/lean4-infoview/src/infoview/goalLocation'
|
||||
|
||||
import { AllMessages, lspDiagToInteractive } from './messages'
|
||||
import { goalsToString, Goal, MainAssumptions, OtherGoals, ProofDisplay } from './goals'
|
||||
import { InteractiveGoals } from './rpc_api'
|
||||
import { MonacoEditorContext, ProofStateProps, InfoStatus, ProofContext } from './context'
|
||||
|
||||
type InfoStatus = 'updating' | 'error' | 'ready';
|
||||
type InfoKind = 'cursor' | 'pin';
|
||||
// TODO: All about pinning could probably be removed
|
||||
type InfoKind = 'cursor' | 'pin'
|
||||
|
||||
interface InfoPinnable {
|
||||
kind: InfoKind;
|
||||
kind: InfoKind
|
||||
/** Takes an argument for caching reasons, but should only ever (un)pin itself. */
|
||||
onPin: (pos: DocumentPosition) => void;
|
||||
onPin: (pos: DocumentPosition) => void
|
||||
}
|
||||
|
||||
interface InfoStatusBarProps extends InfoPinnable, PausableProps {
|
||||
pos: DocumentPosition;
|
||||
status: InfoStatus;
|
||||
triggerUpdate: () => Promise<void>;
|
||||
pos: DocumentPosition
|
||||
status: InfoStatus
|
||||
triggerUpdate: () => Promise<void>
|
||||
}
|
||||
|
||||
const InfoStatusBar = React.memo((props: InfoStatusBarProps) => {
|
||||
const { kind, onPin, status, pos, isPaused, setPaused, triggerUpdate } = props;
|
||||
const { kind, onPin, status, pos, isPaused, setPaused, triggerUpdate } = props
|
||||
|
||||
const ec = React.useContext(EditorContext);
|
||||
const ec = React.useContext(EditorContext)
|
||||
|
||||
const statusColTable: {[T in InfoStatus]: string} = {
|
||||
'updating': 'gold ',
|
||||
'error': 'dark-red ',
|
||||
'ready': '',
|
||||
}
|
||||
const statusColor = statusColTable[status];
|
||||
const locationString = `${basename(pos.uri)}:${pos.line+1}:${pos.character}`;
|
||||
const isPinned = kind === 'pin';
|
||||
const statusColor = statusColTable[status]
|
||||
const locationString = `${basename(pos.uri)}:${pos.line+1}:${pos.character}`
|
||||
const isPinned = kind === 'pin'
|
||||
|
||||
return (
|
||||
<summary style={{transition: 'color 0.5s ease'}} className={'mv2 pointer ' + statusColor}>
|
||||
@@ -67,36 +64,36 @@ const InfoStatusBar = React.memo((props: InfoStatusBarProps) => {
|
||||
title={isPinned ? 'unpin' : 'pin'} />
|
||||
<a className={'link pointer mh2 dim codicon ' + (isPaused ? 'codicon-debug-continue ' : 'codicon-debug-pause ')}
|
||||
data-id='toggle-paused'
|
||||
onClick={e => { e.preventDefault(); setPaused(!isPaused); }}
|
||||
onClick={e => { e.preventDefault(); setPaused(!isPaused) }}
|
||||
title={isPaused ? 'continue updating' : 'pause updating'} />
|
||||
<a className='link pointer mh2 dim codicon codicon-refresh'
|
||||
data-id='update'
|
||||
onClick={e => { e.preventDefault(); void triggerUpdate(); }}
|
||||
onClick={e => { e.preventDefault(); void triggerUpdate() }}
|
||||
title='update'/>
|
||||
</span>
|
||||
</summary>
|
||||
);
|
||||
)
|
||||
})
|
||||
|
||||
interface InfoDisplayContentProps extends PausableProps {
|
||||
pos: DocumentPosition;
|
||||
messages: InteractiveDiagnostic[];
|
||||
goals?: InteractiveGoals;
|
||||
termGoal?: InteractiveTermGoal;
|
||||
error?: string;
|
||||
userWidgets: UserWidgetInstance[];
|
||||
triggerUpdate: () => Promise<void>;
|
||||
proof? : string;
|
||||
pos: DocumentPosition
|
||||
messages: InteractiveDiagnostic[]
|
||||
goals?: InteractiveGoals
|
||||
termGoal?: InteractiveTermGoal
|
||||
error?: string
|
||||
userWidgets: UserWidgetInstance[]
|
||||
triggerUpdate: () => Promise<void>
|
||||
proof? : string
|
||||
}
|
||||
|
||||
const InfoDisplayContent = React.memo((props: InfoDisplayContentProps) => {
|
||||
const {pos, messages, goals, termGoal, error, userWidgets, triggerUpdate, isPaused, setPaused, proof} = props;
|
||||
const {pos, messages, goals, termGoal, error, userWidgets, triggerUpdate, isPaused, setPaused, proof} = props
|
||||
|
||||
const hasWidget = userWidgets.length > 0;
|
||||
const hasError = !!error;
|
||||
const hasMessages = messages.length !== 0;
|
||||
const hasWidget = userWidgets.length > 0
|
||||
const hasError = !!error
|
||||
const hasMessages = messages.length !== 0
|
||||
|
||||
const nothingToShow = !hasError && !goals && !termGoal && !hasMessages && !hasWidget;
|
||||
const nothingToShow = !hasError && !goals && !termGoal && !hasMessages && !hasWidget
|
||||
|
||||
const [selectedLocs, setSelectedLocs] = React.useState<GoalsLocation[]>([])
|
||||
React.useEffect(() => setSelectedLocs([]), [pos.uri, pos.line, pos.character])
|
||||
@@ -121,13 +118,12 @@ const InfoDisplayContent = React.memo((props: InfoDisplayContentProps) => {
|
||||
{hasError &&
|
||||
<div className='error' key='errors'>
|
||||
Error updating:{' '}{error}.
|
||||
<a className='link pointer dim' onClick={e => { e.preventDefault(); void triggerUpdate(); }}>{' '}Try again.</a>
|
||||
<a className='link pointer dim' onClick={e => { e.preventDefault(); void triggerUpdate() }}>{' '}Try again.</a>
|
||||
</div>}
|
||||
<LocationsContext.Provider value={locs}>
|
||||
<div className="goals-section">
|
||||
{ goals && goals.goals.length > 0 && <>
|
||||
<MainAssumptions filter={goalFilter} key='mainGoal' goals={goals.goals} />
|
||||
<ProofDisplay proof={proof}/>
|
||||
<OtherGoals filter={goalFilter} goals={goals.goals} />
|
||||
</>}
|
||||
</div>
|
||||
@@ -149,8 +145,8 @@ const InfoDisplayContent = React.memo((props: InfoDisplayContentProps) => {
|
||||
isPaused ?
|
||||
/* Adding {' '} to manage string literals properly: https://reactjs.org/docs/jsx-in-depth.html#string-literals-1 */
|
||||
<span>Updating is paused.{' '}
|
||||
<a className='link pointer dim' onClick={e => { e.preventDefault(); void triggerUpdate(); }}>Refresh</a>
|
||||
{' '}or <a className='link pointer dim' onClick={e => { e.preventDefault(); setPaused(false); }}>resume updating</a>
|
||||
<a className='link pointer dim' onClick={e => { e.preventDefault(); void triggerUpdate() }}>Refresh</a>
|
||||
{' '}or <a className='link pointer dim' onClick={e => { e.preventDefault(); setPaused(false) }}>resume updating</a>
|
||||
{' '}to see information.
|
||||
</span> :
|
||||
<><CircularProgress /><div>Loading goal...</div></>)}
|
||||
@@ -167,49 +163,49 @@ const InfoDisplayContent = React.memo((props: InfoDisplayContentProps) => {
|
||||
})
|
||||
|
||||
interface InfoDisplayProps {
|
||||
pos: DocumentPosition;
|
||||
status: InfoStatus;
|
||||
messages: InteractiveDiagnostic[];
|
||||
goals?: InteractiveGoals;
|
||||
termGoal?: InteractiveTermGoal;
|
||||
error?: string;
|
||||
userWidgets: UserWidgetInstance[];
|
||||
rpcSess: RpcSessionAtPos;
|
||||
triggerUpdate: () => Promise<void>;
|
||||
pos: DocumentPosition,
|
||||
status: InfoStatus,
|
||||
messages: InteractiveDiagnostic[],
|
||||
goals?: InteractiveGoals,
|
||||
termGoal?: InteractiveTermGoal,
|
||||
error?: string,
|
||||
userWidgets: UserWidgetInstance[],
|
||||
rpcSess: RpcSessionAtPos,
|
||||
triggerUpdate: () => Promise<void>,
|
||||
}
|
||||
|
||||
/** Displays goal state and messages. Can be paused. */
|
||||
function InfoDisplay(props0: InfoDisplayProps & InfoPinnable) {
|
||||
function InfoDisplay(props0: ProofStateProps & InfoDisplayProps & InfoPinnable) {
|
||||
// Used to update the paused state *just once* if it is paused,
|
||||
// but a display update is triggered
|
||||
const [shouldRefresh, setShouldRefresh] = React.useState<boolean>(false);
|
||||
const [{ isPaused, setPaused }, props, propsRef] = usePausableState(false, props0);
|
||||
const [shouldRefresh, setShouldRefresh] = React.useState<boolean>(false)
|
||||
const [{ isPaused, setPaused }, props, propsRef] = usePausableState(false, props0)
|
||||
if (shouldRefresh) {
|
||||
propsRef.current = props0;
|
||||
setShouldRefresh(false);
|
||||
propsRef.current = props0
|
||||
setShouldRefresh(false)
|
||||
}
|
||||
const triggerDisplayUpdate = async () => {
|
||||
await props0.triggerUpdate();
|
||||
setShouldRefresh(true);
|
||||
};
|
||||
await props0.triggerUpdate()
|
||||
setShouldRefresh(true)
|
||||
}
|
||||
|
||||
const {kind, goals, rpcSess} = props;
|
||||
const {kind, goals, rpcSess} = props
|
||||
|
||||
const ec = React.useContext(EditorContext);
|
||||
const ec = React.useContext(EditorContext)
|
||||
|
||||
// If we are the cursor infoview, then we should subscribe to
|
||||
// some commands from the editor extension
|
||||
const isCursor = kind === 'cursor';
|
||||
const isCursor = kind === 'cursor'
|
||||
useEvent(ec.events.requestedAction, act => {
|
||||
if (!isCursor) return;
|
||||
if (act.kind !== 'copyToComment') return;
|
||||
if (goals) void ec.copyToComment(goalsToString(goals));
|
||||
}, [goals]);
|
||||
if (!isCursor) return
|
||||
if (act.kind !== 'copyToComment') return
|
||||
if (goals) void ec.copyToComment(goalsToString(goals))
|
||||
}, [goals])
|
||||
useEvent(ec.events.requestedAction, act => {
|
||||
if (!isCursor) return;
|
||||
if (act.kind !== 'togglePaused') return;
|
||||
setPaused(isPaused => !isPaused);
|
||||
});
|
||||
if (!isCursor) return
|
||||
if (act.kind !== 'togglePaused') return
|
||||
setPaused(isPaused => !isPaused)
|
||||
})
|
||||
|
||||
const editor = React.useContext(MonacoEditorContext)
|
||||
|
||||
@@ -217,12 +213,12 @@ function InfoDisplay(props0: InfoDisplayProps & InfoPinnable) {
|
||||
<RpcContext.Provider value={rpcSess}>
|
||||
{/* <details open> */}
|
||||
{/* <InfoStatusBar {...props} triggerUpdate={triggerDisplayUpdate} isPaused={isPaused} setPaused={setPaused} /> */}
|
||||
<div>
|
||||
<div className="vscode-light">
|
||||
<InfoDisplayContent {...props} proof={editor.getValue()} triggerUpdate={triggerDisplayUpdate} isPaused={isPaused} setPaused={setPaused} />
|
||||
</div>
|
||||
{/* </details> */}
|
||||
</RpcContext.Provider>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -230,7 +226,7 @@ function InfoDisplay(props0: InfoDisplayProps & InfoPinnable) {
|
||||
* to avoid flickering when the cursor moved. Otherwise, the component is re-initialised and the
|
||||
* goal states reset to `undefined` on cursor moves.
|
||||
*/
|
||||
export type InfoProps = InfoPinnable & { pos?: DocumentPosition };
|
||||
export type InfoProps = InfoPinnable & { pos?: DocumentPosition }
|
||||
|
||||
/** Fetches info from the server and renders an {@link InfoDisplay}. */
|
||||
export function Info(props: InfoProps) {
|
||||
@@ -239,22 +235,25 @@ export function Info(props: InfoProps) {
|
||||
}
|
||||
|
||||
function InfoAtCursor(props: InfoProps) {
|
||||
const ec = React.useContext(EditorContext);
|
||||
const ec = React.useContext(EditorContext)
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const [curLoc, setCurLoc] = React.useState<Location>(ec.events.changedCursorLocation.current!);
|
||||
useEvent(ec.events.changedCursorLocation, loc => loc && setCurLoc(loc), []);
|
||||
const pos = { uri: curLoc.uri, ...curLoc.range.start };
|
||||
const [curLoc, setCurLoc] = React.useState<Location>(ec.events.changedCursorLocation.current!)
|
||||
useEvent(ec.events.changedCursorLocation, loc => loc && setCurLoc(loc), [])
|
||||
const pos = { uri: curLoc.uri, ...curLoc.range.start }
|
||||
return <InfoAux {...props} pos={pos} />
|
||||
}
|
||||
|
||||
function useIsProcessingAt(p: DocumentPosition): boolean {
|
||||
const allProgress = React.useContext(ProgressContext);
|
||||
const processing = allProgress.get(p.uri);
|
||||
if (!processing) return false;
|
||||
return processing.some(i => RangeHelpers.contains(i.range, p));
|
||||
const allProgress = React.useContext(ProgressContext)
|
||||
const processing = allProgress.get(p.uri)
|
||||
if (!processing) return false
|
||||
return processing.some(i => RangeHelpers.contains(i.range, p))
|
||||
}
|
||||
|
||||
function InfoAux(props: InfoProps) {
|
||||
|
||||
const proofContext = React.useContext(ProofContext)
|
||||
|
||||
const config = React.useContext(ConfigContext)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
@@ -291,7 +290,7 @@ function InfoAux(props: InfoProps) {
|
||||
// with e.g. a new `pos`.
|
||||
type InfoRequestResult = Omit<InfoDisplayProps, 'triggerUpdate'>
|
||||
const [state, triggerUpdateCore] = useAsyncWithTrigger(() => new Promise<InfoRequestResult>((resolve, reject) => {
|
||||
const goalsReq = rpcSess.call('Game.getInteractiveGoals', DocumentPosition.toTdpp(pos));
|
||||
const goalsReq = rpcSess.call('Game.getInteractiveGoals', DocumentPosition.toTdpp(pos))
|
||||
const termGoalReq = getInteractiveTermGoal(rpcSess, DocumentPosition.toTdpp(pos))
|
||||
const widgetsReq = Widget_getWidgets(rpcSess, pos).catch(discardMethodNotFound)
|
||||
const messagesReq = getInteractiveDiagnostics(rpcSess, {start: pos.line, end: pos.line+1})
|
||||
@@ -405,10 +404,14 @@ function InfoAux(props: InfoProps) {
|
||||
React.useEffect(() => {
|
||||
if (state.state === 'notStarted')
|
||||
void triggerUpdate()
|
||||
else if (state.state === 'loading')
|
||||
setDisplayProps(dp => ({ ...dp, status: 'updating' }))
|
||||
else if (state.state === 'loading') {
|
||||
setDisplayProps(dp => ({ ...dp, status: 'updating' }))
|
||||
}
|
||||
else if (state.state === 'resolved') {
|
||||
setDisplayProps({ ...state.value, triggerUpdate })
|
||||
// if (state.value.goals?.goals?.length) {
|
||||
// hintContext.setHints(state.value.goals.goals[0].hints)
|
||||
// }
|
||||
setDisplayProps({ ...state.value, triggerUpdate })
|
||||
} else if (state.state === 'rejected' && state.error !== 'retry') {
|
||||
// The code inside `useAsyncWithTrigger` may only ever reject with a `retry` exception.
|
||||
console.warn('Unreachable code reached with error: ', state.error)
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
padding: 5px 10px;
|
||||
border-radius: 3px 3px 3px 3px;
|
||||
}
|
||||
.message.info {
|
||||
.message.information, .message.info {
|
||||
/* color: #059; */
|
||||
color: #000;
|
||||
background-color: #DDF6FF;
|
||||
}
|
||||
.message.warning {
|
||||
@@ -17,6 +18,12 @@
|
||||
background-color: #FFBABA;
|
||||
}
|
||||
|
||||
.message.deleted-hint {
|
||||
background-color: #eee;
|
||||
color: #777;
|
||||
box-shadow: .0em .0em .5em .2em #eee;
|
||||
}
|
||||
|
||||
.hyp-group {
|
||||
margin-bottom: 0.3em;
|
||||
}
|
||||
@@ -41,14 +48,14 @@
|
||||
padding: 0.5em;
|
||||
font-family: var(--ff-primary);
|
||||
border-radius: 0.2em;
|
||||
margin: 0.2em 0;
|
||||
/* margin: 0.2em 0 0; */
|
||||
}
|
||||
|
||||
.command-line form {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.command-line button {
|
||||
.command-line button, .undo-button {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
flex: 0;
|
||||
@@ -64,7 +71,7 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.command-line .command-line-input{
|
||||
.command-line .command-line-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -128,3 +135,41 @@
|
||||
border-radius: 1em;
|
||||
padding: 0.6em;
|
||||
}
|
||||
|
||||
|
||||
/* Push the goals to the bottom for now, until we insert the proof history above. */
|
||||
.commandline-interface .content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* TODO this is in the wrong file */
|
||||
.chat {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.commandline-interface .content .tmp-pusher {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.exercise .command {
|
||||
background-color: #bbb;
|
||||
padding: .5em;
|
||||
border-radius: .2em;
|
||||
margin-top: 2rem;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.exercise .command-text {
|
||||
flex: 1;
|
||||
background-color: #fff;
|
||||
font-family: "Droid Sans Mono", "monospace", monospace;
|
||||
font-size: 14px;
|
||||
padding: 0.4em .6em 0;
|
||||
}
|
||||
|
||||
.undo-button {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
@@ -4,100 +4,477 @@ import * as React from 'react';
|
||||
import type { DidCloseTextDocumentParams, DidChangeTextDocumentParams, Location, DocumentUri } from 'vscode-languageserver-protocol';
|
||||
|
||||
import 'tachyons/css/tachyons.css';
|
||||
// import '@vscode/codicons/dist/codicon.ttf';
|
||||
import '@vscode/codicons/dist/codicon.css';
|
||||
import '../../../../node_modules/lean4-infoview/src/infoview/index.css';
|
||||
import './infoview.css'
|
||||
|
||||
import { LeanFileProgressParams, LeanFileProgressProcessingInfo, defaultInfoviewConfig, EditorApi, InfoviewApi } from '@leanprover/infoview-api';
|
||||
|
||||
import { Infos } from './infos';
|
||||
import { AllMessages, WithLspDiagnosticsContext } from './messages';
|
||||
import { useClientNotificationEffect, useServerNotificationEffect, useEventResult, useServerNotificationState } from '../../../../node_modules/lean4-infoview/src/infoview/util';
|
||||
import { EditorContext, ConfigContext, ProgressContext, VersionContext } from '../../../../node_modules/lean4-infoview/src/infoview/contexts';
|
||||
import { WithRpcSessions } from '../../../../node_modules/lean4-infoview/src/infoview/rpcSessions';
|
||||
import { ServerVersion } from '../../../../node_modules/lean4-infoview/src/infoview/serverVersion';
|
||||
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faDeleteLeft, faHome, faArrowRight, faArrowLeft, faRotateLeft } from '@fortawesome/free-solid-svg-icons'
|
||||
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'
|
||||
|
||||
import { GameIdContext } from '../../app';
|
||||
import { useAppDispatch, useAppSelector } from '../../hooks';
|
||||
import { levelCompleted, selectCompleted } from '../../state/progress';
|
||||
import { GameIdContext } from '../../App';
|
||||
import { LevelInfo } from '../../state/api';
|
||||
import { changedInventory, levelCompleted, selectCompleted, selectInventory } from '../../state/progress';
|
||||
import Markdown from '../markdown';
|
||||
|
||||
import { Infos } from './infos';
|
||||
import { AllMessages, Errors, WithLspDiagnosticsContext } from './messages';
|
||||
import { Goal } from './goals';
|
||||
import { DeletedChatContext, InputModeContext, MobileContext, MonacoEditorContext, ProofContext, ProofStep, SelectionContext } from './context';
|
||||
import { CommandLine, hasErrors, hasInteractiveErrors } from './command_line';
|
||||
import { InteractiveDiagnostic } from '@leanprover/infoview/*';
|
||||
import { Button } from '../button';
|
||||
import { CircularProgress } from '@mui/material';
|
||||
import { GameHint } from './rpc_api';
|
||||
import { store } from '../../state/store';
|
||||
import { Hints } from '../hints';
|
||||
|
||||
export function Main(props: {world: string, level: number}) {
|
||||
const ec = React.useContext(EditorContext);
|
||||
const gameId = React.useContext(GameIdContext)
|
||||
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
// Mark level as completed when server gives notification
|
||||
useServerNotificationEffect(
|
||||
'$/game/completed',
|
||||
(params: any) => {
|
||||
|
||||
if (ec.events.changedCursorLocation.current &&
|
||||
ec.events.changedCursorLocation.current.uri === params.uri) {
|
||||
dispatch(levelCompleted({game: gameId, world: props.world, level: props.level}))
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const completed = useAppSelector(selectCompleted(gameId, props.world, props.level))
|
||||
|
||||
/* Set up updates to the global infoview state on editor events. */
|
||||
const config = useEventResult(ec.events.changedInfoviewConfig) ?? defaultInfoviewConfig;
|
||||
|
||||
const [allProgress, _1] = useServerNotificationState(
|
||||
'$/lean/fileProgress',
|
||||
new Map<DocumentUri, LeanFileProgressProcessingInfo[]>(),
|
||||
async (params: LeanFileProgressParams) => (allProgress) => {
|
||||
const newProgress = new Map(allProgress);
|
||||
return newProgress.set(params.textDocument.uri, params.processing);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const curUri = useEventResult(ec.events.changedCursorLocation, loc => loc?.uri);
|
||||
|
||||
useClientNotificationEffect(
|
||||
'textDocument/didClose',
|
||||
(params: DidCloseTextDocumentParams) => {
|
||||
if (ec.events.changedCursorLocation.current &&
|
||||
ec.events.changedCursorLocation.current.uri === params.textDocument.uri) {
|
||||
ec.events.changedCursorLocation.fire(undefined)
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const serverVersion =
|
||||
useEventResult(ec.events.serverRestarted, result => new ServerVersion(result.serverInfo?.version ?? ''))
|
||||
const serverStoppedResult = useEventResult(ec.events.serverStopped);
|
||||
// NB: the cursor may temporarily become `undefined` when a file is closed. In this case
|
||||
// it's important not to reconstruct the `WithBlah` wrappers below since they contain state
|
||||
// that we want to persist.
|
||||
let ret
|
||||
if (!serverVersion) {
|
||||
ret = <p>Waiting for Lean server to start...</p>
|
||||
} else if (serverStoppedResult){
|
||||
ret = <div><p>{serverStoppedResult.message}</p><p className="error">{serverStoppedResult.reason}</p></div>
|
||||
} else {
|
||||
ret = <div className="infoview vscode-light">
|
||||
{completed && <div className="level-completed">Level completed! 🎉</div>}
|
||||
<Infos />
|
||||
</div>
|
||||
/** Wrapper for the two editors. It is important that the `div` with `codeViewRef` is
|
||||
* always present, or the monaco editor cannot start.
|
||||
*/
|
||||
export function DualEditor({ level, codeviewRef, levelId, worldId, worldSize }) {
|
||||
const ec = React.useContext(EditorContext)
|
||||
const { commandLineMode } = React.useContext(InputModeContext)
|
||||
return <>
|
||||
<div className={commandLineMode ? 'hidden' : ''}>
|
||||
<ExerciseStatement data={level} />
|
||||
<div ref={codeviewRef} className={'codeview'}></div>
|
||||
</div>
|
||||
{ec ?
|
||||
<DualEditorMain worldId={worldId} levelId={levelId} level={level} worldSize={worldSize} /> :
|
||||
// TODO: Style this if relevant.
|
||||
<>
|
||||
<p>Editor is starting up...</p>
|
||||
<CircularProgress />
|
||||
</>
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfigContext.Provider value={config}>
|
||||
<VersionContext.Provider value={serverVersion}>
|
||||
<WithRpcSessions>
|
||||
<WithLspDiagnosticsContext>
|
||||
<ProgressContext.Provider value={allProgress}>
|
||||
{ret}
|
||||
</ProgressContext.Provider>
|
||||
</WithLspDiagnosticsContext>
|
||||
</WithRpcSessions>
|
||||
</VersionContext.Provider>
|
||||
</ConfigContext.Provider>
|
||||
);
|
||||
</>
|
||||
}
|
||||
|
||||
/** The part of the two editors that needs the editor connection first */
|
||||
function DualEditorMain({ worldId, levelId, level, worldSize }: { worldId: string, levelId: number, level: LevelInfo, worldSize: number }) {
|
||||
const ec = React.useContext(EditorContext)
|
||||
const gameId = React.useContext(GameIdContext)
|
||||
const { commandLineMode } = React.useContext(InputModeContext)
|
||||
|
||||
// Mark level as completed when server gives notification
|
||||
const dispatch = useAppDispatch()
|
||||
useServerNotificationEffect(
|
||||
'$/game/completed',
|
||||
(params: any) => {
|
||||
if (ec.events.changedCursorLocation.current &&
|
||||
ec.events.changedCursorLocation.current.uri === params.uri) {
|
||||
dispatch(levelCompleted({ game: gameId, world: worldId, level: levelId }))
|
||||
|
||||
// On completion, add the names of all new items to the local storage
|
||||
let newTiles = [
|
||||
...level?.tactics,
|
||||
...level?.lemmas,
|
||||
...level?.definitions
|
||||
].filter((tile) => tile.new).map((tile) => tile.name)
|
||||
|
||||
let inv: string[] = selectInventory(gameId)(store.getState())
|
||||
|
||||
// add new items and remove duplicates
|
||||
let newInv = [...inv, ...newTiles].filter((item, i, array) => array.indexOf(item) == i)
|
||||
|
||||
dispatch(changedInventory({ game: gameId, inventory: newInv }))
|
||||
}
|
||||
}, [level]
|
||||
)
|
||||
|
||||
/* Set up updates to the global infoview state on editor events. */
|
||||
const config = useEventResult(ec.events.changedInfoviewConfig) ?? defaultInfoviewConfig;
|
||||
|
||||
const [allProgress, _1] = useServerNotificationState(
|
||||
'$/lean/fileProgress',
|
||||
new Map<DocumentUri, LeanFileProgressProcessingInfo[]>(),
|
||||
async (params: LeanFileProgressParams) => (allProgress) => {
|
||||
const newProgress = new Map(allProgress);
|
||||
return newProgress.set(params.textDocument.uri, params.processing);
|
||||
}, [])
|
||||
const serverVersion = useEventResult(ec.events.serverRestarted, result => new ServerVersion(result.serverInfo?.version ?? ''))
|
||||
|
||||
return <>
|
||||
<ConfigContext.Provider value={config}>
|
||||
<VersionContext.Provider value={serverVersion}>
|
||||
<WithRpcSessions>
|
||||
<WithLspDiagnosticsContext>
|
||||
<ProgressContext.Provider value={allProgress}>
|
||||
{commandLineMode ?
|
||||
<CommandLineInterface world={worldId} level={levelId} data={level} worldSize={worldSize}/>
|
||||
:
|
||||
<Main key={`${worldId}/${levelId}`} world={worldId} level={levelId} />
|
||||
}
|
||||
</ProgressContext.Provider>
|
||||
</WithLspDiagnosticsContext>
|
||||
</WithRpcSessions>
|
||||
</VersionContext.Provider>
|
||||
</ConfigContext.Provider>
|
||||
</>
|
||||
}
|
||||
|
||||
/** The mathematical formulation of the statement, supporting e.g. Latex
|
||||
* It takes three forms, depending on the precence of name and description:
|
||||
* - Theorem xyz: description
|
||||
* - Theorem xyz
|
||||
* - Exercises: description
|
||||
*/
|
||||
function ExerciseStatement({ data }) {
|
||||
if (!data?.descrText) { return <></> }
|
||||
return <div className="exercise-statement"><Markdown>
|
||||
{(data?.statementName ? `**Theorem** \`${data?.statementName}\`: ` : data?.descrText && "**Exercise**: ") + `${data?.descrText}`}
|
||||
</Markdown></div>
|
||||
}
|
||||
|
||||
// TODO: This is only used in `EditorInterface`
|
||||
// while `CommandLineInterface` has this copy-pasted in.
|
||||
export function Main(props: { world: string, level: number }) {
|
||||
const ec = React.useContext(EditorContext);
|
||||
const gameId = React.useContext(GameIdContext)
|
||||
|
||||
const completed = useAppSelector(selectCompleted(gameId, props.world, props.level))
|
||||
|
||||
/* Set up updates to the global infoview state on editor events. */
|
||||
const config = useEventResult(ec.events.changedInfoviewConfig) ?? defaultInfoviewConfig;
|
||||
|
||||
const [allProgress, _1] = useServerNotificationState(
|
||||
'$/lean/fileProgress',
|
||||
new Map<DocumentUri, LeanFileProgressProcessingInfo[]>(),
|
||||
async (params: LeanFileProgressParams) => (allProgress) => {
|
||||
const newProgress = new Map(allProgress);
|
||||
return newProgress.set(params.textDocument.uri, params.processing);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const curUri = useEventResult(ec.events.changedCursorLocation, loc => loc?.uri);
|
||||
|
||||
useClientNotificationEffect(
|
||||
'textDocument/didClose',
|
||||
(params: DidCloseTextDocumentParams) => {
|
||||
if (ec.events.changedCursorLocation.current &&
|
||||
ec.events.changedCursorLocation.current.uri === params.textDocument.uri) {
|
||||
ec.events.changedCursorLocation.fire(undefined)
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const serverVersion =
|
||||
useEventResult(ec.events.serverRestarted, result => new ServerVersion(result.serverInfo?.version ?? ''))
|
||||
const serverStoppedResult = useEventResult(ec.events.serverStopped);
|
||||
// NB: the cursor may temporarily become `undefined` when a file is closed. In this case
|
||||
// it's important not to reconstruct the `WithBlah` wrappers below since they contain state
|
||||
// that we want to persist.
|
||||
let ret
|
||||
if (!serverVersion) {
|
||||
ret = <p>Waiting for Lean server to start...</p>
|
||||
} else if (serverStoppedResult) {
|
||||
ret = <div><p>{serverStoppedResult.message}</p><p className="error">{serverStoppedResult.reason}</p></div>
|
||||
} else {
|
||||
ret = <div className="infoview vscode-light">
|
||||
{completed && <div className="level-completed">Level completed! 🎉</div>}
|
||||
<Infos />
|
||||
</div>
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
const goalFilter = {
|
||||
reverse: false,
|
||||
showType: true,
|
||||
showInstance: true,
|
||||
showHiddenAssumption: true,
|
||||
showLetValue: true
|
||||
}
|
||||
|
||||
/** The display of a single entered lean command */
|
||||
function Command({ command, deleteProof }: { command: string, deleteProof: any }) {
|
||||
// The first step will always have an empty command
|
||||
if (!command) { return <></> }
|
||||
return <div className="command">
|
||||
<div className="command-text">{command}</div>
|
||||
<Button to="" className="undo-button btn btn-inverted" title="Delete this and future commands" onClick={deleteProof}>
|
||||
<FontAwesomeIcon icon={faDeleteLeft} /> Delete
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
|
||||
// const MessageView = React.memo(({uri, diag}: MessageViewProps) => {
|
||||
// const ec = React.useContext(EditorContext);
|
||||
// const fname = escapeHtml(basename(uri));
|
||||
// const {line, character} = diag.range.start;
|
||||
// const loc: Location = { uri, range: diag.range };
|
||||
// const text = TaggedText_stripTags(diag.message);
|
||||
// const severityClass = diag.severity ? {
|
||||
// [DiagnosticSeverity.Error]: 'error',
|
||||
// [DiagnosticSeverity.Warning]: 'warning',
|
||||
// [DiagnosticSeverity.Information]: 'information',
|
||||
// [DiagnosticSeverity.Hint]: 'hint',
|
||||
// }[diag.severity] : '';
|
||||
// const title = `Line ${line+1}, Character ${character}`;
|
||||
|
||||
// // Hide "unsolved goals" messages
|
||||
// let message;
|
||||
// if ("append" in diag.message && "text" in diag.message.append[0] &&
|
||||
// diag.message?.append[0].text === "unsolved goals") {
|
||||
// message = diag.message.append[0]
|
||||
// } else {
|
||||
// message = diag.message
|
||||
// }
|
||||
|
||||
// const { commandLineMode } = React.useContext(InputModeContext)
|
||||
|
||||
// return (
|
||||
// // <details open>
|
||||
// // <summary className={severityClass + ' mv2 pointer'}>{title}
|
||||
// // <span className="fr">
|
||||
// // <a className="link pointer mh2 dim codicon codicon-go-to-file"
|
||||
// // onClick={e => { e.preventDefault(); void ec.revealLocation(loc); }}
|
||||
// // title="reveal file location"></a>
|
||||
// // <a className="link pointer mh2 dim codicon codicon-quote"
|
||||
// // data-id="copy-to-comment"
|
||||
// // onClick={e => {e.preventDefault(); void ec.copyToComment(text)}}
|
||||
// // title="copy message to comment"></a>
|
||||
// // <a className="link pointer mh2 dim codicon codicon-clippy"
|
||||
// // onClick={e => {e.preventDefault(); void ec.api.copyToClipboard(text)}}
|
||||
// // title="copy message to clipboard"></a>
|
||||
// // </span>
|
||||
// // </summary>
|
||||
// <div className={severityClass + ' ml1 message'}>
|
||||
// {!commandLineMode && <p className="mv2">{title}</p>}
|
||||
// <pre className="font-code pre-wrap">
|
||||
// <InteractiveMessage fmt={message} />
|
||||
// </pre>
|
||||
// </div>
|
||||
// // </details>
|
||||
// )
|
||||
// }, fastIsEqual)
|
||||
|
||||
/** The tabs of goals that lean ahs after the command of this step has been processed */
|
||||
function GoalsTab({ proofStep }: { proofStep: ProofStep }) {
|
||||
const [selectedGoal, setSelectedGoal] = React.useState<number>(0)
|
||||
|
||||
if (!proofStep.goals.length) { return <></> }
|
||||
|
||||
return <div>
|
||||
<div className="tab-bar">
|
||||
{proofStep.goals.map((goal, i) => (
|
||||
// TODO: Should not use index as key.
|
||||
<div key={`proof-goal-${i}`} className={`tab ${i == (selectedGoal) ? "active" : ""}`} onClick={(ev) => { setSelectedGoal(i); ev.stopPropagation() }}>
|
||||
{i ? `Goal ${i + 1}` : "Active Goal"}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="goal-tab vscode-light">
|
||||
<Goal commandLine={false} filter={goalFilter} goal={proofStep.goals[selectedGoal]} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
/** The interface in command line mode */
|
||||
export function CommandLineInterface(props: { world: string, level: number, data: LevelInfo, worldSize: number }) {
|
||||
|
||||
const ec = React.useContext(EditorContext)
|
||||
const editor = React.useContext(MonacoEditorContext)
|
||||
const gameId = React.useContext(GameIdContext)
|
||||
const { proof } = React.useContext(ProofContext)
|
||||
const { selectedStep, setSelectedStep } = React.useContext(SelectionContext)
|
||||
const { setDeletedChat, showHelp, setShowHelp } = React.useContext(DeletedChatContext)
|
||||
|
||||
const {mobile} = React.useContext(MobileContext)
|
||||
|
||||
const proofPanelRef = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
// React.useEffect(() => {
|
||||
// console.debug('updated proof')
|
||||
// // proofPanelRef.current?.lastElementChild?.scrollIntoView() //scrollTo(0,0)
|
||||
// }, [proof])
|
||||
|
||||
/** Delete all proof lines starting from a given line.
|
||||
* Note that the first line (i.e. deleting everything) is `1`!
|
||||
*/
|
||||
function deleteProof(line: number) {
|
||||
return (ev) => {
|
||||
let deletedChat: Array<GameHint> = []
|
||||
proof.slice(line).map((step, i) => {
|
||||
// Only add these hidden hints to the deletion stack which were visible
|
||||
deletedChat = [...deletedChat, ...step.hints.filter(hint => (!hint.hidden || showHelp.has(line + i)))]
|
||||
})
|
||||
setDeletedChat(deletedChat)
|
||||
|
||||
editor.executeEdits("command-line", [{
|
||||
range: monaco.Selection.fromPositions(
|
||||
{ lineNumber: line, column: 1 },
|
||||
editor.getModel().getFullModelRange().getEndPosition()
|
||||
),
|
||||
text: '',
|
||||
forceMoveMarkers: false
|
||||
}])
|
||||
setSelectedStep(undefined)
|
||||
ev.stopPropagation()
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelectStep(line: number) {
|
||||
return (ev) => {
|
||||
if (mobile) {return}
|
||||
if (selectedStep == line) {
|
||||
setSelectedStep(undefined)
|
||||
console.debug(`unselected step`)
|
||||
} else {
|
||||
setSelectedStep(line)
|
||||
console.debug(`step ${line} selected`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll to element if selection changes
|
||||
React.useEffect(() => {
|
||||
if (typeof selectedStep !== 'undefined') {
|
||||
Array.from(proofPanelRef.current?.getElementsByClassName(`step-${selectedStep}`)).map((elem) => {
|
||||
elem.scrollIntoView({ block: "center" })
|
||||
})
|
||||
}
|
||||
}, [selectedStep])
|
||||
|
||||
const completed = useAppSelector(selectCompleted(gameId, props.world, props.level))
|
||||
|
||||
const config = useEventResult(ec.events.changedInfoviewConfig) ?? defaultInfoviewConfig;
|
||||
|
||||
const curUri = useEventResult(ec.events.changedCursorLocation, loc => loc?.uri);
|
||||
|
||||
useClientNotificationEffect(
|
||||
'textDocument/didClose',
|
||||
(params: DidCloseTextDocumentParams) => {
|
||||
if (ec.events.changedCursorLocation.current &&
|
||||
ec.events.changedCursorLocation.current.uri === params.textDocument.uri) {
|
||||
ec.events.changedCursorLocation.fire(undefined)
|
||||
}
|
||||
}, []
|
||||
)
|
||||
|
||||
const serverVersion =
|
||||
useEventResult(ec.events.serverRestarted, result => new ServerVersion(result.serverInfo?.version ?? ''))
|
||||
const serverStoppedResult = useEventResult(ec.events.serverStopped);
|
||||
// NB: the cursor may temporarily become `undefined` when a file is closed. In this case
|
||||
// it's important not to reconstruct the `WithBlah` wrappers below since they contain state
|
||||
// that we want to persist.
|
||||
|
||||
if (!serverVersion) { return <p>Waiting for Lean server to start...</p> }
|
||||
if (serverStoppedResult) {
|
||||
return <div>
|
||||
<p>{serverStoppedResult.message}</p>
|
||||
<p className="error">{serverStoppedResult.reason}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
// TODO: This about hidden hints is all copied from `level.tsx`. Can we move that into `hints.tsx`?
|
||||
|
||||
// If the last step has errors, we want to treat it as if it is part of the second-to-last step
|
||||
let k = proof.length - 1
|
||||
let withErr = hasInteractiveErrors(proof[k]?.errors) ? 1 : 0
|
||||
|
||||
const activateHiddenHints = (ev) => {
|
||||
// If the last step (`k`) has errors, we want the hidden hints from the
|
||||
// second-to-last step to be affected
|
||||
if (!(proof.length)) {return}
|
||||
|
||||
// state must not be mutated, therefore we need to clone the set
|
||||
let tmp = new Set(showHelp)
|
||||
if (tmp.has(k - withErr)) {
|
||||
tmp.delete(k - withErr)
|
||||
} else {
|
||||
tmp.add(k - withErr)
|
||||
}
|
||||
setShowHelp(tmp)
|
||||
console.debug(`help: ${Array.from(tmp.values())}`)
|
||||
}
|
||||
|
||||
function hasHiddenHints(i : number): boolean {
|
||||
let step = proof[i]
|
||||
|
||||
// For example if the proof isn't loaded yet
|
||||
if(!step) {return false}
|
||||
|
||||
return step.hints.some((hint) => hint.hidden)
|
||||
}
|
||||
|
||||
return <div className="commandline-interface">
|
||||
<div className="content" ref={proofPanelRef}>
|
||||
<ExerciseStatement data={props.data} />
|
||||
<div className="tmp-pusher"></div>
|
||||
{proof.length ?
|
||||
<>
|
||||
{proof.map((step, i) => {
|
||||
if (i == proof.length - 1 && hasInteractiveErrors(step.errors)) {
|
||||
// if the last command contains an error, we only display the errors but not the
|
||||
// entered command as it is still present in the command line.
|
||||
// TODO: Should not use index as key.
|
||||
return <div key={`proof-step-${i}`}>
|
||||
<Errors errors={step.errors} commandLineMode={true} />
|
||||
</div>
|
||||
} else {
|
||||
return <div key={`proof-step-${i}`} className={`step step-${i}` + (selectedStep == i ? ' selected' : '')} onClick={toggleSelectStep(i)}>
|
||||
<Command command={step.command} deleteProof={deleteProof(i)} />
|
||||
<Errors errors={step.errors} commandLineMode={true} />
|
||||
{mobile && i == 0 && props.data?.introduction &&
|
||||
<div className={`message information step-0${selectedStep === 0 ? ' selected' : ''}`} onClick={toggleSelectStep(0)}>
|
||||
<Markdown>{props.data?.introduction}</Markdown>
|
||||
</div>
|
||||
}
|
||||
{mobile && <>
|
||||
<Hints key={`hints-${i}`}
|
||||
hints={step.hints} showHidden={showHelp.has(i)} step={i}
|
||||
selected={selectedStep} toggleSelection={toggleSelectStep(i)}/>
|
||||
{i == proof.length - 1 && hasHiddenHints(proof.length - 1) && !showHelp.has(k - withErr) &&
|
||||
<Button className="btn btn-help" to="" onClick={activateHiddenHints}>
|
||||
Show more help!
|
||||
</Button>
|
||||
}
|
||||
</>
|
||||
}
|
||||
<GoalsTab proofStep={step} />
|
||||
{/* Show a message that there are no goals left */}
|
||||
{!step.goals.length && (
|
||||
<div className="message information">
|
||||
{completed ?
|
||||
<p>Level completed! 🎉</p> :
|
||||
<p>
|
||||
<b>no goals left</b><br />
|
||||
<i>This probably means you solved the level with warnings or Lean encountered a parsing error.</i>
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
})}
|
||||
{mobile && completed &&
|
||||
<div className="button-row">
|
||||
{props.level >= props.worldSize ?
|
||||
<Button to={`/${gameId}`}>
|
||||
<FontAwesomeIcon icon={faHome} /> Leave World
|
||||
</Button>
|
||||
:
|
||||
<Button to={`/${gameId}/world/${props.world}/level/${props.level + 1}`}>
|
||||
Next <FontAwesomeIcon icon={faArrowRight} />
|
||||
</Button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</>
|
||||
: <CircularProgress />}
|
||||
</div>
|
||||
<CommandLine proofPanelRef={proofPanelRef} />
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,22 +1,61 @@
|
||||
import * as React from 'react';
|
||||
import fastIsEqual from 'react-fast-compare';
|
||||
import { Location, DocumentUri, Diagnostic, DiagnosticSeverity, PublishDiagnosticsParams } from 'vscode-languageserver-protocol';
|
||||
import * as React from 'react'
|
||||
import fastIsEqual from 'react-fast-compare'
|
||||
import { Location, DocumentUri, Diagnostic, DiagnosticSeverity, PublishDiagnosticsParams } from 'vscode-languageserver-protocol'
|
||||
|
||||
import { LeanDiagnostic, RpcErrorCode } from '@leanprover/infoview-api';
|
||||
import { LeanDiagnostic, RpcErrorCode, getInteractiveDiagnostics, InteractiveDiagnostic, TaggedText_stripTags } from '@leanprover/infoview-api'
|
||||
|
||||
import { basename, escapeHtml, usePausableState, useEvent, addUniqueKeys, DocumentPosition, useServerNotificationState, useEventResult } from '../../../../node_modules/lean4-infoview/src/infoview/util';
|
||||
import { ConfigContext, EditorContext, LspDiagnosticsContext, VersionContext } from '../../../../node_modules/lean4-infoview/src/infoview/contexts';
|
||||
import { Details } from '../../../../node_modules/lean4-infoview/src/infoview/collapsing';
|
||||
import { InteractiveMessage } from '../../../../node_modules/lean4-infoview/src/infoview/traceExplorer';
|
||||
import { getInteractiveDiagnostics, InteractiveDiagnostic, TaggedText_stripTags } from '@leanprover/infoview-api';
|
||||
import { RpcContext, useRpcSessionAtPos } from '../../../../node_modules/lean4-infoview/src/infoview/rpcSessions';
|
||||
import { InputModeContext } from '../Level';
|
||||
import { basename, escapeHtml, usePausableState, useEvent, addUniqueKeys, DocumentPosition, useServerNotificationState, useEventResult } from '../../../../node_modules/lean4-infoview/src/infoview/util'
|
||||
import { ConfigContext, EditorContext, LspDiagnosticsContext, VersionContext } from '../../../../node_modules/lean4-infoview/src/infoview/contexts'
|
||||
import { Details } from '../../../../node_modules/lean4-infoview/src/infoview/collapsing'
|
||||
import { InteractiveMessage } from '../../../../node_modules/lean4-infoview/src/infoview/traceExplorer'
|
||||
import { RpcContext, useRpcSessionAtPos } from '../../../../node_modules/lean4-infoview/src/infoview/rpcSessions'
|
||||
|
||||
import { InputModeContext } from './context'
|
||||
|
||||
interface MessageViewProps {
|
||||
uri: DocumentUri;
|
||||
diag: InteractiveDiagnostic;
|
||||
}
|
||||
|
||||
/** A list of messages (info/warning/error) that are produced after this command */
|
||||
function Error({error, commandLineMode} : {error : InteractiveDiagnostic, commandLineMode : boolean}) {
|
||||
// The first step will always have an empty command
|
||||
|
||||
const severityClass = error.severity ? {
|
||||
[DiagnosticSeverity.Error]: 'error',
|
||||
[DiagnosticSeverity.Warning]: 'warning',
|
||||
[DiagnosticSeverity.Information]: 'information',
|
||||
[DiagnosticSeverity.Hint]: 'hint',
|
||||
}[error.severity] : '';
|
||||
|
||||
const {line, character} = error.range.start;
|
||||
const title = `Line ${line+1}, Character ${character}`;
|
||||
|
||||
// Hide "unsolved goals" messages
|
||||
let message;
|
||||
if ("append" in error.message && "text" in error.message.append[0] &&
|
||||
error.message?.append[0].text === "unsolved goals") {
|
||||
message = error.message.append[0]
|
||||
} else {
|
||||
message = error.message
|
||||
}
|
||||
|
||||
return <div className={severityClass + ' ml1 message'}>
|
||||
{!commandLineMode && <p className="mv2">{title}</p>}
|
||||
<pre className="font-code pre-wrap">
|
||||
<InteractiveMessage fmt={message} />
|
||||
</pre>
|
||||
</div>
|
||||
}
|
||||
|
||||
// TODO: Should not use index as key.
|
||||
/** A list of messages (info/warning/error) that are produced after this command */
|
||||
export function Errors ({errors, commandLineMode} : {errors : InteractiveDiagnostic[], commandLineMode : boolean}) {
|
||||
return <div>
|
||||
{errors.map((err, i) => (<Error key={`error-${i}`} error={err} commandLineMode={commandLineMode}/>))}
|
||||
</div>
|
||||
}
|
||||
|
||||
const MessageView = React.memo(({uri, diag}: MessageViewProps) => {
|
||||
const ec = React.useContext(EditorContext);
|
||||
const fname = escapeHtml(basename(uri));
|
||||
|
||||
+5
-2
@@ -1,5 +1,8 @@
|
||||
/* This file is based on `vscode-lean4/vscode-lean4/src/rpcApi.ts ` */
|
||||
|
||||
/**
|
||||
* @fileOverview Defines the interface for the communication with the server.
|
||||
*
|
||||
* This file is based on `vscode-lean4/vscode-lean4/src/rpcApi.ts`
|
||||
*/
|
||||
import { ContextInfo, FVarId, CodeWithInfos, MVarId } from '@leanprover/infoview-api';
|
||||
|
||||
export interface GameHint {
|
||||
@@ -1,13 +1,22 @@
|
||||
.inventory {
|
||||
.inventory, .documentation {
|
||||
padding: 0 1em 1em 1em;
|
||||
}
|
||||
|
||||
.inventory h2 {
|
||||
.inventory h2, .documentation h2, .documentation h1 {
|
||||
font-size: 1.5em;
|
||||
margin-top: 1em;
|
||||
margin-bottom: .2em;
|
||||
}
|
||||
|
||||
.documentation h1 {
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.documentation.hidden {
|
||||
display: none;
|
||||
transition: display 2s;
|
||||
}
|
||||
|
||||
.inventory-list {
|
||||
display: flex;
|
||||
gap: .5em;
|
||||
@@ -20,17 +29,20 @@
|
||||
padding: .1em .5em;
|
||||
}
|
||||
|
||||
.inventory .item.locked,
|
||||
.inventory .item.disabled {
|
||||
.inventory .item.locked {
|
||||
border: solid 1px #ccc;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.inventory .item.disabled {
|
||||
color: #d92c2c;
|
||||
}
|
||||
|
||||
.inventory .item.new {
|
||||
background-color: rgb(255, 242, 190);
|
||||
}
|
||||
|
||||
.inventory .item:not(.locked):not(.disabled) {
|
||||
.inventory .item:not(.locked), .inventory .item.enabled {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -55,15 +67,3 @@
|
||||
color: black;
|
||||
border-bottom: 0.3em solid var(--clr-primary);
|
||||
}
|
||||
|
||||
|
||||
.doc-panel {
|
||||
padding: 0 1em;
|
||||
}
|
||||
|
||||
.doc-panel h2 {
|
||||
font-size: 1.5em;
|
||||
margin-top: 1em;
|
||||
margin-bottom: .2em;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import './inventory.css'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faLock, faLockOpen, faBook, faHammer, faBan } from '@fortawesome/free-solid-svg-icons'
|
||||
import { GameIdContext } from '../app';
|
||||
import Markdown from './markdown';
|
||||
import { useLoadDocQuery, InventoryTile, LevelInfo, InventoryOverview, useLoadInventoryOverviewQuery } from '../state/api';
|
||||
import { selectDifficulty, selectInventory } from '../state/progress';
|
||||
import { store } from '../state/store';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
export function Inventory({levelInfo, openDoc, enableAll=false} :
|
||||
{
|
||||
levelInfo: LevelInfo|InventoryOverview,
|
||||
openDoc: (props: {name: string, type: string}) => void,
|
||||
enableAll?: boolean,
|
||||
}) {
|
||||
|
||||
return (
|
||||
<div className="inventory">
|
||||
{/* TODO: Click on Tactic: show info
|
||||
TODO: click on paste icon -> paste into command line */}
|
||||
<h2>Tactics</h2>
|
||||
{levelInfo?.tactics &&
|
||||
<InventoryList items={levelInfo?.tactics} docType="Tactic" openDoc={openDoc} enableAll={enableAll}/>
|
||||
}
|
||||
<h2>Definitions</h2>
|
||||
{levelInfo?.definitions &&
|
||||
<InventoryList items={levelInfo?.definitions} docType="Definition" openDoc={openDoc} enableAll={enableAll}/>
|
||||
}
|
||||
<h2>Lemmas</h2>
|
||||
{levelInfo?.lemmas &&
|
||||
<InventoryList items={levelInfo?.lemmas} docType="Lemma" openDoc={openDoc} defaultTab={levelInfo?.lemmaTab} level={levelInfo} enableAll={enableAll}/>
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InventoryList({items, docType, openDoc, defaultTab=null, level=undefined, enableAll=false} :
|
||||
{
|
||||
items: InventoryTile[],
|
||||
docType: string,
|
||||
openDoc(props: {name: string, type: string}): void,
|
||||
defaultTab? : string,
|
||||
level? : LevelInfo|InventoryOverview,
|
||||
enableAll?: boolean,
|
||||
}) {
|
||||
// TODO: `level` is only used in the `useEffect` below to check if a new level has
|
||||
// been loaded. Is there a better way to observe this?
|
||||
|
||||
const gameId = React.useContext(GameIdContext)
|
||||
|
||||
const difficulty = useSelector(selectDifficulty(gameId))
|
||||
|
||||
const categorySet = new Set<string>()
|
||||
for (let item of items) {
|
||||
categorySet.add(item.category)
|
||||
}
|
||||
const categories = Array.from(categorySet).sort()
|
||||
|
||||
const [tab, setTab] = useState(defaultTab)
|
||||
|
||||
// Add inventory items from local store as unlocked.
|
||||
// Items are unlocked if they are in the local store, or if the server says they should be
|
||||
// given the dependency graph. (OR-connection) (TODO: maybe add different logic for different
|
||||
// modi)
|
||||
let inv: string[] = selectInventory(gameId)(store.getState())
|
||||
let modifiedItems : InventoryTile[] = items.map(tile => inv.includes(tile.name) ? {...tile, locked: false} : tile)
|
||||
|
||||
useEffect(() => {
|
||||
// If the level specifies `LemmaTab "Nat"`, we switch to this tab on loading.
|
||||
// `defaultTab` is `null` or `undefined` otherwise, in which case we don't want to switch.
|
||||
if (defaultTab) {
|
||||
setTab(defaultTab)
|
||||
}}, [level])
|
||||
|
||||
return <>
|
||||
{categories.length > 1 &&
|
||||
<div className="tab-bar">
|
||||
{categories.map((cat) =>
|
||||
<div key={`category-${cat}`} className={`tab ${cat == (tab ?? categories[0]) ? "active": ""}`}
|
||||
onClick={() => { setTab(cat) }}>{cat}</div>)}
|
||||
</div>}
|
||||
<div className="inventory-list">
|
||||
{[...modifiedItems].sort(
|
||||
// For lemas, sort entries `available > disabled > locked`
|
||||
// otherwise alphabetically
|
||||
(x, y) => +(docType == "Lemma") * (+x.locked - +y.locked || +x.disabled - +y.disabled)
|
||||
).filter(item => ((tab ?? categories[0]) == item.category)).map((item, i) => {
|
||||
return <InventoryItem key={`${item.category}-${item.name}`}
|
||||
showDoc={() => {openDoc({name: item.name, type: docType})}}
|
||||
name={item.name} displayName={item.displayName} locked={difficulty > 0 ? item.locked : false}
|
||||
disabled={item.disabled} newly={item.new} enableAll={enableAll}/>
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
|
||||
function InventoryItem({name, displayName, locked, disabled, newly, showDoc, enableAll=false}) {
|
||||
const icon = locked ? <FontAwesomeIcon icon={faLock} /> :
|
||||
disabled ? <FontAwesomeIcon icon={faBan} /> : ""
|
||||
const className = locked ? "locked" : disabled ? "disabled" : newly ? "new" : ""
|
||||
const title = locked ? "Not unlocked yet" :
|
||||
disabled ? "Not available in this level" : ""
|
||||
|
||||
const handleClick = () => {
|
||||
if (enableAll || !locked) {
|
||||
showDoc()
|
||||
}
|
||||
}
|
||||
|
||||
return <div className={`item ${className}${enableAll ? ' enabled' : ''}`} onClick={handleClick} title={title}>{icon} {displayName}</div>
|
||||
}
|
||||
|
||||
export function Documentation({name, type, handleClose}) {
|
||||
const gameId = React.useContext(GameIdContext)
|
||||
const doc = useLoadDocQuery({game: gameId, type: type, name: name})
|
||||
|
||||
return <div className="documentation">
|
||||
<div className="codicon codicon-close modal-close" onClick={handleClose}></div>
|
||||
<h1 className="doc">{doc.data?.displayName}</h1>
|
||||
<p><code>{doc.data?.statement}</code></p>
|
||||
{/* <code>docstring: {doc.data?.docstring}</code> */}
|
||||
<Markdown>{doc.data?.content}</Markdown>
|
||||
</div>
|
||||
}
|
||||
|
||||
/** The panel (on the welcome page) showing the user's inventory with tactics, definitions, and lemmas */
|
||||
export function InventoryPanel({visible = true}) {
|
||||
const gameId = React.useContext(GameIdContext)
|
||||
const inventory = useLoadInventoryOverviewQuery({game: gameId})
|
||||
|
||||
// The inventory is overlayed by the doc entry of a clicked item
|
||||
const [inventoryDoc, setInventoryDoc] = useState<{name: string, type: string}>(null)
|
||||
// Set `inventoryDoc` to `null` to close the doc
|
||||
function closeInventoryDoc() {setInventoryDoc(null)}
|
||||
|
||||
return <div className={`column inventory-panel ${visible ? '' : 'hidden'}`}>
|
||||
{inventoryDoc ?
|
||||
<Documentation name={inventoryDoc.name} type={inventoryDoc.type} handleClose={closeInventoryDoc}/>
|
||||
:
|
||||
<Inventory levelInfo={inventory.data} openDoc={setInventoryDoc} enableAll={true}/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -91,7 +91,7 @@ div.image {
|
||||
.info {
|
||||
margin-top: 5px;
|
||||
margin-bottom: 15px;
|
||||
width: calc(100% - 20px);
|
||||
/* width: calc(100% - 20px); */
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import * as React from 'react';
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Link } from 'react-router-dom';
|
||||
import Markdown from './Markdown';
|
||||
import { useNavigate, Link } from "react-router-dom";
|
||||
|
||||
import '@fontsource/roboto/300.css';
|
||||
import '@fontsource/roboto/400.css';
|
||||
import '@fontsource/roboto/500.css';
|
||||
import '@fontsource/roboto/700.css';
|
||||
|
||||
import './LandingPage.css'
|
||||
import {PrivacyPolicyPopup} from './PrivacyPolicy'
|
||||
|
||||
import './landing_page.css'
|
||||
import coverRobo from '../assets/covers/formaloversum.png'
|
||||
import bgImage from '../assets/bg.jpg'
|
||||
|
||||
import Markdown from './markdown';
|
||||
import {PrivacyPolicyPopup} from './privacy_policy'
|
||||
|
||||
const flag = {
|
||||
'Dutch': '🇳🇱',
|
||||
'English': '🇬🇧',
|
||||
@@ -27,7 +26,6 @@ function GithubIcon({url='https://github.com'}) {
|
||||
return <div className="github-link">
|
||||
<a title="view the Lean game server on Github" href={url}>
|
||||
<svg height="24" aria-hidden="true" viewBox="0 0 16 16" version="1.1" width="24" className="">
|
||||
{/* <circle className="world-circle" cx="8" cy="8" r="8" fill="#fff"/> */}
|
||||
<path fill="#fff" d="M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A7.995 7.995 0 0 1 0 8c0-4.42 3.58-8 8-8Z"></path>
|
||||
</svg>
|
||||
</a>
|
||||
@@ -105,18 +103,6 @@ function LandingPage() {
|
||||
</header>
|
||||
<div className="game-list">
|
||||
|
||||
<GameTile
|
||||
title="Formaloversum"
|
||||
gameId="g/hhu-adam/Robo"
|
||||
intro="Erkunde das Leansche Universum mit deinem Robo, welcher dir bei der Verständigung mit den Formalosophen zur Seite steht."
|
||||
description="
|
||||
Dieses Spiel führt die Grundlagen zur Beweisführung in Lean ein und schneidet danach verschiedene Bereiche des Bachelorstudiums an.
|
||||
|
||||
(Das Spiel befindet sich noch in der Entstehungsphase.)"
|
||||
image={coverRobo}
|
||||
language="German"
|
||||
/>
|
||||
|
||||
<GameTile
|
||||
title="Natural Number Game"
|
||||
gameId="g/hhu-adam/NNG4"
|
||||
@@ -130,6 +116,18 @@ This is a good first introduction to Lean!"
|
||||
language="English"
|
||||
/>
|
||||
|
||||
<GameTile
|
||||
title="Formaloversum"
|
||||
gameId="g/hhu-adam/Robo"
|
||||
intro="Erkunde das Leansche Universum mit deinem Robo, welcher dir bei der Verständigung mit den Formalosophen zur Seite steht."
|
||||
description="
|
||||
Dieses Spiel führt die Grundlagen zur Beweisführung in Lean ein und schneidet danach verschiedene Bereiche des Bachelorstudiums an.
|
||||
|
||||
(Das Spiel befindet sich noch in der Entstehungsphase.)"
|
||||
image={coverRobo}
|
||||
language="German"
|
||||
/>
|
||||
|
||||
</div>
|
||||
<section>
|
||||
<div className="wrapper">
|
||||
@@ -155,11 +153,13 @@ This is a good first introduction to Lean!"
|
||||
a template.
|
||||
</p>
|
||||
<p>
|
||||
There will be an option to load and run games through the server
|
||||
directly by specifying a URL, but this is still in development.
|
||||
There is an option to load and run your own games direclty on the server,
|
||||
instructions are in the NNG repo. Since this is still in development we'd like to
|
||||
encourage you to contact us for support creating your own game. The documentation is
|
||||
not polished yet.
|
||||
</p>
|
||||
<p>
|
||||
To add games to this page, you should get in contact as
|
||||
To add games to this main page, you should get in contact as
|
||||
games will need to be added manually.
|
||||
</p>
|
||||
</div>
|
||||
@@ -5,6 +5,13 @@
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.level-mobile {
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
/* display: flex; */
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
@@ -24,18 +31,22 @@
|
||||
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAeCAYAAADkftS9AAAAIklEQVQoU2M4c+bMfxAGAgYYmwGrIIiDjrELjpo5aiZeMwF+yNnOs5KSvgAAAABJRU5ErkJggg==');
|
||||
}
|
||||
|
||||
.inventory-panel, .exercise-panel, .doc-panel {
|
||||
.inventory-panel, .exercise-panel, .doc-panel, .introduction-panel {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.introduction-panel, .infoview, .exercise {
|
||||
.infoview {
|
||||
padding-top: 1em;
|
||||
padding-left: 1em;
|
||||
padding-right: 1em;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.infoview, .exercise-statement {
|
||||
padding-left: .5em;
|
||||
padding-right: .5em;
|
||||
}
|
||||
|
||||
.conclusion {
|
||||
padding: 1em;
|
||||
}
|
||||
@@ -93,12 +104,12 @@ mjx-container[jax="CHTML"][display="true"] {
|
||||
|
||||
|
||||
/* Styling tables for Markdown */
|
||||
.introduction-panel table, .introduction-panel th, .introduction-panel td {
|
||||
.chat-panel table, .chat-panel th, .chat-panel td {
|
||||
/* border: 1px solid rgb(0, 0, 0, 0.12); */
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.introduction-panel th, .introduction-panel td {
|
||||
.chat-panel th, .chat-panel td {
|
||||
padding-left: .5em;
|
||||
padding-right: .5em;
|
||||
}
|
||||
@@ -155,7 +166,7 @@ td code {
|
||||
border: 1px solid rgb(230, 122, 0);
|
||||
}
|
||||
|
||||
.introduction-panel {
|
||||
.chat-panel {
|
||||
border: 1px solid rgb(192, 18, 178);
|
||||
}
|
||||
|
||||
@@ -171,3 +182,133 @@ td code {
|
||||
border: 1px solid rgb(255, 0, 0);
|
||||
margin: 1px;
|
||||
} */
|
||||
|
||||
.exercise {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chat {
|
||||
flex: 1;
|
||||
overflow-y: scroll;
|
||||
margin-left: .5em;
|
||||
margin-right: .5em;
|
||||
}
|
||||
|
||||
.chat-panel {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
}
|
||||
|
||||
.chat-panel .button-row {
|
||||
/* width:100%; */
|
||||
margin-left: .5em;
|
||||
margin-right: .5em;
|
||||
min-height: 2.5em;
|
||||
border-top: 0.1em solid #aaa;
|
||||
}
|
||||
|
||||
.chat-panel .btn {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
margin-left: .5rem;
|
||||
margin-right: .5rem;
|
||||
}
|
||||
|
||||
/* .exercise-panel {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
height: 100%;
|
||||
} */
|
||||
|
||||
.commandline-interface {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.command-line {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
.commandline-interface .content {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: scroll;
|
||||
padding: .5em;
|
||||
}
|
||||
|
||||
.exercise .step {
|
||||
/* background-color: #e6f0f4; */
|
||||
margin-top: 5px;
|
||||
margin-bottom: 5px;
|
||||
border-radius: 0.5em;
|
||||
padding: 0.5em;
|
||||
/* border: 3px dotted rgb(88, 131, 24); */
|
||||
}
|
||||
|
||||
.exercise .step .btn-help {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.chat .message {
|
||||
margin-left: .5em;
|
||||
margin-right: .5em;
|
||||
}
|
||||
|
||||
.exercise .step.selected, .chat .selected {
|
||||
/* border: 3px solid #5191d1; */
|
||||
box-shadow: .0em .0em .4em .1em var(--clr-primary);
|
||||
}
|
||||
|
||||
.introduction-panel {
|
||||
max-width: 900px;
|
||||
width: 80%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.impressum {
|
||||
background-color: #fff;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
padding: .5rem;
|
||||
}
|
||||
|
||||
.menu.dropdown {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
right: 0;
|
||||
top: 100%;
|
||||
background-color: #fff;
|
||||
z-index: 1;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||
box-shadow: -.1rem .3rem .3rem 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.menu.dropdown.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#menu-btn {
|
||||
padding: 0;
|
||||
font-size: 1.3rem;
|
||||
margin-left: .5em;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
#home-btn {
|
||||
margin-right: .5em;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.menu.dropdown .svg-inline--fa {
|
||||
width: 1.8rem;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,685 @@
|
||||
import * as React from 'react'
|
||||
import { useEffect, useState, useRef, useContext } from 'react'
|
||||
import { useSelector, useStore } from 'react-redux'
|
||||
import Split from 'react-split'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faBars, faBook, faCode, faXmark, faHome, faCircleInfo, faArrowRight, faArrowLeft } from '@fortawesome/free-solid-svg-icons'
|
||||
import { CircularProgress } from '@mui/material'
|
||||
import type { Location } from 'vscode-languageserver-protocol'
|
||||
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'
|
||||
import { AbbreviationProvider } from 'lean4web/client/src/editor/abbreviation/AbbreviationProvider'
|
||||
import { AbbreviationRewriter } from 'lean4web/client/src/editor/abbreviation/rewriter/AbbreviationRewriter'
|
||||
import { InfoProvider } from 'lean4web/client/src/editor/infoview'
|
||||
import { LeanTaskGutter } from 'lean4web/client/src/editor/taskgutter'
|
||||
import { InfoviewApi } from '@leanprover/infoview'
|
||||
import { EditorContext } from '../../../node_modules/lean4-infoview/src/infoview/contexts'
|
||||
import { EditorConnection, EditorEvents } from '../../../node_modules/lean4-infoview/src/infoview/editorConnection'
|
||||
import { EventEmitter } from '../../../node_modules/lean4-infoview/src/infoview/event'
|
||||
|
||||
import { GameIdContext } from '../app'
|
||||
import { ConnectionContext, connection, useLeanClient } from '../connection'
|
||||
import { useAppDispatch, useAppSelector } from '../hooks'
|
||||
import { useGetGameInfoQuery, useLoadLevelQuery } from '../state/api'
|
||||
import { changedSelection, codeEdited, selectCode, selectSelections, selectCompleted, helpEdited,
|
||||
selectHelp, selectDifficulty, selectInventory } from '../state/progress'
|
||||
import { store } from '../state/store'
|
||||
import { Button } from './button'
|
||||
import Markdown from './markdown'
|
||||
import {InventoryPanel} from './inventory'
|
||||
import { hasInteractiveErrors } from './infoview/command_line'
|
||||
import { DeletedChatContext, InputModeContext, MobileContext, MonacoEditorContext,
|
||||
ProofContext, ProofStep, SelectionContext, WorldLevelIdContext } from './infoview/context'
|
||||
import { DualEditor } from './infoview/main'
|
||||
import { GameHint } from './infoview/rpc_api'
|
||||
import { DeletedHints, Hints } from './hints'
|
||||
import { Impressum } from './privacy_policy'
|
||||
|
||||
import '@fontsource/roboto/300.css'
|
||||
import '@fontsource/roboto/400.css'
|
||||
import '@fontsource/roboto/500.css'
|
||||
import '@fontsource/roboto/700.css'
|
||||
import 'lean4web/client/src/editor/infoview.css'
|
||||
import 'lean4web/client/src/editor/vscode.css'
|
||||
import './level.css'
|
||||
|
||||
function Level() {
|
||||
const params = useParams()
|
||||
const levelId = parseInt(params.levelId)
|
||||
const worldId = params.worldId
|
||||
useLoadWorldFiles(worldId)
|
||||
|
||||
return <WorldLevelIdContext.Provider value={{worldId, levelId}}>
|
||||
{levelId == 0 ? <Introduction /> : <PlayableLevel />}
|
||||
</WorldLevelIdContext.Provider>
|
||||
}
|
||||
|
||||
function ChatPanel({lastLevel}) {
|
||||
const chatRef = useRef<HTMLDivElement>(null)
|
||||
const {mobile} = useContext(MobileContext)
|
||||
const gameId = useContext(GameIdContext)
|
||||
const {worldId, levelId} = useContext(WorldLevelIdContext)
|
||||
const level = useLoadLevelQuery({game: gameId, world: worldId, level: levelId})
|
||||
const {proof, setProof} = useContext(ProofContext)
|
||||
const {deletedChat, setDeletedChat, showHelp, setShowHelp} = useContext(DeletedChatContext)
|
||||
const {selectedStep, setSelectedStep} = useContext(SelectionContext)
|
||||
const completed = useAppSelector(selectCompleted(gameId, worldId, levelId))
|
||||
|
||||
// If the last step has errors, we want to treat it as if it is part of the second-to-last step
|
||||
let k = proof.length - 1
|
||||
let withErr = hasInteractiveErrors(proof[k]?.errors) ? 1 : 0
|
||||
|
||||
function toggleSelection(line: number) {
|
||||
return (ev) => {
|
||||
console.debug('toggled selection')
|
||||
if (selectedStep == line) {
|
||||
setSelectedStep(undefined)
|
||||
} else {
|
||||
setSelectedStep(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hasHiddenHints(i : number): boolean {
|
||||
let step = proof[i]
|
||||
// For example if the proof isn't loaded yet
|
||||
if(!step) {return false}
|
||||
return step.hints.some((hint) => hint.hidden)
|
||||
}
|
||||
|
||||
const activateHiddenHints = (ev) => {
|
||||
// If the last step (`k`) has errors, we want the hidden hints from the
|
||||
// second-to-last step to be affected
|
||||
if (!(proof.length)) {return}
|
||||
|
||||
// state must not be mutated, therefore we need to clone the set
|
||||
let tmp = new Set(showHelp)
|
||||
if (tmp.has(k - withErr)) {
|
||||
tmp.delete(k - withErr)
|
||||
} else {
|
||||
tmp.add(k - withErr)
|
||||
}
|
||||
setShowHelp(tmp)
|
||||
console.debug(`help: ${Array.from(tmp.values())}`)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// TODO: For some reason this is always called twice
|
||||
console.debug('scroll chat')
|
||||
if (!mobile) {
|
||||
chatRef.current!.lastElementChild?.scrollIntoView() //scrollTo(0,0)
|
||||
}
|
||||
}, [proof, showHelp])
|
||||
|
||||
// Scroll to element if selection changes
|
||||
useEffect(() => {
|
||||
if (typeof selectedStep !== 'undefined') {
|
||||
Array.from(chatRef.current?.getElementsByClassName(`step-${selectedStep}`)).map((elem) => {
|
||||
elem.scrollIntoView({block: "center"})
|
||||
})
|
||||
}
|
||||
}, [selectedStep])
|
||||
|
||||
// useEffect(() => {
|
||||
// // // Scroll to top when loading a new level
|
||||
// // // TODO: Thats the wrong behaviour probably
|
||||
// // chatRef.current!.scrollTo(0,0)
|
||||
// }, [gameId, worldId, levelId])
|
||||
|
||||
return <div className="chat-panel">
|
||||
<div ref={chatRef} className="chat">
|
||||
{level?.data?.introduction &&
|
||||
<div className={`message information step-0${selectedStep === 0 ? ' selected' : ''}`} onClick={toggleSelection(0)}>
|
||||
<Markdown>{level?.data?.introduction}</Markdown>
|
||||
</div>
|
||||
}
|
||||
{proof.map((step, i) => {
|
||||
// It the last step has errors, it will have the same hints
|
||||
// as the second-to-last step. Therefore we should not display them.
|
||||
if (!(i == proof.length - 1 && withErr)) {
|
||||
// TODO: Should not use index as key.
|
||||
return <Hints key={`hints-${i}`}
|
||||
hints={step.hints} showHidden={showHelp.has(i)} step={i}
|
||||
selected={selectedStep} toggleSelection={toggleSelection(i)}/>
|
||||
}
|
||||
})}
|
||||
<DeletedHints hints={deletedChat}/>
|
||||
{completed &&
|
||||
<>
|
||||
<div className={`message information step-${k}${selectedStep == k ? ' selected' : ''}`} onClick={toggleSelection(k)}>
|
||||
Level completed! 🎉
|
||||
</div>
|
||||
{level?.data?.conclusion?.trim() &&
|
||||
<div className={`message information step-${k}${selectedStep == k ? ' selected' : ''}`} onClick={toggleSelection(k)}>
|
||||
<Markdown>{level?.data?.conclusion}</Markdown>
|
||||
</div>
|
||||
}
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
<div className="button-row">
|
||||
{completed && (lastLevel ?
|
||||
<Button to={`/${gameId}`}>
|
||||
<FontAwesomeIcon icon={faHome} /> Leave World
|
||||
</Button> :
|
||||
<Button to={`/${gameId}/world/${worldId}/level/${levelId + 1}`}>
|
||||
Next <FontAwesomeIcon icon={faArrowRight} />
|
||||
</Button>)
|
||||
}
|
||||
{hasHiddenHints(proof.length - 1) && !showHelp.has(k - withErr) &&
|
||||
<Button to="" onClick={activateHiddenHints}>
|
||||
Show more help!
|
||||
</Button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
function ExercisePanel({codeviewRef, impressum, closeImpressum, visible=true}) {
|
||||
const gameId = React.useContext(GameIdContext)
|
||||
const {worldId, levelId} = useContext(WorldLevelIdContext)
|
||||
const level = useLoadLevelQuery({game: gameId, world: worldId, level: levelId})
|
||||
const gameInfo = useGetGameInfoQuery({game: gameId})
|
||||
return <div className={`exercise-panel ${visible ? '' : 'hidden'}`}>
|
||||
<div className="exercise">
|
||||
<DualEditor level={level?.data} codeviewRef={codeviewRef} levelId={levelId} worldId={worldId} worldSize={gameInfo.data?.worldSize[worldId]}/>
|
||||
</div>
|
||||
{impressum ? <Impressum handleClose={closeImpressum} /> : null}
|
||||
</div>
|
||||
}
|
||||
|
||||
function PlayableLevel() {
|
||||
const codeviewRef = useRef<HTMLDivElement>(null)
|
||||
const gameId = React.useContext(GameIdContext)
|
||||
const {worldId, levelId} = useContext(WorldLevelIdContext)
|
||||
const {mobile} = React.useContext(MobileContext)
|
||||
|
||||
const difficulty = useSelector(selectDifficulty(gameId))
|
||||
const initialCode = useAppSelector(selectCode(gameId, worldId, levelId))
|
||||
const initialSelections = useAppSelector(selectSelections(gameId, worldId, levelId))
|
||||
const inventory: Array<String> = useSelector(selectInventory(gameId))
|
||||
|
||||
const gameInfo = useGetGameInfoQuery({game: gameId})
|
||||
const level = useLoadLevelQuery({game: gameId, world: worldId, level: levelId})
|
||||
|
||||
// The state variables for the `ProofContext`
|
||||
const [proof, setProof] = useState<Array<ProofStep>>([])
|
||||
// When deleting the proof, we want to keep to old messages around until
|
||||
// a new proof has been entered. e.g. to consult messages coming from dead ends
|
||||
const [deletedChat, setDeletedChat] = useState<Array<GameHint>>([])
|
||||
// A set of row numbers where help is displayed
|
||||
const [showHelp, setShowHelp] = useState<Set<number>>(new Set())
|
||||
// Only for mobile layout
|
||||
const [pageNumber, setPageNumber] = useState(0)
|
||||
const [commandLineMode, setCommandLineMode] = useState(true)
|
||||
const [commandLineInput, setCommandLineInput] = useState("")
|
||||
const lastLevel = levelId >= gameInfo.data?.worldSize[worldId]
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
// impressum pop-up
|
||||
const [impressum, setImpressum] = React.useState(false)
|
||||
function closeImpressum() {setImpressum(false)}
|
||||
function toggleImpressum() {setImpressum(!impressum)}
|
||||
|
||||
// When clicking on an inventory item, the inventory is overlayed by the item's doc.
|
||||
// If this state is set to a pair `(name, type)` then the according doc will be open.
|
||||
// Set `inventoryDoc` to `null` to close the doc
|
||||
const [inventoryDoc, setInventoryDoc] = useState<{name: string, type: string}>(null)
|
||||
function closeInventoryDoc () {setInventoryDoc(null)}
|
||||
|
||||
|
||||
|
||||
const onDidChangeContent = (code) => {
|
||||
dispatch(codeEdited({game: gameId, world: worldId, level: levelId, code}))
|
||||
}
|
||||
|
||||
const onDidChangeSelection = (monacoSelections) => {
|
||||
const selections = monacoSelections.map(
|
||||
({selectionStartLineNumber, selectionStartColumn, positionLineNumber, positionColumn}) =>
|
||||
{return {selectionStartLineNumber, selectionStartColumn, positionLineNumber, positionColumn}})
|
||||
dispatch(changedSelection({game: gameId, world: worldId, level: levelId, selections}))
|
||||
}
|
||||
|
||||
|
||||
const {editor, infoProvider, editorConnection} =
|
||||
useLevelEditor(codeviewRef, initialCode, initialSelections, onDidChangeContent, onDidChangeSelection)
|
||||
|
||||
/** Unused. Was implementing an undo button, which has been replaced by `deleteProof` inside
|
||||
* `CommandLineInterface`.
|
||||
*/
|
||||
const handleUndo = () => {
|
||||
const endPos = editor.getModel().getFullModelRange().getEndPosition()
|
||||
let range
|
||||
console.log(endPos.column)
|
||||
if (endPos.column === 1) {
|
||||
range = monaco.Selection.fromPositions(
|
||||
new monaco.Position(endPos.lineNumber - 1, 1),
|
||||
endPos
|
||||
)
|
||||
} else {
|
||||
range = monaco.Selection.fromPositions(
|
||||
new monaco.Position(endPos.lineNumber, 1),
|
||||
endPos
|
||||
)
|
||||
}
|
||||
editor.executeEdits("undo-button", [{
|
||||
range,
|
||||
text: "",
|
||||
forceMoveMarkers: false
|
||||
}]);
|
||||
}
|
||||
|
||||
// Select and highlight proof steps and corresponding hints
|
||||
// TODO: with the new design, there is no difference between the introduction and
|
||||
// a hint at the beginning of the proof...
|
||||
const [selectedStep, setSelectedStep] = useState<number>()
|
||||
|
||||
// if the user inventory changes, notify the server
|
||||
useEffect(() => {
|
||||
let leanClient = connection.getLeanClient(gameId)
|
||||
leanClient.sendNotification('$/game/setInventory', {inventory: inventory, checkEnabled: difficulty > 0})
|
||||
}, [inventory])
|
||||
|
||||
useEffect(() => {
|
||||
// TODO: That's a problem if the saved proof contains an error
|
||||
// Reset command line input when loading a new level
|
||||
setCommandLineInput("")
|
||||
|
||||
// Load the selected help steps from the store
|
||||
setShowHelp(new Set(selectHelp(gameId, worldId, levelId)(store.getState())))
|
||||
}, [gameId, worldId, levelId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!commandLineMode) {
|
||||
// Delete last input attempt from command line
|
||||
editor.executeEdits("command-line", [{
|
||||
range: editor.getSelection(),
|
||||
text: "",
|
||||
forceMoveMarkers: false
|
||||
}]);
|
||||
editor.focus()
|
||||
}
|
||||
}, [commandLineMode])
|
||||
|
||||
useEffect(() => {
|
||||
// Forget whether hidden hints are displayed for steps that don't exist yet
|
||||
if (proof.length) {
|
||||
console.debug(Array.from(showHelp))
|
||||
setShowHelp(new Set(Array.from(showHelp).filter(i => (i < proof.length))))
|
||||
}
|
||||
}, [proof])
|
||||
|
||||
// save showed help in store
|
||||
useEffect(() => {
|
||||
if (proof.length) {
|
||||
console.debug(`showHelp:\n ${showHelp}`)
|
||||
dispatch(helpEdited({game: gameId, world: worldId, level: levelId, help: Array.from(showHelp)}))
|
||||
}
|
||||
}, [showHelp])
|
||||
|
||||
// Effect when command line mode gets enabled
|
||||
useEffect(() => {
|
||||
if (editor && commandLineMode) {
|
||||
let endPos = editor.getModel().getFullModelRange().getEndPosition()
|
||||
if (editor.getModel().getLineContent(endPos.lineNumber).trim() !== "") {
|
||||
editor.executeEdits("command-line", [{
|
||||
range: monaco.Selection.fromPositions(endPos, endPos),
|
||||
text: "\n",
|
||||
forceMoveMarkers: true
|
||||
}]);
|
||||
}
|
||||
endPos = editor.getModel().getFullModelRange().getEndPosition()
|
||||
let currPos = editor.getPosition()
|
||||
if (currPos.column != 1 || (currPos.lineNumber != endPos.lineNumber && currPos.lineNumber != endPos.lineNumber - 1)) {
|
||||
// This is not a position that would naturally occur from CommandLine, reset:
|
||||
editor.setSelection(monaco.Selection.fromPositions(endPos, endPos))
|
||||
}
|
||||
}
|
||||
}, [editor, commandLineMode])
|
||||
|
||||
return <>
|
||||
<div style={level.isLoading ? null : {display: "none"}} className="app-content loading"><CircularProgress /></div>
|
||||
<DeletedChatContext.Provider value={{deletedChat, setDeletedChat, showHelp, setShowHelp}}>
|
||||
<SelectionContext.Provider value={{selectedStep, setSelectedStep}}>
|
||||
<InputModeContext.Provider value={{commandLineMode, setCommandLineMode, commandLineInput, setCommandLineInput}}>
|
||||
<ProofContext.Provider value={{proof, setProof}}>
|
||||
<EditorContext.Provider value={editorConnection}>
|
||||
<MonacoEditorContext.Provider value={editor}>
|
||||
<LevelAppBar
|
||||
isLoading={level.isLoading}
|
||||
levelTitle={`${mobile ? '' : 'Level '}${levelId} / ${gameInfo.data?.worldSize[worldId]}` +
|
||||
(level?.data?.title && ` : ${level?.data?.title}`)}
|
||||
toggleImpressum={toggleImpressum}
|
||||
pageNumber={pageNumber} setPageNumber={setPageNumber} />
|
||||
{mobile?
|
||||
// TODO: This is copied from the `Split` component below...
|
||||
<>
|
||||
<div className={`app-content level-mobile ${level.isLoading ? 'hidden' : ''}`}>
|
||||
<ExercisePanel
|
||||
impressum={impressum}
|
||||
closeImpressum={closeImpressum}
|
||||
codeviewRef={codeviewRef}
|
||||
visible={pageNumber == 0} />
|
||||
<InventoryPanel visible={pageNumber == 1} />
|
||||
</div>
|
||||
</>
|
||||
:
|
||||
<Split minSize={0} snapOffset={200} sizes={[25, 50, 25]} className={`app-content level ${level.isLoading ? 'hidden' : ''}`}>
|
||||
<ChatPanel lastLevel={lastLevel}/>
|
||||
<ExercisePanel
|
||||
impressum={impressum}
|
||||
closeImpressum={closeImpressum}
|
||||
codeviewRef={codeviewRef} />
|
||||
<InventoryPanel />
|
||||
</Split>
|
||||
}
|
||||
</MonacoEditorContext.Provider>
|
||||
</EditorContext.Provider>
|
||||
</ProofContext.Provider>
|
||||
</InputModeContext.Provider>
|
||||
</SelectionContext.Provider>
|
||||
</DeletedChatContext.Provider>
|
||||
</>
|
||||
}
|
||||
|
||||
export default Level
|
||||
|
||||
/** The site with the introduction text of a world */
|
||||
function Introduction() {
|
||||
const gameId = React.useContext(GameIdContext)
|
||||
const {worldId} = useContext(WorldLevelIdContext)
|
||||
|
||||
const gameInfo = useGetGameInfoQuery({game: gameId})
|
||||
|
||||
const [impressum, setImpressum] = React.useState(false)
|
||||
|
||||
const closeImpressum = () => {
|
||||
setImpressum(false)
|
||||
}
|
||||
|
||||
const toggleImpressum = () => {
|
||||
setImpressum(!impressum)
|
||||
}
|
||||
|
||||
return <>
|
||||
<div style={gameInfo.isLoading ? null : {display: "none"}} className="app-content loading"><CircularProgress /></div>
|
||||
<LevelAppBar isLoading={gameInfo.isLoading} levelTitle="Introduction" toggleImpressum={toggleImpressum}/>
|
||||
<div style={gameInfo.isLoading ? {display: "none"} : null} className="introduction-panel">
|
||||
<div className="content-wrapper">
|
||||
<Markdown>
|
||||
{gameInfo.data?.worlds.nodes[worldId].introduction}
|
||||
</Markdown>
|
||||
{impressum ? <Impressum handleClose={closeImpressum} /> : null}
|
||||
</div>
|
||||
{gameInfo.data?.worldSize[worldId] == 0 ?
|
||||
<Button to={`/${gameId}`}><FontAwesomeIcon icon={faHome} /></Button> :
|
||||
<Button to={`/${gameId}/world/${worldId}/level/1`}>
|
||||
Start <FontAwesomeIcon icon={faArrowRight} />
|
||||
</Button>
|
||||
}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
|
||||
/** The top-navigation bar */
|
||||
function LevelAppBar({isLoading, levelTitle, toggleImpressum, pageNumber = undefined, setPageNumber = undefined}) {
|
||||
const gameId = React.useContext(GameIdContext)
|
||||
const {worldId, levelId} = useContext(WorldLevelIdContext)
|
||||
const gameInfo = useGetGameInfoQuery({game: gameId})
|
||||
|
||||
const {mobile} = React.useContext(MobileContext)
|
||||
|
||||
const difficulty = useSelector(selectDifficulty(gameId))
|
||||
const completed = useAppSelector(selectCompleted(gameId, worldId, levelId))
|
||||
|
||||
const { commandLineMode, setCommandLineMode } = React.useContext(InputModeContext)
|
||||
|
||||
const [navOpen, setNavOpen] = React.useState(false)
|
||||
|
||||
return <div className="app-bar" style={isLoading ? {display: "none"} : null} >
|
||||
{mobile ?
|
||||
<>
|
||||
<div>
|
||||
<span className="app-bar-title">
|
||||
{levelTitle}
|
||||
</span>
|
||||
</div>
|
||||
<div className="nav-btns">
|
||||
{mobile && pageNumber == 0 ?
|
||||
<Button to=""
|
||||
title="show inventory" inverted="true" onClick={() => {setPageNumber(1)}}>
|
||||
<FontAwesomeIcon icon={faBook}/>
|
||||
</Button>
|
||||
: pageNumber == 1 &&
|
||||
<Button to=""
|
||||
title="show inventory" inverted="true" onClick={() => {setPageNumber(0)}}>
|
||||
<FontAwesomeIcon icon={faArrowLeft}/>
|
||||
</Button>
|
||||
}
|
||||
<Button to="" id="menu-btn" onClick={(ev) => {setNavOpen(!navOpen)}} >
|
||||
{navOpen ? <FontAwesomeIcon icon={faXmark} /> : <FontAwesomeIcon icon={faBars} />}
|
||||
</Button>
|
||||
</div>
|
||||
<div className={'menu dropdown' + (navOpen ? '' : ' hidden')}>
|
||||
{levelId < gameInfo.data?.worldSize[worldId] &&
|
||||
<Button inverted="true"
|
||||
to={`/${gameId}/world/${worldId}/level/${levelId + 1}`} title="next level"
|
||||
disabled={difficulty >= 2 && !(completed || levelId == 0)}
|
||||
onClick={() => setNavOpen(false)}>
|
||||
<FontAwesomeIcon icon={faArrowRight} /> {levelId ? "Next" : "Start"}
|
||||
</Button>
|
||||
}
|
||||
{levelId > 0 && <>
|
||||
<Button disabled={levelId <= 0} inverted="true"
|
||||
to={`/${gameId}/world/${worldId}/level/${levelId - 1}`}
|
||||
title="previous level"
|
||||
onClick={() => setNavOpen(false)}>
|
||||
<FontAwesomeIcon icon={faArrowLeft} /> Previous
|
||||
</Button>
|
||||
</>}
|
||||
<Button to={`/${gameId}`} inverted="true" title="back to world selection">
|
||||
<FontAwesomeIcon icon={faHome} /> Home
|
||||
</Button>
|
||||
<Button disabled={levelId <= 0} inverted="true" to=""
|
||||
onClick={(ev) => { setCommandLineMode(!commandLineMode); setNavOpen(false) }}
|
||||
title="toggle Editor mode">
|
||||
<FontAwesomeIcon icon={faCode} /> Toggle Editor
|
||||
</Button>
|
||||
<Button title="information, Impressum, privacy policy" inverted="true" to="" onClick={(ev) => {toggleImpressum(ev); setNavOpen(false)}}>
|
||||
<FontAwesomeIcon icon={faCircleInfo} /> Info & Impressum
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
:
|
||||
<>
|
||||
<div>
|
||||
<Button to={`/${gameId}`} inverted="true" title="back to world selection" id="home-btn">
|
||||
<FontAwesomeIcon icon={faHome} />
|
||||
</Button>
|
||||
<span className="app-bar-title">
|
||||
{gameInfo.data?.worlds.nodes[worldId].title && `World: ${gameInfo.data?.worlds.nodes[worldId].title}`}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="app-bar-title">
|
||||
{levelTitle}
|
||||
</span>
|
||||
</div>
|
||||
<div className="nav-btns">
|
||||
{levelId > 0 && <>
|
||||
<Button disabled={levelId <= 0} inverted="true"
|
||||
to={`/${gameId}/world/${worldId}/level/${levelId - 1}`}
|
||||
title="previous level"
|
||||
onClick={() => setNavOpen(false)}>
|
||||
<FontAwesomeIcon icon={faArrowLeft} /> Previous
|
||||
</Button>
|
||||
</>}
|
||||
{levelId < gameInfo.data?.worldSize[worldId] &&
|
||||
<Button inverted="true"
|
||||
to={`/${gameId}/world/${worldId}/level/${levelId + 1}`} title="next level"
|
||||
disabled={difficulty >= 2 && !(completed || levelId == 0)}
|
||||
onClick={() => setNavOpen(false)}>
|
||||
<FontAwesomeIcon icon={faArrowRight} /> {levelId ? "Next" : "Start"}
|
||||
</Button>
|
||||
}
|
||||
<Button disabled={levelId <= 0} inverted="true" to=""
|
||||
onClick={(ev) => { setCommandLineMode(!commandLineMode); setNavOpen(false) }}
|
||||
title="toggle Editor mode">
|
||||
<FontAwesomeIcon icon={faCode} />
|
||||
</Button>
|
||||
<Button title="information, Impressum, privacy policy" inverted="true" to="" onClick={(ev) => {toggleImpressum(ev); setNavOpen(false)}}>
|
||||
<FontAwesomeIcon icon={faCircleInfo} />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
function useLevelEditor(codeviewRef, initialCode, initialSelections, onDidChangeContent, onDidChangeSelection) {
|
||||
|
||||
const connection = React.useContext(ConnectionContext)
|
||||
const gameId = React.useContext(GameIdContext)
|
||||
const {worldId, levelId} = useContext(WorldLevelIdContext)
|
||||
|
||||
|
||||
const [editor, setEditor] = useState<monaco.editor.IStandaloneCodeEditor|null>(null)
|
||||
const [infoProvider, setInfoProvider] = useState<null|InfoProvider>(null)
|
||||
const [infoviewApi, setInfoviewApi] = useState<null|InfoviewApi>(null)
|
||||
const [editorConnection, setEditorConnection] = useState<null|EditorConnection>(null)
|
||||
|
||||
// Create Editor
|
||||
useEffect(() => {
|
||||
const editor = monaco.editor.create(codeviewRef.current!, {
|
||||
glyphMargin: true,
|
||||
quickSuggestions: false,
|
||||
lightbulb: {
|
||||
enabled: true
|
||||
},
|
||||
unicodeHighlight: {
|
||||
ambiguousCharacters: false,
|
||||
},
|
||||
automaticLayout: true,
|
||||
minimap: {
|
||||
enabled: false
|
||||
},
|
||||
lineNumbersMinChars: 3,
|
||||
'semanticHighlighting.enabled': true,
|
||||
theme: 'vs-code-theme-converted'
|
||||
})
|
||||
|
||||
const infoProvider = new InfoProvider(connection.getLeanClient(gameId))
|
||||
|
||||
const editorApi = infoProvider.getApi()
|
||||
|
||||
const editorEvents: EditorEvents = {
|
||||
initialize: new EventEmitter(),
|
||||
gotServerNotification: new EventEmitter(),
|
||||
sentClientNotification: new EventEmitter(),
|
||||
serverRestarted: new EventEmitter(),
|
||||
serverStopped: new EventEmitter(),
|
||||
changedCursorLocation: new EventEmitter(),
|
||||
changedInfoviewConfig: new EventEmitter(),
|
||||
runTestScript: new EventEmitter(),
|
||||
requestedAction: new EventEmitter(),
|
||||
};
|
||||
|
||||
// Challenge: write a type-correct fn from `Eventify<T>` to `T` without using `any`
|
||||
const infoviewApi: InfoviewApi = {
|
||||
initialize: async l => editorEvents.initialize.fire(l),
|
||||
gotServerNotification: async (method, params) => {
|
||||
editorEvents.gotServerNotification.fire([method, params]);
|
||||
},
|
||||
sentClientNotification: async (method, params) => {
|
||||
editorEvents.sentClientNotification.fire([method, params]);
|
||||
},
|
||||
serverRestarted: async r => editorEvents.serverRestarted.fire(r),
|
||||
serverStopped: async serverStoppedReason => {
|
||||
editorEvents.serverStopped.fire(serverStoppedReason)
|
||||
},
|
||||
changedCursorLocation: async loc => editorEvents.changedCursorLocation.fire(loc),
|
||||
changedInfoviewConfig: async conf => editorEvents.changedInfoviewConfig.fire(conf),
|
||||
requestedAction: async action => editorEvents.requestedAction.fire(action),
|
||||
// See https://rollupjs.org/guide/en/#avoiding-eval
|
||||
// eslint-disable-next-line @typescript-eslint/no-implied-eval
|
||||
runTestScript: async script => new Function(script)(),
|
||||
getInfoviewHtml: async () => document.body.innerHTML,
|
||||
};
|
||||
|
||||
const ec = new EditorConnection(editorApi, editorEvents);
|
||||
setEditorConnection(ec)
|
||||
|
||||
editorEvents.initialize.on((loc: Location) => ec.events.changedCursorLocation.fire(loc))
|
||||
|
||||
setEditor(editor)
|
||||
setInfoProvider(infoProvider)
|
||||
setInfoviewApi(infoviewApi)
|
||||
|
||||
return () => { infoProvider.dispose(); editor.dispose() }
|
||||
}, [])
|
||||
|
||||
const {leanClient, leanClientStarted} = useLeanClient(gameId)
|
||||
|
||||
const uri = monaco.Uri.parse(`file:///${worldId}/${levelId}`)
|
||||
|
||||
// Create model when level changes
|
||||
useEffect(() => {
|
||||
if (editor && leanClientStarted) {
|
||||
|
||||
let model = monaco.editor.getModel(uri)
|
||||
if (!model) {
|
||||
model = monaco.editor.createModel(initialCode, 'lean4', uri)
|
||||
}
|
||||
model.onDidChangeContent(() => onDidChangeContent(model.getValue()))
|
||||
editor.onDidChangeCursorSelection(() => onDidChangeSelection(editor.getSelections()))
|
||||
editor.setModel(model)
|
||||
if (initialSelections) {
|
||||
console.debug("Initial Selection: ", initialSelections)
|
||||
// BUG: Somehow I get an `invalid arguments` bug here
|
||||
// editor.setSelections(initialSelections)
|
||||
}
|
||||
}
|
||||
}, [editor, levelId, connection, leanClientStarted])
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (editor && leanClientStarted) {
|
||||
|
||||
let model = monaco.editor.getModel(uri)
|
||||
infoviewApi.serverRestarted(leanClient.initializeResult)
|
||||
|
||||
infoProvider.openPreview(editor, infoviewApi)
|
||||
|
||||
const taskGutter = new LeanTaskGutter(infoProvider.client, editor)
|
||||
const abbrevRewriter = new AbbreviationRewriter(new AbbreviationProvider(), model, editor)
|
||||
|
||||
return () => { abbrevRewriter.dispose(); taskGutter.dispose(); }
|
||||
}
|
||||
}, [editor, connection, leanClientStarted])
|
||||
|
||||
return {editor, infoProvider, editorConnection}
|
||||
}
|
||||
|
||||
/** Open all files in this world on the server so that they will load faster when accessed */
|
||||
function useLoadWorldFiles(worldId) {
|
||||
const gameId = React.useContext(GameIdContext)
|
||||
const gameInfo = useGetGameInfoQuery({game: gameId})
|
||||
const store = useStore()
|
||||
|
||||
useEffect(() => {
|
||||
if (gameInfo.data) {
|
||||
const models = []
|
||||
for (let levelId = 1; levelId <= gameInfo.data.worldSize[worldId]; levelId++) {
|
||||
const uri = monaco.Uri.parse(`file:///${worldId}/${levelId}`)
|
||||
let model = monaco.editor.getModel(uri)
|
||||
if (model) {
|
||||
models.push(model)
|
||||
} else {
|
||||
const code = selectCode(gameId, worldId, levelId)(store.getState())
|
||||
models.push(monaco.editor.createModel(code, 'lean4', uri))
|
||||
}
|
||||
}
|
||||
return () => { for (let model of models) { model.dispose() } }
|
||||
}
|
||||
}, [gameInfo.data, worldId])
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import '@fontsource/roboto/500.css';
|
||||
import '@fontsource/roboto/700.css';
|
||||
|
||||
import { Button, Dialog, DialogContent, DialogContentText, DialogActions } from '@mui/material';
|
||||
import Markdown from './Markdown';
|
||||
import Markdown from './markdown';
|
||||
|
||||
function Message({ isOpen, content, close }) {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* @fileOverview The impressum/privacy policy
|
||||
*/
|
||||
import { faShield } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import * as React from 'react'
|
||||
|
||||
/** Pop-up that is displayed when opening the privacy policy.
|
||||
*
|
||||
* `handleClose` is the function to close it again because it's open/closed state is
|
||||
* controlled by the containing element.
|
||||
*/
|
||||
export function PrivacyPolicyPopup ({handleClose}: {handleClose: () => void}) {
|
||||
return <div className="modal-wrapper">
|
||||
<div className="modal-backdrop" onClick={handleClose} />
|
||||
<div className="modal">
|
||||
<div className="codicon codicon-close modal-close" onClick={handleClose}></div>
|
||||
<h2>Privacy Policy & Impressum</h2>
|
||||
<p>
|
||||
Our server collects metadata (such as IP address, browser, operating system)
|
||||
and the data that the user enters into the editor. The data is used to
|
||||
compute the Lean output and display it to the user. The information will be stored
|
||||
as long as the user stays on our website and will be deleted immediately afterwards.
|
||||
We keep logs to improve our software, but the contained data is anonymized.
|
||||
</p>
|
||||
<p>
|
||||
We do not use cookies, but your game progress is stored in the browser
|
||||
as site data. Your game progress is not saved on the server; if you delete
|
||||
your browser storage, it is completely gone.
|
||||
</p>
|
||||
<p>Our server is located in Germany.</p>
|
||||
<p>
|
||||
<strong>Contact information:</strong><br />
|
||||
Jon Eugster<br />
|
||||
Mathematisches Institut der Heinrich-Heine-Universität Düsseldorf<br />
|
||||
Universitätsstr. 1<br />
|
||||
40225 Düsseldorf<br />
|
||||
Germany<br />
|
||||
+49 211 81-12173<br />
|
||||
<a href="https://www.math.hhu.de/en/lehrstuehle-/-personen-/-ansprechpartner/innen/lehrstuehle-des-mathematischen-instituts/lehrstuhl-fuer-algebraische-geometrie/team/jon-eugster">Contact Details</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
export const PrivacyPolicy: React.FC = () => {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const handleOpen = () => setOpen(true)
|
||||
const handleClose = () => setOpen(false)
|
||||
return (
|
||||
<>
|
||||
<div className="privacy" onClick={handleOpen} title="Privacy Policy & Impressum">
|
||||
<FontAwesomeIcon icon={faShield} />
|
||||
<p className="p1">legal</p>
|
||||
<p className="p2">notes</p>
|
||||
</div>
|
||||
{open ? <PrivacyPolicyPopup handleClose={handleClose} /> : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function Impressum({handleClose}) {
|
||||
return <div className="impressum">
|
||||
<div className="codicon codicon-close modal-close" onClick={handleClose}></div>
|
||||
<h2>Funding</h2>
|
||||
<p>
|
||||
This Lean game engine has been developed as part of the
|
||||
project <a href="https://hhu-adam.github.io/" target="_blank">ADAM: Anticipating the Digital
|
||||
Age of Mathematics</a> at
|
||||
Heinrich-Heine-Universität Düsseldorf. It is funded by
|
||||
the <i>Stiftung Innovation in der Hochschullehre</i> as part of project <i>Freiraum 2022</i>.
|
||||
</p>
|
||||
|
||||
<h2>Development</h2>
|
||||
<p>
|
||||
The source code is <a href="https://github.com/leanprover-community/lean4game" target="_blank">available on Github</a>.
|
||||
If you experience any problems, please
|
||||
file an <a href="https://github.com/leanprover-community/lean4game/issues" target="_blank">Issue on Github</a> or
|
||||
get directly in contact.
|
||||
</p>
|
||||
<h2>Privacy Policy & Impressum</h2>
|
||||
<p>
|
||||
Our server collects metadata (such as IP address, browser, operating system)
|
||||
and the data that the user enters into the editor. The data is used to
|
||||
compute the Lean output and display it to the user. The information will be stored
|
||||
as long as the user stays on our website and will be deleted immediately afterwards.
|
||||
We keep logs to improve our software, but the contained data is anonymised.
|
||||
</p>
|
||||
<p>
|
||||
We do not use cookies, but your game progress is stored in the browser storage
|
||||
as site data. Your game progress is not saved on the server; if you delete
|
||||
your browser storage, it is completely gone.
|
||||
</p>
|
||||
<p>Our server is located in Germany.</p>
|
||||
<p>
|
||||
<strong>Contact information:</strong><br />
|
||||
Jon Eugster<br />
|
||||
Mathematisches Institut der Heinrich-Heine-Universität Düsseldorf<br />
|
||||
Universitätsstr. 1<br />
|
||||
40225 Düsseldorf<br />
|
||||
Germany<br />
|
||||
+49 211 81-12173<br />
|
||||
<a href="https://www.math.hhu.de/en/lehrstuehle-/-personen-/-ansprechpartner/innen/lehrstuehle-des-mathematischen-instituts/lehrstuhl-fuer-algebraische-geometrie/team/jon-eugster">Contact Details</a>
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
@@ -17,10 +17,13 @@
|
||||
.welcome .column {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.welcome-text {
|
||||
padding: 20px;
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
i {
|
||||
@@ -54,16 +57,16 @@ h5, h6 {
|
||||
/* SVG Graphic */
|
||||
/***************/
|
||||
|
||||
svg .world-title-wrapper {
|
||||
overflow: auto;
|
||||
svg .world-title-wrapper, svg .level-title-wrapper div {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
svg .world-title-wrapper div {
|
||||
svg .world-title-wrapper div, svg .level-title-wrapper div {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
svg .world-title-wrapper div {
|
||||
svg .world-title-wrapper div, svg .level-title-wrapper div {
|
||||
display: flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
@@ -71,11 +74,36 @@ svg .world-title-wrapper div {
|
||||
|
||||
}
|
||||
|
||||
svg .world-title {
|
||||
font-weight: 500;
|
||||
svg .world-title, svg .level-title {
|
||||
color: white;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
svg .world-title {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
svg .level-title {
|
||||
font-weight: 400;
|
||||
opacity: 0;
|
||||
transition: opacity .3s;
|
||||
}
|
||||
|
||||
svg .level:hover .level-title {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
svg .disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.world-selection {
|
||||
display: block;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
margin-top: 2em;
|
||||
}
|
||||
|
||||
/******************/
|
||||
@@ -87,7 +115,7 @@ svg .world-title {
|
||||
height: 40px;
|
||||
font-size: 25px;
|
||||
border-radius: 20px;
|
||||
position: absolute;
|
||||
position: fixed;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
display: flex;
|
||||
@@ -196,21 +224,41 @@ svg .world-title {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.game-menu {
|
||||
padding: .5em;
|
||||
}
|
||||
|
||||
.game-menu .btn {
|
||||
min-width: 5em;
|
||||
text-align: center;
|
||||
margin-left: .4em;
|
||||
margin-right: .4em;
|
||||
margin-bottom: .2em;
|
||||
}
|
||||
|
||||
.modal a.download-link {
|
||||
cursor: pointer;
|
||||
font-style: italic;
|
||||
text-decoration: underline dotted;
|
||||
|
||||
}
|
||||
|
||||
.mobile-nav {
|
||||
padding-top: .5em;
|
||||
padding-bottom: .5em;
|
||||
position:relative;
|
||||
height: 3em;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mobile-nav .btn-next, .mobile-nav .btn-previous {
|
||||
position: absolute;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mobile-nav .btn-previous {
|
||||
left: 0;
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
padding-left: 0.3rem;
|
||||
}
|
||||
|
||||
.mobile-nav .btn-next {
|
||||
right: 0;
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
padding-right: .3rem;
|
||||
}
|
||||
|
||||
.mobile-nav .svg-inline--fa {
|
||||
margin-left: 0.3rem;
|
||||
margin-right: 0.3rem;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import * as React from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
import Split from 'react-split'
|
||||
import { Box, Typography, CircularProgress } from '@mui/material'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faGlobe, faBook, faArrowRight, faArrowLeft } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
import { GameIdContext } from '../app'
|
||||
import { useAppDispatch } from '../hooks'
|
||||
import { changedOpenedIntro, selectOpenedIntro } from '../state/progress'
|
||||
import { useGetGameInfoQuery } from '../state/api'
|
||||
import { Button } from './button'
|
||||
import { MobileContext } from './infoview/context'
|
||||
import { InventoryPanel } from './inventory'
|
||||
import Markdown from './markdown'
|
||||
import {PrivacyPolicy} from './privacy_policy'
|
||||
import { WelcomeMenu, WorldTreePanel } from './world_tree'
|
||||
|
||||
import './welcome.css'
|
||||
|
||||
/** navigation to switch between pages on mobile */
|
||||
function MobileNav({pageNumber, setPageNumber}:
|
||||
{ pageNumber: number,
|
||||
setPageNumber: any}) {
|
||||
const gameId = React.useContext(GameIdContext)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
let prevText = {0 : null, 1: "Intro", 2: "Game"}[pageNumber]
|
||||
let prevIcon = {0 : faGlobe, 1: null, 2: null}[pageNumber]
|
||||
let prevTitle = {
|
||||
0: "back to games selection",
|
||||
1: "back to introduction",
|
||||
2: "game tree"}[pageNumber]
|
||||
let nextText = {0 : "Game", 1: null, 2: null}[pageNumber]
|
||||
let nextIcon = {0 : null, 1: faBook, 2: null}[pageNumber]
|
||||
let nextTitle = {
|
||||
0: "game tree",
|
||||
1: "inventory",
|
||||
2: null}[pageNumber]
|
||||
|
||||
return <div className="mobile-nav">
|
||||
{(prevText || prevTitle || prevIcon) &&
|
||||
<Button className="btn btn-previous" to={pageNumber == 0 ? "/" : ""} title={prevTitle}
|
||||
onClick={() => {pageNumber == 0 ? null : setPageNumber(pageNumber - 1)}}>
|
||||
|
||||
<FontAwesomeIcon icon={faArrowLeft} />
|
||||
{prevIcon && <FontAwesomeIcon icon={prevIcon} />}
|
||||
{prevText && `${prevText}`}
|
||||
</Button>
|
||||
}
|
||||
{(nextText || nextTitle || nextIcon) &&
|
||||
<Button className="btn btn-next" to=""
|
||||
title={nextTitle} onClick={() => {
|
||||
console.log(`page number: ${pageNumber}`)
|
||||
setPageNumber(pageNumber+1);
|
||||
dispatch(changedOpenedIntro({game: gameId, openedIntro: true}))}}>
|
||||
{nextText && `${nextText}`}
|
||||
{nextIcon && <FontAwesomeIcon icon={nextIcon} />}
|
||||
<FontAwesomeIcon icon={faArrowRight}/>
|
||||
</Button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
/** The panel showing the game's introduction text */
|
||||
function IntroductionPanel({introduction}: { introduction: string}) {
|
||||
const {mobile} = React.useContext(MobileContext)
|
||||
return <div className="column">
|
||||
<Typography variant="body1" component="div" className="welcome-text">
|
||||
{!mobile && <WelcomeMenu />}
|
||||
<Markdown>{introduction}</Markdown>
|
||||
</Typography>
|
||||
</div>
|
||||
}
|
||||
|
||||
/** main page of the game showing amoung others the tree of worlds/levels */
|
||||
function Welcome() {
|
||||
const gameId = React.useContext(GameIdContext)
|
||||
const {mobile} = React.useContext(MobileContext)
|
||||
const gameInfo = useGetGameInfoQuery({game: gameId})
|
||||
// On mobile, the intro page should only be shown the first time
|
||||
const openedIntro = useSelector(selectOpenedIntro(gameId))
|
||||
// On mobile, there are multiple pages to switch between
|
||||
const [pageNumber, setPageNumber] = useState(openedIntro ? 1 : 0)
|
||||
|
||||
// set the window title
|
||||
useEffect(() => {
|
||||
if (gameInfo.data?.title) {
|
||||
window.document.title = gameInfo.data.title
|
||||
}
|
||||
}, [gameInfo.data?.title])
|
||||
|
||||
return <div className="app-content">
|
||||
{ gameInfo.isLoading?
|
||||
<Box display="flex" alignItems="center" justifyContent="center" sx={{ height: "calc(100vh - 64px)" }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
: mobile ?
|
||||
<>
|
||||
<MobileNav pageNumber={pageNumber} setPageNumber={setPageNumber} />
|
||||
{(pageNumber == 0 ?
|
||||
<IntroductionPanel introduction={gameInfo.data?.introduction} />
|
||||
: pageNumber == 1 ?
|
||||
<WorldTreePanel worlds={gameInfo.data?.worlds} worldSize={gameInfo.data?.worldSize} />
|
||||
:
|
||||
<InventoryPanel />
|
||||
)}
|
||||
</>
|
||||
:
|
||||
<Split className="welcome" minSize={0} snapOffset={200} sizes={[40, 35, 25]}>
|
||||
<IntroductionPanel introduction={gameInfo.data?.introduction} />
|
||||
<WorldTreePanel worlds={gameInfo.data?.worlds} worldSize={gameInfo.data?.worldSize} />
|
||||
<InventoryPanel />
|
||||
</Split>
|
||||
}
|
||||
<PrivacyPolicy />
|
||||
</div>
|
||||
}
|
||||
|
||||
export default Welcome
|
||||
@@ -0,0 +1,47 @@
|
||||
.world-selection-menu {
|
||||
padding: .5em;
|
||||
}
|
||||
|
||||
.world-selection-menu .btn, .welcome .btn {
|
||||
min-width: 5em;
|
||||
text-align: center;
|
||||
margin-left: .4em;
|
||||
margin-right: .4em;
|
||||
margin-bottom: .2em;
|
||||
}
|
||||
|
||||
.world-selection-menu .slider-wrap {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
/* min-width: 16em; */
|
||||
padding-left: 0.5em;
|
||||
padding-right: 3em;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.difficulty-label {
|
||||
font-size: 0.875em;
|
||||
padding-top: 4px;
|
||||
padding-right: 1em;
|
||||
}
|
||||
|
||||
/* Test for mobile `title`s */
|
||||
/* @media (pointer: coarse), (hover: none) {
|
||||
[title] {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
}
|
||||
[title]:focus::after {
|
||||
content: attr(title);
|
||||
position: absolute;
|
||||
top: 90%;
|
||||
color: #000;
|
||||
background-color: #fff;
|
||||
border: 1px solid;
|
||||
width: fit-content;
|
||||
padding: 3px;
|
||||
}
|
||||
} */
|
||||
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* @fileOverview Define the menu displayed with the tree of worlds on the welcome page
|
||||
*/
|
||||
import * as React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useStore, useSelector } from 'react-redux'
|
||||
import { Slider } from '@mui/material'
|
||||
import cytoscape, { LayoutOptions } from 'cytoscape'
|
||||
import klay from 'cytoscape-klay'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faDownload, faUpload, faEraser, faGlobe, faArrowLeft } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
import { GameIdContext } from '../app'
|
||||
import { useAppDispatch } from '../hooks'
|
||||
import { deleteProgress, selectProgress, loadProgress, GameProgressState,
|
||||
selectDifficulty, changedDifficulty, selectCompleted } from '../state/progress'
|
||||
import { store } from '../state/store'
|
||||
import { Button } from './button'
|
||||
|
||||
import './world_tree.css'
|
||||
|
||||
// Settings for the world tree
|
||||
cytoscape.use( klay )
|
||||
const N = 18 // max number of levels per world
|
||||
const R = 64 // radius of a world
|
||||
const r = 12 // radius of a level
|
||||
const s = 10 // global scale
|
||||
const padding = R + 2*r // padding of the graphic (on a different scale)
|
||||
const ds = .75 // scale the resulting svg image
|
||||
|
||||
// colours
|
||||
const grey = '#999'
|
||||
const lightgrey = '#bbb'
|
||||
const green = 'green'
|
||||
const lightgreen = '#139e13'
|
||||
const blue = '#1976d2'
|
||||
|
||||
/** svg object for a level in the game tree */
|
||||
export function LevelIcon({ world, level, position, completed, unlocked }:
|
||||
{ world: string,
|
||||
level: number,
|
||||
position: cytoscape.Position,
|
||||
completed: boolean,
|
||||
unlocked: boolean,
|
||||
}) {
|
||||
const gameId = React.useContext(GameIdContext)
|
||||
const difficulty = useSelector(selectDifficulty(gameId))
|
||||
const x = s * position.x + Math.sin(level * 2 * Math.PI / N) * (R + 1.2*r + 2.4*r*Math.floor((level - 1)/N))
|
||||
const y = s * position.y - Math.cos(level * 2 * Math.PI / N) * (R + 1.2*r + 2.4*r*Math.floor((level - 1)/N))
|
||||
const levelDisabled = (difficulty >= 2 && !(unlocked || completed))
|
||||
return (
|
||||
<Link to={levelDisabled ? '' : `/${gameId}/world/${world}/level/${level}`}
|
||||
className={`level${levelDisabled ? ' disabled' : ''}`}>
|
||||
<circle fill={completed ? lightgreen : unlocked? blue : grey} cx={x} cy={y} r={r} />
|
||||
<foreignObject className="level-title-wrapper" x={x} y={y}
|
||||
width={1.42*r} height={1.42*r} transform={"translate("+ -.71*r +","+ -.71*r +")"}>
|
||||
<div>
|
||||
<p className="level-title" style={{fontSize: Math.floor(r) + "px"}}>
|
||||
{level}
|
||||
</p>
|
||||
</div>
|
||||
</foreignObject>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
/** svg object of one world in the game tree */
|
||||
export function WorldIcon({world, title, position, completedLevels, difficulty}:
|
||||
{ world: string,
|
||||
title: string,
|
||||
position: cytoscape.Position,
|
||||
completedLevels: any,
|
||||
difficulty: number }) {
|
||||
|
||||
// index `0` indicates that all prerequisites are completed
|
||||
let unlocked = completedLevels[0]
|
||||
// indices `1`-`n` indicate that the corresponding level is completed
|
||||
let completed = completedLevels.slice(1).every(Boolean)
|
||||
// select the first non-completed level
|
||||
let nextLevel: number = completedLevels.findIndex(c => !c)
|
||||
if (nextLevel <= 1) {
|
||||
// note: `findIndex` returns `-1` on failure, therefore the indices
|
||||
// `-1, 0, 1` indicate all that the introduction should be shown
|
||||
nextLevel = 0
|
||||
}
|
||||
let playable = difficulty <= 1 || completed || unlocked
|
||||
|
||||
const gameId = React.useContext(GameIdContext)
|
||||
|
||||
return <Link
|
||||
to={playable ? `/${gameId}/world/${world}/level/${nextLevel}` : ''}
|
||||
className={playable ? '' : 'disabled'}>
|
||||
<circle className="world-circle" cx={s*position.x} cy={s*position.y} r={R}
|
||||
fill={completed ? green : unlocked ? blue : grey}/>
|
||||
<foreignObject className="world-title-wrapper" x={s*position.x} y={s*position.y}
|
||||
width={1.42*R} height={1.42*R} transform={"translate("+ -.71*R +","+ -.71*R +")"}>
|
||||
<div className={unlocked && !completed ? "playable-world" : ''}>
|
||||
<p className="world-title" style={{fontSize: Math.floor(R/4) + "px"}}>
|
||||
{title ? title : world}
|
||||
</p>
|
||||
</div>
|
||||
</foreignObject>
|
||||
</Link>
|
||||
}
|
||||
|
||||
/** svg object for a connection path between worlds in the game tree */
|
||||
export function WorldPath({source, target, unlocked} : {source: any, target: any, unlocked: boolean}) {
|
||||
return <line x1={s*source.position.x} y1={s*source.position.y}
|
||||
x2={s*target.position.x} y2={s*target.position.y}
|
||||
stroke={unlocked ? green : lightgrey} strokeWidth={s}/>
|
||||
}
|
||||
|
||||
/** Download a file containing `data` */
|
||||
const downloadFile = ({ data, fileName, fileType } :
|
||||
{ data: string
|
||||
fileName: string
|
||||
fileType: string}) => {
|
||||
const blob = new Blob([data], { type: fileType })
|
||||
const a = document.createElement('a')
|
||||
a.download = fileName
|
||||
a.href = window.URL.createObjectURL(blob)
|
||||
const clickEvt = new MouseEvent('click', {
|
||||
view: window,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
a.dispatchEvent(clickEvt)
|
||||
a.remove()
|
||||
}
|
||||
|
||||
// TODO: I think this should be removed and that single button incorporated differently
|
||||
/** Menu on desktop to go back to game server */
|
||||
export function WelcomeMenu() {
|
||||
|
||||
return <nav className="world-selection-menu">
|
||||
<Button inverted="false" title="back to games selection" to="/">
|
||||
<FontAwesomeIcon icon={faArrowLeft} /> <FontAwesomeIcon icon={faGlobe} />
|
||||
</Button>
|
||||
|
||||
</nav>
|
||||
}
|
||||
|
||||
/** The menu that is shown next to the world selection graph */
|
||||
function WorldSelectionMenu() {
|
||||
const [file, setFile] = React.useState<File>();
|
||||
|
||||
const gameId = React.useContext(GameIdContext)
|
||||
const store = useStore()
|
||||
const difficulty = useSelector(selectDifficulty(gameId))
|
||||
|
||||
|
||||
/* state variables to toggle the pop-up menus */
|
||||
const [eraseMenu, setEraseMenu] = React.useState(false);
|
||||
const openEraseMenu = () => setEraseMenu(true);
|
||||
const closeEraseMenu = () => setEraseMenu(false);
|
||||
const [uploadMenu, setUploadMenu] = React.useState(false);
|
||||
const openUploadMenu = () => setUploadMenu(true);
|
||||
const closeUploadMenu = () => setUploadMenu(false);
|
||||
|
||||
const gameProgress = useSelector(selectProgress(gameId))
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
/** Download the current progress (i.e. what's saved in the browser store) */
|
||||
const downloadProgress = (e) => {
|
||||
e.preventDefault()
|
||||
downloadFile({
|
||||
data: JSON.stringify(gameProgress, null, 2),
|
||||
fileName: `lean4game-${gameId}-${new Date().toLocaleDateString()}.json`,
|
||||
fileType: 'text/json',
|
||||
})
|
||||
}
|
||||
|
||||
const handleFileChange = (e) => {
|
||||
if (e.target.files) {
|
||||
setFile(e.target.files[0])
|
||||
}
|
||||
}
|
||||
|
||||
/** Upload progress from a */
|
||||
const uploadProgress = (e) => {
|
||||
if (!file) {return}
|
||||
const fileReader = new FileReader()
|
||||
fileReader.readAsText(file, "UTF-8")
|
||||
fileReader.onload = (e) => {
|
||||
const data = JSON.parse(e.target.result.toString()) as GameProgressState
|
||||
console.debug("Json Data", data)
|
||||
dispatch(loadProgress({game: gameId, data: data}))
|
||||
}
|
||||
closeUploadMenu()
|
||||
}
|
||||
|
||||
const eraseProgress = () => {
|
||||
dispatch(deleteProgress({game: gameId}))
|
||||
closeEraseMenu()
|
||||
}
|
||||
|
||||
const downloadAndErase = (e) => {
|
||||
downloadProgress(e)
|
||||
eraseProgress()
|
||||
}
|
||||
|
||||
function label(x : number) {
|
||||
return x == 0 ? 'playground' : x == 1 ? 'explorer' : 'regular'
|
||||
}
|
||||
|
||||
return <nav className="world-selection-menu">
|
||||
<Button onClick={downloadProgress} title="Download game progress" to=""><FontAwesomeIcon icon={faDownload} /></Button>
|
||||
<Button title="Load game progress from JSON" onClick={openUploadMenu} to=""><FontAwesomeIcon icon={faUpload} /></Button>
|
||||
<Button title="Clear game progress" to="" onClick={openEraseMenu}><FontAwesomeIcon icon={faEraser} /></Button>
|
||||
<div className="slider-wrap">
|
||||
<span className="difficulty-label">difficulty:</span>
|
||||
<Slider
|
||||
title="Difficulties: - regular: 🔐 levels, 🔐 tactics - explorer: 🔓 levels, 🔐 tactics - playground: 🔓 levels, 🔓 tactics"
|
||||
min={0} max={2}
|
||||
aria-label="Mode"
|
||||
defaultValue={difficulty}
|
||||
marks={[
|
||||
{value: 0, label: label(0)},
|
||||
{value: 1, label: label(1)},
|
||||
{value: 2, label: label(2)}
|
||||
]}
|
||||
valueLabelFormat={label}
|
||||
getAriaValueText={label}
|
||||
valueLabelDisplay="auto"
|
||||
onChange={(ev, val: number) => {
|
||||
dispatch(changedDifficulty({game: gameId, difficulty: val}))
|
||||
}}
|
||||
></Slider>
|
||||
|
||||
</div>
|
||||
{eraseMenu?
|
||||
<div className="modal-wrapper">
|
||||
<div className="modal-backdrop" onClick={closeEraseMenu} />
|
||||
<div className="modal">
|
||||
<div className="codicon codicon-close modal-close" onClick={closeEraseMenu}></div>
|
||||
<h2>Delete Progress?</h2>
|
||||
|
||||
<p>Do you want to delete your saved progress irreversibly?</p>
|
||||
<p>
|
||||
(This deletes your proofs and your collected inventory.
|
||||
Saves from other games are not deleted.)
|
||||
</p>
|
||||
|
||||
<Button onClick={eraseProgress} to="">Delete</Button>
|
||||
<Button onClick={downloadAndErase} to="">Download & Delete</Button>
|
||||
<Button onClick={closeEraseMenu} to="">Cancel</Button>
|
||||
</div>
|
||||
</div> : null}
|
||||
{uploadMenu ?
|
||||
<div className="modal-wrapper">
|
||||
<div className="modal-backdrop" onClick={closeUploadMenu} />
|
||||
<div className="modal">
|
||||
<div className="codicon codicon-close modal-close" onClick={closeUploadMenu}></div>
|
||||
<h2>Upload Saved Progress</h2>
|
||||
|
||||
<p>Select a JSON file with the saved game progress to load your progress.</p>
|
||||
|
||||
<p><b>Warning:</b> This will delete your current game progress!
|
||||
Consider <a className="download-link" onClick={downloadProgress} >downloading your current progress</a> first!</p>
|
||||
|
||||
<input type="file" onChange={handleFileChange}/>
|
||||
|
||||
<Button to="" onClick={uploadProgress}>Load selected file</Button>
|
||||
</div>
|
||||
</div> : null}
|
||||
</nav>
|
||||
}
|
||||
|
||||
export function computeWorldLayout(worlds) {
|
||||
let elements = []
|
||||
for (let id in worlds.nodes) {
|
||||
elements.push({ data: { id: id, title: worlds.nodes[id].title } })
|
||||
}
|
||||
for (let edge of worlds.edges) {
|
||||
elements.push({
|
||||
data: {
|
||||
id: edge[0] + " --edge-to--> " + edge[1],
|
||||
source: edge[0],
|
||||
target: edge[1]
|
||||
}
|
||||
})
|
||||
}
|
||||
const cy = cytoscape({
|
||||
container: null,
|
||||
elements,
|
||||
headless: true,
|
||||
styleEnabled: false
|
||||
})
|
||||
const layout = cy.layout({name: "klay", klay: {direction: "DOWN", nodePlacement: "LINEAR_SEGMENTS"}} as LayoutOptions).run()
|
||||
let nodes = {}
|
||||
cy.nodes().forEach((node, id) => {
|
||||
nodes[node.id()] = {
|
||||
position: node.position(),
|
||||
data: node.data()
|
||||
}
|
||||
})
|
||||
const bounds = cy.nodes().boundingBox()
|
||||
return { nodes, bounds }
|
||||
}
|
||||
|
||||
|
||||
export function WorldTreePanel({worlds, worldSize}:
|
||||
{ worlds: any,
|
||||
worldSize: any}) {
|
||||
const gameId = React.useContext(GameIdContext)
|
||||
const difficulty = useSelector(selectDifficulty(gameId))
|
||||
const {nodes, bounds}: any = worlds ? computeWorldLayout(worlds) : {nodes: []}
|
||||
|
||||
// scroll to playable world
|
||||
React.useEffect(() => {
|
||||
let elems = Array.from(document.getElementsByClassName("playable-world"))
|
||||
if (elems.length) {
|
||||
// it seems that the last element is the one furthest up in the tree
|
||||
// TODO: I think they appear in random order. Check there position and select the lowest one
|
||||
// of these positions to scroll to.
|
||||
let elem = elems[0]
|
||||
console.debug(`scrolling to ${elem.textContent}`)
|
||||
elem.scrollIntoView({block: "center"})
|
||||
}
|
||||
}, [worlds, worldSize])
|
||||
|
||||
let svgElements = []
|
||||
|
||||
// for each `worldId` as index, this contains a list of booleans with indices
|
||||
// 0, 1, …, n. Index `0` will be set to `false` if any dependency is not completely solved.
|
||||
// Indices `1, …, n` indicate if the corresponding level is completed
|
||||
var completed = {}
|
||||
|
||||
if (worlds && worldSize) {
|
||||
// Fill `completed` with the level data.
|
||||
for (let worldId in nodes) {
|
||||
completed[worldId] = Array.from({ length: worldSize[worldId] + 1 }, (_, i) => {
|
||||
// index `0` starts off as `true` but can be set to `false` by any edge with non-completed source
|
||||
return i == 0 || selectCompleted(gameId, worldId, i)(store.getState())
|
||||
})
|
||||
}
|
||||
|
||||
// draw all connecting paths
|
||||
for (let i in worlds.edges) {
|
||||
const edge = worlds.edges[i]
|
||||
let sourceCompleted = completed[edge[0]].slice(1).every(Boolean)
|
||||
// if the origin world is not completed, mark the target world as non-playable
|
||||
if (!sourceCompleted) {completed[edge[1]][0] = false}
|
||||
svgElements.push(
|
||||
<WorldPath source={nodes[edge[0]]} target={nodes[edge[1]]} unlocked={sourceCompleted}/>
|
||||
)
|
||||
}
|
||||
|
||||
// draw the worlds and levels
|
||||
for (let worldId in nodes) {
|
||||
let position: cytoscape.Position = nodes[worldId].position
|
||||
svgElements.push(
|
||||
<WorldIcon world={worldId}
|
||||
title={nodes[worldId].data.title || worldId}
|
||||
position={position}
|
||||
completedLevels={completed[worldId]}
|
||||
difficulty={difficulty}
|
||||
key={`${gameId}-${worldId}`} />
|
||||
)
|
||||
for (let i = 1; i <= worldSize[worldId]; i++) {
|
||||
svgElements.push(
|
||||
<LevelIcon
|
||||
world={worldId}
|
||||
level={i}
|
||||
position={position}
|
||||
completed={completed[worldId][i]} unlocked={completed[worldId][i-1]}
|
||||
key={`${gameId}-${worldId}-${i}`} />
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let dx = bounds ? s*(bounds.x2 - bounds.x1) + 2*padding : null
|
||||
|
||||
return <div className="column">
|
||||
<WorldSelectionMenu />
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
width={bounds ? `${ds * dx}` : ''}
|
||||
viewBox={bounds ? `${s*bounds.x1 - padding} ${s*bounds.y1 - padding} ${dx} ${s*(bounds.y2 - bounds.y1) + 2 * padding}` : ''}
|
||||
className="world-selection"
|
||||
>
|
||||
{svgElements}
|
||||
</svg>
|
||||
</div>
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* @fileOverview todo
|
||||
*/
|
||||
|
||||
import * as React from 'react';
|
||||
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'
|
||||
import { LeanClient } from 'lean4web/client/src/editor/leanclient';
|
||||
import * as React from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
|
||||
export class Connection {
|
||||
private game: string = undefined // We only keep a connection to a single game at a time
|
||||
@@ -54,7 +55,7 @@ export const ConnectionContext = React.createContext(null);
|
||||
|
||||
export const useLeanClient = (gameId) => {
|
||||
const leanClient = connection.getLeanClient(gameId)
|
||||
const [leanClientStarted, setLeanClientStarted] = useState(leanClient.isStarted())
|
||||
const [leanClientStarted, setLeanClientStarted] = React.useState(leanClient.isStarted())
|
||||
|
||||
React.useEffect(() => {
|
||||
const t1 = leanClient.restarted(() => { console.log("START"); setLeanClientStarted(true) })
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
import App from './app';
|
||||
import { ConnectionContext, connection } from './connection'
|
||||
import { store } from './state/store';
|
||||
import { Provider } from 'react-redux';
|
||||
@@ -9,10 +9,10 @@ import {
|
||||
RouterProvider,
|
||||
Route,
|
||||
} from "react-router-dom";
|
||||
import ErrorPage from './ErrorPage';
|
||||
import Welcome from './components/Welcome';
|
||||
import LandingPage from './components/LandingPage';
|
||||
import Level from './components/Level';
|
||||
import ErrorPage from './components/error_page';
|
||||
import Welcome from './components/welcome';
|
||||
import LandingPage from './components/landing_page';
|
||||
import Level from './components/level';
|
||||
import { monacoSetup } from 'lean4web/client/src/monacoSetup';
|
||||
import { redirect } from 'react-router-dom';
|
||||
|
||||
|
||||
+15
-1
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* @fileOverview Define API of the server-client communication
|
||||
*/
|
||||
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
|
||||
import { Connection } from '../connection'
|
||||
|
||||
@@ -33,6 +36,14 @@ export interface LevelInfo {
|
||||
statementName: null|string
|
||||
}
|
||||
|
||||
/** Used to display the inventory on the welcome page */
|
||||
export interface InventoryOverview {
|
||||
tactics: InventoryTile[],
|
||||
lemmas: InventoryTile[],
|
||||
definitions: InventoryTile[],
|
||||
lemmaTab: null,
|
||||
}
|
||||
|
||||
interface Doc {
|
||||
name: string,
|
||||
displayName: string,
|
||||
@@ -71,6 +82,9 @@ export const apiSlice = createApi({
|
||||
loadLevel: builder.query<LevelInfo, {game: string, world: string, level: number}>({
|
||||
query: ({game, world, level}) => {return {game, method: "loadLevel", params: {world, level}}},
|
||||
}),
|
||||
loadInventoryOverview: builder.query<InventoryOverview, {game: string}>({
|
||||
query: ({game}) => {return {game, method: "loadInventoryOverview", params: {}}},
|
||||
}),
|
||||
loadDoc: builder.query<Doc, {game: string, name: string, type: "lemma"|"tactic"}>({
|
||||
query: ({game, name, type}) => {return {game, method: "loadDoc", params: {name, type}}},
|
||||
}),
|
||||
@@ -79,4 +93,4 @@ export const apiSlice = createApi({
|
||||
|
||||
// Export hooks for usage in functional components, which are
|
||||
// auto-generated based on the defined endpoints
|
||||
export const { useGetGameInfoQuery, useLoadLevelQuery, useLoadDocQuery } = apiSlice
|
||||
export const { useGetGameInfoQuery, useLoadLevelQuery, useLoadDocQuery, useLoadInventoryOverviewQuery } = apiSlice
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
const KEY = "game_progress";
|
||||
export function loadState() {
|
||||
try {
|
||||
const serializedState = localStorage.getItem(KEY);
|
||||
if (!serializedState) return undefined;
|
||||
return JSON.parse(serializedState);
|
||||
} catch (e) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveState(state: any) {
|
||||
try {
|
||||
const serializedState = JSON.stringify(state);
|
||||
localStorage.setItem(KEY, serializedState);
|
||||
} catch (e) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* @fileOverview Load/save the state to the local browser store
|
||||
*/
|
||||
|
||||
const KEY = "game_progress";
|
||||
|
||||
/** Load from browser storage */
|
||||
export function loadState() {
|
||||
try {
|
||||
const serializedState = localStorage.getItem(KEY);
|
||||
if (!serializedState) return undefined;
|
||||
let x = JSON.parse(serializedState);
|
||||
// Complatibilty because `state.level` has been renamed to `x.games`.
|
||||
// TODO: Does this work?
|
||||
if (x.level) {
|
||||
x.games = x.level
|
||||
x.level = undefined
|
||||
}
|
||||
return x
|
||||
} catch (e) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Save to browser storage */
|
||||
export async function saveState(state: any) {
|
||||
try {
|
||||
const serializedState = JSON.stringify(state)
|
||||
localStorage.setItem(KEY, serializedState);
|
||||
} catch (e) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
+135
-36
@@ -1,15 +1,11 @@
|
||||
/**
|
||||
* @fileOverview Defines the user progress which is loaded from the browser store and kept
|
||||
*/
|
||||
import { createSlice } from '@reduxjs/toolkit'
|
||||
import type { PayloadAction } from '@reduxjs/toolkit'
|
||||
import { loadState } from "./localStorage";
|
||||
import { loadState } from "./local_storage";
|
||||
import { WorkDoneProgressBegin } from 'vscode-languageserver-protocol';
|
||||
|
||||
export interface GameProgressState {
|
||||
[world: string] : {[level: number]: LevelProgressState}
|
||||
|
||||
}
|
||||
|
||||
interface ProgressState {
|
||||
level: {[game: string]: GameProgressState}
|
||||
}
|
||||
interface Selection {
|
||||
selectionStartLineNumber: number,
|
||||
selectionStartColumn: number,
|
||||
@@ -19,21 +15,56 @@ interface Selection {
|
||||
interface LevelProgressState {
|
||||
code: string,
|
||||
selections: Selection[],
|
||||
completed: boolean
|
||||
completed: boolean,
|
||||
help: number[], // A set of rows where hidden hints have been displayed
|
||||
}
|
||||
interface WorldProgressState {
|
||||
[world: string] : {[level: number]: LevelProgressState},
|
||||
}
|
||||
|
||||
const initialProgressState = loadState() ?? { level: {} } as ProgressState
|
||||
const initalLevelProgressState = {code: "", completed: false} as LevelProgressState
|
||||
export interface GameProgressState {
|
||||
inventory: string[],
|
||||
difficulty: number,
|
||||
openedIntro: boolean,
|
||||
data: WorldProgressState
|
||||
}
|
||||
|
||||
function addLevelProgress(state, action: PayloadAction<{game: string, world: string, level: number}>) {
|
||||
if (!state.level[action.payload.game]) {
|
||||
state.level[action.payload.game] = {}
|
||||
/**
|
||||
* Currently we have three difficulties:
|
||||
*
|
||||
* | lock tactics | lock levels |
|
||||
* --|--------------|-------------|
|
||||
* 0 | no | no |
|
||||
* 1 | yes | no |
|
||||
* 2 | yes | yes |
|
||||
*/
|
||||
const DEFAULT_DIFFICULTY = 2
|
||||
|
||||
/** The progress made on all lean4-games */
|
||||
interface ProgressState {
|
||||
games: {[game: string]: GameProgressState}
|
||||
}
|
||||
|
||||
const initialProgressState: ProgressState = loadState() ?? { games: {} }
|
||||
|
||||
// TODO: There was some weird unreproducible bug with removing `as LevelProgressState` here...
|
||||
const initalLevelProgressState: LevelProgressState = {code: "", completed: false, selections: [], help: []}
|
||||
|
||||
/** Add an empty skeleton with progress for the current game */
|
||||
function addGameProgress (state: ProgressState, action: PayloadAction<{game: string}>) {
|
||||
if (!state.games[action.payload.game]) {
|
||||
state.games[action.payload.game] = {inventory: [], openedIntro: true, data: {}, difficulty: DEFAULT_DIFFICULTY}
|
||||
}
|
||||
if (!state.level[action.payload.game][action.payload.world]) {
|
||||
state.level[action.payload.game][action.payload.world] = {}
|
||||
}
|
||||
|
||||
/** Add an empty skeleton with progress for the current level */
|
||||
function addLevelProgress(state: ProgressState, action: PayloadAction<{game: string, world: string, level: number}>) {
|
||||
addGameProgress(state, action)
|
||||
if (!state.games[action.payload.game].data[action.payload.world]) {
|
||||
state.games[action.payload.game].data[action.payload.world] = {}
|
||||
}
|
||||
if (!state.level[action.payload.game][action.payload.world][action.payload.level]) {
|
||||
state.level[action.payload.game][action.payload.world][action.payload.level] = {...initalLevelProgressState}
|
||||
if (!state.games[action.payload.game].data[action.payload.world][action.payload.level]) {
|
||||
state.games[action.payload.game].data[action.payload.world][action.payload.level] = {...initalLevelProgressState}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,60 +72,128 @@ export const progressSlice = createSlice({
|
||||
name: 'progress',
|
||||
initialState: initialProgressState,
|
||||
reducers: {
|
||||
codeEdited(state, action: PayloadAction<{game: string, world: string, level: number, code: string}>) {
|
||||
/** put edited code in the state and set completed to false */
|
||||
codeEdited(state: ProgressState, action: PayloadAction<{game: string, world: string, level: number, code: string}>) {
|
||||
addLevelProgress(state, action)
|
||||
state.level[action.payload.game][action.payload.world][action.payload.level].code = action.payload.code
|
||||
state.level[action.payload.game][action.payload.world][action.payload.level].completed = false
|
||||
state.games[action.payload.game].data[action.payload.world][action.payload.level].code = action.payload.code
|
||||
state.games[action.payload.game].data[action.payload.world][action.payload.level].completed = false
|
||||
},
|
||||
changedSelection(state, action: PayloadAction<{game: string, world: string, level: number, selections: Selection[]}>) {
|
||||
/** TODO: docstring */
|
||||
changedSelection(state: ProgressState, action: PayloadAction<{game: string, world: string, level: number, selections: Selection[]}>) {
|
||||
addLevelProgress(state, action)
|
||||
state.level[action.payload.game][action.payload.world][action.payload.level].selections = action.payload.selections
|
||||
state.games[action.payload.game].data[action.payload.world][action.payload.level].selections = action.payload.selections
|
||||
},
|
||||
levelCompleted(state, action: PayloadAction<{game: string, world: string, level: number}>) {
|
||||
/** mark level as completed */
|
||||
levelCompleted(state: ProgressState, action: PayloadAction<{game: string, world: string, level: number}>) {
|
||||
addLevelProgress(state, action)
|
||||
state.level[action.payload.game][action.payload.world][action.payload.level].completed = true
|
||||
state.games[action.payload.game].data[action.payload.world][action.payload.level].completed = true
|
||||
},
|
||||
deleteProgress(state, action: PayloadAction<{game: string}>) {
|
||||
state.level[action.payload.game] = {}
|
||||
/** Set the list of rows where help is displayed */
|
||||
helpEdited(state: ProgressState, action: PayloadAction<{game: string, world: string, level: number, help: number[]}>) {
|
||||
addLevelProgress(state, action)
|
||||
console.debug(`!setting help to: ${action.payload.help}`)
|
||||
state.games[action.payload.game].data[action.payload.world][action.payload.level].help = action.payload.help
|
||||
},
|
||||
loadProgress(state, action: PayloadAction<{game: string, data:GameProgressState}>) {
|
||||
/** delete all progress for this game */
|
||||
deleteProgress(state: ProgressState, action: PayloadAction<{game: string}>) {
|
||||
state.games[action.payload.game] = {inventory: [], data: {}, openedIntro: true, difficulty: DEFAULT_DIFFICULTY}
|
||||
},
|
||||
/** delete progress for this level */
|
||||
deleteLevelProgress(state: ProgressState, action: PayloadAction<{game: string, world: string, level: number}>) {
|
||||
addLevelProgress(state, action)
|
||||
state.games[action.payload.game].data[action.payload.world][action.payload.level] = initalLevelProgressState
|
||||
},
|
||||
/** load progress, e.g. from external import */
|
||||
loadProgress(state: ProgressState, action: PayloadAction<{game: string, data:GameProgressState}>) {
|
||||
console.debug(`setting data to:\n ${action.payload.data}`)
|
||||
state.level[action.payload.game] = action.payload.data
|
||||
state.games[action.payload.game] = action.payload.data
|
||||
},
|
||||
/** set the current inventory */
|
||||
changedInventory(state: ProgressState, action: PayloadAction<{game: string, inventory: string[]}>) {
|
||||
addGameProgress(state, action)
|
||||
state.games[action.payload.game].inventory = action.payload.inventory
|
||||
},
|
||||
/** set the difficulty */
|
||||
changedDifficulty(state: ProgressState, action: PayloadAction<{game: string, difficulty: number}>) {
|
||||
addGameProgress(state, action)
|
||||
state.games[action.payload.game].difficulty = action.payload.difficulty
|
||||
},
|
||||
/** set the difficulty */
|
||||
changedOpenedIntro(state: ProgressState, action: PayloadAction<{game: string, openedIntro: boolean}>) {
|
||||
addGameProgress(state, action)
|
||||
state.games[action.payload.game].openedIntro = action.payload.openedIntro
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
/** if the level does not exist, return default values */
|
||||
export function selectLevel(game: string, world: string, level: number) {
|
||||
return (state) =>{
|
||||
if (!state.progress.level[game]) { return initalLevelProgressState }
|
||||
if (!state.progress.level[game][world]) { return initalLevelProgressState }
|
||||
if (!state.progress.level[game][world][level]) { return initalLevelProgressState }
|
||||
return state.progress.level[game][world][level]
|
||||
if (!state.progress.games[game]) { return initalLevelProgressState }
|
||||
if (!state.progress.games[game].data[world]) { return initalLevelProgressState }
|
||||
if (!state.progress.games[game].data[world][level]) { return initalLevelProgressState }
|
||||
return state.progress.games[game].data[world][level]
|
||||
}
|
||||
}
|
||||
|
||||
/** return the code of the current level */
|
||||
export function selectCode(game: string, world: string, level: number) {
|
||||
return (state) => {
|
||||
return selectLevel(game, world, level)(state).code
|
||||
}
|
||||
}
|
||||
|
||||
/** return the current inventory */
|
||||
export function selectInventory(game: string) {
|
||||
return (state) => {
|
||||
if (!state.progress.games[game]) { return [] }
|
||||
return state.progress.games[game].inventory
|
||||
}
|
||||
}
|
||||
|
||||
/** return the code of the current level */
|
||||
export function selectHelp(game: string, world: string, level: number) {
|
||||
return (state) => {
|
||||
return selectLevel(game, world, level)(state).help
|
||||
}
|
||||
}
|
||||
|
||||
/** return the selections made in the current level */
|
||||
export function selectSelections(game: string, world: string, level: number) {
|
||||
return (state) => {
|
||||
return selectLevel(game, world, level)(state).selections
|
||||
}
|
||||
}
|
||||
|
||||
/** return whether the current level is clompleted */
|
||||
export function selectCompleted(game: string, world: string, level: number) {
|
||||
return (state) => {
|
||||
return selectLevel(game, world, level)(state).completed
|
||||
}
|
||||
}
|
||||
|
||||
/** return progress for the current game if it exists */
|
||||
export function selectProgress(game: string) {
|
||||
return (state) => {
|
||||
return state.progress.level[game] ?? null
|
||||
return state.progress.games[game] ?? null
|
||||
}
|
||||
}
|
||||
|
||||
export const { changedSelection, codeEdited, levelCompleted, deleteProgress, loadProgress } = progressSlice.actions
|
||||
/** return difficulty for the current game if it exists */
|
||||
export function selectDifficulty(game: string) {
|
||||
return (state) => {
|
||||
return state.progress.games[game]?.difficulty ?? DEFAULT_DIFFICULTY
|
||||
}
|
||||
}
|
||||
|
||||
/** return whether the intro has been read */
|
||||
export function selectOpenedIntro(game: string) {
|
||||
return (state) => {
|
||||
return state.progress.games[game]?.openedIntro
|
||||
}
|
||||
}
|
||||
|
||||
/** Export actions to modify the progress */
|
||||
export const { changedSelection, codeEdited, levelCompleted, deleteProgress,
|
||||
deleteLevelProgress, loadProgress, helpEdited, changedInventory, changedOpenedIntro,
|
||||
changedDifficulty } = progressSlice.actions
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
/**
|
||||
* @fileOverview configure the store and save the state periodically to the browser storage
|
||||
*/
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { debounce } from "debounce";
|
||||
|
||||
import { connection } from '../connection'
|
||||
import thunkMiddleware from 'redux-thunk'
|
||||
import { apiSlice } from './api'
|
||||
import { progressSlice } from './progress'
|
||||
import { saveState } from "./localStorage";
|
||||
import { debounce } from "debounce";
|
||||
import { saveState } from "./local_storage";
|
||||
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
@@ -20,10 +24,11 @@ export const store = configureStore({
|
||||
}).concat(apiSlice.middleware),
|
||||
});
|
||||
|
||||
// Save progress in local storage
|
||||
/**
|
||||
* Save progress in local storage once each 800ms.
|
||||
* This is for better performance when multiple changes occur in a short time
|
||||
*/
|
||||
store.subscribe(
|
||||
// we use debounce to save the state once each 800ms
|
||||
// for better performances in case multiple changes occur in a short time
|
||||
debounce(() => {
|
||||
saveState(store.getState()[progressSlice.name]);
|
||||
}, 800)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import {useState, useEffect} from 'react'
|
||||
|
||||
function getWindowDimensions() {
|
||||
const {innerWidth: width, innerHeight: height } = window
|
||||
return {width, height}
|
||||
}
|
||||
|
||||
export function useWindowDimensions() {
|
||||
const [windowDimensions, setWindowDimensions] = useState(getWindowDimensions())
|
||||
|
||||
useEffect(() => {
|
||||
function handleResize() {
|
||||
setWindowDimensions(getWindowDimensions())
|
||||
}
|
||||
window.addEventListener('resize', handleResize)
|
||||
return () => window.removeEventListener('resize', handleResize)
|
||||
|
||||
}, [])
|
||||
|
||||
return windowDimensions
|
||||
}
|
||||
Generated
+4450
-262
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@
|
||||
"@emotion/styled": "^11.10.5",
|
||||
"@fontsource/roboto": "^4.5.8",
|
||||
"@fontsource/roboto-mono": "^4.5.8",
|
||||
"@leanprover/infoview": "^0.4.3",
|
||||
"@mui/icons-material": "^5.11.0",
|
||||
"@mui/material": "^5.11.1",
|
||||
"@reduxjs/toolkit": "^1.9.1",
|
||||
@@ -26,6 +27,7 @@
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-markdown": "^8.0.4",
|
||||
"react-native": "^0.72.3",
|
||||
"react-redux": "^8.0.5",
|
||||
"react-router-dom": "^6.5.0",
|
||||
"react-split": "^2.0.14",
|
||||
@@ -47,6 +49,7 @@
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.10",
|
||||
"@redux-devtools/core": "^3.13.1",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
"@types/debounce": "^1.2.1",
|
||||
"babel-loader": "^8.3.0",
|
||||
"concurrently": "^7.6.0",
|
||||
"css-loader": "^6.7.3",
|
||||
|
||||
+307
-44
@@ -4,6 +4,12 @@ open Lean Meta Elab Command
|
||||
|
||||
set_option autoImplicit false
|
||||
|
||||
/-- Let `MakeGame` print the reasons why the worlds depend on each other. -/
|
||||
register_option lean4game.showDependencyReasons : Bool := {
|
||||
defValue := false
|
||||
descr := "show reasons for calculated world dependencies."
|
||||
}
|
||||
|
||||
/-! # Game metadata -/
|
||||
|
||||
/-- Switch to the specified `Game` (and create it if non-existent). Example: `Game "NNG"` -/
|
||||
@@ -49,26 +55,6 @@ elab "Conclusion" t:str : command => do
|
||||
| .World => modifyCurWorld fun world => pure {world with conclusion := t.getString}
|
||||
| .Game => modifyCurGame fun game => pure {game with conclusion := t.getString}
|
||||
|
||||
/-! ## World Paths -/
|
||||
|
||||
/-- The worlds of a game are joint by paths. These are defined with the syntax
|
||||
`Path World₁ → World₂ → World₃`. -/
|
||||
def Parser.path := Parser.sepBy1Indent Parser.ident "→"
|
||||
|
||||
/-- The worlds of a game are joint by paths. These are defined with the syntax
|
||||
`Path World₁ → World₂ → World₃`. -/
|
||||
elab "Path" s:Parser.path : command => do
|
||||
let mut last : Option Name := none
|
||||
for stx in s.raw.getArgs.getEvenElems do
|
||||
let some l := last
|
||||
| do
|
||||
last := some stx.getId
|
||||
continue
|
||||
modifyCurGame fun game =>
|
||||
pure {game with worlds := {game.worlds with edges := game.worlds.edges.push (l, stx.getId)}}
|
||||
last := some stx.getId
|
||||
|
||||
|
||||
|
||||
/-! # Inventory
|
||||
|
||||
@@ -80,12 +66,17 @@ in the first level and get enabled during the game.
|
||||
|
||||
/-- Checks if `inventoryTemplateExt` contains an entry with `(type, name)` and yields
|
||||
a warning otherwise. If `template` is provided, it will add such an entry instead of yielding a
|
||||
warning. -/
|
||||
def checkInventoryDoc (type : InventoryType) (name : Ident)
|
||||
warning.
|
||||
|
||||
`ref` is the syntax piece. If `name` is not provided, it will use `ident.getId`.
|
||||
I used this workaround, because I needed a new name (with correct namespace etc)
|
||||
to be used, and I don't know how to create a new ident with same position but different name.
|
||||
-/
|
||||
def checkInventoryDoc (type : InventoryType) (ref : Ident) (name : Name := ref.getId)
|
||||
(template : Option String := none) : CommandElabM Unit := do
|
||||
-- note: `name` is an `Ident` (instead of `Name`) for the log messages.
|
||||
let env ← getEnv
|
||||
let n := name.getId
|
||||
let n := name
|
||||
-- Find a key with matching `(type, name)`.
|
||||
match (inventoryTemplateExt.getState env).findIdx?
|
||||
(fun x => x.name == n && x.type == type) with
|
||||
@@ -98,18 +89,18 @@ def checkInventoryDoc (type : InventoryType) (name : Ident)
|
||||
-- We just add a dummy entry
|
||||
modifyEnv (inventoryTemplateExt.addEntry · {
|
||||
type := type
|
||||
name := name.getId
|
||||
name := name
|
||||
category := if type == .Lemma then s!"{n.getPrefix}" else "" })
|
||||
logWarningAt name (m!"Missing {type} Documentation: {name}\nAdd `{type}Doc {name}` " ++
|
||||
logWarningAt ref (m!"Missing {type} Documentation: {name}\nAdd `{type}Doc {name}` " ++
|
||||
m!"somewhere above this statement.")
|
||||
-- Add the default documentation
|
||||
| some s =>
|
||||
modifyEnv (inventoryTemplateExt.addEntry · {
|
||||
type := type
|
||||
name := name.getId
|
||||
name := name
|
||||
category := if type == .Lemma then s!"{n.getPrefix}" else ""
|
||||
content := s })
|
||||
logInfoAt name (m!"Missing {type} Documentation: {name}, used provided default (e.g. " ++
|
||||
logInfoAt ref (m!"Missing {type} Documentation: {name}, used provided default (e.g. " ++
|
||||
m!"statement description) instead. If you want to write your own description, add " ++
|
||||
m!"`{type}Doc {name}` somewhere above this statement.")
|
||||
|
||||
@@ -282,6 +273,38 @@ elab "LemmaTab" category:str : command =>
|
||||
syntax statementAttr := "(" &"attr" ":=" Parser.Term.attrInstance,* ")"
|
||||
-- TODO
|
||||
|
||||
-- TODO: Reuse the following code for checking available tactics in user code:
|
||||
structure UsedInventory where
|
||||
(tactics : HashSet Name := {})
|
||||
(definitions : HashSet Name := {})
|
||||
(lemmas : HashSet Name := {})
|
||||
|
||||
partial def collectUsedInventory (stx : Syntax) (acc : UsedInventory := {}) : CommandElabM UsedInventory := do
|
||||
match stx with
|
||||
| .missing => return acc
|
||||
| .node info kind args =>
|
||||
if kind == `tacticHint__ || kind == `tacticBranch_ then return acc
|
||||
return ← args.foldlM (fun acc arg => collectUsedInventory arg acc) acc
|
||||
| .atom info val =>
|
||||
-- ignore syntax elements that do not start with a letter
|
||||
-- and ignore some standard keywords
|
||||
let allowed := ["with", "fun", "at", "only", "by"]
|
||||
if 0 < val.length ∧ val.data[0]!.isAlpha ∧ not (allowed.contains val) then
|
||||
let val := val.dropRightWhile (fun c => c == '!' || c == '?') -- treat `simp?` and `simp!` like `simp`
|
||||
return {acc with tactics := acc.tactics.insert val}
|
||||
else
|
||||
return acc
|
||||
| .ident info rawVal val preresolved =>
|
||||
let ns ←
|
||||
try resolveGlobalConst (mkIdent val)
|
||||
catch | _ => pure [] -- catch "unknown constant" error
|
||||
return ← ns.foldlM (fun acc n => do
|
||||
if let some (.thmInfo ..) := (← getEnv).find? n then
|
||||
return {acc with lemmas := acc.lemmas.insertMany ns}
|
||||
else
|
||||
return {acc with definitions := acc.definitions.insertMany ns}
|
||||
) acc
|
||||
|
||||
/-- Define the statement of the current level. -/
|
||||
elab "Statement" statementName:ident ? descr:str ? sig:declSig val:declVal : command => do
|
||||
let lvlIdx ← getCurLevelIdx
|
||||
@@ -298,6 +321,12 @@ elab "Statement" statementName:ident ? descr:str ? sig:declSig val:declVal : com
|
||||
let defaultDeclName : Ident := mkIdent <| (← getCurGame).name ++ (← getCurWorld).name ++
|
||||
("level" ++ toString lvlIdx : String)
|
||||
|
||||
-- Collect all used tactics/lemmas in the sample proof:
|
||||
let usedInventory ← match val with
|
||||
| `(Parser.Command.declVal| := $proof:term) => do
|
||||
collectUsedInventory proof
|
||||
| _ => throwError "expected `:=`"
|
||||
|
||||
-- Add theorem to context.
|
||||
match statementName with
|
||||
| some name =>
|
||||
@@ -314,13 +343,13 @@ elab "Statement" statementName:ident ? descr:str ? sig:declSig val:declVal : com
|
||||
let thmStatement ← `(theorem $defaultDeclName $sig $val)
|
||||
elabCommand thmStatement
|
||||
-- Check that statement has a docs entry.
|
||||
checkInventoryDoc .Lemma name (template := descr)
|
||||
checkInventoryDoc .Lemma name (name := fullName) (template := descr)
|
||||
|
||||
else
|
||||
let thmStatement ← `( theorem $name $sig $val)
|
||||
elabCommand thmStatement
|
||||
-- Check that statement has a docs entry.
|
||||
checkInventoryDoc .Lemma name (template := descr)
|
||||
checkInventoryDoc .Lemma name (name := fullName) (template := descr)
|
||||
|
||||
| none =>
|
||||
let thmStatement ← `(theorem $defaultDeclName $sig $val)
|
||||
@@ -378,7 +407,10 @@ elab "Statement" statementName:ident ? descr:str ? sig:declSig val:declVal : com
|
||||
| none => default
|
||||
| some name => currNamespace ++ name.getId
|
||||
descrFormat := (Format.join [head, " ", st, " := by"]).pretty 10
|
||||
hints := hints }
|
||||
hints := hints
|
||||
tactics := {level.tactics with used := usedInventory.tactics.toArray}
|
||||
definitions := {level.definitions with used := usedInventory.definitions.toArray}
|
||||
lemmas := {level.lemmas with used := usedInventory.lemmas.toArray} }
|
||||
|
||||
/-! # Hints -/
|
||||
|
||||
@@ -572,7 +604,7 @@ def getTacticDocstring (env : Environment) (name: Name) : CommandElabM (Option S
|
||||
if let some doc ← findDocString? env k then
|
||||
return doc
|
||||
|
||||
logWarning <| m!"Could not find a docstring for this tactic, consider adding one " ++
|
||||
logWarning <| m!"Could not find a docstring for tactic {name}, consider adding one " ++
|
||||
m!"using `TacticDoc {name} \"some doc\"`"
|
||||
return none
|
||||
|
||||
@@ -587,15 +619,117 @@ def getDocstring (env : Environment) (name : Name) (type : InventoryType) :
|
||||
-- TODO: for definitions not implemented yet, does it work?
|
||||
| .Definition => findDocString? env name
|
||||
|
||||
|
||||
partial def removeTransitiveAux (id : Name) (arrows : HashMap Name (HashSet Name))
|
||||
(newArrows : HashMap Name (HashSet Name)) (decendants : HashMap Name (HashSet Name)) :
|
||||
HashMap Name (HashSet Name) × HashMap Name (HashSet Name) := Id.run do
|
||||
match (newArrows.find? id, decendants.find? id) with
|
||||
| (some _, some _) => return (newArrows, decendants)
|
||||
| _ =>
|
||||
let mut newArr := newArrows
|
||||
let mut desc := decendants
|
||||
desc := desc.insert id {} -- mark as worked in case of loops
|
||||
newArr := newArr.insert id {} -- mark as worked in case of loops
|
||||
let children := arrows.find! id
|
||||
let mut trimmedChildren := children
|
||||
let mut theseDescs := children
|
||||
for child in children do
|
||||
(newArr, desc) := removeTransitiveAux child arrows newArr desc
|
||||
let childDescs := desc.find! child
|
||||
theseDescs := theseDescs.insertMany childDescs
|
||||
for d in childDescs do
|
||||
trimmedChildren := trimmedChildren.erase d
|
||||
desc := desc.insert id theseDescs
|
||||
newArr := newArr.insert id trimmedChildren
|
||||
return (newArr, desc)
|
||||
|
||||
def removeTransitive (arrows : HashMap Name (HashSet Name)) : CommandElabM (HashMap Name (HashSet Name)) := do
|
||||
let mut newArr := {}
|
||||
let mut desc := {}
|
||||
for id in arrows.toArray.map Prod.fst do
|
||||
(newArr, desc) := removeTransitiveAux id arrows newArr desc
|
||||
if (desc.find! id).contains id then
|
||||
logError <| m!"Loop at {id}. " ++
|
||||
m!"This should not happen and probably means that `findLoops` has a bug."
|
||||
-- DEBUG:
|
||||
-- for ⟨x, hx⟩ in desc.toList do
|
||||
-- m := m ++ m!"{x}: {hx.toList}\n"
|
||||
-- logError m
|
||||
|
||||
return newArr
|
||||
|
||||
/-- The recursive part of `findLoops`. Finds loops that appear as successors of `node`.
|
||||
|
||||
For performance reason it returns a HashSet of visited
|
||||
nodes as well. This is filled with all nodes ever looked at as they cannot be
|
||||
part of a loop anymore. -/
|
||||
partial def findLoopsAux (arrows : HashMap Name (HashSet Name)) (node : Name)
|
||||
(path : Array Name := #[]) (visited : HashSet Name := {}) :
|
||||
Array Name × HashSet Name := Id.run do
|
||||
let mut visited := visited
|
||||
match path.getIdx? node with
|
||||
| some i =>
|
||||
-- Found a loop: `node` is already the iᵗʰ element of the path
|
||||
return (path.extract i path.size, visited.insert node)
|
||||
| none =>
|
||||
for successor in arrows.findD node {} do
|
||||
-- If we already visited the successor, it cannot be part of a loop anymore
|
||||
if visited.contains successor then
|
||||
continue
|
||||
-- Find any loop involving `successor`
|
||||
let (loop, _) := findLoopsAux arrows successor (path.push node) visited
|
||||
visited := visited.insert successor
|
||||
-- No loop found in the dependants of `successor`
|
||||
if loop.isEmpty then
|
||||
continue
|
||||
-- Found a loop, return it
|
||||
return (loop, visited)
|
||||
return (#[], visited.insert node)
|
||||
|
||||
/-- Find a loop in the graph and return it. Returns `[]` if there are no loops. -/
|
||||
partial def findLoops (arrows : HashMap Name (HashSet Name)) : List Name := Id.run do
|
||||
let mut visited : HashSet Name := {}
|
||||
for node in arrows.toArray.map (·.1) do
|
||||
-- Skip a node if it was already visited
|
||||
if visited.contains node then
|
||||
continue
|
||||
-- `findLoopsAux` returns a loop or `[]` together with a set of nodes it visited on its
|
||||
-- search starting from `node`
|
||||
let (loop, moreVisited) := (findLoopsAux arrows node (visited := visited))
|
||||
visited := moreVisited
|
||||
if !loop.isEmpty then
|
||||
return loop.toList
|
||||
return []
|
||||
|
||||
/-- The worlds of a game are joint by dependencies. These are
|
||||
automatically computed but can also be defined with the syntax
|
||||
`Dependency World₁ → World₂ → World₃`. -/
|
||||
def Parser.dependency := Parser.sepBy1Indent Parser.ident "→"
|
||||
|
||||
/-- Manually add a dependency between two worlds.
|
||||
|
||||
Normally, the dependencies are computed automatically by the
|
||||
tactics & lemmas used in the example
|
||||
proof and the ones introduced by `NewLemma`/`NewTactic`.
|
||||
Use the command `Dependency World₁ → World₂` to add a manual edge to the graph,
|
||||
for example if the only dependency between the worlds is given by
|
||||
the narrative. -/
|
||||
elab "Dependency" s:Parser.dependency : command => do
|
||||
let mut last : Option Name := none
|
||||
for stx in s.raw.getArgs.getEvenElems do
|
||||
let some l := last
|
||||
| do
|
||||
last := some stx.getId
|
||||
continue
|
||||
modifyCurGame fun game =>
|
||||
pure {game with worlds := {game.worlds with edges := game.worlds.edges.push (l, stx.getId)}}
|
||||
last := some stx.getId
|
||||
|
||||
/-- Build the game. This command will precompute various things about the game, such as which
|
||||
tactics are available in each level etc. -/
|
||||
elab "MakeGame" : command => do
|
||||
let game ← getCurGame
|
||||
|
||||
-- Check for loops in world graph
|
||||
if game.worlds.hasLoops then
|
||||
throwError "World graph must not contain loops! Check your `Path` declarations."
|
||||
|
||||
let env ← getEnv
|
||||
|
||||
-- Now create The doc entries from the templates
|
||||
@@ -607,7 +741,7 @@ elab "MakeGame" : command => do
|
||||
| "" =>
|
||||
-- If documentation is missing, try using the docstring instead.
|
||||
match docstring with
|
||||
| some ds => s!"*(lean docstring)*\\\n{ds}" -- TODO `\n` does not work in markdown
|
||||
| some ds => s!"*(lean docstring)*\\\n{ds}"
|
||||
| none => "(missing)"
|
||||
| template =>
|
||||
-- TODO: Process content template.
|
||||
@@ -627,10 +761,131 @@ elab "MakeGame" : command => do
|
||||
content := content
|
||||
})
|
||||
|
||||
-- For each `worldId` this contains a set of items used in this world
|
||||
let mut usedItemsInWorld : HashMap Name (HashSet Name) := {}
|
||||
|
||||
-- For each `worldId` this contains a set of items newly defined in this world
|
||||
let mut newItemsInWorld : HashMap Name (HashSet Name) := {}
|
||||
|
||||
-- Calculate which "items" are used/new in which world
|
||||
for (worldId, world) in game.worlds.nodes.toArray do
|
||||
let mut usedItems : HashSet Name := {}
|
||||
let mut newItems : HashSet Name := {}
|
||||
for inventoryType in #[.Tactic, .Definition, .Lemma] do
|
||||
for (levelId, level) in world.levels.toArray do
|
||||
usedItems := usedItems.insertMany (level.getInventory inventoryType).used
|
||||
newItems := newItems.insertMany (level.getInventory inventoryType).new
|
||||
|
||||
-- if the previous level was named, we need to add it as a new lemma
|
||||
if inventoryType == .Lemma then
|
||||
match levelId with
|
||||
| 0 => pure ()
|
||||
| 1 => pure () -- level ids start with 1, so we need to skip 1, too
|
||||
| i₀ + 1 =>
|
||||
match (world.levels.find! (i₀)).statementName with
|
||||
| .anonymous => pure ()
|
||||
| .num _ _ => panic "Did not expect to get a numerical statement name!"
|
||||
| .str pre s =>
|
||||
let name := Name.str pre s
|
||||
newItems := newItems.insert name
|
||||
|
||||
if inventoryType == .Lemma then
|
||||
|
||||
-- if the last level was named, we need to add it as a new lemma
|
||||
let i₀ := world.levels.size
|
||||
match (world.levels.find! (i₀)).statementName with
|
||||
| .anonymous => pure ()
|
||||
| .num _ _ => panic "Did not expect to get a numerical statement name!"
|
||||
| .str pre s =>
|
||||
let name := Name.str pre s
|
||||
newItems := newItems.insert name
|
||||
|
||||
usedItemsInWorld := usedItemsInWorld.insert worldId usedItems
|
||||
newItemsInWorld := newItemsInWorld.insert worldId newItems
|
||||
-- DEBUG: print new/used items
|
||||
-- logInfo m!"{worldId} uses: {usedItems.toList}"
|
||||
-- logInfo m!"{worldId} introduces: {newItems.toList}"
|
||||
|
||||
/- for each "item" this is a HashSet of `worldId`s that introduce this item -/
|
||||
let mut worldsWithNewItem : HashMap Name (HashSet Name) := {}
|
||||
for (worldId, world) in game.worlds.nodes.toArray do
|
||||
for newItem in newItemsInWorld.find! worldId do
|
||||
worldsWithNewItem := worldsWithNewItem.insert newItem $
|
||||
(worldsWithNewItem.findD newItem {}).insert worldId
|
||||
|
||||
-- For each `worldId` this is a HashSet of `worldId`s that this world depends on.
|
||||
let mut worldDependsOnWorlds : HashMap Name (HashSet Name) := {}
|
||||
|
||||
-- For a pair of `worldId`s `(id₁, id₂)` this is a HasSet of "items" why `id₁` depends on `id₂`.
|
||||
let mut dependencyReasons : HashMap (Name × Name) (HashSet Name) := {}
|
||||
|
||||
-- Calculate world dependency graph `game.worlds`
|
||||
for (dependentWorldId, _dependentWorld) in game.worlds.nodes.toArray do
|
||||
let mut dependsOnWorlds : HashSet Name := {}
|
||||
-- Adding manual dependencies that were specified via the `Dependency` command.
|
||||
for (sourceId, targetId) in game.worlds.edges do
|
||||
if targetId = dependentWorldId then
|
||||
dependsOnWorlds := dependsOnWorlds.insert sourceId
|
||||
|
||||
for usedItem in usedItemsInWorld.find! dependentWorldId do
|
||||
match worldsWithNewItem.find? usedItem with
|
||||
| none => logWarning m!"No world introducing {usedItem}, but required by {dependentWorldId}"
|
||||
| some worldIds =>
|
||||
-- Only need a new dependency if the world does not introduce an item itself
|
||||
if !worldIds.contains dependentWorldId then
|
||||
-- Add all worlds as dependencies which introduce this item
|
||||
-- TODO: Could do something more clever here.
|
||||
dependsOnWorlds := dependsOnWorlds.insertMany worldIds
|
||||
-- Store the dependency reasons for debugging
|
||||
for worldId in worldIds do
|
||||
let tmp := (dependencyReasons.findD (dependentWorldId, worldId) {}).insert usedItem
|
||||
dependencyReasons := dependencyReasons.insert (dependentWorldId, worldId) tmp
|
||||
worldDependsOnWorlds := worldDependsOnWorlds.insert dependentWorldId dependsOnWorlds
|
||||
|
||||
-- Debugging: show all dependency reasons if the option `lean4game.showDependencyReasons` is set
|
||||
if lean4game.showDependencyReasons.get (← getOptions) then
|
||||
for (world, dependencies) in worldDependsOnWorlds.toArray do
|
||||
if dependencies.isEmpty then
|
||||
logInfo m!"Dependencies of '{world}': none"
|
||||
else
|
||||
let mut msg := m!"Dependencies of '{world}':"
|
||||
for dep in dependencies do
|
||||
match dependencyReasons.find? (world, dep) with
|
||||
| none =>
|
||||
msg := msg ++ m!"\n· '{dep}': no reason found (manually added?)"
|
||||
| some items =>
|
||||
msg := msg ++ m!"\n· '{dep}' because of:\n {items.toList}"
|
||||
logInfo msg
|
||||
|
||||
-- Check graph for loops and remove transitive edges
|
||||
let loop := findLoops worldDependsOnWorlds
|
||||
if loop != [] then
|
||||
logError m!"Loop: Dependency graph has a loop: {loop}"
|
||||
for i in [:loop.length] do
|
||||
let w1 := loop[i]!
|
||||
let w2 := loop[if i == loop.length - 1 then 0 else i + 1]!
|
||||
match dependencyReasons.find? (w1, w2) with
|
||||
-- This should not happen. Could use `find!` again...
|
||||
| none => logError m!"Did not find a reason why {w1} depends on {w2}."
|
||||
| some items =>
|
||||
logError m!"{w1} depends on {w2} because of {items.toList}."
|
||||
else
|
||||
worldDependsOnWorlds ← removeTransitive worldDependsOnWorlds
|
||||
for (dependentWorldId, worldIds) in worldDependsOnWorlds.toArray do
|
||||
modifyCurGame fun game =>
|
||||
pure {game with worlds := {game.worlds with
|
||||
edges := game.worlds.edges.append (worldIds.toArray.map fun wid => (wid, dependentWorldId))}}
|
||||
|
||||
-- Apparently we need to reload `game` to get the changes to `game.worlds` we just made
|
||||
let game ← getCurGame
|
||||
|
||||
-- Compute which inventory items are available in which level:
|
||||
for inventoryType in #[.Tactic, .Definition, .Lemma] do
|
||||
let mut newItemsInWorld : HashMap Name (HashSet Name) := {}
|
||||
|
||||
-- Which items are introduced in which world?
|
||||
let mut lemmaStatements : HashMap (Name × Nat) Name := {}
|
||||
-- TODO: I believe `newItemsInWorld` has way to many elements in it which we iterate over
|
||||
-- e.g. we iterate over `ring` for `Lemma`s as well, but so far that seems to cause no problems
|
||||
let mut allItems : HashSet Name := {}
|
||||
for (worldId, world) in game.worlds.nodes.toArray do
|
||||
let mut newItems : HashSet Name := {}
|
||||
@@ -670,12 +925,20 @@ elab "MakeGame" : command => do
|
||||
let Availability₀ : HashMap Name InventoryTile :=
|
||||
HashMap.ofList $
|
||||
← allItems.toList.mapM fun item => do
|
||||
let data := (← getInventoryItem? item inventoryType).get!
|
||||
-- TODO: BUG, panic at `get!` in vscode
|
||||
return (item, {
|
||||
name := item
|
||||
displayName := data.displayName
|
||||
category := data.category })
|
||||
-- Using a match statement because the error message of `Option.get!` is not helpful.
|
||||
match (← getInventoryItem? item inventoryType) with
|
||||
| none =>
|
||||
-- Note: we did have a panic here before because lemma statement and doc entry
|
||||
-- had mismatching namespaces
|
||||
logError m!"There is no inventory item ({inventoryType}) for: {item}."
|
||||
panic s!"Inventory item {item} not found!"
|
||||
| some data =>
|
||||
return (item, {
|
||||
name := item
|
||||
displayName := data.displayName
|
||||
category := data.category })
|
||||
|
||||
|
||||
|
||||
-- Availability after a given world
|
||||
let mut itemsInWorld : HashMap Name (HashMap Name InventoryTile) := {}
|
||||
@@ -683,6 +946,7 @@ elab "MakeGame" : command => do
|
||||
-- Unlock all items from previous worlds
|
||||
let mut items := Availability₀
|
||||
let predecessors := game.worlds.predecessors worldId
|
||||
-- logInfo m!"Predecessors: {predecessors.toArray.map fun (a) => (a)}"
|
||||
for predWorldId in predecessors do
|
||||
for item in newItemsInWorld.find! predWorldId do
|
||||
let data := (← getInventoryItem? item inventoryType).get!
|
||||
@@ -740,7 +1004,6 @@ elab "MakeGame" : command => do
|
||||
return level.setComputedInventory inventoryType itemsArray
|
||||
|
||||
|
||||
|
||||
/-! # Debugging tools -/
|
||||
|
||||
-- /-- Print current game for debugging purposes. -/
|
||||
|
||||
@@ -202,6 +202,8 @@ structure LevelId where
|
||||
deriving Inhabited
|
||||
|
||||
structure InventoryInfo where
|
||||
/-- inventory items used by the main sample solution of this level -/
|
||||
used : Array Name
|
||||
/-- new inventory items introduced by this level -/
|
||||
new : Array Name
|
||||
/-- inventory items exceptionally forbidden in this level -/
|
||||
@@ -218,6 +220,7 @@ def getCurLevelId [MonadError m] : m LevelId := do
|
||||
/-- Instance to make GameLevel Repr work -/
|
||||
instance : Repr Elab.Command.Scope := ⟨fun s _ => repr s.currNamespace⟩
|
||||
|
||||
|
||||
structure GameLevel where
|
||||
index: Nat
|
||||
/-- The title of the level. -/
|
||||
|
||||
@@ -74,13 +74,21 @@ partial def findForbiddenTactics (inputCtx : Parser.InputContext)
|
||||
let allowed := ["with", "fun", "at", "only"]
|
||||
if 0 < val.length ∧ val.data[0]!.isAlpha ∧ not (allowed.contains val) then
|
||||
let val := val.dropRightWhile (fun c => c == '!' || c == '?') -- treat `simp?` and `simp!` like `simp`
|
||||
match levelParams.tactics.find? (fun t => t.name.toString == val) with
|
||||
| none => addErrorMessage info s!"You have not unlocked the tactic '{val}' yet!"
|
||||
match levelParams.tactics.find? (·.name.toString == val) with
|
||||
| none =>
|
||||
-- Note: This case means that the tactic will never be introduced in the game.
|
||||
match levelParams.inventory.find? (· == val) with
|
||||
| none =>
|
||||
addWarningMessage info s!"You have not unlocked the tactic '{val}' yet!"
|
||||
| some _ => pure () -- tactic is in the inventory, allow it.
|
||||
| some tac =>
|
||||
if tac.locked then
|
||||
addErrorMessage info s!"You have not unlocked the tactic '{val}' yet!"
|
||||
match levelParams.inventory.find? (· == val) with
|
||||
| none =>
|
||||
addWarningMessage info s!"You have not unlocked the tactic '{val}' yet!"
|
||||
| some _ => pure () -- tactic is in the inventory, allow it.
|
||||
else if tac.disabled then
|
||||
addErrorMessage info s!"The tactic '{val}' is disabled in this level!"
|
||||
addWarningMessage info s!"The tactic '{val}' is disabled in this level!"
|
||||
| .ident info rawVal val preresolved =>
|
||||
let ns ←
|
||||
try resolveGlobalConst (mkIdent val)
|
||||
@@ -90,17 +98,17 @@ partial def findForbiddenTactics (inputCtx : Parser.InputContext)
|
||||
| return () -- not a theroem -> ignore
|
||||
let lemmasAndDefs := levelParams.lemmas ++ levelParams.definitions
|
||||
match lemmasAndDefs.find? (fun l => l.name == n) with
|
||||
| none => addErrorMessage info s!"You have not unlocked the lemma/definition '{n}' yet!"
|
||||
| none => addWarningMessage info s!"You have not unlocked the lemma/definition '{n}' yet!"
|
||||
| some lem =>
|
||||
if lem.locked then
|
||||
addErrorMessage info s!"You have not unlocked the lemma/definition '{n}' yet!"
|
||||
addWarningMessage info s!"You have not unlocked the lemma/definition '{n}' yet!"
|
||||
else if lem.disabled then
|
||||
addErrorMessage info s!"The lemma/definition '{n}' is disabled in this level!"
|
||||
where addErrorMessage (info : SourceInfo) (s : MessageData) :=
|
||||
addWarningMessage info s!"The lemma/definition '{n}' is disabled in this level!"
|
||||
where addWarningMessage (info : SourceInfo) (s : MessageData) :=
|
||||
modify fun st => { st with
|
||||
messages := st.messages.add {
|
||||
fileName := inputCtx.fileName
|
||||
severity := MessageSeverity.error
|
||||
severity := MessageSeverity.warning
|
||||
pos := inputCtx.fileMap.toPosition (info.getPos?.getD 0)
|
||||
data := s
|
||||
}
|
||||
@@ -108,7 +116,8 @@ where addErrorMessage (info : SourceInfo) (s : MessageData) :=
|
||||
|
||||
open Elab Meta Expr in
|
||||
def compileProof (inputCtx : Parser.InputContext) (snap : Snapshot) (hasWidgets : Bool)
|
||||
(couldBeEndSnap : Bool) (levelParams : Game.DidOpenLevelParams) (initParams : Lsp.InitializeParams): IO Snapshot := do
|
||||
(couldBeEndSnap : Bool) (levelParams : Game.DidOpenLevelParams)
|
||||
(initParams : Lsp.InitializeParams) : IO Snapshot := do
|
||||
-- Recognize end snap
|
||||
if inputCtx.input.atEnd snap.mpState.pos ∧ couldBeEndSnap then
|
||||
let endSnap : Snapshot := {
|
||||
@@ -161,7 +170,8 @@ def compileProof (inputCtx : Parser.InputContext) (snap : Snapshot) (hasWidgets
|
||||
parseResultRef.set (tacticStx, cmdParserState)
|
||||
|
||||
-- Check for forbidden tactics
|
||||
findForbiddenTactics inputCtx levelParams tacticStx
|
||||
if levelParams.checkEnabled then
|
||||
findForbiddenTactics inputCtx levelParams tacticStx
|
||||
|
||||
-- Insert invisible `skip` command to make sure we always display the initial goal
|
||||
let skip := Syntax.node (.original default 0 default endOfWhitespace) ``Lean.Parser.Tactic.skip #[]
|
||||
@@ -300,14 +310,21 @@ where
|
||||
|
||||
end Elab
|
||||
|
||||
structure GameWorkerState :=
|
||||
(levelParams : Game.DidOpenLevelParams)
|
||||
|
||||
abbrev GameWorkerM := StateT GameWorkerState Server.FileWorker.WorkerM
|
||||
|
||||
section Updates
|
||||
|
||||
/-- Given the new document, updates editable doc state. -/
|
||||
def updateDocument (newMeta : DocumentMeta) (levelParams : Game.DidOpenLevelParams) : WorkerM Unit := do
|
||||
def updateDocument (newMeta : DocumentMeta) : GameWorkerM Unit := do
|
||||
let s ← get
|
||||
let levelParams := s.levelParams
|
||||
let ctx ← read
|
||||
let oldDoc := (←get).doc
|
||||
let oldDoc := (← StateT.lift get).doc
|
||||
oldDoc.cancelTk.set
|
||||
let initHeaderStx := (← get).initHeaderStx
|
||||
let initHeaderStx := (← StateT.lift get).initHeaderStx
|
||||
let (newHeaderStx, newMpState, _) ← Parser.parseHeader newMeta.mkInputContext
|
||||
let cancelTk ← CancelToken.new
|
||||
let headSnapTask := oldDoc.cmdSnaps.waitHead?
|
||||
@@ -347,7 +364,8 @@ section Updates
|
||||
-- before kicking off any expensive elaboration (TODO: make expensive elaboration cancelable)
|
||||
unfoldSnaps newMeta validSnaps.toArray cancelTk levelParams ctx
|
||||
(startAfterMs := ctx.initParams.editDelay.toUInt32)
|
||||
modify fun st => { st with doc := { meta := newMeta, cmdSnaps := AsyncList.delayed newSnaps, cancelTk } }
|
||||
StateT.lift <| modify fun st => { st with
|
||||
doc := { meta := newMeta, cmdSnaps := AsyncList.delayed newSnaps, cancelTk }}
|
||||
|
||||
end Updates
|
||||
|
||||
@@ -359,7 +377,8 @@ section Initialization
|
||||
fileMap := default
|
||||
|
||||
def compileHeader (m : DocumentMeta) (hOut : FS.Stream) (opts : Options) (hasWidgets : Bool)
|
||||
(levelParams : Game.DidOpenLevelParams) (initParams : InitializeParams): IO (Syntax × Task (Except Error (Snapshot × SearchPath))) := do
|
||||
(levelParams : Game.DidOpenLevelParams) (initParams : InitializeParams) :
|
||||
IO (Syntax × Task (Except Error (Snapshot × SearchPath))) := do
|
||||
-- Determine search paths of the game project by running `lake env printenv LEAN_PATH`.
|
||||
let out ← IO.Process.output
|
||||
{ cwd := levelParams.gameDir, cmd := "lake", args := #["env","printenv","LEAN_PATH"] }
|
||||
@@ -447,10 +466,10 @@ end Initialization
|
||||
|
||||
section NotificationHandling
|
||||
|
||||
def handleDidChange (levelParams : Game.DidOpenLevelParams) (p : DidChangeTextDocumentParams) : WorkerM Unit := do
|
||||
def handleDidChange (p : DidChangeTextDocumentParams) : GameWorkerM Unit := do
|
||||
let docId := p.textDocument
|
||||
let changes := p.contentChanges
|
||||
let oldDoc := (←get).doc
|
||||
let oldDoc := (← StateT.lift get).doc
|
||||
let some newVersion ← pure docId.version?
|
||||
| throwServerError "Expected version number"
|
||||
if newVersion ≤ oldDoc.meta.version then
|
||||
@@ -458,28 +477,28 @@ section NotificationHandling
|
||||
IO.eprintln s!"Got outdated version number: {newVersion} ≤ {oldDoc.meta.version}"
|
||||
else if ¬ changes.isEmpty then
|
||||
let newDocText := foldDocumentChanges changes oldDoc.meta.text
|
||||
updateDocument ⟨docId.uri, newVersion, newDocText⟩ levelParams
|
||||
updateDocument ⟨docId.uri, newVersion, newDocText⟩
|
||||
|
||||
end NotificationHandling
|
||||
|
||||
section MessageHandling
|
||||
|
||||
def handleNotification (method : String) (params : Json) (levelParams : Game.DidOpenLevelParams) : WorkerM Unit := do
|
||||
let handle := fun paramType [FromJson paramType] (handler : paramType → WorkerM Unit) =>
|
||||
parseParams paramType params >>= handler
|
||||
def handleNotification (method : String) (params : Json) : GameWorkerM Unit := do
|
||||
let handle := fun paramType [FromJson paramType] (handler : paramType → GameWorkerM Unit) =>
|
||||
(StateT.lift <| parseParams paramType params) >>= handler
|
||||
match method with
|
||||
| "textDocument/didChange" => handle DidChangeTextDocumentParams (handleDidChange levelParams)
|
||||
| "$/cancelRequest" => handle CancelParams handleCancelRequest
|
||||
| "$/lean/rpc/release" => handle RpcReleaseParams handleRpcRelease
|
||||
| "$/lean/rpc/keepAlive" => handle RpcKeepAliveParams handleRpcKeepAlive
|
||||
| "textDocument/didChange" => handle DidChangeTextDocumentParams (handleDidChange)
|
||||
| "$/cancelRequest" => handle CancelParams (handleCancelRequest ·)
|
||||
| "$/lean/rpc/release" => handle RpcReleaseParams (handleRpcRelease ·)
|
||||
| "$/lean/rpc/keepAlive" => handle RpcKeepAliveParams (handleRpcKeepAlive ·)
|
||||
| _ => throwServerError s!"Got unsupported notification method: {method}"
|
||||
|
||||
end MessageHandling
|
||||
|
||||
section MainLoop
|
||||
partial def mainLoop (levelParams : Game.DidOpenLevelParams) : WorkerM Unit := do
|
||||
partial def mainLoop : GameWorkerM Unit := do
|
||||
let ctx ← read
|
||||
let mut st ← get
|
||||
let mut st ← StateT.lift get
|
||||
let msg ← ctx.hIn.readLspMessage
|
||||
let filterFinishedTasks (acc : PendingRequestMap) (id : RequestID) (task : Task (Except IO.Error Unit))
|
||||
: IO PendingRequestMap := do
|
||||
@@ -502,16 +521,25 @@ section MainLoop
|
||||
set st
|
||||
match msg with
|
||||
| Message.request id method (some params) =>
|
||||
-- TODO: What's this error message?
|
||||
if method == "Game.getInteractiveGoals" then throwServerError "HELLO"
|
||||
handleRequest id method (toJson params)
|
||||
mainLoop levelParams
|
||||
mainLoop
|
||||
| Message.notification "exit" none =>
|
||||
let doc := st.doc
|
||||
doc.cancelTk.set
|
||||
return ()
|
||||
| Message.notification "$/game/setInventory" params =>
|
||||
let p := (← parseParams Game.SetInventoryParams (toJson params))
|
||||
let s ← get
|
||||
|
||||
set {s with levelParams := {s.levelParams with
|
||||
inventory := p.inventory,
|
||||
checkEnabled := p.checkEnabled}}
|
||||
mainLoop
|
||||
| Message.notification method (some params) =>
|
||||
handleNotification method (toJson params) levelParams
|
||||
mainLoop levelParams
|
||||
handleNotification method (toJson params)
|
||||
mainLoop
|
||||
| _ => throwServerError "Got invalid JSON-RPC message"
|
||||
end MainLoop
|
||||
|
||||
@@ -533,11 +561,15 @@ def initAndRunWorker (i o e : FS.Stream) (opts : Options) : IO UInt32 := do
|
||||
let _ ← IO.setStderr e
|
||||
try
|
||||
let (ctx, st) ← initializeWorker meta i o e initParams.param opts levelParams
|
||||
let _ ← StateRefT'.run (s := st) <| ReaderT.run (r := ctx) (mainLoop levelParams)
|
||||
let _ ← StateRefT'.run (s := st) <| ReaderT.run (r := ctx) <|
|
||||
StateT.run (s := {levelParams := levelParams}) <| (mainLoop)
|
||||
return (0 : UInt32)
|
||||
catch e =>
|
||||
IO.eprintln e
|
||||
publishDiagnostics meta #[{ range := ⟨⟨0, 0⟩, ⟨0, 0⟩⟩, severity? := DiagnosticSeverity.error, message := e.toString }] o
|
||||
publishDiagnostics meta #[{
|
||||
range := ⟨⟨0, 0⟩, ⟨0, 0⟩⟩,
|
||||
severity? := DiagnosticSeverity.error,
|
||||
message := e.toString }] o
|
||||
return (1 : UInt32)
|
||||
|
||||
def workerMain (opts : Options) : IO UInt32 := do
|
||||
|
||||
@@ -6,6 +6,8 @@ structure GameServerState :=
|
||||
(env : Lean.Environment)
|
||||
(game : Name)
|
||||
(gameDir : String)
|
||||
(inventory : Array String)
|
||||
(checkEnabled : Bool)
|
||||
|
||||
abbrev GameServerM := StateT GameServerState Server.Watchdog.ServerM
|
||||
|
||||
@@ -51,6 +53,13 @@ structure LevelInfo where
|
||||
statementName : Option String
|
||||
deriving ToJson, FromJson
|
||||
|
||||
structure InventoryOverview where
|
||||
tactics : Array InventoryTile
|
||||
lemmas : Array InventoryTile
|
||||
definitions : Array InventoryTile
|
||||
lemmaTab : Option String
|
||||
deriving ToJson, FromJson
|
||||
|
||||
structure LoadLevelParams where
|
||||
world : Name
|
||||
level : Nat
|
||||
@@ -63,6 +72,9 @@ structure DidOpenLevelParams where
|
||||
tactics : Array InventoryTile
|
||||
lemmas : Array InventoryTile
|
||||
definitions : Array InventoryTile
|
||||
inventory : Array String
|
||||
/-- if true the server gives warnings for used tactics/lemmas that are not unlocked. -/
|
||||
checkEnabled : Bool
|
||||
deriving ToJson, FromJson
|
||||
|
||||
structure LoadDocParams where
|
||||
@@ -70,6 +82,11 @@ structure LoadDocParams where
|
||||
type : InventoryType
|
||||
deriving ToJson, FromJson
|
||||
|
||||
structure SetInventoryParams where
|
||||
inventory : Array String
|
||||
checkEnabled : Bool
|
||||
deriving ToJson, FromJson
|
||||
|
||||
def handleDidOpenLevel (params : Json) : GameServerM Unit := do
|
||||
let p ← parseParams _ params
|
||||
let m := p.textDocument
|
||||
@@ -83,15 +100,18 @@ def handleDidOpenLevel (params : Json) : GameServerM Unit := do
|
||||
c.hLog.putStr s!"Level not found: {m.uri} {c.initParams.rootUri?}"
|
||||
c.hLog.flush
|
||||
-- Send an extra notification to the file worker to inform it about the level data
|
||||
let s ← get
|
||||
fw.stdin.writeLspNotification {
|
||||
method := "$/game/didOpenLevel"
|
||||
param := {
|
||||
uri := m.uri
|
||||
gameDir := (← get).gameDir
|
||||
gameDir := s.gameDir
|
||||
levelModule := lvl.module
|
||||
tactics := lvl.tactics.tiles
|
||||
lemmas := lvl.lemmas.tiles
|
||||
definitions := lvl.definitions.tiles
|
||||
inventory := s.inventory
|
||||
checkEnabled := s.checkEnabled
|
||||
: DidOpenLevelParams
|
||||
}
|
||||
}
|
||||
@@ -141,7 +161,7 @@ partial def handleServerEvent (ev : ServerEvent) : GameServerM Bool := do
|
||||
return true
|
||||
| Message.request id "loadDoc" params =>
|
||||
let p ← parseParams LoadDocParams (toJson params)
|
||||
-- let s ← get
|
||||
let s ← get
|
||||
let c ← read
|
||||
let some doc ← getInventoryItem? p.name p.type
|
||||
| do
|
||||
@@ -154,6 +174,39 @@ partial def handleServerEvent (ev : ServerEvent) : GameServerM Bool := do
|
||||
-- name := doc.name.toString }
|
||||
c.hOut.writeLspResponse ⟨id, ToJson.toJson doc⟩
|
||||
return true
|
||||
| Message.notification "$/game/setInventory" params =>
|
||||
let p := (← parseParams SetInventoryParams (toJson params))
|
||||
let s ← get
|
||||
set {s with inventory := p.inventory, checkEnabled := p.checkEnabled}
|
||||
let st ← read
|
||||
let workers ← st.fileWorkersRef.get
|
||||
for (_, fw) in workers do
|
||||
fw.stdin.writeLspMessage msg
|
||||
|
||||
return true
|
||||
| Message.request id "loadInventoryOverview" _ =>
|
||||
let s ← get
|
||||
let some game ← getGame? s.game
|
||||
| return false
|
||||
-- All Levels have the same tiles, so we just load them from level 1 of an arbitrary world
|
||||
-- and reset `new`, `disabled` and `unlocked`
|
||||
match game.worlds.nodes.toList with
|
||||
| [] => return false
|
||||
| ⟨worldId, _⟩ :: _ =>
|
||||
let some lvl ← getLevel? {game := s.game, world := worldId, level := 1}
|
||||
| do return false
|
||||
let inventory : InventoryOverview := {
|
||||
tactics := lvl.tactics.tiles.map
|
||||
({ · with locked := true, disabled := false, new := false }),
|
||||
lemmas := lvl.lemmas.tiles.map
|
||||
({ · with locked := true, disabled := false, new := false }),
|
||||
definitions := lvl.definitions.tiles.map
|
||||
({ · with locked := true, disabled := false, new := false }),
|
||||
lemmaTab := none
|
||||
}
|
||||
let c ← read
|
||||
c.hOut.writeLspResponse ⟨id, ToJson.toJson inventory⟩
|
||||
return true
|
||||
| _ => return false
|
||||
| _ => return false
|
||||
|
||||
|
||||
@@ -23,41 +23,6 @@ instance : EmptyCollection (Graph α β) := ⟨default⟩
|
||||
def Graph.insertNode (g : Graph α β) (a : α) (b : β) :=
|
||||
{g with nodes := g.nodes.insert a b}
|
||||
|
||||
/-- Check if graph contains loops -/
|
||||
partial def Graph.hasLoops (g : Graph α β) (visited0 : HashSet α := {}): Bool := Id.run do
|
||||
let mut visited : HashSet α := visited0
|
||||
let all : Array α := g.nodes.toArray.map (·.1)
|
||||
|
||||
-- find some node that we haven't visited
|
||||
let some x := all.find? fun x => ¬ visited.contains x
|
||||
| return false -- We have visted all nodes and found no loops
|
||||
visited := visited.insert x
|
||||
|
||||
match visitSuccessors x x visited with -- visit all recursive successors of x
|
||||
| some visited' => visited := visited'
|
||||
| none => return true -- none means a loop has been found
|
||||
|
||||
g.hasLoops visited -- continue looking for unvisited nodes
|
||||
|
||||
where
|
||||
visitSuccessors (x : α) (x0 : α) (visited0 : HashSet α) : Option (HashSet α) := Id.run do
|
||||
let mut visited : HashSet α := visited0
|
||||
|
||||
let directSuccessors := (g.edges.filter (·.1 == x)).map (·.2)
|
||||
for y in directSuccessors do
|
||||
if y == x0 then
|
||||
return none -- loop found
|
||||
if visited.contains y then
|
||||
continue -- no loop possible here because the visited nodes do not lead to x0
|
||||
visited := visited.insert y
|
||||
match visitSuccessors y x0 visited with
|
||||
| some visited' => visited := visited'
|
||||
| none => return none
|
||||
|
||||
return some visited
|
||||
|
||||
-- #eval Graph.hasLoops ⟨HashMap.ofList [(2,2), (1,1)], #[(2,1)]⟩
|
||||
|
||||
partial def Graph.predecessors (g : Graph α β) (x : α) (acc : HashSet α := {}) : HashSet α := Id.run do
|
||||
let mut res := acc
|
||||
let directPredecessors := (g.edges.filter (·.2 == x)).map (·.1)
|
||||
|
||||
@@ -109,7 +109,13 @@ def initAndRunWatchdog (args : List String) (i o e : FS.Stream) : IO Unit := do
|
||||
let i ← maybeTee "wdIn.txt" false i
|
||||
let o ← maybeTee "wdOut.txt" true o
|
||||
let e ← maybeTee "wdErr.txt" true e
|
||||
let state := {env := ← createEnv gameDir module, game := gameName, gameDir := gameDir}
|
||||
let state := {
|
||||
env := ← createEnv gameDir module,
|
||||
game := gameName,
|
||||
gameDir := gameDir,
|
||||
inventory := #[]
|
||||
checkEnabled := false
|
||||
}
|
||||
let initRequest ← i.readLspRequestAs "initialize" InitializeParams
|
||||
-- We misuse the `rootUri` field to the gameName
|
||||
let rootUri? := gameName
|
||||
|
||||
@@ -1 +1 @@
|
||||
leanprover/lean4:nightly-2023-03-09
|
||||
leanprover/lean4:nightly-2023-06-20
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import GameServer.Commands
|
||||
|
||||
open Lean
|
||||
|
||||
-- E → A → B → C → A and
|
||||
-- F → G → F
|
||||
open HashMap in
|
||||
def testArrows : HashMap Name (HashSet Name) :=
|
||||
ofList [("a", (HashSet.empty.insert "b": HashSet Name).insert "d"),
|
||||
("b", (HashSet.empty.insert "c": HashSet Name)),
|
||||
("c", (HashSet.empty.insert "a": HashSet Name)),
|
||||
("d", {}),
|
||||
("f", (HashSet.empty.insert "g": HashSet Name)),
|
||||
("g", (HashSet.empty.insert "f": HashSet Name)),
|
||||
("e", (HashSet.empty.insert "a": HashSet Name).insert "f")]
|
||||
|
||||
-- some permutation of ``[`c, `a, `b]`` or ``[`f, `g]``
|
||||
#eval findLoops testArrows
|
||||
Reference in New Issue
Block a user