commit d3d23271124a57b1abf48660513940d49fe1778e Author: eternal-flame-AD Date: Thu Jul 6 04:42:03 2023 -0500 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4fffb2f --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +/Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..2cd2fb0 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "unitdc" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +log = "0.4.19" +num-bigint = { version = "0.4.3", features = ["serde"] } +num-rational = { version = "0.4.1", features = ["serde"] } +num-traits = "0.2.15" +ouroboros = "0.17.0" +serde = { version = "1.0.164", features = ["derive"] } +thiserror = "1.0.40" + +[workspace] +members = [ + "crates/unitdc-cli", + "crates/unitdc-web", +] + +[profile.release.package.unitdc-web] +opt-level = "s" diff --git a/crates/unitdc-cli/Cargo.toml b/crates/unitdc-cli/Cargo.toml new file mode 100644 index 0000000..8a4eae2 --- /dev/null +++ b/crates/unitdc-cli/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "unitdc-cli" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +unitdc = { path = "../../" } diff --git a/crates/unitdc-cli/src/main.rs b/crates/unitdc-cli/src/main.rs new file mode 100644 index 0000000..7434e70 --- /dev/null +++ b/crates/unitdc-cli/src/main.rs @@ -0,0 +1,25 @@ +use unitdc::interpreter::{Interpreter, Output}; + +fn main() { + let mut interpreter = Interpreter::new(Box::new(|output| match output { + Output::Message(e) => eprintln!("message: {}", e), + Output::Quantity(q) => println!("[0]: {}", q), + Output::QuantityList(mut q) => { + q.reverse(); + for (i, q) in q.iter().enumerate() { + println!("[{}]: {}", i, q) + } + } + })); + + interpreter + .run_str(include_str!("../../../unitdc.rc")) + .expect("unitdc.rc should run"); + + for line in std::io::stdin().lines() { + let line = line.expect("line should exist"); + if let Err(e) = interpreter.run_str(&line) { + eprintln!("{}", e) + } + } +} diff --git a/crates/unitdc-web/.gitignore b/crates/unitdc-web/.gitignore new file mode 100644 index 0000000..4e30131 --- /dev/null +++ b/crates/unitdc-web/.gitignore @@ -0,0 +1,6 @@ +/target +**/*.rs.bk +Cargo.lock +bin/ +pkg/ +wasm-pack.log diff --git a/crates/unitdc-web/Cargo.toml b/crates/unitdc-web/Cargo.toml new file mode 100644 index 0000000..4727ab8 --- /dev/null +++ b/crates/unitdc-web/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "unitdc-web" +version = "0.1.0" +authors = ["eternal-flame-AD "] +edition = "2018" + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +default = ["console_error_panic_hook"] + +[dependencies] +wasm-bindgen = "0.2.63" +unitdc = { path = "../../" } + +# The `console_error_panic_hook` crate provides better debugging of panics by +# logging them with `console.error`. This is great for development, but requires +# all the `std::fmt` and `std::panicking` infrastructure, so isn't great for +# code size when deploying. +console_error_panic_hook = { version = "0.1.6", optional = true } + +# `wee_alloc` is a tiny allocator for wasm that is only ~1K in code size +# compared to the default allocator's ~10K. It is slower than the default +# allocator, however. +wee_alloc = { version = "0.4.5", optional = true } +serde_json = "1.0.99" +web-sys = { version = "0.3.64", features = ["Window"] } +serde-wasm-bindgen = "0.5.0" +js-sys = "0.3.64" +console_log = { version = "1.0.0", features = ["wasm-bindgen"] } + +[dev-dependencies] +wasm-bindgen-test = "0.3.13" diff --git a/crates/unitdc-web/src/lib.rs b/crates/unitdc-web/src/lib.rs new file mode 100644 index 0000000..179f9b1 --- /dev/null +++ b/crates/unitdc-web/src/lib.rs @@ -0,0 +1,75 @@ +mod utils; + +use js_sys::Function; +use unitdc::interpreter::{Interpreter, Output}; +use utils::set_panic_hook; +use wasm_bindgen::prelude::*; + +// When the `wee_alloc` feature is enabled, use `wee_alloc` as the global +// allocator. +#[cfg(feature = "wee_alloc")] +#[global_allocator] +static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT; + +static mut INTERPRETER: Option = None; + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(js_namespace = console)] + fn log(s: String); +} + +#[wasm_bindgen] +pub fn unitdc_input(input: String) -> Result<(), JsValue> { + unsafe { + if let Some(ref mut interpreter) = INTERPRETER { + interpreter.run_str(&input).map_err(|e| e.to_string())?; + } + } + Ok(()) +} + +#[wasm_bindgen] +pub fn unitdc_init(js_output: Function) { + unsafe { + INTERPRETER = Some(Interpreter::new(Box::new(move |output| match output { + Output::Quantity(q) => { + js_output + .call2( + &JsValue::NULL, + &JsValue::from("quantity"), + &serde_wasm_bindgen::to_value(&q).unwrap(), + ) + .unwrap(); + } + Output::QuantityList(q) => { + js_output + .call2( + &JsValue::NULL, + &JsValue::from("quantity_list"), + &serde_wasm_bindgen::to_value(&q).unwrap(), + ) + .unwrap(); + } + Output::Message(e) => { + js_output + .call2(&JsValue::NULL, &JsValue::from("message"), &JsValue::from(e)) + .unwrap(); + } + }))); + INTERPRETER + .as_mut() + .unwrap() + .run_str(include_str!("../../../unitdc.rc")) + .unwrap(); + } +} + +#[wasm_bindgen(start)] +pub fn run() -> Result<(), JsValue> { + #[cfg(feature = "console_log")] + console_log::init().expect("could not initialize logger"); + set_panic_hook(); + + Ok(()) +} diff --git a/crates/unitdc-web/src/utils.rs b/crates/unitdc-web/src/utils.rs new file mode 100644 index 0000000..b1d7929 --- /dev/null +++ b/crates/unitdc-web/src/utils.rs @@ -0,0 +1,10 @@ +pub fn set_panic_hook() { + // When the `console_error_panic_hook` feature is enabled, we can call the + // `set_panic_hook` function at least once during initialization, and then + // we will get better error messages if our code ever panics. + // + // For more details see + // https://github.com/rustwasm/console_error_panic_hook#readme + #[cfg(feature = "console_error_panic_hook")] + console_error_panic_hook::set_once(); +} diff --git a/crates/unitdc-web/ui/.eslintrc.cjs b/crates/unitdc-web/ui/.eslintrc.cjs new file mode 100644 index 0000000..4020bcb --- /dev/null +++ b/crates/unitdc-web/ui/.eslintrc.cjs @@ -0,0 +1,14 @@ +module.exports = { + env: { browser: true, es2020: true }, + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:react-hooks/recommended', + ], + parser: '@typescript-eslint/parser', + parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, + plugins: ['react-refresh'], + rules: { + 'react-refresh/only-export-components': 'warn', + }, +} diff --git a/crates/unitdc-web/ui/.gitignore b/crates/unitdc-web/ui/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/crates/unitdc-web/ui/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/crates/unitdc-web/ui/index.html b/crates/unitdc-web/ui/index.html new file mode 100644 index 0000000..e0d1c84 --- /dev/null +++ b/crates/unitdc-web/ui/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + TS + + +
+ + + diff --git a/crates/unitdc-web/ui/package.json b/crates/unitdc-web/ui/package.json new file mode 100644 index 0000000..a642d22 --- /dev/null +++ b/crates/unitdc-web/ui/package.json @@ -0,0 +1,28 @@ +{ + "name": "ui", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@types/react": "^18.0.37", + "@types/react-dom": "^18.0.11", + "@typescript-eslint/eslint-plugin": "^5.59.0", + "@typescript-eslint/parser": "^5.59.0", + "@vitejs/plugin-react": "^4.0.0", + "eslint": "^8.38.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.3.4", + "typescript": "^5.0.2", + "vite": "^4.3.9" + } +} \ No newline at end of file diff --git a/crates/unitdc-web/ui/public/vite.svg b/crates/unitdc-web/ui/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/crates/unitdc-web/ui/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/unitdc-web/ui/src/App.css b/crates/unitdc-web/ui/src/App.css new file mode 100644 index 0000000..a22edb6 --- /dev/null +++ b/crates/unitdc-web/ui/src/App.css @@ -0,0 +1,123 @@ +html { + height: 100%; +} + +.unitdc-io { + margin: .5em .25em .5em; + min-height: 1em; + font-size: 20; + padding: .25rem .25rem .5rem; +} + +#unitdc-dialog { + max-height: calc(60vh); + padding-bottom: .5rem; + overflow-y: scroll; + overflow-x: visible; +} + +.unitdc-container { + overflow-y: hidden; + position: relative; + height: 100%; +} + +label span.submit-hint { + font-style: italic; + font-size: smaller; + float: right; + color: #808080; + display: none; +} + +.input-active label span.submit-hint { + display: block; +} + +.input { + background-color: #ececec; + white-space: pre; +} + +.output { + background-color: lightgreen; + white-space: pre; +} + +.error { + background-color: lightpink; + white-space: pre; +} + +.input textarea { + display: block; + width: 80%; + overflow: hidden; + resize: none; + background-color: rgba(0, 0, 0, 0); + border: none; + outline: none; + padding-left: 2em; +} + +.input input[type=button] { + width: 100%; +} + +.unitdc-keyboard { + padding: .25rem .25rem .5rem; + position: absolute; + bottom: 0px; + display: grid; + grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr; + background-color: #efefef; + width: 100%; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.unitdc-keyboard-spacer { + height: calc(18vh); + width: 100%; + display: block; +} + +.unitdc-keyboard .keyboard-key { + font-size: calc(2vh); + padding: calc(.5vh); + height: calc(3vh); + font-weight: bolder; + text-align: center; + background-color: #ffffff; + margin: .15em; +} + +.keyboard-key:hover { + background-color: #efefef; +} + +.keyboard-key:active { + background-color: gray; +} + +.unitdc-keyboard .modifier-pressed { + filter: brightness(.6); +} + +div.keyboard-key[data-tokentype=operator] { + background-color: pink; +} + +div.keyboard-key[data-tokentype=literal_num] { + background-color: lightskyblue; +} + +div.keyboard-key[data-tokentype=unit_modifier] { + background-color: ivory; +} + +div.keyboard-key[data-tokentype=unit] { + background-color: lightsteelblue; +} \ No newline at end of file diff --git a/crates/unitdc-web/ui/src/App.tsx b/crates/unitdc-web/ui/src/App.tsx new file mode 100644 index 0000000..70575b6 --- /dev/null +++ b/crates/unitdc-web/ui/src/App.tsx @@ -0,0 +1,225 @@ + +import { useState, useReducer } from 'react' + +import './App.css' +import { Keyboard, TokenType } from './components/Keyboard' +import { InputCell } from './components/InputCell'; +import unitdc_wasm, { unitdc_input, unitdc_init } from '../../pkg'; +import { ErrorCell } from './components/ErrorCell'; +import { Quantity } from './types'; +import { OutputCell } from './components/OutputCell'; +import { useForceUpdate } from './util'; + +type IoCellDef = IOTextCellDef | IOQuantityCellDef; + +interface IOQuantityCellDef { + type: 'output', + quantity: Quantity[] +} + +interface IOTextCellDef { + type: 'input' | 'message' | 'error', + text: string, +} + +type IoCellAction = IoCellAddAction | IoCellUpdateTextAction; + +interface IoCellAddAction { + type: 'add', + cell: IoCellDef, +} + +interface IoCellUpdateTextAction { + type: 'updateText', + index: number, + text: string, +} + +function IoCellReducer(state: IoCellDef[], action: IoCellAction) { + switch (action.type) { + case 'add': + return [ + ...state, + action.cell, + ] + case 'updateText': + const newState = [...state]; + (newState[action.index] as IOTextCellDef).text = action.text; + return newState; + default: + throw new Error(); + } +} + +function App() { + const [ioCells, ioCellsDispatch] = useReducer(IoCellReducer, []); + const [mounted, setMounted] = useState(false); + const [lastTokenType, setLastTokenType] = useState(""); + const forceUpdate = useForceUpdate(); + + const addCells = (cells: IoCellDef[]) => { + cells.forEach((cell) => { + ioCellsDispatch({ type: 'add', cell: cell }) + }) + } + + const lastInputCell = () => ioCells.filter((cell) => cell.type === 'input').slice(-1)[0] as IOTextCellDef; + + const appendToken = (token: string, tokenType: TokenType) => { + let cell = lastInputCell(); + if (lastTokenType != tokenType) { + cell.text += ' '; + } + if (tokenType === 'unit') { + token = '(' + token + ')'; + } + cell.text += token; + setLastTokenType(tokenType); + forceUpdate(); + } + + + const processOutput = (type: 'quantity' | 'quantity_list' | 'message', data: any) => { + if (type === 'quantity') { + addCells([ + { + type: 'output', + quantity: [data], + } + ]) + } else if (type === 'quantity_list') { + addCells([ + { + type: 'output', + quantity: data, + } + ]) + } else if (type === 'message') { + addCells([ + { + type: 'message', + text: data, + } + ]) + } + } + + const submit = () => { + let success = false; + let text = lastInputCell().text; + console.log('submit', text); + try { + unitdc_input(text) + success = true; + } catch (e) { + addCells([ + { + type: 'error', + text: (e as any).toString(), + } + ]) + } + addCells([ + { + type: 'input', + text: success ? '' : text, + } + ]) + }; + + if (!mounted) { + setMounted(true); + unitdc_wasm().then(() => { + unitdc_init(processOutput); + addCells([ + { + type: 'input', + text: '', + } + ]) + }); + } + + + return ( + <> +
+

+ UnitDC + Unit-aware Desk Calculator +

+ +
+ { + ioCells.map((cell, index) => { + switch (cell.type) { + case 'output': + return ( + + ) + case 'input': + return ( + { + console.log('onchange', value); + ioCellsDispatch({ type: 'updateText', index: index, text: value }) + }} + onsubmit={submit} + /> + ) + case 'error': + return ( + + ) + case 'message': + return ( +
Message: {cell.text}
+ ) + } + }) + } +
+ + { +
+ } +
+ { + { + switch (action) { + case 'append_space': + lastInputCell().text += ' '; + forceUpdate(); + break; + case 'append_newline': + lastInputCell().text += '\n'; + forceUpdate(); + break; + case 'clear': + lastInputCell().text = ''; + forceUpdate(); + break; + case 'backspace': + lastInputCell().text = lastInputCell().text.slice(0, -1); + forceUpdate(); + break; + case 'submit': + submit(); + break; + } + }} + /> + } + + ) +} + +export default App diff --git a/crates/unitdc-web/ui/src/components/ErrorCell.tsx b/crates/unitdc-web/ui/src/components/ErrorCell.tsx new file mode 100644 index 0000000..a3f6663 --- /dev/null +++ b/crates/unitdc-web/ui/src/components/ErrorCell.tsx @@ -0,0 +1,12 @@ +export interface ErrorCellProps { + text: string +} + +export function ErrorCell(props: ErrorCellProps) { + return ( +
+ +
{props.text}
+
+ ) +} \ No newline at end of file diff --git a/crates/unitdc-web/ui/src/components/InputCell.tsx b/crates/unitdc-web/ui/src/components/InputCell.tsx new file mode 100644 index 0000000..658aaeb --- /dev/null +++ b/crates/unitdc-web/ui/src/components/InputCell.tsx @@ -0,0 +1,58 @@ +import { useEffect, useRef } from "react" + +export interface InputCellProps { + number: number, + active?: boolean + text: string + onchange?: (value: string) => void + onsubmit?: (value: string) => void +} + +export function InputCell(props: InputCellProps) { + const ref = useRef(null); + + useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Enter" && event.shiftKey) { + event.preventDefault() + if (props.onsubmit) { + props.onsubmit(ref.current?.value || "") + } + } + } + if (props.active) { + ref.current?.focus() + document.addEventListener("keydown", handleKeyDown) + } else { + document.removeEventListener("keydown", handleKeyDown) + } + return () => { + document.removeEventListener("keydown", handleKeyDown) + } + }, [props.active]) + + const onInput = () => { + ref.current!.style.height = "auto" + ref.current!.style.height = ref.current!.scrollHeight + "px" + if (props.onchange) { + props.onchange(ref.current?.value || "") + } + } + + useEffect(() => { + ref.current!.value = props.text + ref.current!.addEventListener("input", onInput) + return () => { + ref.current!.removeEventListener("input", onInput) + } + + }, [props.text]) + + + return ( +
+ +