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`.
|
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.
|
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
|
## Without Dev Containers
|
||||||
Install `nvm`:
|
Install `nvm`:
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -21,6 +21,16 @@
|
|||||||
<i>You need to enable JavaScript to use the Lean Game Server, as it is built
|
<i>You need to enable JavaScript to use the Lean Game Server, as it is built
|
||||||
using React.</i>
|
using React.</i>
|
||||||
</p>
|
</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>
|
</div>
|
||||||
</noscript>
|
</noscript>
|
||||||
<script src="bundle.js"></script>
|
<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 {
|
:root {
|
||||||
--clr-primary: #1976d2;
|
--clr-primary: #1976d2;
|
||||||
|
--clr-primary-dark: #104a83;
|
||||||
--clr-light-gray: #ddd;
|
--clr-light-gray: #ddd;
|
||||||
--clr-dark-gray: #aaa;
|
--clr-dark-gray: #aaa;
|
||||||
--clr-darker-gray: #555;
|
--clr-darker-gray: #555;
|
||||||
@@ -73,6 +74,10 @@ body {
|
|||||||
background: white;
|
background: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-inverted:hover {
|
||||||
|
color: var(--clr-primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
.btn-disabled, .btn[disabled] {
|
.btn-disabled, .btn[disabled] {
|
||||||
color: var(--clr-dark-gray);
|
color: var(--clr-dark-gray);
|
||||||
background: var(--clr-light-gray);
|
background: var(--clr-light-gray);
|
||||||
@@ -106,7 +111,8 @@ em {
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 1.1em;
|
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 {
|
.app-bar-title, .app-bar-subtitle {
|
||||||
@@ -114,7 +120,8 @@ em {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: 1.3rem;
|
font-size: 1.3rem;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin: 0 1em;
|
margin: 0;
|
||||||
|
/* margin: 0 1em; */
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-content {
|
.app-content {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import App from './App';
|
import App from './app';
|
||||||
|
|
||||||
test('renders learn react link', () => {
|
test('renders learn react link', () => {
|
||||||
render(<App />);
|
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 * 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 { 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 { EditorContext } from '../../../../node_modules/lean4-infoview/src/infoview/contexts';
|
||||||
import { Locations, LocationsContext, SelectableLocation } from '../../../../node_modules/lean4-infoview/src/infoview/goalLocation';
|
import { Locations, LocationsContext, SelectableLocation } from '../../../../node_modules/lean4-infoview/src/infoview/goalLocation';
|
||||||
import { InteractiveGoal, InteractiveGoals, InteractiveHypothesisBundle } from './rpcApi';
|
import { InteractiveCode } from '../../../../node_modules/lean4-infoview/src/infoview/interactiveCode'
|
||||||
import { Hints } from './hints';
|
import { WithTooltipOnHover } from '../../../../node_modules/lean4-infoview/src/infoview/tooltips';
|
||||||
import { CommandLine } from './CommandLine';
|
import { InputModeContext } from './context';
|
||||||
import { InputModeContext } from '../Level';
|
import { InteractiveGoal, InteractiveGoals, InteractiveHypothesisBundle } from './rpc_api';
|
||||||
|
|
||||||
/** Returns true if `h` is inaccessible according to Lean's default name rendering. */
|
/** Returns true if `h` is inaccessible according to Lean's default name rendering. */
|
||||||
function isInaccessibleName(h: string): boolean {
|
function isInaccessibleName(h: string): boolean {
|
||||||
@@ -137,6 +138,9 @@ interface ProofDisplayProps {
|
|||||||
export const Goal = React.memo((props: GoalProps) => {
|
export const Goal = React.memo((props: GoalProps) => {
|
||||||
const { goal, filter, showHints, commandLine } = props
|
const { goal, filter, showHints, commandLine } = props
|
||||||
|
|
||||||
|
// TODO: Apparently `goal` can be `undefined`
|
||||||
|
if (!goal) {return <></>}
|
||||||
|
|
||||||
const filteredList = getFilteredHypotheses(goal.hyps, filter);
|
const filteredList = getFilteredHypotheses(goal.hyps, filter);
|
||||||
const hyps = filter.reverse ? filteredList.slice().reverse() : filteredList;
|
const hyps = filter.reverse ? filteredList.slice().reverse() : filteredList;
|
||||||
const locs = React.useContext(LocationsContext)
|
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.isInserted) cn += 'b--inserted '
|
||||||
// if (props.goal.isRemoved) cn += 'b--removed '
|
// 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 objectHyps = hyps.filter(hyp => !hyp.isAssumption)
|
||||||
const assumptionHyps = hyps.filter(hyp => hyp.isAssumption)
|
const assumptionHyps = hyps.filter(hyp => hyp.isAssumption)
|
||||||
const {commandLineMode} = React.useContext(InputModeContext)
|
const {commandLineMode} = React.useContext(InputModeContext)
|
||||||
@@ -170,9 +174,9 @@ export const Goal = React.memo((props: GoalProps) => {
|
|||||||
{!commandLine && assumptionHyps.length > 0 &&
|
{!commandLine && assumptionHyps.length > 0 &&
|
||||||
<div className="hyp-group"><div className="hyp-group-title">Assumptions:</div>
|
<div className="hyp-group"><div className="hyp-group-title">Assumptions:</div>
|
||||||
{assumptionHyps.map((h, i) => <Hyp hyp={h} mvarId={goal.mvarId} key={i} />)}</div> }
|
{assumptionHyps.map((h, i) => <Hyp hyp={h} mvarId={goal.mvarId} key={i} />)}</div> }
|
||||||
{commandLine && commandLineMode && <CommandLine />}
|
{/* {commandLine && commandLineMode && <CommandLine />} */}
|
||||||
{!filter.reverse && goalLi}
|
{!filter.reverse && goalLi}
|
||||||
{showHints && hints}
|
{/* {showHints && hints} */}
|
||||||
</div>
|
</div>
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -231,6 +235,7 @@ export const OtherGoals = React.memo((props: GoalProps2) => {
|
|||||||
</>
|
</>
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// TODO: deprecated
|
||||||
export const ProofDisplay = React.memo((props : ProofDisplayProps) => {
|
export const ProofDisplay = React.memo((props : ProofDisplayProps) => {
|
||||||
const { proof } = props
|
const { proof } = props
|
||||||
const steps = proof.match(/.+/g)
|
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 */
|
/* Mostly copied from https://github.com/leanprover/vscode-lean4/blob/master/lean4-infoview/src/infoview/info.tsx */
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react'
|
||||||
import type { Location, Diagnostic } from 'vscode-languageserver-protocol';
|
import { CircularProgress } from '@mui/material'
|
||||||
|
import type { Location, Diagnostic } from 'vscode-languageserver-protocol'
|
||||||
import { goalsToString, Goal, MainAssumptions, OtherGoals, FilteredGoals, ProofDisplay } from './goals'
|
import { getInteractiveTermGoal, InteractiveDiagnostic, UserWidgetInstance, Widget_getWidgets, RpcSessionAtPos, isRpcError,
|
||||||
|
RpcErrorCode, getInteractiveDiagnostics, InteractiveTermGoal } from '@leanprover/infoview-api'
|
||||||
import { basename, DocumentPosition, RangeHelpers, useEvent, usePausableState, discardMethodNotFound,
|
import { basename, DocumentPosition, RangeHelpers, useEvent, usePausableState, discardMethodNotFound,
|
||||||
mapRpcError, useAsyncWithTrigger, PausableProps } from '../../../../node_modules/lean4-infoview/src/infoview/util';
|
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 { 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';
|
|
||||||
import { PanelWidgetDisplay } from '../../../../node_modules/lean4-infoview/src/infoview/userWidget'
|
import { PanelWidgetDisplay } from '../../../../node_modules/lean4-infoview/src/infoview/userWidget'
|
||||||
import { RpcContext, useRpcSessionAtPos } from '../../../../node_modules/lean4-infoview/src/infoview/rpcSessions';
|
import { RpcContext, useRpcSessionAtPos } from '../../../../node_modules/lean4-infoview/src/infoview/rpcSessions'
|
||||||
import { GoalsLocation, Locations, LocationsContext } from '../../../../node_modules/lean4-infoview/src/infoview/goalLocation';
|
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 { 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';
|
// TODO: All about pinning could probably be removed
|
||||||
type InfoKind = 'cursor' | 'pin';
|
type InfoKind = 'cursor' | 'pin'
|
||||||
|
|
||||||
interface InfoPinnable {
|
interface InfoPinnable {
|
||||||
kind: InfoKind;
|
kind: InfoKind
|
||||||
/** Takes an argument for caching reasons, but should only ever (un)pin itself. */
|
/** 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 {
|
interface InfoStatusBarProps extends InfoPinnable, PausableProps {
|
||||||
pos: DocumentPosition;
|
pos: DocumentPosition
|
||||||
status: InfoStatus;
|
status: InfoStatus
|
||||||
triggerUpdate: () => Promise<void>;
|
triggerUpdate: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
const InfoStatusBar = React.memo((props: InfoStatusBarProps) => {
|
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} = {
|
const statusColTable: {[T in InfoStatus]: string} = {
|
||||||
'updating': 'gold ',
|
'updating': 'gold ',
|
||||||
'error': 'dark-red ',
|
'error': 'dark-red ',
|
||||||
'ready': '',
|
'ready': '',
|
||||||
}
|
}
|
||||||
const statusColor = statusColTable[status];
|
const statusColor = statusColTable[status]
|
||||||
const locationString = `${basename(pos.uri)}:${pos.line+1}:${pos.character}`;
|
const locationString = `${basename(pos.uri)}:${pos.line+1}:${pos.character}`
|
||||||
const isPinned = kind === 'pin';
|
const isPinned = kind === 'pin'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<summary style={{transition: 'color 0.5s ease'}} className={'mv2 pointer ' + statusColor}>
|
<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'} />
|
title={isPinned ? 'unpin' : 'pin'} />
|
||||||
<a className={'link pointer mh2 dim codicon ' + (isPaused ? 'codicon-debug-continue ' : 'codicon-debug-pause ')}
|
<a className={'link pointer mh2 dim codicon ' + (isPaused ? 'codicon-debug-continue ' : 'codicon-debug-pause ')}
|
||||||
data-id='toggle-paused'
|
data-id='toggle-paused'
|
||||||
onClick={e => { e.preventDefault(); setPaused(!isPaused); }}
|
onClick={e => { e.preventDefault(); setPaused(!isPaused) }}
|
||||||
title={isPaused ? 'continue updating' : 'pause updating'} />
|
title={isPaused ? 'continue updating' : 'pause updating'} />
|
||||||
<a className='link pointer mh2 dim codicon codicon-refresh'
|
<a className='link pointer mh2 dim codicon codicon-refresh'
|
||||||
data-id='update'
|
data-id='update'
|
||||||
onClick={e => { e.preventDefault(); void triggerUpdate(); }}
|
onClick={e => { e.preventDefault(); void triggerUpdate() }}
|
||||||
title='update'/>
|
title='update'/>
|
||||||
</span>
|
</span>
|
||||||
</summary>
|
</summary>
|
||||||
);
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
interface InfoDisplayContentProps extends PausableProps {
|
interface InfoDisplayContentProps extends PausableProps {
|
||||||
pos: DocumentPosition;
|
pos: DocumentPosition
|
||||||
messages: InteractiveDiagnostic[];
|
messages: InteractiveDiagnostic[]
|
||||||
goals?: InteractiveGoals;
|
goals?: InteractiveGoals
|
||||||
termGoal?: InteractiveTermGoal;
|
termGoal?: InteractiveTermGoal
|
||||||
error?: string;
|
error?: string
|
||||||
userWidgets: UserWidgetInstance[];
|
userWidgets: UserWidgetInstance[]
|
||||||
triggerUpdate: () => Promise<void>;
|
triggerUpdate: () => Promise<void>
|
||||||
proof? : string;
|
proof? : string
|
||||||
}
|
}
|
||||||
|
|
||||||
const InfoDisplayContent = React.memo((props: InfoDisplayContentProps) => {
|
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 hasWidget = userWidgets.length > 0
|
||||||
const hasError = !!error;
|
const hasError = !!error
|
||||||
const hasMessages = messages.length !== 0;
|
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[]>([])
|
const [selectedLocs, setSelectedLocs] = React.useState<GoalsLocation[]>([])
|
||||||
React.useEffect(() => setSelectedLocs([]), [pos.uri, pos.line, pos.character])
|
React.useEffect(() => setSelectedLocs([]), [pos.uri, pos.line, pos.character])
|
||||||
@@ -121,13 +118,12 @@ const InfoDisplayContent = React.memo((props: InfoDisplayContentProps) => {
|
|||||||
{hasError &&
|
{hasError &&
|
||||||
<div className='error' key='errors'>
|
<div className='error' key='errors'>
|
||||||
Error updating:{' '}{error}.
|
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>}
|
</div>}
|
||||||
<LocationsContext.Provider value={locs}>
|
<LocationsContext.Provider value={locs}>
|
||||||
<div className="goals-section">
|
<div className="goals-section">
|
||||||
{ goals && goals.goals.length > 0 && <>
|
{ goals && goals.goals.length > 0 && <>
|
||||||
<MainAssumptions filter={goalFilter} key='mainGoal' goals={goals.goals} />
|
<MainAssumptions filter={goalFilter} key='mainGoal' goals={goals.goals} />
|
||||||
<ProofDisplay proof={proof}/>
|
|
||||||
<OtherGoals filter={goalFilter} goals={goals.goals} />
|
<OtherGoals filter={goalFilter} goals={goals.goals} />
|
||||||
</>}
|
</>}
|
||||||
</div>
|
</div>
|
||||||
@@ -149,8 +145,8 @@ const InfoDisplayContent = React.memo((props: InfoDisplayContentProps) => {
|
|||||||
isPaused ?
|
isPaused ?
|
||||||
/* Adding {' '} to manage string literals properly: https://reactjs.org/docs/jsx-in-depth.html#string-literals-1 */
|
/* Adding {' '} to manage string literals properly: https://reactjs.org/docs/jsx-in-depth.html#string-literals-1 */
|
||||||
<span>Updating is paused.{' '}
|
<span>Updating is paused.{' '}
|
||||||
<a className='link pointer dim' onClick={e => { e.preventDefault(); void triggerUpdate(); }}>Refresh</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>
|
{' '}or <a className='link pointer dim' onClick={e => { e.preventDefault(); setPaused(false) }}>resume updating</a>
|
||||||
{' '}to see information.
|
{' '}to see information.
|
||||||
</span> :
|
</span> :
|
||||||
<><CircularProgress /><div>Loading goal...</div></>)}
|
<><CircularProgress /><div>Loading goal...</div></>)}
|
||||||
@@ -167,49 +163,49 @@ const InfoDisplayContent = React.memo((props: InfoDisplayContentProps) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
interface InfoDisplayProps {
|
interface InfoDisplayProps {
|
||||||
pos: DocumentPosition;
|
pos: DocumentPosition,
|
||||||
status: InfoStatus;
|
status: InfoStatus,
|
||||||
messages: InteractiveDiagnostic[];
|
messages: InteractiveDiagnostic[],
|
||||||
goals?: InteractiveGoals;
|
goals?: InteractiveGoals,
|
||||||
termGoal?: InteractiveTermGoal;
|
termGoal?: InteractiveTermGoal,
|
||||||
error?: string;
|
error?: string,
|
||||||
userWidgets: UserWidgetInstance[];
|
userWidgets: UserWidgetInstance[],
|
||||||
rpcSess: RpcSessionAtPos;
|
rpcSess: RpcSessionAtPos,
|
||||||
triggerUpdate: () => Promise<void>;
|
triggerUpdate: () => Promise<void>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Displays goal state and messages. Can be paused. */
|
/** 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,
|
// Used to update the paused state *just once* if it is paused,
|
||||||
// but a display update is triggered
|
// but a display update is triggered
|
||||||
const [shouldRefresh, setShouldRefresh] = React.useState<boolean>(false);
|
const [shouldRefresh, setShouldRefresh] = React.useState<boolean>(false)
|
||||||
const [{ isPaused, setPaused }, props, propsRef] = usePausableState(false, props0);
|
const [{ isPaused, setPaused }, props, propsRef] = usePausableState(false, props0)
|
||||||
if (shouldRefresh) {
|
if (shouldRefresh) {
|
||||||
propsRef.current = props0;
|
propsRef.current = props0
|
||||||
setShouldRefresh(false);
|
setShouldRefresh(false)
|
||||||
}
|
}
|
||||||
const triggerDisplayUpdate = async () => {
|
const triggerDisplayUpdate = async () => {
|
||||||
await props0.triggerUpdate();
|
await props0.triggerUpdate()
|
||||||
setShouldRefresh(true);
|
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
|
// If we are the cursor infoview, then we should subscribe to
|
||||||
// some commands from the editor extension
|
// some commands from the editor extension
|
||||||
const isCursor = kind === 'cursor';
|
const isCursor = kind === 'cursor'
|
||||||
useEvent(ec.events.requestedAction, act => {
|
useEvent(ec.events.requestedAction, act => {
|
||||||
if (!isCursor) return;
|
if (!isCursor) return
|
||||||
if (act.kind !== 'copyToComment') return;
|
if (act.kind !== 'copyToComment') return
|
||||||
if (goals) void ec.copyToComment(goalsToString(goals));
|
if (goals) void ec.copyToComment(goalsToString(goals))
|
||||||
}, [goals]);
|
}, [goals])
|
||||||
useEvent(ec.events.requestedAction, act => {
|
useEvent(ec.events.requestedAction, act => {
|
||||||
if (!isCursor) return;
|
if (!isCursor) return
|
||||||
if (act.kind !== 'togglePaused') return;
|
if (act.kind !== 'togglePaused') return
|
||||||
setPaused(isPaused => !isPaused);
|
setPaused(isPaused => !isPaused)
|
||||||
});
|
})
|
||||||
|
|
||||||
const editor = React.useContext(MonacoEditorContext)
|
const editor = React.useContext(MonacoEditorContext)
|
||||||
|
|
||||||
@@ -217,12 +213,12 @@ function InfoDisplay(props0: InfoDisplayProps & InfoPinnable) {
|
|||||||
<RpcContext.Provider value={rpcSess}>
|
<RpcContext.Provider value={rpcSess}>
|
||||||
{/* <details open> */}
|
{/* <details open> */}
|
||||||
{/* <InfoStatusBar {...props} triggerUpdate={triggerDisplayUpdate} isPaused={isPaused} setPaused={setPaused} /> */}
|
{/* <InfoStatusBar {...props} triggerUpdate={triggerDisplayUpdate} isPaused={isPaused} setPaused={setPaused} /> */}
|
||||||
<div>
|
<div className="vscode-light">
|
||||||
<InfoDisplayContent {...props} proof={editor.getValue()} triggerUpdate={triggerDisplayUpdate} isPaused={isPaused} setPaused={setPaused} />
|
<InfoDisplayContent {...props} proof={editor.getValue()} triggerUpdate={triggerDisplayUpdate} isPaused={isPaused} setPaused={setPaused} />
|
||||||
</div>
|
</div>
|
||||||
{/* </details> */}
|
{/* </details> */}
|
||||||
</RpcContext.Provider>
|
</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
|
* to avoid flickering when the cursor moved. Otherwise, the component is re-initialised and the
|
||||||
* goal states reset to `undefined` on cursor moves.
|
* 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}. */
|
/** Fetches info from the server and renders an {@link InfoDisplay}. */
|
||||||
export function Info(props: InfoProps) {
|
export function Info(props: InfoProps) {
|
||||||
@@ -239,22 +235,25 @@ export function Info(props: InfoProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function InfoAtCursor(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
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
const [curLoc, setCurLoc] = React.useState<Location>(ec.events.changedCursorLocation.current!);
|
const [curLoc, setCurLoc] = React.useState<Location>(ec.events.changedCursorLocation.current!)
|
||||||
useEvent(ec.events.changedCursorLocation, loc => loc && setCurLoc(loc), []);
|
useEvent(ec.events.changedCursorLocation, loc => loc && setCurLoc(loc), [])
|
||||||
const pos = { uri: curLoc.uri, ...curLoc.range.start };
|
const pos = { uri: curLoc.uri, ...curLoc.range.start }
|
||||||
return <InfoAux {...props} pos={pos} />
|
return <InfoAux {...props} pos={pos} />
|
||||||
}
|
}
|
||||||
|
|
||||||
function useIsProcessingAt(p: DocumentPosition): boolean {
|
function useIsProcessingAt(p: DocumentPosition): boolean {
|
||||||
const allProgress = React.useContext(ProgressContext);
|
const allProgress = React.useContext(ProgressContext)
|
||||||
const processing = allProgress.get(p.uri);
|
const processing = allProgress.get(p.uri)
|
||||||
if (!processing) return false;
|
if (!processing) return false
|
||||||
return processing.some(i => RangeHelpers.contains(i.range, p));
|
return processing.some(i => RangeHelpers.contains(i.range, p))
|
||||||
}
|
}
|
||||||
|
|
||||||
function InfoAux(props: InfoProps) {
|
function InfoAux(props: InfoProps) {
|
||||||
|
|
||||||
|
const proofContext = React.useContext(ProofContext)
|
||||||
|
|
||||||
const config = React.useContext(ConfigContext)
|
const config = React.useContext(ConfigContext)
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
@@ -291,7 +290,7 @@ function InfoAux(props: InfoProps) {
|
|||||||
// with e.g. a new `pos`.
|
// with e.g. a new `pos`.
|
||||||
type InfoRequestResult = Omit<InfoDisplayProps, 'triggerUpdate'>
|
type InfoRequestResult = Omit<InfoDisplayProps, 'triggerUpdate'>
|
||||||
const [state, triggerUpdateCore] = useAsyncWithTrigger(() => new Promise<InfoRequestResult>((resolve, reject) => {
|
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 termGoalReq = getInteractiveTermGoal(rpcSess, DocumentPosition.toTdpp(pos))
|
||||||
const widgetsReq = Widget_getWidgets(rpcSess, pos).catch(discardMethodNotFound)
|
const widgetsReq = Widget_getWidgets(rpcSess, pos).catch(discardMethodNotFound)
|
||||||
const messagesReq = getInteractiveDiagnostics(rpcSess, {start: pos.line, end: pos.line+1})
|
const messagesReq = getInteractiveDiagnostics(rpcSess, {start: pos.line, end: pos.line+1})
|
||||||
@@ -405,10 +404,14 @@ function InfoAux(props: InfoProps) {
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (state.state === 'notStarted')
|
if (state.state === 'notStarted')
|
||||||
void triggerUpdate()
|
void triggerUpdate()
|
||||||
else if (state.state === 'loading')
|
else if (state.state === 'loading') {
|
||||||
setDisplayProps(dp => ({ ...dp, status: 'updating' }))
|
setDisplayProps(dp => ({ ...dp, status: 'updating' }))
|
||||||
|
}
|
||||||
else if (state.state === 'resolved') {
|
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') {
|
} else if (state.state === 'rejected' && state.error !== 'retry') {
|
||||||
// The code inside `useAsyncWithTrigger` may only ever reject with a `retry` exception.
|
// The code inside `useAsyncWithTrigger` may only ever reject with a `retry` exception.
|
||||||
console.warn('Unreachable code reached with error: ', state.error)
|
console.warn('Unreachable code reached with error: ', state.error)
|
||||||
|
|||||||
@@ -4,8 +4,9 @@
|
|||||||
padding: 5px 10px;
|
padding: 5px 10px;
|
||||||
border-radius: 3px 3px 3px 3px;
|
border-radius: 3px 3px 3px 3px;
|
||||||
}
|
}
|
||||||
.message.info {
|
.message.information, .message.info {
|
||||||
/* color: #059; */
|
/* color: #059; */
|
||||||
|
color: #000;
|
||||||
background-color: #DDF6FF;
|
background-color: #DDF6FF;
|
||||||
}
|
}
|
||||||
.message.warning {
|
.message.warning {
|
||||||
@@ -17,6 +18,12 @@
|
|||||||
background-color: #FFBABA;
|
background-color: #FFBABA;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message.deleted-hint {
|
||||||
|
background-color: #eee;
|
||||||
|
color: #777;
|
||||||
|
box-shadow: .0em .0em .5em .2em #eee;
|
||||||
|
}
|
||||||
|
|
||||||
.hyp-group {
|
.hyp-group {
|
||||||
margin-bottom: 0.3em;
|
margin-bottom: 0.3em;
|
||||||
}
|
}
|
||||||
@@ -41,14 +48,14 @@
|
|||||||
padding: 0.5em;
|
padding: 0.5em;
|
||||||
font-family: var(--ff-primary);
|
font-family: var(--ff-primary);
|
||||||
border-radius: 0.2em;
|
border-radius: 0.2em;
|
||||||
margin: 0.2em 0;
|
/* margin: 0.2em 0 0; */
|
||||||
}
|
}
|
||||||
|
|
||||||
.command-line form {
|
.command-line form {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.command-line button {
|
.command-line button, .undo-button {
|
||||||
display: block;
|
display: block;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
flex: 0;
|
flex: 0;
|
||||||
@@ -64,7 +71,7 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.command-line .command-line-input{
|
.command-line .command-line-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,3 +135,41 @@
|
|||||||
border-radius: 1em;
|
border-radius: 1em;
|
||||||
padding: 0.6em;
|
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 type { DidCloseTextDocumentParams, DidChangeTextDocumentParams, Location, DocumentUri } from 'vscode-languageserver-protocol';
|
||||||
|
|
||||||
import 'tachyons/css/tachyons.css';
|
import 'tachyons/css/tachyons.css';
|
||||||
// import '@vscode/codicons/dist/codicon.ttf';
|
|
||||||
import '@vscode/codicons/dist/codicon.css';
|
import '@vscode/codicons/dist/codicon.css';
|
||||||
import '../../../../node_modules/lean4-infoview/src/infoview/index.css';
|
import '../../../../node_modules/lean4-infoview/src/infoview/index.css';
|
||||||
import './infoview.css'
|
import './infoview.css'
|
||||||
|
|
||||||
import { LeanFileProgressParams, LeanFileProgressProcessingInfo, defaultInfoviewConfig, EditorApi, InfoviewApi } from '@leanprover/infoview-api';
|
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 { 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 { EditorContext, ConfigContext, ProgressContext, VersionContext } from '../../../../node_modules/lean4-infoview/src/infoview/contexts';
|
||||||
import { WithRpcSessions } from '../../../../node_modules/lean4-infoview/src/infoview/rpcSessions';
|
import { WithRpcSessions } from '../../../../node_modules/lean4-infoview/src/infoview/rpcSessions';
|
||||||
import { ServerVersion } from '../../../../node_modules/lean4-infoview/src/infoview/serverVersion';
|
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 { useAppDispatch, useAppSelector } from '../../hooks';
|
||||||
import { levelCompleted, selectCompleted } from '../../state/progress';
|
import { LevelInfo } from '../../state/api';
|
||||||
import { GameIdContext } from '../../App';
|
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}) {
|
/** Wrapper for the two editors. It is important that the `div` with `codeViewRef` is
|
||||||
const ec = React.useContext(EditorContext);
|
* always present, or the monaco editor cannot start.
|
||||||
const gameId = React.useContext(GameIdContext)
|
*/
|
||||||
|
export function DualEditor({ level, codeviewRef, levelId, worldId, worldSize }) {
|
||||||
const dispatch = useAppDispatch()
|
const ec = React.useContext(EditorContext)
|
||||||
|
const { commandLineMode } = React.useContext(InputModeContext)
|
||||||
// Mark level as completed when server gives notification
|
return <>
|
||||||
useServerNotificationEffect(
|
<div className={commandLineMode ? 'hidden' : ''}>
|
||||||
'$/game/completed',
|
<ExerciseStatement data={level} />
|
||||||
(params: any) => {
|
<div ref={codeviewRef} className={'codeview'}></div>
|
||||||
|
</div>
|
||||||
if (ec.events.changedCursorLocation.current &&
|
{ec ?
|
||||||
ec.events.changedCursorLocation.current.uri === params.uri) {
|
<DualEditorMain worldId={worldId} levelId={levelId} level={level} worldSize={worldSize} /> :
|
||||||
dispatch(levelCompleted({game: gameId, world: props.world, level: props.level}))
|
// TODO: Style this if relevant.
|
||||||
}
|
<>
|
||||||
},
|
<p>Editor is starting up...</p>
|
||||||
[]
|
<CircularProgress />
|
||||||
);
|
</>
|
||||||
|
|
||||||
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 (
|
}
|
||||||
<ConfigContext.Provider value={config}>
|
|
||||||
<VersionContext.Provider value={serverVersion}>
|
/** The part of the two editors that needs the editor connection first */
|
||||||
<WithRpcSessions>
|
function DualEditorMain({ worldId, levelId, level, worldSize }: { worldId: string, levelId: number, level: LevelInfo, worldSize: number }) {
|
||||||
<WithLspDiagnosticsContext>
|
const ec = React.useContext(EditorContext)
|
||||||
<ProgressContext.Provider value={allProgress}>
|
const gameId = React.useContext(GameIdContext)
|
||||||
{ret}
|
const { commandLineMode } = React.useContext(InputModeContext)
|
||||||
</ProgressContext.Provider>
|
|
||||||
</WithLspDiagnosticsContext>
|
// Mark level as completed when server gives notification
|
||||||
</WithRpcSessions>
|
const dispatch = useAppDispatch()
|
||||||
</VersionContext.Provider>
|
useServerNotificationEffect(
|
||||||
</ConfigContext.Provider>
|
'$/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 * as React from 'react'
|
||||||
import fastIsEqual from 'react-fast-compare';
|
import fastIsEqual from 'react-fast-compare'
|
||||||
import { Location, DocumentUri, Diagnostic, DiagnosticSeverity, PublishDiagnosticsParams } from 'vscode-languageserver-protocol';
|
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 { 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 { ConfigContext, EditorContext, LspDiagnosticsContext, VersionContext } from '../../../../node_modules/lean4-infoview/src/infoview/contexts'
|
||||||
import { Details } from '../../../../node_modules/lean4-infoview/src/infoview/collapsing';
|
import { Details } from '../../../../node_modules/lean4-infoview/src/infoview/collapsing'
|
||||||
import { InteractiveMessage } from '../../../../node_modules/lean4-infoview/src/infoview/traceExplorer';
|
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 { RpcContext, useRpcSessionAtPos } from '../../../../node_modules/lean4-infoview/src/infoview/rpcSessions';
|
|
||||||
import { InputModeContext } from '../Level';
|
import { InputModeContext } from './context'
|
||||||
|
|
||||||
interface MessageViewProps {
|
interface MessageViewProps {
|
||||||
uri: DocumentUri;
|
uri: DocumentUri;
|
||||||
diag: InteractiveDiagnostic;
|
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 MessageView = React.memo(({uri, diag}: MessageViewProps) => {
|
||||||
const ec = React.useContext(EditorContext);
|
const ec = React.useContext(EditorContext);
|
||||||
const fname = escapeHtml(basename(uri));
|
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';
|
import { ContextInfo, FVarId, CodeWithInfos, MVarId } from '@leanprover/infoview-api';
|
||||||
|
|
||||||
export interface GameHint {
|
export interface GameHint {
|
||||||
@@ -1,13 +1,22 @@
|
|||||||
.inventory {
|
.inventory, .documentation {
|
||||||
padding: 0 1em 1em 1em;
|
padding: 0 1em 1em 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inventory h2 {
|
.inventory h2, .documentation h2, .documentation h1 {
|
||||||
font-size: 1.5em;
|
font-size: 1.5em;
|
||||||
margin-top: 1em;
|
margin-top: 1em;
|
||||||
margin-bottom: .2em;
|
margin-bottom: .2em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.documentation h1 {
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documentation.hidden {
|
||||||
|
display: none;
|
||||||
|
transition: display 2s;
|
||||||
|
}
|
||||||
|
|
||||||
.inventory-list {
|
.inventory-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: .5em;
|
gap: .5em;
|
||||||
@@ -20,17 +29,20 @@
|
|||||||
padding: .1em .5em;
|
padding: .1em .5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inventory .item.locked,
|
.inventory .item.locked {
|
||||||
.inventory .item.disabled {
|
|
||||||
border: solid 1px #ccc;
|
border: solid 1px #ccc;
|
||||||
color: #ccc;
|
color: #ccc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inventory .item.disabled {
|
||||||
|
color: #d92c2c;
|
||||||
|
}
|
||||||
|
|
||||||
.inventory .item.new {
|
.inventory .item.new {
|
||||||
background-color: rgb(255, 242, 190);
|
background-color: rgb(255, 242, 190);
|
||||||
}
|
}
|
||||||
|
|
||||||
.inventory .item:not(.locked):not(.disabled) {
|
.inventory .item:not(.locked), .inventory .item.enabled {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,15 +67,3 @@
|
|||||||
color: black;
|
color: black;
|
||||||
border-bottom: 0.3em solid var(--clr-primary);
|
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 {
|
.info {
|
||||||
margin-top: 5px;
|
margin-top: 5px;
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
width: calc(100% - 20px);
|
/* width: calc(100% - 20px); */
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,19 +1,18 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate, Link } from "react-router-dom";
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import Markdown from './Markdown';
|
|
||||||
|
|
||||||
import '@fontsource/roboto/300.css';
|
import '@fontsource/roboto/300.css';
|
||||||
import '@fontsource/roboto/400.css';
|
import '@fontsource/roboto/400.css';
|
||||||
import '@fontsource/roboto/500.css';
|
import '@fontsource/roboto/500.css';
|
||||||
import '@fontsource/roboto/700.css';
|
import '@fontsource/roboto/700.css';
|
||||||
|
|
||||||
import './LandingPage.css'
|
import './landing_page.css'
|
||||||
import {PrivacyPolicyPopup} from './PrivacyPolicy'
|
|
||||||
|
|
||||||
import coverRobo from '../assets/covers/formaloversum.png'
|
import coverRobo from '../assets/covers/formaloversum.png'
|
||||||
import bgImage from '../assets/bg.jpg'
|
import bgImage from '../assets/bg.jpg'
|
||||||
|
|
||||||
|
import Markdown from './markdown';
|
||||||
|
import {PrivacyPolicyPopup} from './privacy_policy'
|
||||||
|
|
||||||
const flag = {
|
const flag = {
|
||||||
'Dutch': '🇳🇱',
|
'Dutch': '🇳🇱',
|
||||||
'English': '🇬🇧',
|
'English': '🇬🇧',
|
||||||
@@ -27,7 +26,6 @@ function GithubIcon({url='https://github.com'}) {
|
|||||||
return <div className="github-link">
|
return <div className="github-link">
|
||||||
<a title="view the Lean game server on Github" href={url}>
|
<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="">
|
<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>
|
<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>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
@@ -105,18 +103,6 @@ function LandingPage() {
|
|||||||
</header>
|
</header>
|
||||||
<div className="game-list">
|
<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
|
<GameTile
|
||||||
title="Natural Number Game"
|
title="Natural Number Game"
|
||||||
gameId="g/hhu-adam/NNG4"
|
gameId="g/hhu-adam/NNG4"
|
||||||
@@ -130,6 +116,18 @@ This is a good first introduction to Lean!"
|
|||||||
language="English"
|
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>
|
</div>
|
||||||
<section>
|
<section>
|
||||||
<div className="wrapper">
|
<div className="wrapper">
|
||||||
@@ -155,11 +153,13 @@ This is a good first introduction to Lean!"
|
|||||||
a template.
|
a template.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
There will be an option to load and run games through the server
|
There is an option to load and run your own games direclty on the server,
|
||||||
directly by specifying a URL, but this is still in development.
|
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>
|
||||||
<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.
|
games will need to be added manually.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -5,6 +5,13 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.level-mobile {
|
||||||
|
height: 100%;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
/* display: flex; */
|
||||||
|
}
|
||||||
|
|
||||||
.hidden {
|
.hidden {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -24,18 +31,22 @@
|
|||||||
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAeCAYAAADkftS9AAAAIklEQVQoU2M4c+bMfxAGAgYYmwGrIIiDjrELjpo5aiZeMwF+yNnOs5KSvgAAAABJRU5ErkJggg==');
|
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%;
|
height: 100%;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.introduction-panel, .infoview, .exercise {
|
.infoview {
|
||||||
padding-top: 1em;
|
padding-top: 1em;
|
||||||
padding-left: 1em;
|
|
||||||
padding-right: 1em;
|
|
||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.infoview, .exercise-statement {
|
||||||
|
padding-left: .5em;
|
||||||
|
padding-right: .5em;
|
||||||
|
}
|
||||||
|
|
||||||
.conclusion {
|
.conclusion {
|
||||||
padding: 1em;
|
padding: 1em;
|
||||||
}
|
}
|
||||||
@@ -93,12 +104,12 @@ mjx-container[jax="CHTML"][display="true"] {
|
|||||||
|
|
||||||
|
|
||||||
/* Styling tables for Markdown */
|
/* 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: 1px solid rgb(0, 0, 0, 0.12); */
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
}
|
}
|
||||||
|
|
||||||
.introduction-panel th, .introduction-panel td {
|
.chat-panel th, .chat-panel td {
|
||||||
padding-left: .5em;
|
padding-left: .5em;
|
||||||
padding-right: .5em;
|
padding-right: .5em;
|
||||||
}
|
}
|
||||||
@@ -155,7 +166,7 @@ td code {
|
|||||||
border: 1px solid rgb(230, 122, 0);
|
border: 1px solid rgb(230, 122, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.introduction-panel {
|
.chat-panel {
|
||||||
border: 1px solid rgb(192, 18, 178);
|
border: 1px solid rgb(192, 18, 178);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,3 +182,133 @@ td code {
|
|||||||
border: 1px solid rgb(255, 0, 0);
|
border: 1px solid rgb(255, 0, 0);
|
||||||
margin: 1px;
|
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 '@fontsource/roboto/700.css';
|
||||||
|
|
||||||
import { Button, Dialog, DialogContent, DialogContentText, DialogActions } from '@mui/material';
|
import { Button, Dialog, DialogContent, DialogContentText, DialogActions } from '@mui/material';
|
||||||
import Markdown from './Markdown';
|
import Markdown from './markdown';
|
||||||
|
|
||||||
function Message({ isOpen, content, close }) {
|
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 {
|
.welcome .column {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
scroll-behavior: smooth;
|
||||||
}
|
}
|
||||||
|
|
||||||
.welcome-text {
|
.welcome-text {
|
||||||
padding: 20px;
|
padding-left: 20px;
|
||||||
|
padding-right: 20px;
|
||||||
|
padding-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
i {
|
i {
|
||||||
@@ -54,16 +57,16 @@ h5, h6 {
|
|||||||
/* SVG Graphic */
|
/* SVG Graphic */
|
||||||
/***************/
|
/***************/
|
||||||
|
|
||||||
svg .world-title-wrapper {
|
svg .world-title-wrapper, svg .level-title-wrapper div {
|
||||||
overflow: auto;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
svg .world-title-wrapper div {
|
svg .world-title-wrapper div, svg .level-title-wrapper div {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
svg .world-title-wrapper div {
|
svg .world-title-wrapper div, svg .level-title-wrapper div {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items:center;
|
align-items:center;
|
||||||
justify-content:center;
|
justify-content:center;
|
||||||
@@ -71,11 +74,36 @@ svg .world-title-wrapper div {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
svg .world-title {
|
svg .world-title, svg .level-title {
|
||||||
font-weight: 500;
|
|
||||||
color: white;
|
color: white;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 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;
|
height: 40px;
|
||||||
font-size: 25px;
|
font-size: 25px;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
position: absolute;
|
position: fixed;
|
||||||
right: 10px;
|
right: 10px;
|
||||||
bottom: 10px;
|
bottom: 10px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -196,21 +224,41 @@ svg .world-title {
|
|||||||
width: 100%;
|
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 {
|
.modal a.download-link {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
text-decoration: underline dotted;
|
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 * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'
|
||||||
import { LeanClient } from 'lean4web/client/src/editor/leanclient';
|
import { LeanClient } from 'lean4web/client/src/editor/leanclient';
|
||||||
import * as React from 'react';
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
|
|
||||||
export class Connection {
|
export class Connection {
|
||||||
private game: string = undefined // We only keep a connection to a single game at a time
|
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) => {
|
export const useLeanClient = (gameId) => {
|
||||||
const leanClient = connection.getLeanClient(gameId)
|
const leanClient = connection.getLeanClient(gameId)
|
||||||
const [leanClientStarted, setLeanClientStarted] = useState(leanClient.isStarted())
|
const [leanClientStarted, setLeanClientStarted] = React.useState(leanClient.isStarted())
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const t1 = leanClient.restarted(() => { console.log("START"); setLeanClientStarted(true) })
|
const t1 = leanClient.restarted(() => { console.log("START"); setLeanClientStarted(true) })
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import App from './App';
|
import App from './app';
|
||||||
import { ConnectionContext, connection } from './connection'
|
import { ConnectionContext, connection } from './connection'
|
||||||
import { store } from './state/store';
|
import { store } from './state/store';
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
@@ -9,10 +9,10 @@ import {
|
|||||||
RouterProvider,
|
RouterProvider,
|
||||||
Route,
|
Route,
|
||||||
} from "react-router-dom";
|
} from "react-router-dom";
|
||||||
import ErrorPage from './ErrorPage';
|
import ErrorPage from './components/error_page';
|
||||||
import Welcome from './components/Welcome';
|
import Welcome from './components/welcome';
|
||||||
import LandingPage from './components/LandingPage';
|
import LandingPage from './components/landing_page';
|
||||||
import Level from './components/Level';
|
import Level from './components/level';
|
||||||
import { monacoSetup } from 'lean4web/client/src/monacoSetup';
|
import { monacoSetup } from 'lean4web/client/src/monacoSetup';
|
||||||
import { redirect } from 'react-router-dom';
|
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 { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
|
||||||
import { Connection } from '../connection'
|
import { Connection } from '../connection'
|
||||||
|
|
||||||
@@ -33,6 +36,14 @@ export interface LevelInfo {
|
|||||||
statementName: null|string
|
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 {
|
interface Doc {
|
||||||
name: string,
|
name: string,
|
||||||
displayName: string,
|
displayName: string,
|
||||||
@@ -71,6 +82,9 @@ export const apiSlice = createApi({
|
|||||||
loadLevel: builder.query<LevelInfo, {game: string, world: string, level: number}>({
|
loadLevel: builder.query<LevelInfo, {game: string, world: string, level: number}>({
|
||||||
query: ({game, world, level}) => {return {game, method: "loadLevel", params: {world, level}}},
|
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"}>({
|
loadDoc: builder.query<Doc, {game: string, name: string, type: "lemma"|"tactic"}>({
|
||||||
query: ({game, name, type}) => {return {game, method: "loadDoc", params: {name, type}}},
|
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
|
// Export hooks for usage in functional components, which are
|
||||||
// auto-generated based on the defined endpoints
|
// 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 { createSlice } from '@reduxjs/toolkit'
|
||||||
import type { PayloadAction } 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 {
|
interface Selection {
|
||||||
selectionStartLineNumber: number,
|
selectionStartLineNumber: number,
|
||||||
selectionStartColumn: number,
|
selectionStartColumn: number,
|
||||||
@@ -19,21 +15,56 @@ interface Selection {
|
|||||||
interface LevelProgressState {
|
interface LevelProgressState {
|
||||||
code: string,
|
code: string,
|
||||||
selections: Selection[],
|
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
|
export interface GameProgressState {
|
||||||
const initalLevelProgressState = {code: "", completed: false} as LevelProgressState
|
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]) {
|
* Currently we have three difficulties:
|
||||||
state.level[action.payload.game] = {}
|
*
|
||||||
|
* | 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]) {
|
if (!state.games[action.payload.game].data[action.payload.world][action.payload.level]) {
|
||||||
state.level[action.payload.game][action.payload.world][action.payload.level] = {...initalLevelProgressState}
|
state.games[action.payload.game].data[action.payload.world][action.payload.level] = {...initalLevelProgressState}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,60 +72,128 @@ export const progressSlice = createSlice({
|
|||||||
name: 'progress',
|
name: 'progress',
|
||||||
initialState: initialProgressState,
|
initialState: initialProgressState,
|
||||||
reducers: {
|
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)
|
addLevelProgress(state, action)
|
||||||
state.level[action.payload.game][action.payload.world][action.payload.level].code = action.payload.code
|
state.games[action.payload.game].data[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].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)
|
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)
|
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}>) {
|
/** Set the list of rows where help is displayed */
|
||||||
state.level[action.payload.game] = {}
|
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}`)
|
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) {
|
export function selectLevel(game: string, world: string, level: number) {
|
||||||
return (state) =>{
|
return (state) =>{
|
||||||
if (!state.progress.level[game]) { return initalLevelProgressState }
|
if (!state.progress.games[game]) { return initalLevelProgressState }
|
||||||
if (!state.progress.level[game][world]) { return initalLevelProgressState }
|
if (!state.progress.games[game].data[world]) { return initalLevelProgressState }
|
||||||
if (!state.progress.level[game][world][level]) { return initalLevelProgressState }
|
if (!state.progress.games[game].data[world][level]) { return initalLevelProgressState }
|
||||||
return state.progress.level[game][world][level]
|
return state.progress.games[game].data[world][level]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** return the code of the current level */
|
||||||
export function selectCode(game: string, world: string, level: number) {
|
export function selectCode(game: string, world: string, level: number) {
|
||||||
return (state) => {
|
return (state) => {
|
||||||
return selectLevel(game, world, level)(state).code
|
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) {
|
export function selectSelections(game: string, world: string, level: number) {
|
||||||
return (state) => {
|
return (state) => {
|
||||||
return selectLevel(game, world, level)(state).selections
|
return selectLevel(game, world, level)(state).selections
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** return whether the current level is clompleted */
|
||||||
export function selectCompleted(game: string, world: string, level: number) {
|
export function selectCompleted(game: string, world: string, level: number) {
|
||||||
return (state) => {
|
return (state) => {
|
||||||
return selectLevel(game, world, level)(state).completed
|
return selectLevel(game, world, level)(state).completed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** return progress for the current game if it exists */
|
||||||
export function selectProgress(game: string) {
|
export function selectProgress(game: string) {
|
||||||
return (state) => {
|
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 { configureStore } from '@reduxjs/toolkit';
|
||||||
|
import { debounce } from "debounce";
|
||||||
|
|
||||||
import { connection } from '../connection'
|
import { connection } from '../connection'
|
||||||
import thunkMiddleware from 'redux-thunk'
|
|
||||||
import { apiSlice } from './api'
|
import { apiSlice } from './api'
|
||||||
import { progressSlice } from './progress'
|
import { progressSlice } from './progress'
|
||||||
import { saveState } from "./localStorage";
|
import { saveState } from "./local_storage";
|
||||||
import { debounce } from "debounce";
|
|
||||||
|
|
||||||
export const store = configureStore({
|
export const store = configureStore({
|
||||||
reducer: {
|
reducer: {
|
||||||
@@ -20,10 +24,11 @@ export const store = configureStore({
|
|||||||
}).concat(apiSlice.middleware),
|
}).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(
|
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(() => {
|
debounce(() => {
|
||||||
saveState(store.getState()[progressSlice.name]);
|
saveState(store.getState()[progressSlice.name]);
|
||||||
}, 800)
|
}, 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",
|
"@emotion/styled": "^11.10.5",
|
||||||
"@fontsource/roboto": "^4.5.8",
|
"@fontsource/roboto": "^4.5.8",
|
||||||
"@fontsource/roboto-mono": "^4.5.8",
|
"@fontsource/roboto-mono": "^4.5.8",
|
||||||
|
"@leanprover/infoview": "^0.4.3",
|
||||||
"@mui/icons-material": "^5.11.0",
|
"@mui/icons-material": "^5.11.0",
|
||||||
"@mui/material": "^5.11.1",
|
"@mui/material": "^5.11.1",
|
||||||
"@reduxjs/toolkit": "^1.9.1",
|
"@reduxjs/toolkit": "^1.9.1",
|
||||||
@@ -26,6 +27,7 @@
|
|||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-markdown": "^8.0.4",
|
"react-markdown": "^8.0.4",
|
||||||
|
"react-native": "^0.72.3",
|
||||||
"react-redux": "^8.0.5",
|
"react-redux": "^8.0.5",
|
||||||
"react-router-dom": "^6.5.0",
|
"react-router-dom": "^6.5.0",
|
||||||
"react-split": "^2.0.14",
|
"react-split": "^2.0.14",
|
||||||
@@ -47,6 +49,7 @@
|
|||||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.10",
|
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.10",
|
||||||
"@redux-devtools/core": "^3.13.1",
|
"@redux-devtools/core": "^3.13.1",
|
||||||
"@testing-library/react": "^13.4.0",
|
"@testing-library/react": "^13.4.0",
|
||||||
|
"@types/debounce": "^1.2.1",
|
||||||
"babel-loader": "^8.3.0",
|
"babel-loader": "^8.3.0",
|
||||||
"concurrently": "^7.6.0",
|
"concurrently": "^7.6.0",
|
||||||
"css-loader": "^6.7.3",
|
"css-loader": "^6.7.3",
|
||||||
|
|||||||
+307
-44
@@ -4,6 +4,12 @@ open Lean Meta Elab Command
|
|||||||
|
|
||||||
set_option autoImplicit false
|
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 -/
|
/-! # Game metadata -/
|
||||||
|
|
||||||
/-- Switch to the specified `Game` (and create it if non-existent). Example: `Game "NNG"` -/
|
/-- 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}
|
| .World => modifyCurWorld fun world => pure {world with conclusion := t.getString}
|
||||||
| .Game => modifyCurGame fun game => pure {game 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
|
/-! # 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
|
/-- 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
|
a warning otherwise. If `template` is provided, it will add such an entry instead of yielding a
|
||||||
warning. -/
|
warning.
|
||||||
def checkInventoryDoc (type : InventoryType) (name : Ident)
|
|
||||||
|
`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
|
(template : Option String := none) : CommandElabM Unit := do
|
||||||
-- note: `name` is an `Ident` (instead of `Name`) for the log messages.
|
-- note: `name` is an `Ident` (instead of `Name`) for the log messages.
|
||||||
let env ← getEnv
|
let env ← getEnv
|
||||||
let n := name.getId
|
let n := name
|
||||||
-- Find a key with matching `(type, name)`.
|
-- Find a key with matching `(type, name)`.
|
||||||
match (inventoryTemplateExt.getState env).findIdx?
|
match (inventoryTemplateExt.getState env).findIdx?
|
||||||
(fun x => x.name == n && x.type == type) with
|
(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
|
-- We just add a dummy entry
|
||||||
modifyEnv (inventoryTemplateExt.addEntry · {
|
modifyEnv (inventoryTemplateExt.addEntry · {
|
||||||
type := type
|
type := type
|
||||||
name := name.getId
|
name := name
|
||||||
category := if type == .Lemma then s!"{n.getPrefix}" else "" })
|
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.")
|
m!"somewhere above this statement.")
|
||||||
-- Add the default documentation
|
-- Add the default documentation
|
||||||
| some s =>
|
| some s =>
|
||||||
modifyEnv (inventoryTemplateExt.addEntry · {
|
modifyEnv (inventoryTemplateExt.addEntry · {
|
||||||
type := type
|
type := type
|
||||||
name := name.getId
|
name := name
|
||||||
category := if type == .Lemma then s!"{n.getPrefix}" else ""
|
category := if type == .Lemma then s!"{n.getPrefix}" else ""
|
||||||
content := s })
|
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!"statement description) instead. If you want to write your own description, add " ++
|
||||||
m!"`{type}Doc {name}` somewhere above this statement.")
|
m!"`{type}Doc {name}` somewhere above this statement.")
|
||||||
|
|
||||||
@@ -282,6 +273,38 @@ elab "LemmaTab" category:str : command =>
|
|||||||
syntax statementAttr := "(" &"attr" ":=" Parser.Term.attrInstance,* ")"
|
syntax statementAttr := "(" &"attr" ":=" Parser.Term.attrInstance,* ")"
|
||||||
-- TODO
|
-- 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. -/
|
/-- Define the statement of the current level. -/
|
||||||
elab "Statement" statementName:ident ? descr:str ? sig:declSig val:declVal : command => do
|
elab "Statement" statementName:ident ? descr:str ? sig:declSig val:declVal : command => do
|
||||||
let lvlIdx ← getCurLevelIdx
|
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 ++
|
let defaultDeclName : Ident := mkIdent <| (← getCurGame).name ++ (← getCurWorld).name ++
|
||||||
("level" ++ toString lvlIdx : String)
|
("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.
|
-- Add theorem to context.
|
||||||
match statementName with
|
match statementName with
|
||||||
| some name =>
|
| some name =>
|
||||||
@@ -314,13 +343,13 @@ elab "Statement" statementName:ident ? descr:str ? sig:declSig val:declVal : com
|
|||||||
let thmStatement ← `(theorem $defaultDeclName $sig $val)
|
let thmStatement ← `(theorem $defaultDeclName $sig $val)
|
||||||
elabCommand thmStatement
|
elabCommand thmStatement
|
||||||
-- Check that statement has a docs entry.
|
-- Check that statement has a docs entry.
|
||||||
checkInventoryDoc .Lemma name (template := descr)
|
checkInventoryDoc .Lemma name (name := fullName) (template := descr)
|
||||||
|
|
||||||
else
|
else
|
||||||
let thmStatement ← `( theorem $name $sig $val)
|
let thmStatement ← `( theorem $name $sig $val)
|
||||||
elabCommand thmStatement
|
elabCommand thmStatement
|
||||||
-- Check that statement has a docs entry.
|
-- Check that statement has a docs entry.
|
||||||
checkInventoryDoc .Lemma name (template := descr)
|
checkInventoryDoc .Lemma name (name := fullName) (template := descr)
|
||||||
|
|
||||||
| none =>
|
| none =>
|
||||||
let thmStatement ← `(theorem $defaultDeclName $sig $val)
|
let thmStatement ← `(theorem $defaultDeclName $sig $val)
|
||||||
@@ -378,7 +407,10 @@ elab "Statement" statementName:ident ? descr:str ? sig:declSig val:declVal : com
|
|||||||
| none => default
|
| none => default
|
||||||
| some name => currNamespace ++ name.getId
|
| some name => currNamespace ++ name.getId
|
||||||
descrFormat := (Format.join [head, " ", st, " := by"]).pretty 10
|
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 -/
|
/-! # Hints -/
|
||||||
|
|
||||||
@@ -572,7 +604,7 @@ def getTacticDocstring (env : Environment) (name: Name) : CommandElabM (Option S
|
|||||||
if let some doc ← findDocString? env k then
|
if let some doc ← findDocString? env k then
|
||||||
return doc
|
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\"`"
|
m!"using `TacticDoc {name} \"some doc\"`"
|
||||||
return none
|
return none
|
||||||
|
|
||||||
@@ -587,15 +619,117 @@ def getDocstring (env : Environment) (name : Name) (type : InventoryType) :
|
|||||||
-- TODO: for definitions not implemented yet, does it work?
|
-- TODO: for definitions not implemented yet, does it work?
|
||||||
| .Definition => findDocString? env name
|
| .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
|
/-- Build the game. This command will precompute various things about the game, such as which
|
||||||
tactics are available in each level etc. -/
|
tactics are available in each level etc. -/
|
||||||
elab "MakeGame" : command => do
|
elab "MakeGame" : command => do
|
||||||
let game ← getCurGame
|
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
|
let env ← getEnv
|
||||||
|
|
||||||
-- Now create The doc entries from the templates
|
-- 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.
|
-- If documentation is missing, try using the docstring instead.
|
||||||
match docstring with
|
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)"
|
| none => "(missing)"
|
||||||
| template =>
|
| template =>
|
||||||
-- TODO: Process content template.
|
-- TODO: Process content template.
|
||||||
@@ -627,10 +761,131 @@ elab "MakeGame" : command => do
|
|||||||
content := content
|
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:
|
-- Compute which inventory items are available in which level:
|
||||||
for inventoryType in #[.Tactic, .Definition, .Lemma] do
|
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 := {}
|
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 := {}
|
let mut allItems : HashSet Name := {}
|
||||||
for (worldId, world) in game.worlds.nodes.toArray do
|
for (worldId, world) in game.worlds.nodes.toArray do
|
||||||
let mut newItems : HashSet Name := {}
|
let mut newItems : HashSet Name := {}
|
||||||
@@ -670,12 +925,20 @@ elab "MakeGame" : command => do
|
|||||||
let Availability₀ : HashMap Name InventoryTile :=
|
let Availability₀ : HashMap Name InventoryTile :=
|
||||||
HashMap.ofList $
|
HashMap.ofList $
|
||||||
← allItems.toList.mapM fun item => do
|
← allItems.toList.mapM fun item => do
|
||||||
let data := (← getInventoryItem? item inventoryType).get!
|
-- Using a match statement because the error message of `Option.get!` is not helpful.
|
||||||
-- TODO: BUG, panic at `get!` in vscode
|
match (← getInventoryItem? item inventoryType) with
|
||||||
return (item, {
|
| none =>
|
||||||
name := item
|
-- Note: we did have a panic here before because lemma statement and doc entry
|
||||||
displayName := data.displayName
|
-- had mismatching namespaces
|
||||||
category := data.category })
|
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
|
-- Availability after a given world
|
||||||
let mut itemsInWorld : HashMap Name (HashMap Name InventoryTile) := {}
|
let mut itemsInWorld : HashMap Name (HashMap Name InventoryTile) := {}
|
||||||
@@ -683,6 +946,7 @@ elab "MakeGame" : command => do
|
|||||||
-- Unlock all items from previous worlds
|
-- Unlock all items from previous worlds
|
||||||
let mut items := Availability₀
|
let mut items := Availability₀
|
||||||
let predecessors := game.worlds.predecessors worldId
|
let predecessors := game.worlds.predecessors worldId
|
||||||
|
-- logInfo m!"Predecessors: {predecessors.toArray.map fun (a) => (a)}"
|
||||||
for predWorldId in predecessors do
|
for predWorldId in predecessors do
|
||||||
for item in newItemsInWorld.find! predWorldId do
|
for item in newItemsInWorld.find! predWorldId do
|
||||||
let data := (← getInventoryItem? item inventoryType).get!
|
let data := (← getInventoryItem? item inventoryType).get!
|
||||||
@@ -740,7 +1004,6 @@ elab "MakeGame" : command => do
|
|||||||
return level.setComputedInventory inventoryType itemsArray
|
return level.setComputedInventory inventoryType itemsArray
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/-! # Debugging tools -/
|
/-! # Debugging tools -/
|
||||||
|
|
||||||
-- /-- Print current game for debugging purposes. -/
|
-- /-- Print current game for debugging purposes. -/
|
||||||
|
|||||||
@@ -202,6 +202,8 @@ structure LevelId where
|
|||||||
deriving Inhabited
|
deriving Inhabited
|
||||||
|
|
||||||
structure InventoryInfo where
|
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 inventory items introduced by this level -/
|
||||||
new : Array Name
|
new : Array Name
|
||||||
/-- inventory items exceptionally forbidden in this level -/
|
/-- 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 to make GameLevel Repr work -/
|
||||||
instance : Repr Elab.Command.Scope := ⟨fun s _ => repr s.currNamespace⟩
|
instance : Repr Elab.Command.Scope := ⟨fun s _ => repr s.currNamespace⟩
|
||||||
|
|
||||||
|
|
||||||
structure GameLevel where
|
structure GameLevel where
|
||||||
index: Nat
|
index: Nat
|
||||||
/-- The title of the level. -/
|
/-- The title of the level. -/
|
||||||
|
|||||||
@@ -74,13 +74,21 @@ partial def findForbiddenTactics (inputCtx : Parser.InputContext)
|
|||||||
let allowed := ["with", "fun", "at", "only"]
|
let allowed := ["with", "fun", "at", "only"]
|
||||||
if 0 < val.length ∧ val.data[0]!.isAlpha ∧ not (allowed.contains val) then
|
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`
|
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
|
match levelParams.tactics.find? (·.name.toString == val) with
|
||||||
| none => addErrorMessage info s!"You have not unlocked the tactic '{val}' yet!"
|
| 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 =>
|
| some tac =>
|
||||||
if tac.locked then
|
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
|
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 =>
|
| .ident info rawVal val preresolved =>
|
||||||
let ns ←
|
let ns ←
|
||||||
try resolveGlobalConst (mkIdent val)
|
try resolveGlobalConst (mkIdent val)
|
||||||
@@ -90,17 +98,17 @@ partial def findForbiddenTactics (inputCtx : Parser.InputContext)
|
|||||||
| return () -- not a theroem -> ignore
|
| return () -- not a theroem -> ignore
|
||||||
let lemmasAndDefs := levelParams.lemmas ++ levelParams.definitions
|
let lemmasAndDefs := levelParams.lemmas ++ levelParams.definitions
|
||||||
match lemmasAndDefs.find? (fun l => l.name == n) with
|
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 =>
|
| some lem =>
|
||||||
if lem.locked then
|
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
|
else if lem.disabled then
|
||||||
addErrorMessage info s!"The lemma/definition '{n}' is disabled in this level!"
|
addWarningMessage info s!"The lemma/definition '{n}' is disabled in this level!"
|
||||||
where addErrorMessage (info : SourceInfo) (s : MessageData) :=
|
where addWarningMessage (info : SourceInfo) (s : MessageData) :=
|
||||||
modify fun st => { st with
|
modify fun st => { st with
|
||||||
messages := st.messages.add {
|
messages := st.messages.add {
|
||||||
fileName := inputCtx.fileName
|
fileName := inputCtx.fileName
|
||||||
severity := MessageSeverity.error
|
severity := MessageSeverity.warning
|
||||||
pos := inputCtx.fileMap.toPosition (info.getPos?.getD 0)
|
pos := inputCtx.fileMap.toPosition (info.getPos?.getD 0)
|
||||||
data := s
|
data := s
|
||||||
}
|
}
|
||||||
@@ -108,7 +116,8 @@ where addErrorMessage (info : SourceInfo) (s : MessageData) :=
|
|||||||
|
|
||||||
open Elab Meta Expr in
|
open Elab Meta Expr in
|
||||||
def compileProof (inputCtx : Parser.InputContext) (snap : Snapshot) (hasWidgets : Bool)
|
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
|
-- Recognize end snap
|
||||||
if inputCtx.input.atEnd snap.mpState.pos ∧ couldBeEndSnap then
|
if inputCtx.input.atEnd snap.mpState.pos ∧ couldBeEndSnap then
|
||||||
let endSnap : Snapshot := {
|
let endSnap : Snapshot := {
|
||||||
@@ -161,7 +170,8 @@ def compileProof (inputCtx : Parser.InputContext) (snap : Snapshot) (hasWidgets
|
|||||||
parseResultRef.set (tacticStx, cmdParserState)
|
parseResultRef.set (tacticStx, cmdParserState)
|
||||||
|
|
||||||
-- Check for forbidden tactics
|
-- 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
|
-- 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 #[]
|
let skip := Syntax.node (.original default 0 default endOfWhitespace) ``Lean.Parser.Tactic.skip #[]
|
||||||
@@ -300,14 +310,21 @@ where
|
|||||||
|
|
||||||
end Elab
|
end Elab
|
||||||
|
|
||||||
|
structure GameWorkerState :=
|
||||||
|
(levelParams : Game.DidOpenLevelParams)
|
||||||
|
|
||||||
|
abbrev GameWorkerM := StateT GameWorkerState Server.FileWorker.WorkerM
|
||||||
|
|
||||||
section Updates
|
section Updates
|
||||||
|
|
||||||
/-- Given the new document, updates editable doc state. -/
|
/-- 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 ctx ← read
|
||||||
let oldDoc := (←get).doc
|
let oldDoc := (← StateT.lift get).doc
|
||||||
oldDoc.cancelTk.set
|
oldDoc.cancelTk.set
|
||||||
let initHeaderStx := (← get).initHeaderStx
|
let initHeaderStx := (← StateT.lift get).initHeaderStx
|
||||||
let (newHeaderStx, newMpState, _) ← Parser.parseHeader newMeta.mkInputContext
|
let (newHeaderStx, newMpState, _) ← Parser.parseHeader newMeta.mkInputContext
|
||||||
let cancelTk ← CancelToken.new
|
let cancelTk ← CancelToken.new
|
||||||
let headSnapTask := oldDoc.cmdSnaps.waitHead?
|
let headSnapTask := oldDoc.cmdSnaps.waitHead?
|
||||||
@@ -347,7 +364,8 @@ section Updates
|
|||||||
-- before kicking off any expensive elaboration (TODO: make expensive elaboration cancelable)
|
-- before kicking off any expensive elaboration (TODO: make expensive elaboration cancelable)
|
||||||
unfoldSnaps newMeta validSnaps.toArray cancelTk levelParams ctx
|
unfoldSnaps newMeta validSnaps.toArray cancelTk levelParams ctx
|
||||||
(startAfterMs := ctx.initParams.editDelay.toUInt32)
|
(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
|
end Updates
|
||||||
|
|
||||||
@@ -359,7 +377,8 @@ section Initialization
|
|||||||
fileMap := default
|
fileMap := default
|
||||||
|
|
||||||
def compileHeader (m : DocumentMeta) (hOut : FS.Stream) (opts : Options) (hasWidgets : Bool)
|
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`.
|
-- Determine search paths of the game project by running `lake env printenv LEAN_PATH`.
|
||||||
let out ← IO.Process.output
|
let out ← IO.Process.output
|
||||||
{ cwd := levelParams.gameDir, cmd := "lake", args := #["env","printenv","LEAN_PATH"] }
|
{ cwd := levelParams.gameDir, cmd := "lake", args := #["env","printenv","LEAN_PATH"] }
|
||||||
@@ -447,10 +466,10 @@ end Initialization
|
|||||||
|
|
||||||
section NotificationHandling
|
section NotificationHandling
|
||||||
|
|
||||||
def handleDidChange (levelParams : Game.DidOpenLevelParams) (p : DidChangeTextDocumentParams) : WorkerM Unit := do
|
def handleDidChange (p : DidChangeTextDocumentParams) : GameWorkerM Unit := do
|
||||||
let docId := p.textDocument
|
let docId := p.textDocument
|
||||||
let changes := p.contentChanges
|
let changes := p.contentChanges
|
||||||
let oldDoc := (←get).doc
|
let oldDoc := (← StateT.lift get).doc
|
||||||
let some newVersion ← pure docId.version?
|
let some newVersion ← pure docId.version?
|
||||||
| throwServerError "Expected version number"
|
| throwServerError "Expected version number"
|
||||||
if newVersion ≤ oldDoc.meta.version then
|
if newVersion ≤ oldDoc.meta.version then
|
||||||
@@ -458,28 +477,28 @@ section NotificationHandling
|
|||||||
IO.eprintln s!"Got outdated version number: {newVersion} ≤ {oldDoc.meta.version}"
|
IO.eprintln s!"Got outdated version number: {newVersion} ≤ {oldDoc.meta.version}"
|
||||||
else if ¬ changes.isEmpty then
|
else if ¬ changes.isEmpty then
|
||||||
let newDocText := foldDocumentChanges changes oldDoc.meta.text
|
let newDocText := foldDocumentChanges changes oldDoc.meta.text
|
||||||
updateDocument ⟨docId.uri, newVersion, newDocText⟩ levelParams
|
updateDocument ⟨docId.uri, newVersion, newDocText⟩
|
||||||
|
|
||||||
end NotificationHandling
|
end NotificationHandling
|
||||||
|
|
||||||
section MessageHandling
|
section MessageHandling
|
||||||
|
|
||||||
def handleNotification (method : String) (params : Json) (levelParams : Game.DidOpenLevelParams) : WorkerM Unit := do
|
def handleNotification (method : String) (params : Json) : GameWorkerM Unit := do
|
||||||
let handle := fun paramType [FromJson paramType] (handler : paramType → WorkerM Unit) =>
|
let handle := fun paramType [FromJson paramType] (handler : paramType → GameWorkerM Unit) =>
|
||||||
parseParams paramType params >>= handler
|
(StateT.lift <| parseParams paramType params) >>= handler
|
||||||
match method with
|
match method with
|
||||||
| "textDocument/didChange" => handle DidChangeTextDocumentParams (handleDidChange levelParams)
|
| "textDocument/didChange" => handle DidChangeTextDocumentParams (handleDidChange)
|
||||||
| "$/cancelRequest" => handle CancelParams handleCancelRequest
|
| "$/cancelRequest" => handle CancelParams (handleCancelRequest ·)
|
||||||
| "$/lean/rpc/release" => handle RpcReleaseParams handleRpcRelease
|
| "$/lean/rpc/release" => handle RpcReleaseParams (handleRpcRelease ·)
|
||||||
| "$/lean/rpc/keepAlive" => handle RpcKeepAliveParams handleRpcKeepAlive
|
| "$/lean/rpc/keepAlive" => handle RpcKeepAliveParams (handleRpcKeepAlive ·)
|
||||||
| _ => throwServerError s!"Got unsupported notification method: {method}"
|
| _ => throwServerError s!"Got unsupported notification method: {method}"
|
||||||
|
|
||||||
end MessageHandling
|
end MessageHandling
|
||||||
|
|
||||||
section MainLoop
|
section MainLoop
|
||||||
partial def mainLoop (levelParams : Game.DidOpenLevelParams) : WorkerM Unit := do
|
partial def mainLoop : GameWorkerM Unit := do
|
||||||
let ctx ← read
|
let ctx ← read
|
||||||
let mut st ← get
|
let mut st ← StateT.lift get
|
||||||
let msg ← ctx.hIn.readLspMessage
|
let msg ← ctx.hIn.readLspMessage
|
||||||
let filterFinishedTasks (acc : PendingRequestMap) (id : RequestID) (task : Task (Except IO.Error Unit))
|
let filterFinishedTasks (acc : PendingRequestMap) (id : RequestID) (task : Task (Except IO.Error Unit))
|
||||||
: IO PendingRequestMap := do
|
: IO PendingRequestMap := do
|
||||||
@@ -502,16 +521,25 @@ section MainLoop
|
|||||||
set st
|
set st
|
||||||
match msg with
|
match msg with
|
||||||
| Message.request id method (some params) =>
|
| Message.request id method (some params) =>
|
||||||
|
-- TODO: What's this error message?
|
||||||
if method == "Game.getInteractiveGoals" then throwServerError "HELLO"
|
if method == "Game.getInteractiveGoals" then throwServerError "HELLO"
|
||||||
handleRequest id method (toJson params)
|
handleRequest id method (toJson params)
|
||||||
mainLoop levelParams
|
mainLoop
|
||||||
| Message.notification "exit" none =>
|
| Message.notification "exit" none =>
|
||||||
let doc := st.doc
|
let doc := st.doc
|
||||||
doc.cancelTk.set
|
doc.cancelTk.set
|
||||||
return ()
|
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) =>
|
| Message.notification method (some params) =>
|
||||||
handleNotification method (toJson params) levelParams
|
handleNotification method (toJson params)
|
||||||
mainLoop levelParams
|
mainLoop
|
||||||
| _ => throwServerError "Got invalid JSON-RPC message"
|
| _ => throwServerError "Got invalid JSON-RPC message"
|
||||||
end MainLoop
|
end MainLoop
|
||||||
|
|
||||||
@@ -533,11 +561,15 @@ def initAndRunWorker (i o e : FS.Stream) (opts : Options) : IO UInt32 := do
|
|||||||
let _ ← IO.setStderr e
|
let _ ← IO.setStderr e
|
||||||
try
|
try
|
||||||
let (ctx, st) ← initializeWorker meta i o e initParams.param opts levelParams
|
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)
|
return (0 : UInt32)
|
||||||
catch e =>
|
catch e =>
|
||||||
IO.eprintln 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)
|
return (1 : UInt32)
|
||||||
|
|
||||||
def workerMain (opts : Options) : IO UInt32 := do
|
def workerMain (opts : Options) : IO UInt32 := do
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ structure GameServerState :=
|
|||||||
(env : Lean.Environment)
|
(env : Lean.Environment)
|
||||||
(game : Name)
|
(game : Name)
|
||||||
(gameDir : String)
|
(gameDir : String)
|
||||||
|
(inventory : Array String)
|
||||||
|
(checkEnabled : Bool)
|
||||||
|
|
||||||
abbrev GameServerM := StateT GameServerState Server.Watchdog.ServerM
|
abbrev GameServerM := StateT GameServerState Server.Watchdog.ServerM
|
||||||
|
|
||||||
@@ -51,6 +53,13 @@ structure LevelInfo where
|
|||||||
statementName : Option String
|
statementName : Option String
|
||||||
deriving ToJson, FromJson
|
deriving ToJson, FromJson
|
||||||
|
|
||||||
|
structure InventoryOverview where
|
||||||
|
tactics : Array InventoryTile
|
||||||
|
lemmas : Array InventoryTile
|
||||||
|
definitions : Array InventoryTile
|
||||||
|
lemmaTab : Option String
|
||||||
|
deriving ToJson, FromJson
|
||||||
|
|
||||||
structure LoadLevelParams where
|
structure LoadLevelParams where
|
||||||
world : Name
|
world : Name
|
||||||
level : Nat
|
level : Nat
|
||||||
@@ -63,6 +72,9 @@ structure DidOpenLevelParams where
|
|||||||
tactics : Array InventoryTile
|
tactics : Array InventoryTile
|
||||||
lemmas : Array InventoryTile
|
lemmas : Array InventoryTile
|
||||||
definitions : 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
|
deriving ToJson, FromJson
|
||||||
|
|
||||||
structure LoadDocParams where
|
structure LoadDocParams where
|
||||||
@@ -70,6 +82,11 @@ structure LoadDocParams where
|
|||||||
type : InventoryType
|
type : InventoryType
|
||||||
deriving ToJson, FromJson
|
deriving ToJson, FromJson
|
||||||
|
|
||||||
|
structure SetInventoryParams where
|
||||||
|
inventory : Array String
|
||||||
|
checkEnabled : Bool
|
||||||
|
deriving ToJson, FromJson
|
||||||
|
|
||||||
def handleDidOpenLevel (params : Json) : GameServerM Unit := do
|
def handleDidOpenLevel (params : Json) : GameServerM Unit := do
|
||||||
let p ← parseParams _ params
|
let p ← parseParams _ params
|
||||||
let m := p.textDocument
|
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.putStr s!"Level not found: {m.uri} {c.initParams.rootUri?}"
|
||||||
c.hLog.flush
|
c.hLog.flush
|
||||||
-- Send an extra notification to the file worker to inform it about the level data
|
-- Send an extra notification to the file worker to inform it about the level data
|
||||||
|
let s ← get
|
||||||
fw.stdin.writeLspNotification {
|
fw.stdin.writeLspNotification {
|
||||||
method := "$/game/didOpenLevel"
|
method := "$/game/didOpenLevel"
|
||||||
param := {
|
param := {
|
||||||
uri := m.uri
|
uri := m.uri
|
||||||
gameDir := (← get).gameDir
|
gameDir := s.gameDir
|
||||||
levelModule := lvl.module
|
levelModule := lvl.module
|
||||||
tactics := lvl.tactics.tiles
|
tactics := lvl.tactics.tiles
|
||||||
lemmas := lvl.lemmas.tiles
|
lemmas := lvl.lemmas.tiles
|
||||||
definitions := lvl.definitions.tiles
|
definitions := lvl.definitions.tiles
|
||||||
|
inventory := s.inventory
|
||||||
|
checkEnabled := s.checkEnabled
|
||||||
: DidOpenLevelParams
|
: DidOpenLevelParams
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,7 +161,7 @@ partial def handleServerEvent (ev : ServerEvent) : GameServerM Bool := do
|
|||||||
return true
|
return true
|
||||||
| Message.request id "loadDoc" params =>
|
| Message.request id "loadDoc" params =>
|
||||||
let p ← parseParams LoadDocParams (toJson params)
|
let p ← parseParams LoadDocParams (toJson params)
|
||||||
-- let s ← get
|
let s ← get
|
||||||
let c ← read
|
let c ← read
|
||||||
let some doc ← getInventoryItem? p.name p.type
|
let some doc ← getInventoryItem? p.name p.type
|
||||||
| do
|
| do
|
||||||
@@ -154,6 +174,39 @@ partial def handleServerEvent (ev : ServerEvent) : GameServerM Bool := do
|
|||||||
-- name := doc.name.toString }
|
-- name := doc.name.toString }
|
||||||
c.hOut.writeLspResponse ⟨id, ToJson.toJson doc⟩
|
c.hOut.writeLspResponse ⟨id, ToJson.toJson doc⟩
|
||||||
return true
|
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
|
||||||
| _ => return false
|
| _ => return false
|
||||||
|
|
||||||
|
|||||||
@@ -23,41 +23,6 @@ instance : EmptyCollection (Graph α β) := ⟨default⟩
|
|||||||
def Graph.insertNode (g : Graph α β) (a : α) (b : β) :=
|
def Graph.insertNode (g : Graph α β) (a : α) (b : β) :=
|
||||||
{g with nodes := g.nodes.insert 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
|
partial def Graph.predecessors (g : Graph α β) (x : α) (acc : HashSet α := {}) : HashSet α := Id.run do
|
||||||
let mut res := acc
|
let mut res := acc
|
||||||
let directPredecessors := (g.edges.filter (·.2 == x)).map (·.1)
|
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 i ← maybeTee "wdIn.txt" false i
|
||||||
let o ← maybeTee "wdOut.txt" true o
|
let o ← maybeTee "wdOut.txt" true o
|
||||||
let e ← maybeTee "wdErr.txt" true e
|
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
|
let initRequest ← i.readLspRequestAs "initialize" InitializeParams
|
||||||
-- We misuse the `rootUri` field to the gameName
|
-- We misuse the `rootUri` field to the gameName
|
||||||
let rootUri? := 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