├── .github └── workflows │ └── build-pkg.yml ├── .gitignore ├── .vscode └── extensions.json ├── LICENSE ├── README.md ├── app-icon.png ├── index.html ├── package.json ├── pnpm-lock.yaml ├── public ├── imgs │ ├── app-icon.png │ ├── screen_editing.png │ ├── screen_preview.png │ └── screen_projects.png ├── tauri.svg └── vite.svg ├── src-tauri ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── assets │ └── fonts │ │ ├── DejaVuSansMono-Bold.ttf │ │ ├── DejaVuSansMono-BoldOblique.ttf │ │ ├── DejaVuSansMono-Oblique.ttf │ │ ├── DejaVuSansMono.ttf │ │ ├── LinLibertine_R.ttf │ │ ├── LinLibertine_RB.ttf │ │ ├── LinLibertine_RBI.ttf │ │ ├── LinLibertine_RI.ttf │ │ ├── NewCMMath-Book.otf │ │ └── NewCMMath-Regular.otf ├── build.rs ├── capabilities │ └── default.json ├── gen │ └── schemas │ │ ├── acl-manifests.json │ │ ├── capabilities.json │ │ ├── desktop-schema.json │ │ └── macOS-schema.json ├── icons │ ├── 128x128.png │ ├── 128x128@2x.png │ ├── 32x32.png │ ├── Square107x107Logo.png │ ├── Square142x142Logo.png │ ├── Square150x150Logo.png │ ├── Square284x284Logo.png │ ├── Square30x30Logo.png │ ├── Square310x310Logo.png │ ├── Square44x44Logo.png │ ├── Square71x71Logo.png │ ├── Square89x89Logo.png │ ├── StoreLogo.png │ ├── icon.icns │ ├── icon.ico │ └── icon.png ├── src │ ├── cmd.rs │ ├── data.json │ ├── ipc │ │ ├── commands │ │ │ ├── clipboard.rs │ │ │ ├── fs.rs │ │ │ ├── mod.rs │ │ │ └── typst.rs │ │ ├── events │ │ │ ├── mod.rs │ │ │ └── view.rs │ │ ├── mod.rs │ │ └── model.rs │ ├── lib.rs │ ├── main.rs │ └── project │ │ ├── manager.rs │ │ ├── mod.rs │ │ ├── package.rs │ │ ├── project.rs │ │ └── world.rs └── tauri.conf.json ├── src ├── App.vue ├── assets │ ├── rendering.svg │ └── vue.svg ├── components │ ├── MonacoEditor.vue │ ├── MoveBar.vue │ └── PageLoading.vue ├── main.ts ├── pages │ ├── home │ │ ├── Home.vue │ │ ├── Sidebar.vue │ │ └── SidebarToggle.vue │ ├── project │ │ ├── AddProject.vue │ │ ├── Project.vue │ │ └── interface.ts │ └── typst │ │ ├── DiagnosticsTip.vue │ │ ├── PreviewPage.vue │ │ ├── TypstEditor.vue │ │ ├── ViewScale.vue │ │ └── interface.ts ├── router.ts ├── shared │ ├── lang │ │ ├── bibtex.json │ │ ├── completion.ts │ │ ├── grammar.ts │ │ ├── typst-config.json │ │ └── typst-tm.json │ ├── monaco-hook.ts │ ├── move-hook.ts │ └── util.ts ├── store │ └── store.ts ├── style │ ├── base.css │ └── styles.css └── vite-env.d.ts ├── tests ├── app-icon.png ├── basic-resume │ └── main.typ ├── main.typ └── works.bib ├── tsconfig.json └── vite.config.mjs /.github/workflows/build-pkg.yml: -------------------------------------------------------------------------------- 1 | name: "build pkg" 2 | 3 | on: 4 | push: 5 | branches: 6 | - release 7 | 8 | jobs: 9 | publish-tauri: 10 | permissions: 11 | contents: write 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | platform: [ windows-latest ] 16 | runs-on: ${{ matrix.platform }} 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: setup node 20 | uses: actions/setup-node@v3 21 | with: 22 | node-version: 20 23 | - name: install Rust stable 24 | uses: dtolnay/rust-toolchain@stable 25 | 26 | - name: install frontend dependencies 27 | run: | 28 | npm install -g pnpm 29 | pnpm install --frozen-lockfile 30 | - name: configure macOS build 31 | if: matrix.platform == 'macos-latest' 32 | run: | 33 | # Ensure that we have the aarch64 toolchain available for cross-compilation 34 | rustup target add aarch64-apple-darwin 35 | - uses: tauri-apps/tauri-action@v0 36 | env: 37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | with: 39 | args: ${{ matrix.platform == 'macos-latest' && '--target universal-apple-darwin' || '' }} 40 | tagName: v__VERSION__.${{ github.run_number }} # the action automatically replaces \_\_VERSION\_\_ with the app version 41 | releaseName: "Development Build v__VERSION__.${{ github.run_number }}" 42 | releaseBody: "This is a development release from the master branch (Commit ${{ github.sha }})." 43 | releaseDraft: false 44 | prerelease: true 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | */**/target/ 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "Vue.volar", 4 | "tauri-apps.tauri-vscode", 5 | "rust-lang.rust-analyzer" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Lixu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![typster](./app-icon.png) 2 | 3 | # typster 4 | 5 | A desktop application for a new markup-based typesetting language, [typst](https://github.com/typst/typst). 6 | Typster is built using [Tauri](https://tauri.app/). 7 | 8 | 9 | # features 10 | - [x] auto code completion 11 | - [x] export PDF file 12 | - [x] error tip 13 | - [x] use latest typst v0.12 14 | 15 | 16 | ## screenshot 17 | 18 | 19 | ![typster](./public/imgs/screen_projects.png) 20 | 21 | 22 | 23 | ![typster](./public/imgs/screen_editing.png) 24 | 25 | 26 | 27 | ![typster](./public/imgs/screen_preview.png) 28 | 29 | 30 | # Download 31 | 32 | 33 | [download link](https://github.com/wflixu/typster/releases) 34 | 35 | ### MacOS 36 | 37 | 38 | ``` 39 | xattr -c /Applications/typster.app 40 | ``` 41 | ### rebuild app icon 42 | 43 | ``` 44 | pnpm tauri icon 45 | ``` 46 | 47 | 48 | ## Other similar projects: 49 | 50 | - https://github.com/Cubxity/typster 51 | - https://github.com/Enter-tainer/typst-preview 52 | 53 | ## Related projects 54 | - https://github.com/Enter-tainer/typstyle 55 | - https://github.com/nvarner/typst-lsp 56 | -------------------------------------------------------------------------------- /app-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wflixu/typster/34afb9b69a9db6cc89c4e7e682a3122160acb0ab/app-icon.png -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Tauri + Vue + TS 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typster", 3 | "private": true, 4 | "version": "0.12.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vue-tsc --noEmit && vite build", 9 | "preview": "vite preview", 10 | "tauri": "tauri", 11 | "td": "tauri dev", 12 | "tb": "tauri build", 13 | "start": "pnpm tauri dev", 14 | "pack": "pnpm tauri build" 15 | }, 16 | "dependencies": { 17 | "@ant-design/icons-vue": "^7.0.1", 18 | "@tauri-apps/api": "^2.0.3", 19 | "@tauri-apps/plugin-dialog": "^2.0.1", 20 | "@tauri-apps/plugin-fs": "^2.0.1", 21 | "ant-design-vue": "~4.2.5", 22 | "monaco-editor": "^0.46.0", 23 | "pinia": "^2.2.4", 24 | "radash": "^12.1.0", 25 | "today-ui": "^0.0.23", 26 | "vscode-oniguruma": "^2.0.1", 27 | "vscode-textmate": "^9.1.0", 28 | "vue": "^3.5.12", 29 | "vue-router": "^4.4.5" 30 | }, 31 | "devDependencies": { 32 | "@tauri-apps/cli": "^2.0.4", 33 | "@types/node": "^20.17.1", 34 | "@vitejs/plugin-vue": "^5.1.4", 35 | "typescript": "^5.6.3", 36 | "vite": "^5.4.10", 37 | "vue-tsc": "^2.1.8" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /public/imgs/app-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wflixu/typster/34afb9b69a9db6cc89c4e7e682a3122160acb0ab/public/imgs/app-icon.png -------------------------------------------------------------------------------- /public/imgs/screen_editing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wflixu/typster/34afb9b69a9db6cc89c4e7e682a3122160acb0ab/public/imgs/screen_editing.png -------------------------------------------------------------------------------- /public/imgs/screen_preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wflixu/typster/34afb9b69a9db6cc89c4e7e682a3122160acb0ab/public/imgs/screen_preview.png -------------------------------------------------------------------------------- /public/imgs/screen_projects.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wflixu/typster/34afb9b69a9db6cc89c4e7e682a3122160acb0ab/public/imgs/screen_projects.png -------------------------------------------------------------------------------- /public/tauri.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src-tauri/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | -------------------------------------------------------------------------------- /src-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "typster" 3 | version = "0.2.0" 4 | description = "A Tauri App" 5 | authors = ["you"] 6 | license = "" 7 | repository = "" 8 | edition = "2021" 9 | rust-version = "1.60" 10 | 11 | [lib] 12 | name = "typster_lib" 13 | crate-type = ["staticlib", "cdylib", "rlib"] 14 | 15 | 16 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 17 | 18 | [build-dependencies] 19 | tauri-build = { version = "2.0.3", features = [] } 20 | 21 | [dependencies] 22 | anyhow = "1.0" 23 | thiserror = "1.0" 24 | arboard = "3.3" 25 | base64 = "0.22" 26 | enumset = { version = "1.1", features = ["serde"] } 27 | png = "0.17" 28 | parking_lot = "0.12.1" 29 | 30 | hex = "0.4" 31 | 32 | tauri = { version = "2.1.1", features = [ "macos-private-api", "devtools", ] } 33 | 34 | tauri-plugin-clipboard = "2" 35 | tauri-plugin-fs = "2" 36 | tauri-plugin-dialog = "2" 37 | 38 | serde = { version = "1.0", features = ["derive"] } 39 | serde_json = "1.0" 40 | serde_repr = "0.1" 41 | 42 | siphasher = "1.0" 43 | tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] } 44 | 45 | 46 | log = "0.4" 47 | env_logger = "0.11" 48 | notify = "6.1" 49 | comemo = "0.4.0" 50 | chrono = { version = "0.4.24", default-features = false, features = ["clock", "std", "serde"] } 51 | dirs = "5.0" 52 | walkdir = "2.5" 53 | memmap2 = "0.9" 54 | once_cell = "1.19" 55 | 56 | 57 | typst = { version = "0.12.0" } 58 | typst-ide = { version = "0.12.0" } 59 | typst-pdf = { version = "0.12.0" } 60 | typst-render = { version = "0.12.0" } 61 | typst-syntax = { version = "0.12.0" } 62 | typst-kit = { version = "0.12.0" } 63 | typst-timing = { version = "0.12.0" } 64 | typst-utils = { version = "0.12.0" } 65 | 66 | ureq = { version = "2", default-features = false, features = ["native-tls", "gzip", "json"] } 67 | ecow = { version = "0.2", features = ["serde"] } 68 | native-tls = "0.2" 69 | env_proxy = "0.4" 70 | flate2 = "1" 71 | tar = "0.4" 72 | -------------------------------------------------------------------------------- /src-tauri/assets/fonts/DejaVuSansMono-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wflixu/typster/34afb9b69a9db6cc89c4e7e682a3122160acb0ab/src-tauri/assets/fonts/DejaVuSansMono-Bold.ttf -------------------------------------------------------------------------------- /src-tauri/assets/fonts/DejaVuSansMono-BoldOblique.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wflixu/typster/34afb9b69a9db6cc89c4e7e682a3122160acb0ab/src-tauri/assets/fonts/DejaVuSansMono-BoldOblique.ttf -------------------------------------------------------------------------------- /src-tauri/assets/fonts/DejaVuSansMono-Oblique.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wflixu/typster/34afb9b69a9db6cc89c4e7e682a3122160acb0ab/src-tauri/assets/fonts/DejaVuSansMono-Oblique.ttf -------------------------------------------------------------------------------- /src-tauri/assets/fonts/DejaVuSansMono.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wflixu/typster/34afb9b69a9db6cc89c4e7e682a3122160acb0ab/src-tauri/assets/fonts/DejaVuSansMono.ttf -------------------------------------------------------------------------------- /src-tauri/assets/fonts/LinLibertine_R.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wflixu/typster/34afb9b69a9db6cc89c4e7e682a3122160acb0ab/src-tauri/assets/fonts/LinLibertine_R.ttf -------------------------------------------------------------------------------- /src-tauri/assets/fonts/LinLibertine_RB.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wflixu/typster/34afb9b69a9db6cc89c4e7e682a3122160acb0ab/src-tauri/assets/fonts/LinLibertine_RB.ttf -------------------------------------------------------------------------------- /src-tauri/assets/fonts/LinLibertine_RBI.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wflixu/typster/34afb9b69a9db6cc89c4e7e682a3122160acb0ab/src-tauri/assets/fonts/LinLibertine_RBI.ttf -------------------------------------------------------------------------------- /src-tauri/assets/fonts/LinLibertine_RI.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wflixu/typster/34afb9b69a9db6cc89c4e7e682a3122160acb0ab/src-tauri/assets/fonts/LinLibertine_RI.ttf -------------------------------------------------------------------------------- /src-tauri/assets/fonts/NewCMMath-Book.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wflixu/typster/34afb9b69a9db6cc89c4e7e682a3122160acb0ab/src-tauri/assets/fonts/NewCMMath-Book.otf -------------------------------------------------------------------------------- /src-tauri/assets/fonts/NewCMMath-Regular.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wflixu/typster/34afb9b69a9db6cc89c4e7e682a3122160acb0ab/src-tauri/assets/fonts/NewCMMath-Regular.otf -------------------------------------------------------------------------------- /src-tauri/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | tauri_build::build() 3 | } 4 | -------------------------------------------------------------------------------- /src-tauri/capabilities/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "identifier": "main-capability", 3 | "description": "Capability for the main window", 4 | "windows": [ 5 | "main" 6 | ], 7 | "permissions": [ 8 | "core:default", 9 | "fs:allow-read-file", 10 | "fs:allow-write-file", 11 | "fs:allow-write-text-file", 12 | "fs:allow-read-dir", 13 | "fs:allow-copy-file", 14 | "fs:allow-mkdir", 15 | "fs:allow-remove", 16 | "fs:allow-rename", 17 | "fs:allow-exists", 18 | "dialog:allow-save", 19 | { 20 | "identifier": "fs:scope", 21 | "allow": [ 22 | "$HOME/**/*" 23 | ] 24 | }, 25 | { 26 | "identifier": "fs:allow-read-dir", 27 | "allow": [ 28 | "$HOME/**/*" 29 | ] 30 | }, 31 | { 32 | "identifier": "fs:allow-read-file", 33 | "allow": [ 34 | "$HOME/**/*" 35 | ] 36 | }, 37 | { 38 | "identifier": "fs:allow-write-file", 39 | "allow": [ 40 | "$HOME/**/*" 41 | ] 42 | }, 43 | "core:window:allow-center", 44 | "core:window:allow-set-position", 45 | "core:window:allow-set-size", 46 | "core:window:allow-set-maximizable", 47 | "core:window:allow-set-minimizable", 48 | "fs:default" 49 | ] 50 | } -------------------------------------------------------------------------------- /src-tauri/gen/schemas/capabilities.json: -------------------------------------------------------------------------------- 1 | {"main-capability":{"identifier":"main-capability","description":"Capability for the main window","local":true,"windows":["main"],"permissions":["core:default","fs:allow-read-file","fs:allow-write-file","fs:allow-write-text-file","fs:allow-read-dir","fs:allow-copy-file","fs:allow-mkdir","fs:allow-remove","fs:allow-rename","fs:allow-exists","dialog:allow-save",{"identifier":"fs:scope","allow":["$HOME/**/*"]},{"identifier":"fs:allow-read-dir","allow":["$HOME/**/*"]},{"identifier":"fs:allow-read-file","allow":["$HOME/**/*"]},{"identifier":"fs:allow-write-file","allow":["$HOME/**/*"]},"core:window:allow-center","core:window:allow-set-position","core:window:allow-set-size","core:window:allow-set-maximizable","core:window:allow-set-minimizable","fs:default"]}} -------------------------------------------------------------------------------- /src-tauri/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wflixu/typster/34afb9b69a9db6cc89c4e7e682a3122160acb0ab/src-tauri/icons/128x128.png -------------------------------------------------------------------------------- /src-tauri/icons/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wflixu/typster/34afb9b69a9db6cc89c4e7e682a3122160acb0ab/src-tauri/icons/128x128@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wflixu/typster/34afb9b69a9db6cc89c4e7e682a3122160acb0ab/src-tauri/icons/32x32.png -------------------------------------------------------------------------------- /src-tauri/icons/Square107x107Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wflixu/typster/34afb9b69a9db6cc89c4e7e682a3122160acb0ab/src-tauri/icons/Square107x107Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square142x142Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wflixu/typster/34afb9b69a9db6cc89c4e7e682a3122160acb0ab/src-tauri/icons/Square142x142Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square150x150Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wflixu/typster/34afb9b69a9db6cc89c4e7e682a3122160acb0ab/src-tauri/icons/Square150x150Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square284x284Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wflixu/typster/34afb9b69a9db6cc89c4e7e682a3122160acb0ab/src-tauri/icons/Square284x284Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square30x30Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wflixu/typster/34afb9b69a9db6cc89c4e7e682a3122160acb0ab/src-tauri/icons/Square30x30Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square310x310Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wflixu/typster/34afb9b69a9db6cc89c4e7e682a3122160acb0ab/src-tauri/icons/Square310x310Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square44x44Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wflixu/typster/34afb9b69a9db6cc89c4e7e682a3122160acb0ab/src-tauri/icons/Square44x44Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square71x71Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wflixu/typster/34afb9b69a9db6cc89c4e7e682a3122160acb0ab/src-tauri/icons/Square71x71Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square89x89Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wflixu/typster/34afb9b69a9db6cc89c4e7e682a3122160acb0ab/src-tauri/icons/Square89x89Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/StoreLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wflixu/typster/34afb9b69a9db6cc89c4e7e682a3122160acb0ab/src-tauri/icons/StoreLogo.png -------------------------------------------------------------------------------- /src-tauri/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wflixu/typster/34afb9b69a9db6cc89c4e7e682a3122160acb0ab/src-tauri/icons/icon.icns -------------------------------------------------------------------------------- /src-tauri/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wflixu/typster/34afb9b69a9db6cc89c4e7e682a3122160acb0ab/src-tauri/icons/icon.ico -------------------------------------------------------------------------------- /src-tauri/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wflixu/typster/34afb9b69a9db6cc89c4e7e682a3122160acb0ab/src-tauri/icons/icon.png -------------------------------------------------------------------------------- /src-tauri/src/cmd.rs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | // Learn more about Tauri commands at https://tauri.app/v1/guides/features/command 5 | #[tauri::command] 6 | pub fn greet(name: &str) -> String { 7 | format!("Hello, {}! You've been greeted from Rust!", name) 8 | } 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src-tauri/src/data.json: -------------------------------------------------------------------------------- 1 | { 2 | "children": [ 3 | { 4 | "children": [ 5 | { 6 | "position": { 7 | "end": { 8 | "column": 23, 9 | "line": 1, 10 | "offset": 22 11 | }, 12 | "start": { 13 | "column": 4, 14 | "line": 1, 15 | "offset": 3 16 | } 17 | }, 18 | "type": "text", 19 | "value": "MDX 能干什么?" 20 | } 21 | ], 22 | "depth": 2, 23 | "position": { 24 | "end": { 25 | "column": 23, 26 | "line": 1, 27 | "offset": 22 28 | }, 29 | "start": { 30 | "column": 1, 31 | "line": 1, 32 | "offset": 0 33 | } 34 | }, 35 | "type": "heading" 36 | }, 37 | { 38 | "children": [ 39 | { 40 | "position": { 41 | "end": { 42 | "column": 42, 43 | "line": 3, 44 | "offset": 65 45 | }, 46 | "start": { 47 | "column": 1, 48 | "line": 3, 49 | "offset": 24 50 | } 51 | }, 52 | "type": "text", 53 | "value": "它能够让我们在 markdown 中写 JSX" 54 | } 55 | ], 56 | "position": { 57 | "end": { 58 | "column": 42, 59 | "line": 3, 60 | "offset": 65 61 | }, 62 | "start": { 63 | "column": 1, 64 | "line": 3, 65 | "offset": 24 66 | } 67 | }, 68 | "type": "paragraph" 69 | }, 70 | { 71 | "children": [ 72 | { 73 | "position": { 74 | "end": { 75 | "column": 40, 76 | "line": 5, 77 | "offset": 106 78 | }, 79 | "start": { 80 | "column": 1, 81 | "line": 5, 82 | "offset": 67 83 | } 84 | }, 85 | "type": "text", 86 | "value": "这是 2018 年,降水量柱状图。" 87 | } 88 | ], 89 | "position": { 90 | "end": { 91 | "column": 40, 92 | "line": 5, 93 | "offset": 106 94 | }, 95 | "start": { 96 | "column": 1, 97 | "line": 5, 98 | "offset": 67 99 | } 100 | }, 101 | "type": "paragraph" 102 | }, 103 | { 104 | "children": [ 105 | { 106 | "alt": "", 107 | "position": { 108 | "end": { 109 | "column": 20, 110 | "line": 7, 111 | "offset": 127 112 | }, 113 | "start": { 114 | "column": 1, 115 | "line": 7, 116 | "offset": 108 117 | } 118 | }, 119 | "title": null, 120 | "type": "image", 121 | "url": "./app-icon.png" 122 | } 123 | ], 124 | "position": { 125 | "end": { 126 | "column": 20, 127 | "line": 7, 128 | "offset": 127 129 | }, 130 | "start": { 131 | "column": 1, 132 | "line": 7, 133 | "offset": 108 134 | } 135 | }, 136 | "type": "paragraph" 137 | } 138 | ], 139 | "position": { 140 | "end": { 141 | "column": 1, 142 | "line": 8, 143 | "offset": 128 144 | }, 145 | "start": { 146 | "column": 1, 147 | "line": 1, 148 | "offset": 0 149 | } 150 | }, 151 | "type": "root" 152 | } -------------------------------------------------------------------------------- /src-tauri/src/ipc/commands/clipboard.rs: -------------------------------------------------------------------------------- 1 | use super::{Error, Result}; 2 | use crate::ipc::commands::project_path; 3 | use crate::project::ProjectManager; 4 | use arboard::Clipboard; 5 | use chrono::Local; 6 | use log::info; 7 | use serde::Serialize; 8 | use std::fs; 9 | use std::fs::File; 10 | use std::io::BufWriter; 11 | use std::path::PathBuf; 12 | use std::sync::Arc; 13 | use tauri::Runtime; 14 | 15 | #[derive(Serialize, Debug)] 16 | pub struct ClipboardPasteResponse { 17 | path: PathBuf, 18 | } 19 | 20 | #[tauri::command] 21 | pub async fn clipboard_paste( 22 | window: tauri::Window, 23 | project_manager: tauri::State<'_, Arc>>, 24 | ) -> Result { 25 | let now = Local::now(); 26 | let (_, path) = project_path(&window, &project_manager, PathBuf::from("assets"))?; 27 | 28 | let now_format = now.format("%Y-%m-%d %H:%M:%S.png"); 29 | 30 | fs::create_dir_all(&path).map_err(Into::::into)?; 31 | let path = path.join(now_format.to_string()); 32 | 33 | // TODO: Better error handling 34 | let mut clipboard = Clipboard::new().map_err(|_| Error::Unknown)?; 35 | let data = clipboard.get_image().map_err(|_| Error::Unknown)?; 36 | 37 | let file = File::create(&path).map_err(Into::::into)?; 38 | let ref mut w = BufWriter::new(file); 39 | let mut encoder = png::Encoder::new(w, data.width as u32, data.height as u32); 40 | encoder.set_color(png::ColorType::Rgba); 41 | encoder.set_depth(png::BitDepth::Eight); 42 | 43 | let mut writer = encoder.write_header().map_err(|_| Error::Unknown)?; 44 | writer 45 | .write_image_data(&*data.bytes) 46 | .map_err(|_| Error::Unknown)?; 47 | 48 | info!( 49 | "wrote {}x{} image from clipboard to {:?}", 50 | data.width, data.height, path 51 | ); 52 | Ok(ClipboardPasteResponse { 53 | path: PathBuf::from(format!("assets/{}", now_format)), 54 | }) 55 | } 56 | -------------------------------------------------------------------------------- /src-tauri/src/ipc/commands/fs.rs: -------------------------------------------------------------------------------- 1 | use super::{Error, Result}; 2 | use crate::ipc::commands::project_path; 3 | use crate::project::{Project, ProjectManager}; 4 | use ecow::eco_format; 5 | use enumset::EnumSetType; 6 | use log::info; 7 | use serde::Serialize; 8 | use std::cmp::Ordering; 9 | use std::fs; 10 | use std::fs::{File, OpenOptions}; 11 | use std::io::Write; 12 | use std::path::PathBuf; 13 | use std::sync::Arc; 14 | use tauri::{Runtime, State, Window}; 15 | use typst::foundations::Smart; 16 | use typst_pdf::{PdfOptions, PdfStandard, PdfStandards}; 17 | use chrono::{DateTime, Datelike, Utc, Timelike}; 18 | use typst::foundations::Datetime; 19 | 20 | /// Convert [`chrono::DateTime`] to [`Datetime`] 21 | pub fn convert_datetime(date_time:DateTime) -> Option { 22 | Datetime::from_ymd_hms( 23 | date_time.year(), 24 | date_time.month().try_into().ok()?, 25 | date_time.day().try_into().ok()?, 26 | date_time.hour().try_into().ok()?, 27 | date_time.minute().try_into().ok()?, 28 | date_time.second().try_into().ok()?, 29 | ) 30 | } 31 | 32 | #[derive(EnumSetType, Serialize, Debug)] 33 | #[serde(rename_all = "snake_case")] 34 | pub enum FileType { 35 | File, 36 | Directory, 37 | } 38 | 39 | #[derive(Serialize, Debug)] 40 | pub struct FileItem { 41 | pub name: String, 42 | #[serde(rename = "type")] 43 | pub file_type: FileType, 44 | } 45 | 46 | #[tauri::command] 47 | pub async fn load_project_from_path( 48 | window: Window, 49 | project_manager: State<'_, Arc>>, 50 | path: String, 51 | ) -> std::result::Result<(), Error> { 52 | let path_buf = PathBuf::from(&path); 53 | let project = Arc::new(Project::load_from_path(path_buf)); 54 | project_manager.set_project(&window, Some(project)); 55 | info!("succeed load_project_from_path {}", &path); 56 | 57 | Ok(()) 58 | } 59 | 60 | #[tauri::command] 61 | pub async fn export_pdf( 62 | window: Window, 63 | project_manager: State<'_, Arc>>, 64 | path: String, 65 | ) -> std::result::Result { 66 | info!("Starting export of PDF to path: {}", &path); 67 | 68 | let path_buf = PathBuf::from(&path); 69 | 70 | // 获取项目 71 | if let Some(project) = project_manager.get_project(&window) { 72 | let cache = project.cache.read().expect("Failed to read cache"); 73 | 74 | if let Some(doc) = &cache.document { 75 | // 创建 CompileCommand 76 | 77 | let options = PdfOptions { 78 | ident: Smart::Auto, 79 | timestamp: convert_datetime(Utc::now()), 80 | page_ranges: None, 81 | standards: PdfStandards::new(&[PdfStandard::A_2b, PdfStandard::V_1_7]).unwrap(), 82 | }; 83 | 84 | // 生成 PDF 85 | let buffer = typst_pdf::pdf(doc, &options).map_err(|e| Error::Unknown)?; 86 | 87 | // 写入 PDF 文件 88 | fs::write(&path_buf, &buffer)?; 89 | 90 | info!("PDF successfully exported to {}", path_buf.display()); 91 | return Ok(buffer.len() as u64); 92 | } else { 93 | return Err(Error::UnknownProject); 94 | } 95 | } else { 96 | return Err(Error::UnknownProject); 97 | } 98 | } 99 | 100 | /// Reads raw bytes from a specified path. 101 | /// Note that this command is slow compared to the text API due to Wry's 102 | /// messaging system in v1. See: https://github.com/tauri-apps/tauri/issues/1817 103 | #[tauri::command] 104 | pub async fn fs_read_file_binary( 105 | window: Window, 106 | project_manager: State<'_, Arc>>, 107 | path: PathBuf, 108 | ) -> std::result::Result, Error> { 109 | let (_, path) = project_path(&window, &project_manager, path)?; 110 | fs::read(path).map_err(Into::into) 111 | } 112 | 113 | #[tauri::command] 114 | pub async fn fs_read_file_text( 115 | window: Window, 116 | project_manager: State<'_, Arc>>, 117 | path: PathBuf, 118 | ) -> std::result::Result { 119 | if path.is_absolute() { 120 | return fs::read_to_string(path).map_err(Into::into); 121 | } 122 | let (_, path) = project_path(&window, &project_manager, path)?; 123 | fs::read_to_string(path).map_err(Into::into) 124 | } 125 | 126 | #[tauri::command] 127 | pub async fn fs_create_file( 128 | window: Window, 129 | project_manager: State<'_, Arc>>, 130 | path: PathBuf, 131 | ) -> std::result::Result<(), Error> { 132 | let (_, path) = project_path(&window, &project_manager, path)?; 133 | 134 | // Not sure if there's a scenario where this condition is not met 135 | // unless the project is located at `/` 136 | if let Some(parent) = path.parent() { 137 | fs::create_dir_all(parent).map_err(Into::::into)?; 138 | } 139 | OpenOptions::new() 140 | .read(true) 141 | .write(true) 142 | .create_new(true) 143 | .open(&*path) 144 | .map_err(Into::::into)?; 145 | 146 | Ok(()) 147 | } 148 | 149 | #[tauri::command] 150 | pub async fn fs_write_file_binary( 151 | window: Window, 152 | project_manager: State<'_, Arc>>, 153 | path: PathBuf, 154 | content: Vec, 155 | ) -> std::result::Result<(), Error> { 156 | let (_, path) = project_path(&window, &project_manager, path)?; 157 | fs::write(path, content).map_err(Into::into) 158 | } 159 | 160 | #[tauri::command] 161 | pub async fn fs_write_file_text( 162 | window: Window, 163 | project_manager: State<'_, Arc>>, 164 | path: PathBuf, 165 | content: String, 166 | ) -> std::result::Result<(), Error> { 167 | let (project, absolute_path) = project_path(&window, &project_manager, &path)?; 168 | let _ = File::create(absolute_path) 169 | .map(|mut f| f.write_all(content.as_bytes())) 170 | .map_err(Into::::into)?; 171 | 172 | let mut world = project.world.lock().unwrap(); 173 | let _ = world 174 | .slot_update(&path, Some(content)) 175 | .map_err(Into::::into)?; 176 | Ok(()) 177 | } 178 | 179 | #[tauri::command] 180 | pub async fn fs_list_dir( 181 | window: Window, 182 | project_manager: State<'_, Arc>>, 183 | path: PathBuf, 184 | ) -> std::result::Result, Error> { 185 | let (_, path) = project_path(&window, &project_manager, path)?; 186 | let list = fs::read_dir(path).map_err(Into::::into)?; 187 | 188 | let mut files: Vec = vec![]; 189 | list.into_iter().for_each(|entry| { 190 | if let Ok(entry) = entry { 191 | if let (Ok(file_type), Ok(name)) = (entry.file_type(), entry.file_name().into_string()) 192 | { 193 | // File should only be directory or file. 194 | // Symlinks should be resolved in project_path. 195 | let t = if file_type.is_dir() { 196 | FileType::Directory 197 | } else { 198 | FileType::File 199 | }; 200 | files.push(FileItem { name, file_type: t }); 201 | } 202 | } 203 | }); 204 | 205 | files.sort_by(|a, b| { 206 | if a.file_type == FileType::Directory && b.file_type == FileType::File { 207 | Ordering::Less 208 | } else if a.file_type == FileType::File && b.file_type == FileType::Directory { 209 | Ordering::Greater 210 | } else { 211 | a.name.cmp(&b.name) 212 | } 213 | }); 214 | 215 | Ok(files) 216 | } 217 | -------------------------------------------------------------------------------- /src-tauri/src/ipc/commands/mod.rs: -------------------------------------------------------------------------------- 1 | mod clipboard; 2 | mod fs; 3 | mod typst; 4 | 5 | pub use self::typst::*; 6 | pub use clipboard::*; 7 | pub use fs::*; 8 | 9 | use crate::project::{Project, ProjectManager}; 10 | use ::typst::diag::FileError; 11 | use serde::{Serialize, Serializer}; 12 | use std::io; 13 | use std::path::{Component, Path, PathBuf}; 14 | use std::sync::Arc; 15 | use tauri::{Runtime, State, Window}; 16 | use anyhow::{self, Context}; 17 | 18 | 19 | #[derive(thiserror::Error, Debug)] 20 | pub enum Error { 21 | #[error("unknown error")] 22 | Unknown, 23 | #[error("unknown project")] 24 | UnknownProject, 25 | #[error("io error occurred")] 26 | IO(#[from] io::Error), 27 | #[error("typst file error occurred")] 28 | TypstFile(#[from] FileError), 29 | #[error("the provided path does not belong to the project")] 30 | UnrelatedPath, 31 | } 32 | 33 | impl Serialize for Error { 34 | fn serialize(&self, serializer: S) -> std::result::Result 35 | where 36 | S: Serializer, 37 | { 38 | serializer.serialize_str(self.to_string().as_ref()) 39 | } 40 | } 41 | 42 | pub type Result = std::result::Result; 43 | 44 | /// Retrieves the project and resolves the path. Furthermore, 45 | /// this function will resolve the path relative to project's root 46 | /// and checks whether the path belongs to the project root. 47 | pub fn project( 48 | window: &Window, 49 | project_manager: &State>>, 50 | ) -> Result> { 51 | project_manager 52 | .get_project(window) 53 | .ok_or(Error::UnknownProject) 54 | } 55 | 56 | /// Retrieves the project and resolves the path. Furthermore, 57 | /// this function will resolve the path relative to project's root 58 | /// and checks whether the path belongs to the project root. 59 | pub fn project_path>( 60 | window: &Window, 61 | project_manager: &State>>, 62 | path: P, 63 | ) -> Result<(Arc, PathBuf)> { 64 | let project = project_manager 65 | .get_project(window) 66 | .ok_or(Error::UnknownProject)?; 67 | let root_len = project.root.as_os_str().len(); 68 | let mut out = project.root.to_path_buf(); 69 | for component in path.as_ref().components() { 70 | match component { 71 | Component::Prefix(_) => {} 72 | Component::RootDir => {} 73 | Component::CurDir => {} 74 | Component::ParentDir => { 75 | out.pop(); 76 | if out.as_os_str().len() < root_len { 77 | return Err(Error::UnrelatedPath); 78 | } 79 | } 80 | Component::Normal(_) => out.push(component), 81 | } 82 | } 83 | Ok((project, out)) 84 | } 85 | -------------------------------------------------------------------------------- /src-tauri/src/ipc/commands/typst.rs: -------------------------------------------------------------------------------- 1 | use super::{Error, Result}; 2 | use crate::ipc::commands::project; 3 | use crate::ipc::model::TypstRenderResponse; 4 | use crate::ipc::{ 5 | TypstCompileEvent, TypstDiagnosticSeverity, TypstDocument, TypstPage, TypstSourceDiagnostic, 6 | }; 7 | use crate::project::ProjectManager; 8 | use base64::Engine; 9 | use log::{debug, info}; 10 | use serde::Serialize; 11 | use serde_repr::Serialize_repr; 12 | use siphasher::sip128::{Hasher128, SipHasher}; 13 | use std::hash::Hash; 14 | use std::ops::Range; 15 | use std::path::PathBuf; 16 | use std::sync::Arc; 17 | use std::time::Instant; 18 | use tauri::Runtime; 19 | use typst::diag::Severity; 20 | use typst::visualize::Color; 21 | use typst::World; 22 | use typst_ide::{Completion, CompletionKind}; 23 | 24 | #[derive(Serialize_repr, Debug)] 25 | #[repr(u8)] 26 | pub enum TypstCompletionKind { 27 | Syntax = 1, 28 | Function = 2, 29 | Parameter = 3, 30 | Constant = 4, 31 | Symbol = 5, 32 | Type = 6, 33 | } 34 | 35 | #[derive(Serialize, Debug)] 36 | pub struct TypstCompletion { 37 | kind: TypstCompletionKind, 38 | label: String, 39 | apply: Option, 40 | detail: Option, 41 | } 42 | 43 | #[derive(Serialize, Debug)] 44 | pub struct TypstCompleteResponse { 45 | offset: usize, 46 | completions: Vec, 47 | } 48 | 49 | impl From for TypstCompletion { 50 | fn from(value: Completion) -> Self { 51 | Self { 52 | kind: match value.kind { 53 | CompletionKind::Syntax => TypstCompletionKind::Syntax, 54 | CompletionKind::Func => TypstCompletionKind::Function, 55 | CompletionKind::Param => TypstCompletionKind::Parameter, 56 | CompletionKind::Constant => TypstCompletionKind::Constant, 57 | CompletionKind::Symbol(_) => TypstCompletionKind::Symbol, 58 | CompletionKind::Type => TypstCompletionKind::Type, 59 | }, 60 | label: value.label.to_string(), 61 | apply: value.apply.map(|s| s.to_string()), 62 | detail: value.detail.map(|s| s.to_string()), 63 | } 64 | } 65 | } 66 | // todo delete 67 | #[tauri::command] 68 | pub async fn typst_slot_update( 69 | window: tauri::Window, 70 | project_manager: tauri::State<'_, Arc>>, 71 | path: PathBuf, 72 | content: String, 73 | ) -> Result<()> { 74 | let project = project(&window, &project_manager)?; 75 | 76 | let mut world = project.world.lock().unwrap(); 77 | let _ = world 78 | .slot_update(&path, Some(content)) 79 | .map_err(Into::::into)?; 80 | Ok(()) 81 | } 82 | 83 | #[tauri::command] 84 | pub async fn typst_compile_doc( 85 | window: tauri::Window, 86 | project_manager: tauri::State<'_, Arc>>, 87 | path: PathBuf, 88 | content: String, 89 | ) -> Result<(Vec, Vec)> { 90 | 91 | let project = project(&window, &project_manager)?; 92 | let mut world = project.world.lock().unwrap(); 93 | let source_id = world 94 | .slot_update(&path, Some(content.clone())) 95 | .map_err(Into::::into)?; 96 | 97 | if !world.is_main_set() { 98 | 99 | let config = project.config.read().unwrap(); 100 | if config.apply_main(&project, &mut world).is_err() { 101 | debug!("skipped compilation for {:?} (main not set)", project); 102 | return Err(Error::Unknown); 103 | } 104 | } 105 | 106 | let now = Instant::now(); 107 | 108 | let mut pages: Vec = Vec::new(); 109 | let mut diags: Vec = Vec::new(); 110 | match typst::compile(&*world).output { 111 | Ok(doc) => { 112 | let elapsed = now.elapsed(); 113 | debug!( 114 | "compilation succeeded for {:?} in {:?} ms", 115 | project, 116 | elapsed.as_millis() 117 | ); 118 | let mut idx: u32 = 0; 119 | for page in &doc.pages { 120 | let mut hasher = SipHasher::new(); 121 | page.frame.hash(&mut hasher); 122 | let hash = hex::encode(hasher.finish128().as_bytes()); 123 | let width = page.frame.width().to_pt(); 124 | let height = page.frame.height().to_pt(); 125 | idx += 1; 126 | let pag = TypstPage { 127 | num: idx, 128 | width, 129 | height, 130 | hash: hash.clone(), 131 | }; 132 | pages.push(pag); 133 | } 134 | 135 | project.cache.write().unwrap().document = Some(doc); 136 | } 137 | Err(diagnostics) => { 138 | debug!("compilation failed with {:?} diagnostics", &diagnostics); 139 | 140 | let source = world.source(source_id); 141 | let diagnostics: Vec = match source { 142 | Ok(source) => diagnostics 143 | .iter() 144 | .filter(|d| d.span.id() == Some(source_id)) 145 | .filter_map(|d| { 146 | let span = source.find(d.span)?; 147 | let range = span.range(); 148 | 149 | let message = d.message.to_string(); 150 | Some(TypstSourceDiagnostic { 151 | pos: get_range_position(&content, range.clone()), 152 | range, 153 | severity: match d.severity { 154 | Severity::Error => TypstDiagnosticSeverity::Error, 155 | Severity::Warning => TypstDiagnosticSeverity::Warning, 156 | }, 157 | message, 158 | hints: d.hints.iter().map(|hint| hint.to_string()).collect(), 159 | }) 160 | }) 161 | .collect(), 162 | Err(_) => vec![], 163 | }; 164 | 165 | diags = diagnostics.clone(); 166 | } 167 | } 168 | 169 | Ok((pages, diags)) 170 | } 171 | 172 | 173 | pub fn get_range_position(text: &str, rang: Range) -> (usize, usize) { 174 | let mut ln = 0; 175 | let mut cn = 0; 176 | let mut total: usize = 0; 177 | for line in text.lines() { 178 | 179 | ln += 1; 180 | let row = line.chars().count() + 1; 181 | 182 | if total <= rang.start && rang.start <= total + row { 183 | cn = rang.start - total; 184 | break; 185 | } 186 | 187 | total += row; 188 | } 189 | return (ln, cn); 190 | } 191 | 192 | #[tauri::command] 193 | pub async fn typst_render( 194 | window: tauri::Window, 195 | project_manager: tauri::State<'_, Arc>>, 196 | page: usize, 197 | scale: f32, 198 | nonce: u32, 199 | ) -> Result { 200 | info!( 201 | "typst_render page:{} scale: {} nonce: {}", 202 | page, scale, nonce 203 | ); 204 | let project = project_manager 205 | .get_project(&window) 206 | .ok_or(Error::UnknownProject)?; 207 | 208 | let cache = project.cache.read().unwrap(); 209 | 210 | if let Some(p) = cache 211 | .document 212 | .as_ref() 213 | .and_then(|doc| doc.pages.get(page - 1)) 214 | { 215 | let now = Instant::now(); 216 | 217 | let bmp = typst_render::render(p, scale); 218 | if let Ok(image) = bmp.encode_png() { 219 | let elapsed = now.elapsed(); 220 | debug!( 221 | "rendering complete for page {} in {} ms", 222 | page, 223 | elapsed.as_millis() 224 | ); 225 | let b64 = base64::engine::general_purpose::STANDARD.encode(image); 226 | return Ok(TypstRenderResponse { 227 | image: b64, 228 | width: bmp.width(), 229 | height: bmp.height(), 230 | nonce, 231 | }); 232 | } 233 | } 234 | 235 | Err(Error::Unknown) 236 | } 237 | 238 | #[tauri::command] 239 | pub async fn typst_autocomplete( 240 | window: tauri::Window, 241 | project_manager: tauri::State<'_, Arc>>, 242 | path: PathBuf, 243 | content: String, 244 | offset: usize, 245 | explicit: bool, 246 | ) -> Result { 247 | let project = project(&window, &project_manager)?; 248 | let mut world = project.world.lock().unwrap(); 249 | 250 | let offset = content 251 | .char_indices() 252 | .nth(offset) 253 | .map(|a| a.0) 254 | .unwrap_or(content.len()); 255 | 256 | // TODO: Improve error typing 257 | let source_id = world 258 | .slot_update(&*path, Some(content.clone())) 259 | .map_err(Into::::into)?; 260 | 261 | let source = world.source(source_id).map_err(Into::::into)?; 262 | 263 | let (completed_offset, completions) = 264 | typst_ide::autocomplete(&*world, None, &source, offset, explicit) 265 | .ok_or_else(|| Error::Unknown)?; 266 | 267 | let completed_char_offset = content[..completed_offset].chars().count(); 268 | Ok(TypstCompleteResponse { 269 | offset: completed_char_offset, 270 | completions: completions.into_iter().map(TypstCompletion::from).collect(), 271 | }) 272 | } 273 | -------------------------------------------------------------------------------- /src-tauri/src/ipc/events/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod view; 2 | -------------------------------------------------------------------------------- /src-tauri/src/ipc/events/view.rs: -------------------------------------------------------------------------------- 1 | use serde::Serialize; 2 | use tauri::{Emitter, Runtime, Window}; 3 | 4 | // For some reason, Tauri requires an event payload... 5 | #[derive(Debug, Clone, Serialize)] 6 | struct EmptyPayload {} 7 | 8 | // Instructs the front-end to hide or show the preview 9 | pub fn toggle_preview_visibility(window: &Window) { 10 | let _ = window.emit("toggle_preview_visibility", EmptyPayload {}); 11 | } 12 | -------------------------------------------------------------------------------- /src-tauri/src/ipc/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod commands; 2 | pub mod events; 3 | 4 | mod model; 5 | pub use model::*; 6 | -------------------------------------------------------------------------------- /src-tauri/src/ipc/model.rs: -------------------------------------------------------------------------------- 1 | use serde::Serialize; 2 | use std::ops::Range; 3 | use std::path::PathBuf; 4 | 5 | #[derive(Serialize, Clone, Debug)] 6 | pub struct TypstCompileEvent { 7 | pub document: Option, 8 | pub diagnostics: Option>, 9 | } 10 | 11 | #[derive(Serialize, Clone, Debug)] 12 | pub struct TypstDocument { 13 | pub pages: usize, 14 | pub hash: String, 15 | pub width: f64, 16 | pub height: f64, 17 | } 18 | 19 | 20 | #[derive(Serialize, Clone, Debug)] 21 | pub struct TypstPage { 22 | pub num: u32, 23 | pub hash: String, 24 | pub width: f64, 25 | pub height: f64, 26 | 27 | } 28 | 29 | #[derive(Serialize, Clone, Debug)] 30 | #[serde(rename_all = "snake_case")] 31 | pub enum TypstDiagnosticSeverity { 32 | Error, 33 | Warning, 34 | } 35 | 36 | #[derive(Serialize, Clone, Debug)] 37 | pub struct TypstSourceDiagnostic { 38 | pub range: Range, 39 | pub severity: TypstDiagnosticSeverity, 40 | pub message: String, 41 | pub hints: Vec, 42 | pub pos:(usize, usize) 43 | } 44 | 45 | #[derive(Serialize, Clone, Debug)] 46 | pub struct TypstRenderResponse { 47 | pub image: String, 48 | pub width: u32, 49 | pub height: u32, 50 | pub nonce: u32, 51 | } 52 | 53 | #[derive(Serialize, Clone, Debug)] 54 | pub struct ProjectChangeEvent { 55 | pub project: Option, 56 | } 57 | 58 | #[derive(Serialize, Clone, Debug)] 59 | pub struct ProjectModel { 60 | pub root: PathBuf, 61 | } 62 | 63 | #[derive(Serialize, Clone, Debug)] 64 | pub struct FSRefreshEvent { 65 | pub path: PathBuf, 66 | } 67 | -------------------------------------------------------------------------------- /src-tauri/src/lib.rs: -------------------------------------------------------------------------------- 1 | // Prevents additional console window on Windows in release, DO NOT REMOVE!! 2 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] 3 | #![allow(unused_imports, unused_variables, dead_code, unused_mut)] 4 | 5 | mod cmd; 6 | mod ipc; 7 | mod project; 8 | 9 | use crate::project::ProjectManager; 10 | use env_logger::Env; 11 | use log::info; 12 | use std::sync::Arc; 13 | use tauri::Wry; 14 | 15 | pub fn run() { 16 | env_logger::init_from_env(Env::default().default_filter_or("debug")); 17 | info!("initializing typster"); 18 | 19 | let project_manager = Arc::new(ProjectManager::::new()); 20 | if let Ok(watcher) = ProjectManager::init_watcher(project_manager.clone()) { 21 | project_manager.set_watcher(watcher); 22 | } 23 | 24 | tauri::Builder::default() 25 | .plugin(tauri_plugin_fs::init()) 26 | .plugin(tauri_plugin_dialog::init()) 27 | .manage(project_manager) 28 | .invoke_handler(tauri::generate_handler![ 29 | cmd::greet, 30 | ipc::commands::fs_list_dir, 31 | ipc::commands::fs_read_file_binary, 32 | ipc::commands::fs_read_file_text, 33 | ipc::commands::fs_create_file, 34 | ipc::commands::fs_write_file_binary, 35 | ipc::commands::fs_write_file_text, 36 | ipc::commands::load_project_from_path, 37 | ipc::commands::typst_compile_doc, 38 | ipc::commands::typst_render, 39 | ipc::commands::typst_autocomplete, 40 | ipc::commands::typst_slot_update, 41 | ipc::commands::export_pdf, 42 | ipc::commands::clipboard_paste 43 | ]) 44 | .run(tauri::generate_context!()) 45 | .expect("error while running tauri application"); 46 | } 47 | -------------------------------------------------------------------------------- /src-tauri/src/main.rs: -------------------------------------------------------------------------------- 1 | // Prevents additional console window on Windows in release, DO NOT REMOVE!! 2 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] 3 | 4 | 5 | #[tokio::main] 6 | async fn main() { 7 | typster_lib::run() 8 | } 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src-tauri/src/project/manager.rs: -------------------------------------------------------------------------------- 1 | use crate::ipc::{FSRefreshEvent, ProjectChangeEvent, ProjectModel}; 2 | use crate::project::{is_project_config_file, Project, ProjectConfig}; 3 | use log::{debug, error, info, trace, warn}; 4 | use notify::event::ModifyKind; 5 | use notify::{Config, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; 6 | use std::collections::HashMap; 7 | use std::path::{Path, PathBuf}; 8 | use std::sync::{Arc, Mutex, RwLock}; 9 | use tauri::{Emitter, Runtime, WebviewWindow, Window}; 10 | use tokio::sync::mpsc::channel; 11 | 12 | #[derive(Clone, Copy, Debug)] 13 | enum FSHandleKind { 14 | Refresh, 15 | Reload, 16 | } 17 | 18 | pub struct ProjectManager { 19 | projects: RwLock, Arc>>, 20 | watcher: Mutex>>, 21 | } 22 | 23 | impl ProjectManager { 24 | pub fn init_watcher( 25 | project_manager: Arc>, 26 | ) -> anyhow::Result> { 27 | let (tx, mut rx) = channel(1); 28 | 29 | let rt = tokio::runtime::Builder::new_current_thread() 30 | .enable_all() 31 | .build()?; 32 | 33 | let watcher = RecommendedWatcher::new( 34 | move |res| { 35 | let _ = rt.block_on(tx.send(res)); 36 | }, 37 | Config::default(), 38 | )?; 39 | 40 | tokio::spawn(async move { 41 | while let Some(res) = rx.recv().await { 42 | match res { 43 | Ok(event) => project_manager.handle_fs_event(event), 44 | Err(e) => error!("watch error {:?}", e), 45 | } 46 | } 47 | }); 48 | 49 | Ok(Box::new(watcher)) 50 | } 51 | 52 | pub fn set_watcher(&self, watcher: Box) { 53 | let mut inner = self.watcher.lock().unwrap(); 54 | *inner = Some(watcher); 55 | } 56 | 57 | pub fn get_project(&self, window: &Window) -> Option> { 58 | self.projects.read().unwrap().get(window).cloned() 59 | } 60 | 61 | pub fn set_project(&self, window: &Window, project: Option>) { 62 | let mut projects = self.projects.write().unwrap(); 63 | let model = project.as_ref().map(|p| ProjectModel { 64 | root: p.root.clone(), 65 | }); 66 | match project { 67 | None => { 68 | if let Some(old) = projects.remove(window) { 69 | let mut guard = self.watcher.lock().unwrap(); 70 | if let Some(watcher) = guard.as_mut() { 71 | let _ = watcher.unwatch(&old.root); 72 | } 73 | } 74 | } 75 | Some(p) => { 76 | p.config.read().unwrap().apply(&*p); 77 | 78 | let root = &p.root.clone(); 79 | let mut guard = self.watcher.lock().unwrap(); 80 | if let Some(old) = projects.insert(window.clone(), p) { 81 | if let Some(watcher) = guard.as_mut() { 82 | let _ = watcher.unwatch(&old.root); 83 | } 84 | } 85 | if let Some(watcher) = guard.as_mut() { 86 | let _ = watcher.watch(root, RecursiveMode::Recursive); 87 | } 88 | } 89 | }; 90 | 91 | info!("project set for window {}: {:?}", window.label(), model); 92 | let _ = window.emit("project_changed", ProjectChangeEvent { project: model }); 93 | } 94 | 95 | fn handle_fs_event(&self, event: notify::Event) { 96 | let opt = match event.kind { 97 | EventKind::Create(_) | EventKind::Remove(_) => event.paths[0] 98 | .parent() 99 | .map(|p| (p.to_path_buf(), FSHandleKind::Refresh)), 100 | EventKind::Modify(kind) => match kind { 101 | ModifyKind::Name(_) => event.paths[0] 102 | .parent() 103 | .map(|p| (p.to_path_buf(), FSHandleKind::Refresh)), 104 | ModifyKind::Data(_) => Some((event.paths[0].clone(), FSHandleKind::Reload)), 105 | _ => None, 106 | }, 107 | _ => None, 108 | }; 109 | 110 | if let Some((path, kind)) = opt { 111 | let path = path.canonicalize().unwrap_or(path); 112 | let projects = self.projects.read().unwrap(); 113 | 114 | for (window, project) in &*projects { 115 | if path.starts_with(&project.root) { 116 | self.handle_project_fs_event(project, window, &path, kind); 117 | } 118 | } 119 | } 120 | } 121 | 122 | fn handle_project_fs_event( 123 | &self, 124 | project: &Project, 125 | window: &Window, 126 | path: &PathBuf, 127 | kind: FSHandleKind, 128 | ) { 129 | trace!( 130 | "handling fs event for {:?} (path: {:?}, kind: {:?})", 131 | project, 132 | path, 133 | kind 134 | ); 135 | match kind { 136 | // Refreshes the explorer view 137 | FSHandleKind::Refresh => { 138 | if let Ok(relative) = path.strip_prefix(&project.root) { 139 | let event = FSRefreshEvent { 140 | path: relative.to_path_buf(), 141 | }; 142 | let _ = window.emit("fs_refresh", &event); 143 | } 144 | } 145 | // Reloads the file content, eg. project config or project source files 146 | FSHandleKind::Reload => { 147 | if let Ok(relative) = path.strip_prefix(&project.root) { 148 | if is_project_config_file(relative) { 149 | if let Ok(config) = ProjectConfig::read_from_file(path) { 150 | debug!("updating project config for {:?}: {:?}", project, config); 151 | let mut config_write = project.config.write().unwrap(); 152 | *config_write = config; 153 | config_write.apply(project); 154 | } 155 | } else { 156 | let mut world = project.world.lock().unwrap(); 157 | let path = Path::new("/").join(relative); 158 | match world.slot_update(&path, None) { 159 | Ok(id) => { 160 | debug!("updated slot for {:?} {:?} in {:?}", path, id, project); 161 | } 162 | Err(e) => { 163 | warn!( 164 | "unable to update slot for {:?} in {:?}: {:?}", 165 | path, project, e 166 | ); 167 | } 168 | } 169 | } 170 | } 171 | } 172 | } 173 | } 174 | 175 | pub fn new() -> Self { 176 | Self { 177 | projects: RwLock::new(HashMap::new()), 178 | watcher: Mutex::new(None), 179 | } 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /src-tauri/src/project/mod.rs: -------------------------------------------------------------------------------- 1 | mod project; 2 | mod world; 3 | mod manager; 4 | mod package; 5 | 6 | pub use project::*; 7 | pub use world::*; 8 | pub use manager::*; 9 | pub use package::*; 10 | -------------------------------------------------------------------------------- /src-tauri/src/project/package.rs: -------------------------------------------------------------------------------- 1 | use ecow::eco_format; 2 | use log::info; 3 | use native_tls::TlsConnector; 4 | use std::collections::VecDeque; 5 | use std::fmt::Display; 6 | use std::fs; 7 | use std::io::{self, ErrorKind, Read}; 8 | use std::path::{Path, PathBuf}; 9 | use std::sync::Arc; 10 | use std::time::{Duration, Instant}; 11 | use typst::diag::{PackageError, PackageResult}; 12 | use typst::syntax::package::PackageSpec; 13 | use typst_kit::download::{DownloadState, Downloader, Progress}; 14 | use typst_kit::package::PackageStorage; 15 | use typst_utils::format_duration; 16 | use ureq::Response; 17 | 18 | const HOST: &str = "https://packages.typst.org"; 19 | /// Keep track of this many download speed samples. 20 | const SPEED_SAMPLES: usize = 5; 21 | 22 | /// Prints download progress by writing `downloading {0}` followed by repeatedly 23 | /// updating the last terminal line. 24 | pub struct PrintDownload(pub T); 25 | 26 | impl Progress for PrintDownload { 27 | fn print_start(&mut self) { 28 | println!("downloading"); 29 | } 30 | 31 | fn print_progress(&mut self, state: &DownloadState) { 32 | let state_str = display_download_progress(state).expect("print progress error"); 33 | println!("{}", state_str); 34 | } 35 | 36 | fn print_finish(&mut self, state: &DownloadState) { 37 | let state_str = display_download_progress(state).expect("print progress finish error"); 38 | println!("{}", state_str); 39 | } 40 | } 41 | 42 | /// Compile and format several download statistics and make and attempt at 43 | /// displaying them on standard error. 44 | pub fn display_download_progress(state: &DownloadState) -> io::Result { 45 | let sum: usize = state.bytes_per_second.iter().sum(); 46 | let len = state.bytes_per_second.len(); 47 | let speed = if len > 0 { 48 | sum / len 49 | } else { 50 | state.content_len.unwrap_or(0) 51 | }; 52 | 53 | let total_downloaded = as_bytes_unit(state.total_downloaded); 54 | let speed_h = as_throughput_unit(speed); 55 | let elapsed = Instant::now().saturating_duration_since(state.start_time); 56 | let res: String; 57 | match state.content_len { 58 | Some(content_len) => { 59 | let percent = (state.total_downloaded as f64 / content_len as f64) * 100.; 60 | let remaining = content_len - state.total_downloaded; 61 | 62 | let download_size = as_bytes_unit(content_len); 63 | let eta = Duration::from_secs(if speed == 0 { 64 | 0 65 | } else { 66 | (remaining / speed) as u64 67 | }); 68 | res = format!( 69 | "{total_downloaded} / {download_size} ({percent:3.0} %) \ 70 | {speed_h} in {elapsed} ETA: {eta}", 71 | elapsed = format_duration(elapsed), 72 | eta = format_duration(eta), 73 | ); 74 | } 75 | None => { 76 | res = format!( 77 | "{total_downloaded} / {speed_h} in {elapsed}", 78 | elapsed = format_duration(elapsed), 79 | ) 80 | } 81 | }; 82 | Ok(res) 83 | } 84 | 85 | 86 | /// Format a given size as a unit of time. Setting `include_suffix` to true 87 | /// appends a '/s' (per second) suffix. 88 | fn as_bytes_unit(size: usize) -> String { 89 | const KI: f64 = 1024.0; 90 | const MI: f64 = KI * KI; 91 | const GI: f64 = KI * KI * KI; 92 | 93 | let size = size as f64; 94 | 95 | if size >= GI { 96 | format!("{:5.1} GiB", size / GI) 97 | } else if size >= MI { 98 | format!("{:5.1} MiB", size / MI) 99 | } else if size >= KI { 100 | format!("{:5.1} KiB", size / KI) 101 | } else { 102 | format!("{size:3} B") 103 | } 104 | } 105 | 106 | fn as_throughput_unit(size: usize) -> String { 107 | as_bytes_unit(size) + "/s" 108 | } 109 | 110 | /// Download from a URL. 111 | #[allow(clippy::result_large_err)] 112 | pub fn download(url: &str) -> Result { 113 | let mut builder = ureq::AgentBuilder::new(); 114 | let tls = TlsConnector::builder(); 115 | 116 | // Set user agent. 117 | builder = builder.user_agent(concat!("typst/", env!("CARGO_PKG_VERSION"))); 118 | 119 | // Get the network proxy config from the environment and apply it. 120 | if let Some(proxy) = env_proxy::for_url_str(url) 121 | .to_url() 122 | .and_then(|url| ureq::Proxy::new(url).ok()) 123 | { 124 | builder = builder.proxy(proxy); 125 | } 126 | 127 | // Configure native TLS. 128 | let connector = tls 129 | .build() 130 | .map_err(|err| io::Error::new(io::ErrorKind::Other, err))?; 131 | builder = builder.tls_connector(Arc::new(connector)); 132 | 133 | builder.build().get(url).call() 134 | } 135 | 136 | /// A wrapper around [`ureq::Response`] that reads the response body in chunks 137 | /// over a websocket and displays statistics about its progress. 138 | /// 139 | /// Downloads will _never_ fail due to statistics failing to print, print errors 140 | /// are silently ignored. 141 | struct RemoteReader { 142 | reader: Box, 143 | content_len: Option, 144 | total_downloaded: usize, 145 | downloaded_this_sec: usize, 146 | downloaded_last_few_secs: VecDeque, 147 | start_time: Instant, 148 | last_print: Option, 149 | } 150 | impl RemoteReader { 151 | /// Wraps a [`ureq::Response`] and prepares it for downloading. 152 | /// 153 | /// The 'Content-Length' header is used as a size hint for read 154 | /// optimization, if present. 155 | pub fn from_response(response: Response) -> Self { 156 | let content_len: Option = response 157 | .header("Content-Length") 158 | .and_then(|header| header.parse().ok()); 159 | 160 | Self { 161 | reader: response.into_reader(), 162 | content_len, 163 | total_downloaded: 0, 164 | downloaded_this_sec: 0, 165 | downloaded_last_few_secs: VecDeque::with_capacity(SPEED_SAMPLES), 166 | start_time: Instant::now(), 167 | last_print: None, 168 | } 169 | } 170 | 171 | /// Download the bodies content as raw bytes while attempting to print 172 | /// download statistics to standard error. Download progress gets displayed 173 | /// and updated every second. 174 | /// 175 | /// These statistics will never prevent a download from completing, errors 176 | /// are silently ignored. 177 | pub fn download(mut self) -> io::Result> { 178 | let mut buffer = vec![0; 8192]; 179 | let mut data = match self.content_len { 180 | Some(content_len) => Vec::with_capacity(content_len), 181 | None => Vec::with_capacity(8192), 182 | }; 183 | 184 | loop { 185 | let read = match self.reader.read(&mut buffer) { 186 | Ok(0) => break, 187 | Ok(n) => n, 188 | // If the data is not yet ready but will be available eventually 189 | // keep trying until we either get an actual error, receive data 190 | // or an Ok(0). 191 | Err(ref e) if e.kind() == ErrorKind::Interrupted => continue, 192 | Err(e) => return Err(e), 193 | }; 194 | 195 | data.extend(&buffer[..read]); 196 | 197 | let last_printed = match self.last_print { 198 | Some(prev) => prev, 199 | None => { 200 | let current_time = Instant::now(); 201 | self.last_print = Some(current_time); 202 | current_time 203 | } 204 | }; 205 | let elapsed = Instant::now().saturating_duration_since(last_printed); 206 | 207 | self.total_downloaded += read; 208 | self.downloaded_this_sec += read; 209 | 210 | if elapsed >= Duration::from_secs(1) { 211 | if self.downloaded_last_few_secs.len() == SPEED_SAMPLES { 212 | self.downloaded_last_few_secs.pop_back(); 213 | } 214 | 215 | self.downloaded_last_few_secs 216 | .push_front(self.downloaded_this_sec); 217 | self.downloaded_this_sec = 0; 218 | 219 | self.last_print = Some(Instant::now()); 220 | } 221 | } 222 | 223 | Ok(data) 224 | } 225 | } 226 | 227 | /// Download binary data and display its progress. 228 | #[allow(clippy::result_large_err)] 229 | pub fn download_with_progress(url: &str) -> Result, ureq::Error> { 230 | let response = download(url)?; 231 | Ok(RemoteReader::from_response(response).download()?) 232 | } 233 | /// Download a package over the network. 234 | pub fn download_package(spec: &PackageSpec, package_dir: &Path) -> PackageResult<()> { 235 | // The `@preview` namespace is the only namespace that supports on-demand 236 | // fetching. 237 | assert_eq!(spec.namespace, "preview"); 238 | 239 | let url = format!("{HOST}/preview/{}-{}.tar.gz", spec.name, spec.version); 240 | 241 | info!("downloading {}-{}", &spec.name, &spec.version); 242 | 243 | let data = match download_with_progress(&url) { 244 | Ok(data) => data, 245 | Err(ureq::Error::Status(404, _)) => return Err(PackageError::NotFound(spec.clone())), 246 | Err(err) => return Err(PackageError::NetworkFailed(Some(eco_format!("{err}")))), 247 | }; 248 | 249 | let decompressed = flate2::read::GzDecoder::new(data.as_slice()); 250 | tar::Archive::new(decompressed) 251 | .unpack(package_dir) 252 | .map_err(|err| { 253 | fs::remove_dir_all(package_dir).ok(); 254 | PackageError::MalformedArchive(Some(eco_format!("{err}"))) 255 | }) 256 | } 257 | 258 | /// Returns a new downloader. 259 | pub fn downloader(cert: Option) -> Downloader { 260 | let user_agent = concat!("typst/", env!("CARGO_PKG_VERSION")); 261 | match cert.clone() { 262 | Some(cert) => Downloader::with_path(user_agent, cert), 263 | None => Downloader::new(user_agent), 264 | } 265 | } 266 | 267 | /// Returns a new package storage for the given args. 268 | pub fn storage( 269 | package_path: Option, 270 | package_cache_path: Option, 271 | cert: Option, 272 | ) -> PackageStorage { 273 | PackageStorage::new( 274 | package_cache_path.clone(), 275 | package_path.clone(), 276 | downloader(cert), 277 | ) 278 | } 279 | -------------------------------------------------------------------------------- /src-tauri/src/project/project.rs: -------------------------------------------------------------------------------- 1 | use super::world::ProjectWorld; 2 | use chrono::{DateTime, Utc}; 3 | use log::{debug, info}; 4 | use serde::{Deserialize, Serialize}; 5 | use std::fmt::{self,Debug, Display, Formatter}; 6 | use std::path::{Path, PathBuf}; 7 | use std::sync::{Mutex, RwLock}; 8 | use std::{fs, io}; 9 | use thiserror::Error; 10 | use typst::diag::{FileError, FileResult}; 11 | use typst::model::Document; 12 | use typst::syntax::VirtualPath; 13 | 14 | const PATH_PROJECT_CONFIG_FILE: &str = ".typster/project.json"; 15 | 16 | pub struct Project { 17 | pub root: PathBuf, 18 | pub world: Mutex, 19 | pub cache: RwLock, 20 | pub config: RwLock, 21 | } 22 | 23 | #[derive(Default)] 24 | pub struct ProjectCache { 25 | pub document: Option, 26 | } 27 | 28 | #[derive(Serialize, Deserialize, Debug, Clone, Hash)] 29 | pub struct ProjectConfig { 30 | pub input: Option, 31 | pub root: Option, 32 | pub main: Option, 33 | pub font_paths: Vec, 34 | pub ignore_system_fonts: bool, 35 | pub creation_timestamp: Option>, 36 | pub diagnostic_format: DiagnosticFormat, 37 | pub package_path: Option, 38 | pub package_cache_path: Option, 39 | pub jobs: Option, 40 | pub cert: Option, 41 | } 42 | 43 | /// Which format to use for diagnostics. 44 | #[derive(Serialize, Deserialize, Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)] 45 | pub enum DiagnosticFormat { 46 | Human, 47 | Short, 48 | } 49 | 50 | impl Display for DiagnosticFormat { 51 | fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 52 | match self { 53 | DiagnosticFormat::Human => write!(f, "human"), 54 | DiagnosticFormat::Short => write!(f, "short"), 55 | } 56 | } 57 | } 58 | 59 | 60 | 61 | 62 | #[derive(Error, Debug)] 63 | pub enum ProjectConfigError { 64 | #[error("io error")] 65 | IO(#[from] io::Error), 66 | #[error("serial error")] 67 | Serial(#[from] serde_json::Error), 68 | } 69 | 70 | impl ProjectConfig { 71 | pub fn read_from_file>(path: P) -> Result { 72 | let json = fs::read_to_string(path).map_err(Into::::into)?; 73 | serde_json::from_str(&json).map_err(Into::into) 74 | } 75 | 76 | pub fn write_to_file>(&self, path: P) -> Result<(), ProjectConfigError> { 77 | let json = serde_json::to_string(&self).map_err(Into::::into)?; 78 | fs::write(path, json).map_err(Into::into) 79 | } 80 | 81 | pub fn apply(&self, project: &Project) { 82 | let mut world = project.world.lock().unwrap(); 83 | match self.apply_main(project, &mut world) { 84 | Ok(_) => debug!( 85 | "applied main source configuration for project {:?}", 86 | project 87 | ), 88 | Err(e) => debug!( 89 | "unable to apply main source configuration for project {:?}: {:?}", 90 | project, e 91 | ), 92 | } 93 | } 94 | 95 | pub fn apply_main(&self, project: &Project, world: &mut ProjectWorld) -> FileResult<()> { 96 | if let Some(main) = self.main.as_ref() { 97 | let vpath = VirtualPath::within_root(main, &project.root).expect("apply_main error"); 98 | debug!("setting main path {:?} for {:?}, vpath: {:?}", main, project, vpath); 99 | world.set_main_path(vpath); 100 | return Ok(()); 101 | } 102 | 103 | // ?? 104 | // world.set_main(None); 105 | 106 | Err(FileError::NotSource) 107 | } 108 | } 109 | 110 | impl Default for ProjectConfig { 111 | fn default() -> Self { 112 | Self { 113 | input: None, 114 | root: None, 115 | main: None, 116 | font_paths: Vec::new(), 117 | ignore_system_fonts: false, 118 | creation_timestamp: Some(Utc::now()), 119 | diagnostic_format: DiagnosticFormat::Human, 120 | package_path: None, 121 | package_cache_path: None, 122 | jobs: None, 123 | cert: None, 124 | } 125 | } 126 | } 127 | 128 | impl Project { 129 | pub fn load_from_path(path: PathBuf) -> Self { 130 | let path = fs::canonicalize(&path).unwrap_or(path); 131 | let config: ProjectConfig = { 132 | match ProjectConfig::read_from_file(path.join(PATH_PROJECT_CONFIG_FILE)) { 133 | Ok(config) => config, 134 | Err(e) => { 135 | let mut config = ProjectConfig::default(); 136 | config.input = Some(PathBuf::from("main.typ")); 137 | config.root = Some(path.clone()); 138 | config.main = Some(path.join("main.typ")); 139 | config 140 | } 141 | } 142 | }; 143 | info!("the config is: {:#?}", &config); 144 | Self { 145 | world: Mutex::new(ProjectWorld::new(path.clone(), config.clone()).expect("failed to create project world")), 146 | cache: RwLock::new(Default::default()), 147 | config: RwLock::new(config), 148 | root: path, 149 | } 150 | } 151 | } 152 | 153 | impl Debug for Project { 154 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 155 | f.debug_struct("Project").field("root", &self.root).finish() 156 | } 157 | } 158 | 159 | pub fn is_project_config_file(relative: &Path) -> bool { 160 | relative.as_os_str() == PATH_PROJECT_CONFIG_FILE 161 | } 162 | -------------------------------------------------------------------------------- /src-tauri/src/project/world.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, Datelike, FixedOffset, Local, Utc}; 2 | use log::{debug, info}; 3 | use parking_lot::{Mutex, RwLock}; 4 | use std::cell::{OnceCell, RefCell, RefMut}; 5 | use std::collections::hash_map::Entry; 6 | use std::collections::HashMap; 7 | use std::io::Read; 8 | use std::path::{Path, PathBuf}; 9 | use std::sync::{Arc, LazyLock, OnceLock}; 10 | use std::{fmt, fs, io, mem}; 11 | use typst::diag::{FileError, FileResult, PackageError, PackageResult}; 12 | use typst::foundations::{Bytes, Datetime}; 13 | use typst::layout::Frame; 14 | use typst::syntax::package::PackageSpec; 15 | use typst::syntax::{FileId, Source, VirtualPath}; 16 | use typst::text::{Font, FontBook}; 17 | use typst::utils::LazyHash; 18 | use typst::{Library, World}; 19 | use typst_kit::fonts::{FontSearcher, FontSlot, Fonts}; 20 | use typst_kit::package::PackageStorage; 21 | use typst_timing::timed; 22 | 23 | use super::package::{self, PrintDownload}; 24 | 25 | use super::{download_package, ProjectConfig}; 26 | 27 | 28 | 29 | /// An error that occurs during world construction. 30 | #[derive(Debug)] 31 | pub enum WorldCreationError { 32 | /// The input file does not appear to exist. 33 | InputNotFound(PathBuf), 34 | /// The input file is not contained within the root folder. 35 | InputOutsideRoot, 36 | /// The root directory does not appear to exist. 37 | RootNotFound(PathBuf), 38 | /// Another type of I/O error. 39 | Io(io::Error), 40 | } 41 | impl fmt::Display for WorldCreationError { 42 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 43 | match self { 44 | WorldCreationError::InputNotFound(path) => { 45 | write!(f, "input file not found (searched at {})", path.display()) 46 | } 47 | WorldCreationError::InputOutsideRoot => { 48 | write!(f, "source file must be contained in project root") 49 | } 50 | WorldCreationError::RootNotFound(path) => { 51 | write!( 52 | f, 53 | "root directory not found (searched at {})", 54 | path.display() 55 | ) 56 | } 57 | WorldCreationError::Io(err) => write!(f, "{err}"), 58 | } 59 | } 60 | } 61 | 62 | /// Lazily processes data for a file. 63 | struct SlotCell { 64 | /// The processed data. 65 | data: Option>, 66 | /// A hash of the raw file contents / access error. 67 | fingerprint: u128, 68 | /// Whether the slot has been accessed in the current compilation. 69 | accessed: bool, 70 | } 71 | 72 | /// The current date and time. 73 | enum Now { 74 | /// The date and time if the environment `SOURCE_DATE_EPOCH` is set. 75 | /// Used for reproducible builds. 76 | Fixed(DateTime), 77 | /// The current date and time if the time is not externally fixed. 78 | System(OnceLock>), 79 | } 80 | 81 | struct FileSlot { 82 | /// The slot's file id. 83 | id: FileId, 84 | /// The lazily loaded and incrementally updated source file. 85 | source: SlotCell, 86 | /// The lazily loaded raw byte buffer. 87 | file: SlotCell, 88 | } 89 | 90 | impl FileSlot { 91 | /// Create a new file slot. 92 | fn new(id: FileId) -> Self { 93 | Self { 94 | id, 95 | file: SlotCell::new(), 96 | source: SlotCell::new(), 97 | } 98 | } 99 | 100 | /// Whether the file was accessed in the ongoing compilation. 101 | fn accessed(&self) -> bool { 102 | self.source.accessed() || self.file.accessed() 103 | } 104 | 105 | /// Marks the file as not yet accessed in preparation of the next 106 | /// compilation. 107 | fn reset(&mut self) { 108 | self.source.reset(); 109 | self.file.reset(); 110 | } 111 | 112 | /// Retrieve the source for this file. 113 | fn source( 114 | &mut self, 115 | project_root: &Path, 116 | package_storage: &PackageStorage, 117 | ) -> FileResult { 118 | info!("fn source: {:?}", self.id.vpath()); 119 | self.source.get_or_init( 120 | || read(self.id, project_root, package_storage), 121 | |data, prev| { 122 | let text = decode_utf8(&data)?; 123 | if let Some(mut prev) = prev { 124 | prev.replace(text); 125 | Ok(prev) 126 | } else { 127 | Ok(Source::new(self.id, text.into())) 128 | } 129 | }, 130 | ) 131 | } 132 | 133 | /// Retrieve the file's bytes. 134 | fn file(&mut self, project_root: &Path, package_storage: &PackageStorage) -> FileResult { 135 | info!("fn file: {:?}", self.id.vpath()); 136 | self.file.get_or_init( 137 | || read(self.id, project_root, package_storage), 138 | |data, _| Ok(data.into()), 139 | ) 140 | } 141 | } 142 | 143 | /// Decode UTF-8 with an optional BOM. 144 | fn decode_utf8(buf: &[u8]) -> FileResult<&str> { 145 | // Remove UTF-8 BOM. 146 | Ok(std::str::from_utf8( 147 | buf.strip_prefix(b"\xef\xbb\xbf").unwrap_or(buf), 148 | )?) 149 | } 150 | 151 | /// Resolves the path of a file id on the system, downloading a package if 152 | /// necessary. 153 | fn system_path( 154 | project_root: &Path, 155 | id: FileId, 156 | package_storage: &PackageStorage, 157 | ) -> FileResult { 158 | // Determine the root path relative to which the file path 159 | // will be resolved. 160 | let buf; 161 | let mut root = project_root; 162 | if let Some(spec) = id.package() { 163 | buf = package_storage.prepare_package(spec, &mut PrintDownload(&spec))?; 164 | root = &buf; 165 | } 166 | info!("system_path: {:?}", root, ); 167 | info!("id path: {:?}", id.vpath()); 168 | // Join the path to the root. If it tries to escape, deny 169 | // access. Note: It can still escape via symlinks. 170 | id.vpath().resolve(root).ok_or(FileError::AccessDenied) 171 | } 172 | 173 | /// Reads a file from a `FileId`. 174 | /// 175 | /// If the ID represents stdin it will read from standard input, 176 | /// otherwise it gets the file path of the ID and reads the file from disk. 177 | fn read(id: FileId, project_root: &Path, package_storage: &PackageStorage) -> FileResult> { 178 | info!("read file: {}", project_root.display()); 179 | read_from_disk(&system_path(project_root, id, package_storage)?) 180 | 181 | } 182 | 183 | /// Read a file from disk. 184 | fn read_from_disk(path: &Path) -> FileResult> { 185 | info!("reading file: {}", path.display()); 186 | let f = |e| FileError::from_io(e, path); 187 | if fs::metadata(path).map_err(f)?.is_dir() { 188 | Err(FileError::IsDirectory) 189 | } else { 190 | debug!("reading file: {}", path.display()); 191 | fs::read(path).map_err(|e| { 192 | eprintln!("Error reading from disk: {}", e); // Print the error 193 | f(e) // Call the error handling function f 194 | }) 195 | } 196 | } 197 | 198 | /// Read from stdin. 199 | fn read_from_stdin() -> FileResult> { 200 | let mut buf = Vec::new(); 201 | let result = io::stdin().read_to_end(&mut buf); 202 | match result { 203 | Ok(_) => (), 204 | Err(err) if err.kind() == io::ErrorKind::BrokenPipe => (), 205 | Err(err) => return Err(FileError::from_io(err, Path::new(""))), 206 | } 207 | Ok(buf) 208 | } 209 | 210 | impl SlotCell { 211 | /// Creates a new, empty cell. 212 | fn new() -> Self { 213 | Self { 214 | data: None, 215 | fingerprint: 0, 216 | accessed: false, 217 | } 218 | } 219 | 220 | /// Whether the cell was accessed in the ongoing compilation. 221 | fn accessed(&self) -> bool { 222 | self.accessed 223 | } 224 | 225 | /// Marks the cell as not yet accessed in preparation of the next 226 | /// compilation. 227 | fn reset(&mut self) { 228 | self.accessed = false; 229 | } 230 | 231 | /// Gets the contents of the cell or initialize them. 232 | fn get_or_init( 233 | &mut self, 234 | load: impl FnOnce() -> FileResult>, 235 | f: impl FnOnce(Vec, Option) -> FileResult, 236 | ) -> FileResult { 237 | // If we accessed the file already in this compilation, retrieve it. 238 | if mem::replace(&mut self.accessed, true) { 239 | if let Some(data) = &self.data { 240 | return data.clone(); 241 | } 242 | } 243 | 244 | // Read and hash the file. 245 | let result = timed!("loading file", load()); 246 | let fingerprint = timed!("hashing file", typst::utils::hash128(&result)); 247 | 248 | // If the file contents didn't change, yield the old processed data. 249 | if mem::replace(&mut self.fingerprint, fingerprint) == fingerprint { 250 | if let Some(data) = &self.data { 251 | return data.clone(); 252 | } 253 | } 254 | 255 | let prev = self.data.take().and_then(Result::ok); 256 | let value = result.and_then(|data| f(data, prev)); 257 | self.data = Some(value.clone()); 258 | 259 | value 260 | } 261 | } 262 | 263 | pub struct ProjectWorld { 264 | /// The working directory. 265 | workdir: Option, 266 | /// The root relative to which absolute paths are resolved. 267 | root: PathBuf, 268 | /// The input path. 269 | main: FileId, 270 | /// Typst's standard library. 271 | library: LazyHash, 272 | /// Metadata about discovered fonts. 273 | book: LazyHash, 274 | /// Locations of and storage for lazily loaded fonts. 275 | fonts: Vec, 276 | /// Maps file ids to source files and buffers. 277 | slots: Mutex>, 278 | /// Holds information about where packages are stored. 279 | package_storage: PackageStorage, 280 | /// The current datetime if requested. This is stored here to ensure it is 281 | /// always the same within one compilation. 282 | /// Reset between compilations if not [`Now::Fixed`]. 283 | now: Now, 284 | /// The export cache, used for caching output files in `typst watch` 285 | /// sessions. 286 | export_cache: ExportCache, 287 | } 288 | 289 | impl ProjectWorld { 290 | pub fn new(root: PathBuf, config: ProjectConfig) -> Result { 291 | let main_path = VirtualPath::within_root(&config.main.expect("input is required"), &root) 292 | .ok_or(WorldCreationError::InputOutsideRoot)?; 293 | info!("main_path: {:?} root: {:?} ", main_path, root); 294 | let main: FileId = FileId::new(None, main_path); 295 | 296 | let library: Library = { Library::builder().build() }; 297 | 298 | let now = Now::System(OnceLock::new()); 299 | let fonts = Fonts::searcher().search(); 300 | 301 | Ok(Self { 302 | workdir: Some(root.clone()), 303 | root, 304 | main, 305 | library: LazyHash::new(library), 306 | book: LazyHash::new(fonts.book), 307 | fonts: fonts.fonts, 308 | slots: Mutex::new(HashMap::new()), 309 | package_storage: package::storage( 310 | config.package_path, 311 | config.package_cache_path, 312 | config.cert, 313 | ), 314 | now, 315 | export_cache: ExportCache::new(), 316 | }) 317 | } 318 | pub fn slot_update>( 319 | &mut self, 320 | path: P, 321 | content: Option, 322 | ) -> FileResult { 323 | let vpath = VirtualPath::new(path); 324 | info!("slot update fn vpath: {:?}", vpath); 325 | let id = FileId::new(None, vpath.clone()); 326 | self.slot(id, |slot| { 327 | 328 | if let Some(res) = &mut slot.source.data { 329 | info!("res: {:?} vpath: {:?} content: {:?} ", res, vpath, content); 330 | match self.take_or_read(&vpath, content.clone()) { 331 | Ok(content) => match res { 332 | Ok(src) => { 333 | src.replace(&content); 334 | } 335 | Err(_) => { 336 | *res = Ok(Source::new(id, content)); 337 | } 338 | }, 339 | Err(e) => { 340 | // nothing todo 341 | } 342 | } 343 | } 344 | // Write content to slot file 345 | if let Some(res) = &mut slot.file.data { 346 | info!("res: {:?} vpath: {:?} content: {:?} ", res, vpath, content); 347 | match self.take_or_read_bytes(&vpath, content.clone()) { 348 | Ok(bytes) => match res { 349 | Ok(b) => { 350 | *b = bytes; 351 | } 352 | Err(_) => { 353 | *res = Ok(bytes); 354 | } 355 | }, 356 | Err(e) => { 357 | // nothing todo 358 | } 359 | } 360 | }; 361 | }); 362 | 363 | Ok(id) 364 | } 365 | 366 | pub fn set_main(&mut self, id: FileId) { 367 | self.main = id.clone(); 368 | } 369 | 370 | pub fn set_main_path(&mut self, main: VirtualPath) { 371 | self.set_main(FileId::new(None, main)) 372 | } 373 | 374 | pub fn is_main_set(&self) -> bool { 375 | // TODO: Check if the file exists 376 | true 377 | } 378 | 379 | 380 | 381 | /// Access the canonical slot for the given file id. 382 | fn slot(&self, id: FileId, f: F) -> T 383 | where 384 | F: FnOnce(&mut FileSlot) -> T, 385 | { 386 | let mut map = self.slots.lock(); 387 | f(map.entry(id).or_insert_with(|| FileSlot::new(id))) 388 | } 389 | 390 | fn take_or_read(&self, vpath: &VirtualPath, content: Option) -> FileResult { 391 | if let Some(content) = content { 392 | return Ok(content); 393 | } 394 | 395 | let path = vpath.resolve(&self.root).ok_or(FileError::AccessDenied)?; 396 | fs::read_to_string(&path).map_err(|e| FileError::from_io(e, &path)) 397 | } 398 | 399 | fn take_or_read_bytes( 400 | &self, 401 | vpath: &VirtualPath, 402 | content: Option, 403 | ) -> FileResult { 404 | if let Some(content) = content { 405 | return Ok(Bytes::from(content.into_bytes())); 406 | } 407 | 408 | let path = vpath.resolve(&self.root).ok_or(FileError::AccessDenied)?; 409 | fs::read(&path) 410 | .map_err(|e| FileError::from_io(e, &path)) 411 | .map(Bytes::from) 412 | } 413 | 414 | fn prepare_package(spec: &PackageSpec) -> PackageResult { 415 | let subdir = format!( 416 | "typst/packages/{}/{}/{}", 417 | spec.namespace, spec.name, spec.version 418 | ); 419 | 420 | if let Some(data_dir) = dirs::data_dir() { 421 | let dir = data_dir.join(&subdir); 422 | info!("----package load_from_path: {:?}", &dir); 423 | if dir.exists() { 424 | return Ok(dir); 425 | } 426 | } 427 | 428 | if let Some(cache_dir) = dirs::cache_dir() { 429 | let dir = cache_dir.join(&subdir); 430 | if dir.exists() { 431 | return Ok(dir); 432 | } 433 | // Download from network if it doesn't exist yet. 434 | if spec.namespace == "preview" { 435 | download_package(spec, &dir)?; 436 | if dir.exists() { 437 | return Ok(dir); 438 | } 439 | } 440 | } 441 | 442 | Err(PackageError::NotFound(spec.clone())) 443 | } 444 | } 445 | 446 | impl World for ProjectWorld { 447 | fn library(&self) -> &LazyHash { 448 | &self.library 449 | } 450 | 451 | fn book(&self) -> &LazyHash { 452 | &self.book 453 | } 454 | 455 | fn main(&self) -> FileId { 456 | self.main 457 | } 458 | 459 | fn source(&self, id: FileId) -> FileResult { 460 | self.slot(id, |slot| slot.source(&self.root, &self.package_storage)) 461 | } 462 | 463 | fn file(&self, id: FileId) -> FileResult { 464 | self.slot(id, |slot| slot.file(&self.root, &self.package_storage)) 465 | } 466 | 467 | fn font(&self, id: usize) -> Option { 468 | self.fonts[id].get() 469 | } 470 | 471 | fn today(&self, offset: Option) -> Option { 472 | let now = match &self.now { 473 | Now::Fixed(time) => time, 474 | Now::System(time) => time.get_or_init(Utc::now), 475 | }; 476 | 477 | // The time with the specified UTC offset, or within the local time zone. 478 | let with_offset = match offset { 479 | None => now.with_timezone(&Local).fixed_offset(), 480 | Some(hours) => { 481 | let seconds = i32::try_from(hours).ok()?.checked_mul(3600)?; 482 | now.with_timezone(&FixedOffset::east_opt(seconds)?) 483 | } 484 | }; 485 | 486 | Datetime::from_ymd( 487 | with_offset.year(), 488 | with_offset.month().try_into().ok()?, 489 | with_offset.day().try_into().ok()?, 490 | ) 491 | } 492 | fn packages(&self) -> &[(PackageSpec, Option)] { 493 | &[] 494 | } 495 | } 496 | 497 | 498 | /// Caches exported files so that we can avoid re-exporting them if they haven't 499 | /// changed. 500 | /// 501 | /// This is done by having a list of size `files.len()` that contains the hashes 502 | /// of the last rendered frame in each file. If a new frame is inserted, this 503 | /// will invalidate the rest of the cache, this is deliberate as to decrease the 504 | /// complexity and memory usage of such a cache. 505 | pub struct ExportCache { 506 | /// The hashes of last compilation's frames. 507 | pub cache: RwLock>, 508 | } 509 | 510 | impl ExportCache { 511 | /// Creates a new export cache. 512 | pub fn new() -> Self { 513 | Self { 514 | cache: RwLock::new(Vec::with_capacity(32)), 515 | } 516 | } 517 | 518 | /// Returns true if the entry is cached and appends the new hash to the 519 | /// cache (for the next compilation). 520 | pub fn is_cached(&self, i: usize, frame: &Frame) -> bool { 521 | let hash = typst::utils::hash128(frame); 522 | 523 | let mut cache = self.cache.upgradable_read(); 524 | if i >= cache.len() { 525 | cache.with_upgraded(|cache| cache.push(hash)); 526 | return false; 527 | } 528 | 529 | cache.with_upgraded(|cache| std::mem::replace(&mut cache[i], hash) == hash) 530 | } 531 | } 532 | -------------------------------------------------------------------------------- /src-tauri/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "productName": "typster", 3 | "version": "0.12.0", 4 | "mainBinaryName": "typster", 5 | "identifier": "cn.wflixu.typster", 6 | "build": { 7 | "beforeDevCommand": "pnpm dev", 8 | "beforeBuildCommand": "pnpm build", 9 | "frontendDist": "../dist", 10 | "devUrl": "http://localhost:1420" 11 | }, 12 | "bundle": { 13 | "active": true, 14 | "icon": [ 15 | "icons/32x32.png", 16 | "icons/128x128.png", 17 | "icons/128x128@2x.png", 18 | "icons/icon.icns", 19 | "icons/icon.ico" 20 | ], 21 | "targets": [ 22 | "dmg", 23 | "msi" 24 | ], 25 | 26 | "windows": { 27 | "webviewInstallMode": { 28 | "type": "embedBootstrapper" 29 | } 30 | }, 31 | "macOS": { 32 | "dmg": { 33 | "appPosition": { 34 | "x": 180, 35 | "y": 170 36 | }, 37 | "applicationFolderPosition": { 38 | "x": 480, 39 | "y": 170 40 | }, 41 | "windowSize": { 42 | "height": 400, 43 | "width": 660 44 | } 45 | }, 46 | "files": {}, 47 | "hardenedRuntime": true, 48 | "signingIdentity": "6B979C438205BE9EEB54B5068A18AD32486B6572", 49 | "minimumSystemVersion": "14.0.0" 50 | } 51 | }, 52 | 53 | 54 | "plugins": {}, 55 | "app": { 56 | "macOSPrivateApi": true, 57 | "withGlobalTauri": false, 58 | "windows": [ 59 | { 60 | "fullscreen": false, 61 | "resizable": true, 62 | "title": "typster", 63 | "center": true, 64 | "hiddenTitle": true, 65 | "titleBarStyle": "Transparent", 66 | "theme": "Light", 67 | "width": 1200, 68 | "height": 900, 69 | "minWidth": 800, 70 | "minHeight": 400 71 | } 72 | ], 73 | "security": { 74 | "csp": null 75 | } 76 | } 77 | } -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/assets/rendering.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 8 | 10 | 11 | 12 | 14 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/assets/vue.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/MonacoEditor.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 135 | 136 | 144 | -------------------------------------------------------------------------------- /src/components/MoveBar.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 16 | 17 | -------------------------------------------------------------------------------- /src/components/PageLoading.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue"; 2 | import "ant-design-vue/dist/reset.css"; 3 | import "./style/styles.css"; 4 | import "./shared/monaco-hook"; 5 | 6 | import Antd from "ant-design-vue"; 7 | 8 | import TodayUI from "today-ui"; 9 | 10 | import App from "./App.vue"; 11 | import { pinia } from "./store/store"; 12 | import { router } from "./router"; 13 | 14 | const app = createApp(App); 15 | 16 | app.use(TodayUI); 17 | app.use(Antd); 18 | app.use(router); 19 | app.use(pinia); 20 | 21 | app.mount("#app"); 22 | -------------------------------------------------------------------------------- /src/pages/home/Home.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 19 | 20 | -------------------------------------------------------------------------------- /src/pages/home/Sidebar.vue: -------------------------------------------------------------------------------- 1 | 46 | 47 | 178 | 179 | -------------------------------------------------------------------------------- /src/pages/home/SidebarToggle.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 34 | 35 | -------------------------------------------------------------------------------- /src/pages/project/AddProject.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /src/pages/project/Project.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 75 | 76 | -------------------------------------------------------------------------------- /src/pages/project/interface.ts: -------------------------------------------------------------------------------- 1 | export interface IProject { 2 | title: string; 3 | path: string; 4 | } 5 | -------------------------------------------------------------------------------- /src/pages/typst/DiagnosticsTip.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 25 | 26 | -------------------------------------------------------------------------------- /src/pages/typst/PreviewPage.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 94 | 95 | -------------------------------------------------------------------------------- /src/pages/typst/TypstEditor.vue: -------------------------------------------------------------------------------- 1 | 69 | 70 | 188 | 189 | -------------------------------------------------------------------------------- /src/pages/typst/ViewScale.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 36 | 37 | -------------------------------------------------------------------------------- /src/pages/typst/interface.ts: -------------------------------------------------------------------------------- 1 | import { invoke } from "@tauri-apps/api/core"; 2 | 3 | export interface TypstRenderResponse { 4 | image: string; 5 | width: number; 6 | height: number; 7 | nonce: number; 8 | } 9 | 10 | export interface TypstCompileEvent { 11 | document: TypstDocument | null; 12 | diagnostics: TypstSourceDiagnostic[] | null; 13 | } 14 | 15 | export interface TypstDocument { 16 | pages: number; 17 | hash: string; 18 | width: number; 19 | height: number; 20 | } 21 | export interface TypstPage { 22 | hash: string; 23 | width: number; 24 | height: number; 25 | num: number; 26 | } 27 | 28 | export type TypstCompileResult = [[TypstPage], [TypstSourceDiagnostic]] 29 | 30 | export type TypstDiagnosticSeverity = "error" | "warning"; 31 | 32 | export interface TypstSourceDiagnostic { 33 | range: { start: number; end: number }; 34 | severity: TypstDiagnosticSeverity; 35 | message: string; 36 | hints: string[]; 37 | pos: [number, number] 38 | } 39 | 40 | export enum TypstCompletionKind { 41 | Syntax = 1, 42 | Function = 2, 43 | Parameter = 3, 44 | Constant = 4, 45 | Symbol = 5, 46 | Type = 6, 47 | } 48 | 49 | export interface TypstCompletion { 50 | kind: TypstCompletionKind; 51 | label: string; 52 | apply: string | null; 53 | detail: string | null; 54 | } 55 | 56 | export type IMode = "all" | "edit" | "preview"; 57 | export type IAdjust = "full" | "width" | "height"; 58 | 59 | export interface TypstCompleteResponse { 60 | offset: number; 61 | completions: TypstCompletion[]; 62 | } 63 | 64 | 65 | -------------------------------------------------------------------------------- /src/router.ts: -------------------------------------------------------------------------------- 1 | import { RouteRecordRaw, createRouter, createWebHashHistory } from "vue-router"; 2 | import Home from "./pages/home/Home.vue"; 3 | import Project from "./pages/project/Project.vue"; 4 | // const AsyncHome = 5 | const routes = [ 6 | { path: "/", redirect: "/project" }, 7 | { 8 | path: "/home", 9 | component: Home, 10 | }, 11 | { 12 | path: "/project", 13 | component: Project, 14 | }, 15 | ]; 16 | 17 | const router = createRouter({ 18 | // 4. Provide the history implementation to use. We are using the hash history for simplicity here. 19 | history: createWebHashHistory(), 20 | routes, // short for `routes: routes` 21 | }); 22 | 23 | export { router }; 24 | -------------------------------------------------------------------------------- /src/shared/lang/bibtex.json: -------------------------------------------------------------------------------- 1 | { 2 | "displayName": "BibTeX", 3 | "name": "bibtex", 4 | "mimeTypes": [ 5 | "text/bibtex" 6 | ], 7 | "fileExtensions": [ 8 | "bib" 9 | ], 10 | "ignoreCase": true, 11 | "lineComment": "% ", 12 | "entries": [ 13 | "article", 14 | "book", 15 | "booklet", 16 | "conference", 17 | "inbook", 18 | "incollection", 19 | "inproceedings", 20 | "manual", 21 | "mastersthesis", 22 | "misc", 23 | "phdthesis", 24 | "proceedings", 25 | "techreport", 26 | "unpublished", 27 | "xdata", 28 | "preamble", 29 | "string", 30 | "comment" 31 | ], 32 | "fields": [ 33 | "address", 34 | "annote", 35 | "author", 36 | "booktitle", 37 | "chapter", 38 | "crossref", 39 | "edition", 40 | "editor", 41 | "howpublished", 42 | "institution", 43 | "journal", 44 | "key", 45 | "month", 46 | "note", 47 | "number", 48 | "organization", 49 | "pages", 50 | "publisher", 51 | "school", 52 | "series", 53 | "title", 54 | "type", 55 | "volume", 56 | "year", 57 | "url", 58 | "isbn", 59 | "issn", 60 | "lccn", 61 | "abstract", 62 | "keywords", 63 | "price", 64 | "copyright", 65 | "language", 66 | "contents", 67 | "numpages", 68 | "doi", 69 | "http", 70 | "eds", 71 | "editors", 72 | "location", 73 | "eprinttype", 74 | "etype", 75 | "eprint", 76 | "eprintpath", 77 | "primaryclass", 78 | "eprintclass", 79 | "archiveprefix", 80 | "origpublisher", 81 | "origlocation", 82 | "venue", 83 | "volumes", 84 | "pagetotal", 85 | "annotation", 86 | "annote", 87 | "pubstate", 88 | "date", 89 | "urldate", 90 | "eventdate", 91 | "origdate", 92 | "urltext" 93 | ], 94 | "tokenizer": { 95 | "root": [ 96 | [ 97 | "\\\\[^a-z]", 98 | "string.escape" 99 | ], 100 | [ 101 | "(@)([a-z]+)(\\{)(\\s*[^\\s,=]+)?", 102 | [ 103 | "keyword", 104 | { 105 | "cases": { 106 | "$2@entries": "keyword", 107 | "@default": "" 108 | } 109 | }, 110 | "@brackets", 111 | "type" 112 | ] 113 | ], 114 | [ 115 | "\\b([a-z]+)(?=\\s*=)", 116 | { 117 | "cases": { 118 | "$1@fields": "constructor", 119 | "@default": "" 120 | } 121 | } 122 | ], 123 | [ 124 | "[=]", 125 | "keyword" 126 | ], 127 | { 128 | "include": "@whitespace" 129 | }, 130 | [ 131 | "[{}()\\[\\]]", 132 | "@brackets" 133 | ] 134 | ], 135 | "whitespace": [ 136 | [ 137 | "[ \\t\\r\\n]+", 138 | "white" 139 | ], 140 | [ 141 | "%.*$", 142 | "comment" 143 | ] 144 | ] 145 | } 146 | } -------------------------------------------------------------------------------- /src/shared/lang/completion.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | CancellationToken, 3 | editor, 4 | IRange, 5 | Position, 6 | } from "monaco-editor"; 7 | import { languages } from "monaco-editor"; 8 | 9 | import { invoke } from "@tauri-apps/api/core"; 10 | 11 | import CompletionTriggerKind = languages.CompletionTriggerKind; 12 | import { TypstCompleteResponse, TypstCompletionKind } from "../../pages/typst/interface"; 13 | 14 | export const autocomplete = ( 15 | path: string, 16 | content: string, 17 | offset: number, 18 | explicit: boolean 19 | ): Promise => 20 | invoke("typst_autocomplete", { 21 | path, 22 | content, 23 | offset, 24 | explicit, 25 | }); 26 | 27 | 28 | export class TypstCompletionProvider 29 | implements languages.CompletionItemProvider { 30 | triggerCharacters = [" ", "(", "[", "{", "$", "@", "#", "."]; 31 | 32 | async provideCompletionItems( 33 | model: editor.ITextModel, 34 | position: Position, 35 | context: languages.CompletionContext, 36 | token: CancellationToken 37 | ): Promise { 38 | console.warn("completing", position, context); 39 | const { offset: completionOffset, completions } = await autocomplete( 40 | model.uri.path, 41 | model.getValue(), 42 | model.getOffsetAt(position), 43 | context.triggerKind === CompletionTriggerKind.Invoke 44 | ); 45 | console.log("completed", completionOffset, completions); 46 | 47 | const completionPosition = model.getPositionAt(completionOffset); 48 | const range: IRange = { 49 | startLineNumber: completionPosition.lineNumber, 50 | startColumn: completionPosition.column, 51 | endLineNumber: position.lineNumber, 52 | endColumn: position.column, 53 | }; 54 | 55 | return { 56 | suggestions: completions.map((completion: any) => { 57 | let kind = languages.CompletionItemKind.Snippet; 58 | switch (completion.kind) { 59 | case TypstCompletionKind.Syntax: 60 | kind = languages.CompletionItemKind.Snippet; 61 | break; 62 | case TypstCompletionKind.Function: 63 | kind = languages.CompletionItemKind.Function; 64 | break; 65 | case TypstCompletionKind.Parameter: 66 | kind = languages.CompletionItemKind.Variable; 67 | break; 68 | case TypstCompletionKind.Constant: 69 | kind = languages.CompletionItemKind.Constant; 70 | break; 71 | case TypstCompletionKind.Symbol: 72 | kind = languages.CompletionItemKind.Keyword; 73 | break; 74 | case TypstCompletionKind.Type: 75 | kind = languages.CompletionItemKind.Class; 76 | break; 77 | } 78 | 79 | let count = 0; 80 | const insertText = 81 | completion.apply?.replace(/\${/g, (r: any) => `${r}${++count}:`) || 82 | completion.label; 83 | 84 | return { 85 | label: completion.label, 86 | kind, 87 | insertText: insertText, 88 | detail: completion.detail ?? undefined, 89 | insertTextRules: 90 | languages.CompletionItemInsertTextRule.InsertAsSnippet, 91 | range, 92 | }; 93 | }), 94 | }; 95 | } 96 | } 97 | 98 | -------------------------------------------------------------------------------- /src/shared/lang/grammar.ts: -------------------------------------------------------------------------------- 1 | import * as monaco from "monaco-editor"; 2 | import type { Registry, StateStack } from "vscode-textmate"; 3 | import { INITIAL } from "vscode-textmate"; 4 | 5 | // Wires up monaco-editor with monaco-textmate 6 | // Taken from: https://github.com/microsoft/monaco-editor/discussions/3830 7 | export const wireTextMateGrammars = ( 8 | // TmGrammar `Registry` this wiring should rely on to provide the grammars. 9 | registry: Registry, 10 | // `Map` of language ids (string) to TM names (string). 11 | languages: Record 12 | ) => 13 | Promise.all( 14 | Array.from(Object.keys(languages)).map(async (languageId) => { 15 | const grammar = await registry.loadGrammar(languages[languageId]); 16 | if (!grammar) return; 17 | 18 | monaco.languages.setTokensProvider(languageId, { 19 | getInitialState: () => new TokenizerState(INITIAL), 20 | tokenize: (line: string, state: TokenizerState) => { 21 | const result = grammar.tokenizeLine(line, state.ruleStack); 22 | 23 | return { 24 | endState: new TokenizerState(result.ruleStack), 25 | tokens: result.tokens.map((token) => { 26 | const scopes = token.scopes.slice(0); 27 | 28 | // for (let i = scopes.length - 1; i >= 0; i--) { 29 | // const scope = scopes[i]; 30 | // console.log(scope); 31 | // const foreground = tokenTheme._match(scope)._foreground; 32 | 33 | // if (foreground !== defaultForeground) { 34 | // return { 35 | // ...token, 36 | // scopes: scope, 37 | // }; 38 | // } 39 | // } 40 | 41 | return { 42 | ...token, 43 | scopes: scopes[scopes.length - 1], 44 | }; 45 | }), 46 | }; 47 | }, 48 | }); 49 | }) 50 | ); 51 | 52 | class TokenizerState implements monaco.languages.IState { 53 | constructor(private _ruleStack: StateStack) {} 54 | 55 | public get ruleStack(): StateStack { 56 | return this._ruleStack; 57 | } 58 | 59 | public clone(): TokenizerState { 60 | return new TokenizerState(this._ruleStack); 61 | } 62 | 63 | public equals(other: monaco.languages.IState): boolean { 64 | return ( 65 | other instanceof TokenizerState && (other === this || other.ruleStack === this.ruleStack) 66 | ); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/shared/lang/typst-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "comments": { 3 | "lineComment": "//", 4 | "blockComment": [ 5 | "/*", 6 | "*/" 7 | ] 8 | }, 9 | "brackets": [ 10 | [ 11 | "[", 12 | "]" 13 | ], 14 | [ 15 | "{", 16 | "}" 17 | ], 18 | [ 19 | "(", 20 | ")" 21 | ] 22 | ], 23 | "autoClosingPairs": [ 24 | { 25 | "open": "[", 26 | "close": "]" 27 | }, 28 | { 29 | "open": "{", 30 | "close": "}" 31 | }, 32 | { 33 | "open": "(", 34 | "close": ")" 35 | }, 36 | { 37 | "open": "\"", 38 | "close": "\"", 39 | "notIn": [ 40 | "string" 41 | ] 42 | }, 43 | { 44 | "open": "$", 45 | "close": "$", 46 | "notIn": [ 47 | "string" 48 | ] 49 | } 50 | ], 51 | "autoCloseBefore": "$ \n\t", 52 | "surroundingPairs": [ 53 | [ 54 | "[", 55 | "]" 56 | ], 57 | [ 58 | "{", 59 | "}" 60 | ], 61 | [ 62 | "(", 63 | ")" 64 | ], 65 | [ 66 | "\"", 67 | "\"" 68 | ], 69 | [ 70 | "*", 71 | "*" 72 | ], 73 | [ 74 | "_", 75 | "_" 76 | ], 77 | [ 78 | "`", 79 | "`" 80 | ], 81 | [ 82 | "$", 83 | "$" 84 | ] 85 | ] 86 | } -------------------------------------------------------------------------------- /src/shared/lang/typst-tm.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typst", 3 | "patterns": [ 4 | { 5 | "include": "#markup" 6 | } 7 | ], 8 | "repository": { 9 | "comments": { 10 | "patterns": [ 11 | { 12 | "name": "comment.block.typst", 13 | "begin": "/\\*", 14 | "end": "\\*/", 15 | "captures": { 16 | "0": { 17 | "name": "punctuation.definition.comment.typst" 18 | } 19 | }, 20 | "patterns": [ 21 | { 22 | "include": "#comments" 23 | } 24 | ] 25 | }, 26 | { 27 | "name": "comment.line.double-slash.typst", 28 | "begin": "(?", 189 | "captures": { 190 | "1": { 191 | "name": "punctuation.definition.label.typst" 192 | } 193 | } 194 | }, 195 | { 196 | "name": "entity.other.reference.typst", 197 | "match": "(@)[[:alpha:]_][[:alnum:]_-]*", 198 | "captures": { 199 | "1": { 200 | "name": "punctuation.definition.reference.typst" 201 | } 202 | } 203 | }, 204 | { 205 | "begin": "(#)(let|set|show)\\b", 206 | "end": "\n|(;)|(?=])", 207 | "beginCaptures": { 208 | "0": { 209 | "name": "keyword.other.typst" 210 | }, 211 | "1": { 212 | "name": "punctuation.definition.keyword.typst" 213 | } 214 | }, 215 | "endCaptures": { 216 | "1": { 217 | "name": "punctuation.terminator.statement.typst" 218 | } 219 | }, 220 | "patterns": [ 221 | { 222 | "include": "#code" 223 | } 224 | ] 225 | }, 226 | { 227 | "name": "keyword.other.typst", 228 | "match": "(#)(as|in)\\b", 229 | "captures": { 230 | "1": { 231 | "name": "punctuation.definition.keyword.typst" 232 | } 233 | } 234 | }, 235 | { 236 | "begin": "((#)if|(?<=(}|])\\s*)else)\\b", 237 | "end": "\n|(?=])|(?<=}|])", 238 | "beginCaptures": { 239 | "0": { 240 | "name": "keyword.control.conditional.typst" 241 | }, 242 | "2": { 243 | "name": "punctuation.definition.keyword.typst" 244 | } 245 | }, 246 | "patterns": [ 247 | { 248 | "include": "#code" 249 | } 250 | ] 251 | }, 252 | { 253 | "begin": "(#)(for|while)\\b", 254 | "end": "\n|(?=])|(?<=}|])", 255 | "beginCaptures": { 256 | "0": { 257 | "name": "keyword.control.loop.typst" 258 | }, 259 | "1": { 260 | "name": "punctuation.definition.keyword.typst" 261 | } 262 | }, 263 | "patterns": [ 264 | { 265 | "include": "#code" 266 | } 267 | ] 268 | }, 269 | { 270 | "name": "keyword.control.loop.typst", 271 | "match": "(#)(break|continue)\\b", 272 | "captures": { 273 | "1": { 274 | "name": "punctuation.definition.keyword.typst" 275 | } 276 | } 277 | }, 278 | { 279 | "begin": "(#)(import|include|export)\\b", 280 | "end": "\n|(;)|(?=])", 281 | "beginCaptures": { 282 | "0": { 283 | "name": "keyword.control.import.typst" 284 | }, 285 | "1": { 286 | "name": "punctuation.definition.keyword.typst" 287 | } 288 | }, 289 | "endCaptures": { 290 | "1": { 291 | "name": "punctuation.terminator.statement.typst" 292 | } 293 | }, 294 | "patterns": [ 295 | { 296 | "include": "#code" 297 | } 298 | ] 299 | }, 300 | { 301 | "name": "keyword.control.flow.typst", 302 | "match": "(#)(return)\\b", 303 | "captures": { 304 | "1": { 305 | "name": "punctuation.definition.keyword.typst" 306 | } 307 | } 308 | }, 309 | { 310 | "comment": "Function name", 311 | "name": "entity.name.function.typst", 312 | "match": "((#)[[:alpha:]_][[:alnum:]_-]*!?)(?=\\[|\\()", 313 | "captures": { 314 | "2": { 315 | "name": "punctuation.definition.function.typst" 316 | } 317 | } 318 | }, 319 | { 320 | "comment": "Function arguments", 321 | "begin": "(?<=#[[:alpha:]_][[:alnum:]_-]*!?)\\(", 322 | "end": "\\)", 323 | "captures": { 324 | "0": { 325 | "name": "punctuation.definition.group.typst" 326 | } 327 | }, 328 | "patterns": [ 329 | { 330 | "include": "#arguments" 331 | } 332 | ] 333 | }, 334 | { 335 | "name": "entity.other.interpolated.typst", 336 | "match": "(#)[[:alpha:]_][.[:alnum:]_-]*", 337 | "captures": { 338 | "1": { 339 | "name": "punctuation.definition.variable.typst" 340 | } 341 | } 342 | }, 343 | { 344 | "name": "meta.block.content.typst", 345 | "begin": "#", 346 | "end": "\\s", 347 | "patterns": [ 348 | { 349 | "include": "#code" 350 | } 351 | ] 352 | } 353 | ] 354 | }, 355 | "code": { 356 | "patterns": [ 357 | { 358 | "include": "#common" 359 | }, 360 | { 361 | "name": "meta.block.code.typst", 362 | "begin": "{", 363 | "end": "}", 364 | "captures": { 365 | "0": { 366 | "name": "punctuation.definition.block.code.typst" 367 | } 368 | }, 369 | "patterns": [ 370 | { 371 | "include": "#code" 372 | } 373 | ] 374 | }, 375 | { 376 | "name": "meta.block.content.typst", 377 | "begin": "\\[", 378 | "end": "\\]", 379 | "captures": { 380 | "0": { 381 | "name": "punctuation.definition.block.content.typst" 382 | } 383 | }, 384 | "patterns": [ 385 | { 386 | "include": "#markup" 387 | } 388 | ] 389 | }, 390 | { 391 | "name": "comment.line.double-slash.typst", 392 | "begin": "//", 393 | "end": "\n", 394 | "beginCaptures": { 395 | "0": { 396 | "name": "punctuation.definition.comment.typst" 397 | } 398 | } 399 | }, 400 | { 401 | "name": "punctuation.separator.colon.typst", 402 | "match": ":" 403 | }, 404 | { 405 | "name": "punctuation.separator.comma.typst", 406 | "match": "," 407 | }, 408 | { 409 | "name": "keyword.operator.typst", 410 | "match": "=>|\\.\\." 411 | }, 412 | { 413 | "name": "keyword.operator.relational.typst", 414 | "match": "==|!=|<=|<|>=|>" 415 | }, 416 | { 417 | "name": "keyword.operator.assignment.typst", 418 | "match": "\\+=|-=|\\*=|/=|=" 419 | }, 420 | { 421 | "name": "keyword.operator.arithmetic.typst", 422 | "match": "\\+|\\*|/|(? { 27 | await fetch(onigurumaWasm) 28 | .then((res) => res.arrayBuffer()) 29 | .then((wasm) => { 30 | return oniguruma.loadWASM(wasm); 31 | }); 32 | 33 | // Register TextMate grammars 34 | const registry = new Registry({ 35 | onigLib: Promise.resolve(oniguruma), 36 | // @ts-ignore 37 | loadGrammar() { 38 | return Promise.resolve(typstTm); 39 | }, 40 | }); 41 | 42 | const grammars = new Map(); 43 | grammars.set("typst", "source.typst"); 44 | 45 | monaco.languages.register({ id: "typst", extensions: ["typ"] }); 46 | monaco.languages.setLanguageConfiguration( 47 | "typst", 48 | typstConfig as unknown as monaco.languages.LanguageConfiguration 49 | ); 50 | 51 | await wireTextMateGrammars(registry, { typst: "source.typst" }).then( 52 | () => {} 53 | ); 54 | 55 | // Register Monarch languages 56 | monaco.languages.register({ id: "bibtex", extensions: ["bib"] }); 57 | monaco.languages.setMonarchTokensProvider( 58 | "bibtex", 59 | bibtex as IMonarchLanguage 60 | ); 61 | 62 | // Register completion providers 63 | monaco.languages.registerCompletionItemProvider( 64 | "typst", 65 | new TypstCompletionProvider() 66 | ); 67 | }; 68 | 69 | useInitMonaco() 70 | .then((res) => { 71 | console.log(res); 72 | }) 73 | .catch((err) => console.log(err)); 74 | -------------------------------------------------------------------------------- /src/shared/move-hook.ts: -------------------------------------------------------------------------------- 1 | import { LogicalPosition, getCurrentWindow } from "@tauri-apps/api/window"; 2 | import { ref } from "vue"; 3 | const appWindow = getCurrentWindow() 4 | 5 | export function useWinMove() { 6 | const moving = ref(false); 7 | const lastPos = ref([]); 8 | 9 | const move = async (evt: MouseEvent) => { 10 | if (evt.target != evt.currentTarget) { 11 | return 12 | } 13 | const { screenX, screenY, pageX, pageY } = evt; 14 | const [sX, sY] = lastPos.value; 15 | const ph = new LogicalPosition( 16 | screenX - pageX + (screenX - sX), 17 | screenY - pageY + (screenY - sY) 18 | ); 19 | await appWindow.setPosition(ph); 20 | lastPos.value = [screenX, screenY]; 21 | }; 22 | 23 | const mousedownHandler = async (evt: MouseEvent) => { 24 | if (evt.target != evt.currentTarget) { 25 | return 26 | } 27 | const { screenX, screenY } = evt; 28 | moving.value = true; 29 | lastPos.value = [screenX, screenY]; 30 | console.log("mousedown poiont", lastPos.value); 31 | }; 32 | 33 | const mouseupHandler = async (evt: MouseEvent) => { 34 | if (evt.target != evt.currentTarget) { 35 | return 36 | } 37 | console.log(" mouseup"); 38 | moving.value = false; 39 | await move(evt); 40 | }; 41 | 42 | const mousemoveHandler = async (evt: MouseEvent) => { 43 | if (evt.target != evt.currentTarget) { 44 | return 45 | } 46 | if (moving.value) { 47 | console.log("mousemove"); 48 | await move(evt); 49 | } 50 | }; 51 | 52 | const mouseleaveHandler = async (evt: MouseEvent) => { 53 | if (evt.target != evt.currentTarget) { 54 | return 55 | } 56 | if (moving.value) { 57 | moving.value = false; 58 | await move(evt); 59 | } 60 | }; 61 | 62 | return { 63 | moving, 64 | lastPos, 65 | mousedownHandler, 66 | mouseupHandler, 67 | mousemoveHandler, 68 | mouseleaveHandler, 69 | }; 70 | } 71 | -------------------------------------------------------------------------------- /src/shared/util.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | export function relativePath(root: string, cur: string) { 4 | return cur.replace(root, ""); 5 | } 6 | -------------------------------------------------------------------------------- /src/store/store.ts: -------------------------------------------------------------------------------- 1 | import { createPinia, defineStore } from "pinia"; 2 | import { reactive, ref, } from "vue"; 3 | import { IProject } from "../pages/project/interface"; 4 | import { IMode } from "../pages/typst/interface"; 5 | 6 | const pinia = createPinia(); 7 | const EDITING_FILE = "EDITING_FILE"; 8 | const PROJECTS_KEY = "PROJECTS_KEY"; 9 | const EDITING_PROJECT = "EDITING_PROJECT"; 10 | 11 | const useSystemStoreHook = defineStore("system", () => { 12 | const editingFilePath = ref(window.localStorage.getItem(EDITING_FILE) ?? ""); 13 | const setEditingFilePath = (val: string) => { 14 | editingFilePath.value = val; 15 | window.localStorage.setItem(EDITING_FILE, val); 16 | }; 17 | 18 | const dirs = reactive([]); 19 | 20 | const projects = reactive( 21 | JSON.parse(window.localStorage.getItem(PROJECTS_KEY) ?? "[]") 22 | ); 23 | const addProject = (p: IProject) => { 24 | projects.push(p); 25 | window.localStorage.setItem(PROJECTS_KEY, JSON.stringify(projects)); 26 | }; 27 | const deleteProject = (project: IProject) => { 28 | let index = projects.findIndex((item) => { 29 | return item.title == project.title; 30 | }); 31 | projects.splice(index, 1); 32 | window.localStorage.setItem(PROJECTS_KEY, JSON.stringify(projects)); 33 | }; 34 | 35 | const editingProject = ref( 36 | JSON.parse(window.localStorage.getItem(EDITING_PROJECT) ?? "null") 37 | ); 38 | const selectProject = (pr: IProject | null) => { 39 | editingProject.value = pr; 40 | window.localStorage.setItem(EDITING_PROJECT, JSON.stringify(pr)); 41 | if (pr) { 42 | setEditingFilePath(pr.path + '/main.typ'); 43 | } else { 44 | setEditingFilePath(''); 45 | } 46 | }; 47 | 48 | const mode = ref("all"); 49 | const setMode = (m: IMode) => { 50 | mode.value = m; 51 | }; 52 | 53 | const showSidebar = ref(true); 54 | const toggleShowSidebar = (show?: boolean) => { 55 | showSidebar.value = show ?? !showSidebar.value; 56 | }; 57 | 58 | const loading = ref(false); 59 | 60 | const setLoading = (state: boolean) => { 61 | loading.value = state; 62 | }; 63 | 64 | return { 65 | loading, 66 | setLoading, 67 | mode, 68 | setMode, 69 | 70 | showSidebar, 71 | toggleShowSidebar, 72 | 73 | editingProject, 74 | selectProject, 75 | projects, 76 | addProject, 77 | deleteProject, 78 | editingFilePath, 79 | setEditingFilePath, 80 | dirs, 81 | }; 82 | }); 83 | 84 | export { pinia, useSystemStoreHook }; 85 | -------------------------------------------------------------------------------- /src/style/base.css: -------------------------------------------------------------------------------- 1 | /* css reset */ 2 | :root { 3 | --text-color: #666; 4 | } 5 | 6 | 7 | ul { 8 | list-style: none; 9 | padding-left:0; 10 | } 11 | 12 | 13 | 14 | .ml-4 { 15 | margin-left: 16px; 16 | } 17 | .ml-2 { 18 | margin-left: 8px; 19 | } 20 | 21 | .w-full { 22 | width: 100%; 23 | } 24 | .mb-4 { 25 | margin-bottom: 16px; 26 | } 27 | .bbox { 28 | box-sizing: border-box; 29 | } 30 | .hover { 31 | cursor: pointer; 32 | } 33 | 34 | -------------------------------------------------------------------------------- /src/style/styles.css: -------------------------------------------------------------------------------- 1 | @import url(base.css); 2 | 3 | 4 | 5 | body,html { 6 | margin: 0; 7 | padding: 0; 8 | font-family: system-ui, -apple-system, "Segoe UI", "Microsoft YaHei UI", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", Roboto, Helvetica, Arial, sans-serif ; 9 | color: var(--text-color) 10 | } 11 | 12 | #app { 13 | min-height: 100vh; 14 | position: relative; 15 | } 16 | .page { 17 | height: 100%; 18 | } 19 | 20 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare module "*.vue" { 4 | import type { DefineComponent } from "vue"; 5 | const component: DefineComponent<{}, {}, any>; 6 | export default component; 7 | } 8 | -------------------------------------------------------------------------------- /tests/app-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wflixu/typster/34afb9b69a9db6cc89c4e7e682a3122160acb0ab/tests/app-icon.png -------------------------------------------------------------------------------- /tests/basic-resume/main.typ: -------------------------------------------------------------------------------- 1 | #import "@preview/basic-resume:0.1.4": * 2 | 3 | // Put your personal information here, replacing mine 4 | #let name = "Stephen Xu" 5 | #let location = "San Diego, CA" 6 | #let email = "stxu@hmc.edu" 7 | #let github = "github.com/stuxf" 8 | #let linkedin = "linkedin.com/in/stuxf" 9 | #let phone = "+1 (xxx) xxx-xxxx" 10 | #let personal-site = "stuxf.dev" 11 | 12 | #show: resume.with( 13 | author: name, 14 | // All the lines below are optional. 15 | // For example, if you want to to hide your phone number: 16 | // feel free to comment those lines out and they will not show. 17 | location: location, 18 | email: email, 19 | github: github, 20 | linkedin: linkedin, 21 | phone: phone, 22 | personal-site: personal-site, 23 | accent-color: "#26428b", 24 | font: "New Computer Modern", 25 | ) 26 | 27 | /* 28 | * Lines that start with == are formatted into section headings 29 | * You can use the specific formatting functions if needed 30 | * The following formatting functions are listed below 31 | * #edu(dates: "", degree: "", gpa: "", institution: "", location: "") 32 | * #work(company: "", dates: "", location: "", title: "") 33 | * #project(dates: "", name: "", role: "", url: "") 34 | * certificates(name: "", issuer: "", url: "", date: "") 35 | * #extracurriculars(activity: "", dates: "") 36 | * There are also the following generic functions that don't apply any formatting 37 | * #generic-two-by-two(top-left: "", top-right: "", bottom-left: "", bottom-right: "") 38 | * #generic-one-by-two(left: "", right: "") 39 | */ 40 | == Education 41 | 42 | #edu( 43 | institution: "Harvey Mudd College", 44 | location: "Claremont, CA", 45 | dates: dates-helper(start-date: "Aug 2023", end-date: "May 2027"), 46 | degree: "Bachelor's of Science, Computer Science and Mathematics", 47 | ) 48 | - Cumulative GPA: 4.0\/4.0 | Dean's List, Harvey S. Mudd Merit Scholarship, National Merit Scholarship 49 | - Relevant Coursework: Data Structures, Program Development, Microprocessors, Abstract Algebra I: Groups and Rings, Linear Algebra, Discrete Mathematics, Multivariable & Single Variable Calculus, Principles and Practice of Comp Sci 50 | 51 | == Work Experience 52 | 53 | 54 | #work( 55 | title: "Subatomic Shepherd and Caffeine Connoisseur", 56 | location: "Atomville, CA", 57 | company: "Microscopic Circus, Schrodinger's University", 58 | dates: dates-helper(start-date: "May 2024", end-date: "Present"), 59 | ) 60 | - Played God with tiny molecules, making them dance to uncover the secrets of the universe 61 | - Convinced high-performance computers to work overtime without unions, reducing simulation time by 50% 62 | - Wowed a room full of nerds with pretty pictures of invisible things and imaginary findings 63 | 64 | #work( 65 | title: "AI Wrangler and Code Ninja", 66 | location: "Silicon Mirage, CA", 67 | company: "Organic Stupidity Startup", 68 | dates: dates-helper(start-date: "Dec 2023", end-date: "Mar 2024"), 69 | ) 70 | - Taught robots to predict when (and how much!) humans will empty their wallets at the doctor's office 71 | - Developed HIPAA-compliant digital signatures, because doctors' handwriting wasn't illegible enough already 72 | - Turned spaghetti code into a gourmet dish, making other interns drool with envy 73 | 74 | #work( 75 | title: "Digital Playground Architect", 76 | location: "The Cloud", 77 | company: "Pixels & Profit Interactive", 78 | dates: dates-helper(start-date: "Jun 2020", end-date: "May 2023"), 79 | ) 80 | - Scaled user base from 10 to 2000+, accidentally becoming a small wealthy nation in the process 81 | - Crafted Bash scripts so clever they occasionally made other engineers weep with joy 82 | - Automated support responses, reducing human interaction to a level that would make introverts proud 83 | - Built a documentation site that actually got read, breaking the ancient RTFM curse 84 | 85 | #work( 86 | title: "Code Conjurer Intern", 87 | location: "Silicon Suburb, CA", 88 | company: "Bits & Bytes Consulting", 89 | dates: dates-helper(start-date: "Jun 2022", end-date: "Aug 2022"), 90 | ) 91 | - Developed a cross-platform mobile app that turned every user into a potential paparazzi 92 | - Led a security overhaul, heroically saving the company from the menace of "password123" 93 | 94 | == Projects 95 | 96 | #project( 97 | role: "Maintainer", 98 | name: "Hyperschedule", 99 | dates: dates-helper(start-date: "Nov 2023", end-date: "Present"), 100 | // URL is optional 101 | url: "hyperschedule.io", 102 | ) 103 | - Maintain open-source scheduler used by 7000+ users at the Claremont Consortium with TypesScript, React and MongoDB 104 | - Manage PR reviews, bug fixes, and coordinate with college for releasing scheduling data and over \$1500 of yearly funding 105 | - Ensure 99.99% uptime during peak loads of 1M daily requests during course registration through redundant servers 106 | 107 | == Extracurricular Activities 108 | 109 | #extracurriculars( 110 | activity: "Capture The Flag Competitions", 111 | dates: dates-helper(start-date: "Jan 2021", end-date: "Present"), 112 | ) 113 | - Founder of Les Amateurs (#link("https://amateurs.team")[amateurs.team]), currently ranked \#4 US, \#33 global on CTFTime (2023: \#4 US, \#42 global) 114 | - Organized AmateursCTF 2023 and 2024, with 1000+ teams solving at least one challenge and \$2000+ in cash prizes 115 | - Scaled infrastructure using GCP, Digital Ocean with Kubernetes and Docker; deployed custom software on fly.io 116 | - Qualified for DEFCON CTF 32 and CSAW CTF 2023, two of the most prestigious cybersecurity competitions globally 117 | 118 | // #extracurriculars( 119 | // activity: "Science Olympiad Volunteering", 120 | // dates: "Sep 2023 --- Present" 121 | // ) 122 | // - Volunteer and write tests for tournaments, including LA Regionals and SoCal State \@ Caltech 123 | 124 | // #certificates( 125 | // name: "OSCP", 126 | // issuer: "Offensive Security", 127 | // // url: "", 128 | // date: "Oct 2024", 129 | // ) 130 | == Skills and Awards 131 | - *Programming Languages*: JavaScript, Python, C/C++, HTML/CSS, Java, Bash, R, Flutter, Dart 132 | - *Technologies*: React, Astro, Svelte, Tailwind CSS, Git, UNIX, Docker, Caddy, NGINX, Google Cloud Platform 133 | - *Awards*: 1st CorCTF 2024 (\$1337), 3rd PicoCTF 2023 (\$1000), 1st BCACTF 2023 (\$500) 134 | - *Interests*: Classical Literature, Creative Writing, Tetris -------------------------------------------------------------------------------- /tests/main.typ: -------------------------------------------------------------------------------- 1 | #set page( 2 | paper: "a4", 3 | margin: (x: 1.8cm, y: 1.5cm), 4 | ) 5 | #set text( 6 | font: "New Computer Modern", 7 | size: 16pt 8 | ) 9 | 10 | #set par( 11 | justify: true, 12 | leading: 0.52em, 13 | ) 14 | 15 | 16 | = Introduction 17 | 18 | In this report, we will explore the 19 | various factors that influence fluid 20 | dynamics in glaciers and how they 21 | contribute to the formation and 22 | behaviour of these natural structures. 23 | 24 | + The climate 25 | - Temperature 26 | - Precipitation 27 | + The topography 28 | + The geology 29 | 30 | #image("./app-icon.png") 31 | 32 | 33 | The flow rate of a glacier is 34 | defined by the following equation: 35 | 36 | $ Q = rho A v + C $ 37 | 38 | 39 | 40 | = Introduction 41 | 42 | In this report, we will explore the 43 | various factors that influence fluid 44 | dynamics in glaciers and how they 45 | contribute to the formation and 46 | behaviour of these natural structures. 47 | 48 | = picture 49 | #figure( 50 | image("app-icon.png", width: 70%), 51 | caption: [ 52 | _Glaciers_ form an important part 53 | of the earth's climate system. 54 | ], 55 | ) 56 | 57 | 58 | = Methods 59 | We follow the glacier melting models 60 | 61 | 62 | 63 | The equation $Q = rho A v + C$ 64 | defines the glacial flow rate. 65 | 66 | Total displaced soil by glacial flow: 67 | 68 | $ 7.32 beta + 69 | sum_(i=0)^nabla 70 | (Q_i (a_i - epsilon)) / 2 $ 71 | 72 | 73 | $ v := vec(x_1, x_2, x_3) $ 74 | 75 | 76 | #par(justify: true)[ 77 | = Background 78 | In the case of glaciers, fluid 79 | dynamics principles can be used 80 | to understand how the movement 81 | and behaviour of the ice is 82 | influenced by factors such as 83 | temperature, pressure, and the 84 | presence of other fluids (such as 85 | water). 86 | ] 87 | 88 | 89 | == Background 90 | 91 | #lorem(100) 92 | 93 | = package 94 | 95 | #import "@preview/cetz:0.2.2": canvas, plot 96 | 97 | 98 | #let style = (stroke: black, fill: rgb(0, 0, 200, 75)) 99 | 100 | #canvas(length: 1cm, { 101 | plot.plot(size: (8, 6), 102 | x-tick-step: none, 103 | x-ticks: ((-calc.pi, $-pi$), (0, $0$), (calc.pi, $pi$)), 104 | y-tick-step: 1, 105 | { 106 | plot.add( 107 | style: style, 108 | domain: (-calc.pi, calc.pi), calc.sin) 109 | plot.add( 110 | hypograph: true, 111 | style: style, 112 | domain: (-calc.pi, calc.pi), calc.cos) 113 | plot.add( 114 | hypograph: true, 115 | style: style, 116 | domain: (-calc.pi, calc.pi), x => calc.cos(x + calc.pi)) 117 | }) 118 | }) 119 | 120 | 121 | = bib 122 | 123 | #lorem(40) 124 | @smith2020 125 | 126 | #bibliography("works.bib", full: true) 127 | -------------------------------------------------------------------------------- /tests/works.bib: -------------------------------------------------------------------------------- 1 | @ARTICLE{smith2020, 2 | author = "John Smith", 3 | title = "A New Algorithm for Sorting Numbers", 4 | journal = "Journal of Algorithms", 5 | year = 2020, 6 | volume = 100, 7 | number = 1, 8 | pages = "1-10", 9 | keywords = {sorting, algorithms, complexity}, 10 | abstract = "This paper presents a new algorithm for sorting numbers. The algorithm is based on the idea of...", 11 | note = "This is a preliminary version of the paper." 12 | } 13 | 14 | @BOOK{jones2019, 15 | author = "Jane Jones", 16 | title = "Introduction to Machine Learning", 17 | publisher = "MIT Press", 18 | year = 2019, 19 | keywords = {machine learning, artificial intelligence, data science}, 20 | abstract = "This book provides an introduction to the field of machine learning.", 21 | note = "" 22 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "moduleResolution": "Node", 7 | "strict": true, 8 | "jsx": "preserve", 9 | "sourceMap": true, 10 | "resolveJsonModule": true, 11 | "isolatedModules": true, 12 | "esModuleInterop": true, 13 | "lib": ["ESNext", "DOM"], 14 | "skipLibCheck": true, 15 | "types": [ 16 | "vite/client", 17 | "@tauri-apps/api/window", 18 | ] 19 | }, 20 | "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], 21 | } 22 | -------------------------------------------------------------------------------- /vite.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import vue from "@vitejs/plugin-vue"; 3 | 4 | const mobile = 5 | process.env.TAURI_PLATFORM === "android" || 6 | process.env.TAURI_PLATFORM === "ios"; 7 | 8 | // https://vitejs.dev/config/ 9 | export default defineConfig(async () => ({ 10 | plugins: [vue()], 11 | 12 | // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` 13 | // prevent vite from obscuring rust errors 14 | clearScreen: false, 15 | // tauri expects a fixed port, fail if that port is not available 16 | server: { 17 | port: 1420, 18 | strictPort: true, 19 | }, 20 | // to make use of `TAURI_DEBUG` and other env variables 21 | // https://tauri.studio/v1/api/config#buildconfig.beforedevcommand 22 | envPrefix: ["VITE_", "TAURI_"], 23 | build: { 24 | // Tauri supports es2021 25 | target: process.env.TAURI_PLATFORM == "windows" ? "chrome105" : "safari15", 26 | // don't minify for debug builds 27 | minify: !process.env.TAURI_DEBUG ? "esbuild" : false, 28 | // produce sourcemaps for debug builds 29 | sourcemap: !!process.env.TAURI_DEBUG, 30 | }, 31 | optimizeDeps: { 32 | exclude: ["monaco-editor"], 33 | }, 34 | })); 35 | --------------------------------------------------------------------------------