├── .cm └── gitstream.cm ├── .github └── workflows │ ├── gitstream.yml │ └── release.yml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── demo.gif ├── examples ├── intro.rs ├── intro.yaml ├── set_title.rs └── set_title.yaml ├── src ├── commands.rs ├── functions.rs ├── lib.rs ├── main.rs ├── state.rs └── terminal.rs ├── swordfish_hack_scene.gif └── tests └── screenplay.rs /.cm/gitstream.cm: -------------------------------------------------------------------------------- 1 | # -*- mode: yaml -*- 2 | # This example configuration for provides basic automations to get started with gitStream. 3 | # View the gitStream quickstart for more examples: https://docs.gitstream.cm/quick-start/ 4 | manifest: 5 | version: 1.0 6 | automations: 7 | # Add a label that indicates how many minutes it will take to review the PR. 8 | estimated_time_to_review: 9 | if: 10 | - true 11 | run: 12 | - action: add-label@v1 13 | # etr is defined in the last section of this example 14 | args: 15 | label: "{{ calc.etr }} min review" 16 | color: {{ 'E94637' if (calc.etr >= 20) else ('FBBD10' if (calc.etr >= 5) else '36A853') }} 17 | # Post a comment that lists the best experts for the files that were modified. 18 | suggest_code_experts: 19 | # Triggered when someone applies a suggest-reviewer label to a PR. 20 | if: 21 | - {{ pr.labels | match(term='suggest-reviewer') }} 22 | # Identify the best experts to assign for review and post a comment that explains why 23 | # More info about code experts 24 | # https://docs.gitstream.cm/filter-functions/#codeexperts 25 | run: 26 | - action: add-comment@v1 27 | args: 28 | comment: | 29 | {{ repo | explainCodeExperts(gt=10) }} 30 | # The next function calculates the estimated time to review and makes it available in the automation above. 31 | calc: 32 | etr: {{ branch | estimatedReviewTime }} 33 | -------------------------------------------------------------------------------- /.github/workflows/gitstream.yml: -------------------------------------------------------------------------------- 1 | # Code generated by gitStream GitHub app - DO NOT EDIT 2 | 3 | name: gitStream workflow automation 4 | run-name: | 5 | /:\ gitStream: PR #${{ fromJSON(fromJSON(github.event.inputs.client_payload)).pullRequestNumber }} from ${{ github.event.inputs.full_repository }} 6 | 7 | on: 8 | workflow_dispatch: 9 | inputs: 10 | client_payload: 11 | description: The Client payload 12 | required: true 13 | full_repository: 14 | description: the repository name include the owner in `owner/repo_name` format 15 | required: true 16 | head_ref: 17 | description: the head sha 18 | required: true 19 | base_ref: 20 | description: the base ref 21 | required: true 22 | installation_id: 23 | description: the installation id 24 | required: false 25 | resolver_url: 26 | description: the resolver url to pass results to 27 | required: true 28 | resolver_token: 29 | description: Optional resolver token for resolver service 30 | required: false 31 | default: '' 32 | 33 | jobs: 34 | gitStream: 35 | timeout-minutes: 5 36 | runs-on: ubuntu-latest 37 | name: gitStream workflow automation 38 | steps: 39 | - name: Evaluate Rules 40 | uses: linear-b/gitstream-github-action@v1 41 | id: rules-engine 42 | with: 43 | full_repository: ${{ github.event.inputs.full_repository }} 44 | head_ref: ${{ github.event.inputs.head_ref }} 45 | base_ref: ${{ github.event.inputs.base_ref }} 46 | client_payload: ${{ github.event.inputs.client_payload }} 47 | installation_id: ${{ github.event.inputs.installation_id }} 48 | resolver_url: ${{ github.event.inputs.resolver_url }} 49 | resolver_token: ${{ github.event.inputs.resolver_token }} 50 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # .github/workflows/release.yml 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | release: 9 | name: release ${{ matrix.target }} 10 | runs-on: ubuntu-latest 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | include: 15 | - target: x86_64-pc-windows-gnu 16 | archive: zip 17 | - target: x86_64-unknown-linux-musl 18 | archive: tar.gz tar.xz 19 | - target: x86_64-apple-darwin 20 | archive: zip 21 | steps: 22 | - uses: actions/checkout@master 23 | - name: Compile and release 24 | uses: rust-build/rust-build.action@v1.3.2 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | with: 28 | RUSTTARGET: ${{ matrix.target }} 29 | ARCHIVE_TYPES: ${{ matrix.archive }} 30 | 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "aho-corasick" 7 | version = "0.7.19" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "b4f55bd91a0978cbfd91c457a164bab8b4001c833b7f323132c0a4e1922dd44e" 10 | dependencies = [ 11 | "memchr", 12 | ] 13 | 14 | [[package]] 15 | name = "anyhow" 16 | version = "1.0.64" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "b9a8f622bcf6ff3df478e9deba3e03e4e04b300f8e6a139e192c05fa3490afc7" 19 | 20 | [[package]] 21 | name = "atty" 22 | version = "0.2.14" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" 25 | dependencies = [ 26 | "hermit-abi", 27 | "libc", 28 | "winapi", 29 | ] 30 | 31 | [[package]] 32 | name = "autocfg" 33 | version = "1.1.0" 34 | source = "registry+https://github.com/rust-lang/crates.io-index" 35 | checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" 36 | 37 | [[package]] 38 | name = "bitflags" 39 | version = "1.3.2" 40 | source = "registry+https://github.com/rust-lang/crates.io-index" 41 | checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" 42 | 43 | [[package]] 44 | name = "clap" 45 | version = "3.2.20" 46 | source = "registry+https://github.com/rust-lang/crates.io-index" 47 | checksum = "23b71c3ce99b7611011217b366d923f1d0a7e07a92bb2dbf1e84508c673ca3bd" 48 | dependencies = [ 49 | "atty", 50 | "bitflags", 51 | "clap_derive", 52 | "clap_lex", 53 | "indexmap", 54 | "once_cell", 55 | "strsim", 56 | "termcolor", 57 | "textwrap", 58 | ] 59 | 60 | [[package]] 61 | name = "clap_derive" 62 | version = "3.2.18" 63 | source = "registry+https://github.com/rust-lang/crates.io-index" 64 | checksum = "ea0c8bce528c4be4da13ea6fead8965e95b6073585a2f05204bd8f4119f82a65" 65 | dependencies = [ 66 | "heck", 67 | "proc-macro-error", 68 | "proc-macro2", 69 | "quote", 70 | "syn", 71 | ] 72 | 73 | [[package]] 74 | name = "clap_lex" 75 | version = "0.2.4" 76 | source = "registry+https://github.com/rust-lang/crates.io-index" 77 | checksum = "2850f2f5a82cbf437dd5af4d49848fbdfc27c157c3d010345776f952765261c5" 78 | dependencies = [ 79 | "os_str_bytes", 80 | ] 81 | 82 | [[package]] 83 | name = "colored" 84 | version = "2.0.0" 85 | source = "registry+https://github.com/rust-lang/crates.io-index" 86 | checksum = "b3616f750b84d8f0de8a58bda93e08e2a81ad3f523089b05f1dffecab48c6cbd" 87 | dependencies = [ 88 | "atty", 89 | "lazy_static", 90 | "winapi", 91 | ] 92 | 93 | [[package]] 94 | name = "enum_dispatch" 95 | version = "0.3.8" 96 | source = "registry+https://github.com/rust-lang/crates.io-index" 97 | checksum = "0eb359f1476bf611266ac1f5355bc14aeca37b299d0ebccc038ee7058891c9cb" 98 | dependencies = [ 99 | "once_cell", 100 | "proc-macro2", 101 | "quote", 102 | "syn", 103 | ] 104 | 105 | [[package]] 106 | name = "hashbrown" 107 | version = "0.12.3" 108 | source = "registry+https://github.com/rust-lang/crates.io-index" 109 | checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" 110 | 111 | [[package]] 112 | name = "heck" 113 | version = "0.4.0" 114 | source = "registry+https://github.com/rust-lang/crates.io-index" 115 | checksum = "2540771e65fc8cb83cd6e8a237f70c319bd5c29f78ed1084ba5d50eeac86f7f9" 116 | 117 | [[package]] 118 | name = "hermit-abi" 119 | version = "0.1.19" 120 | source = "registry+https://github.com/rust-lang/crates.io-index" 121 | checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" 122 | dependencies = [ 123 | "libc", 124 | ] 125 | 126 | [[package]] 127 | name = "indexmap" 128 | version = "1.9.1" 129 | source = "registry+https://github.com/rust-lang/crates.io-index" 130 | checksum = "10a35a97730320ffe8e2d410b5d3b69279b98d2c14bdb8b70ea89ecf7888d41e" 131 | dependencies = [ 132 | "autocfg", 133 | "hashbrown", 134 | ] 135 | 136 | [[package]] 137 | name = "itoa" 138 | version = "1.0.3" 139 | source = "registry+https://github.com/rust-lang/crates.io-index" 140 | checksum = "6c8af84674fe1f223a982c933a0ee1086ac4d4052aa0fb8060c12c6ad838e754" 141 | 142 | [[package]] 143 | name = "lazy_static" 144 | version = "1.4.0" 145 | source = "registry+https://github.com/rust-lang/crates.io-index" 146 | checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" 147 | 148 | [[package]] 149 | name = "libc" 150 | version = "0.2.132" 151 | source = "registry+https://github.com/rust-lang/crates.io-index" 152 | checksum = "8371e4e5341c3a96db127eb2465ac681ced4c433e01dd0e938adbef26ba93ba5" 153 | 154 | [[package]] 155 | name = "memchr" 156 | version = "2.5.0" 157 | source = "registry+https://github.com/rust-lang/crates.io-index" 158 | checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" 159 | 160 | [[package]] 161 | name = "once_cell" 162 | version = "1.14.0" 163 | source = "registry+https://github.com/rust-lang/crates.io-index" 164 | checksum = "2f7254b99e31cad77da24b08ebf628882739a608578bb1bcdfc1f9c21260d7c0" 165 | 166 | [[package]] 167 | name = "os_str_bytes" 168 | version = "6.3.0" 169 | source = "registry+https://github.com/rust-lang/crates.io-index" 170 | checksum = "9ff7415e9ae3fff1225851df9e0d9e4e5479f947619774677a63572e55e80eff" 171 | 172 | [[package]] 173 | name = "proc-macro-error" 174 | version = "1.0.4" 175 | source = "registry+https://github.com/rust-lang/crates.io-index" 176 | checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" 177 | dependencies = [ 178 | "proc-macro-error-attr", 179 | "proc-macro2", 180 | "quote", 181 | "syn", 182 | "version_check", 183 | ] 184 | 185 | [[package]] 186 | name = "proc-macro-error-attr" 187 | version = "1.0.4" 188 | source = "registry+https://github.com/rust-lang/crates.io-index" 189 | checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" 190 | dependencies = [ 191 | "proc-macro2", 192 | "quote", 193 | "version_check", 194 | ] 195 | 196 | [[package]] 197 | name = "proc-macro2" 198 | version = "1.0.43" 199 | source = "registry+https://github.com/rust-lang/crates.io-index" 200 | checksum = "0a2ca2c61bc9f3d74d2886294ab7b9853abd9c1ad903a3ac7815c58989bb7bab" 201 | dependencies = [ 202 | "unicode-ident", 203 | ] 204 | 205 | [[package]] 206 | name = "quote" 207 | version = "1.0.21" 208 | source = "registry+https://github.com/rust-lang/crates.io-index" 209 | checksum = "bbe448f377a7d6961e30f5955f9b8d106c3f5e449d493ee1b125c1d43c2b5179" 210 | dependencies = [ 211 | "proc-macro2", 212 | ] 213 | 214 | [[package]] 215 | name = "regex" 216 | version = "1.6.0" 217 | source = "registry+https://github.com/rust-lang/crates.io-index" 218 | checksum = "4c4eb3267174b8c6c2f654116623910a0fef09c4753f8dd83db29c48a0df988b" 219 | dependencies = [ 220 | "aho-corasick", 221 | "memchr", 222 | "regex-syntax", 223 | ] 224 | 225 | [[package]] 226 | name = "regex-syntax" 227 | version = "0.6.27" 228 | source = "registry+https://github.com/rust-lang/crates.io-index" 229 | checksum = "a3f87b73ce11b1619a3c6332f45341e0047173771e8b8b73f87bfeefb7b56244" 230 | 231 | [[package]] 232 | name = "ryu" 233 | version = "1.0.11" 234 | source = "registry+https://github.com/rust-lang/crates.io-index" 235 | checksum = "4501abdff3ae82a1c1b477a17252eb69cee9e66eb915c1abaa4f44d873df9f09" 236 | 237 | [[package]] 238 | name = "serde" 239 | version = "1.0.144" 240 | source = "registry+https://github.com/rust-lang/crates.io-index" 241 | checksum = "0f747710de3dcd43b88c9168773254e809d8ddbdf9653b84e2554ab219f17860" 242 | dependencies = [ 243 | "serde_derive", 244 | ] 245 | 246 | [[package]] 247 | name = "serde_derive" 248 | version = "1.0.144" 249 | source = "registry+https://github.com/rust-lang/crates.io-index" 250 | checksum = "94ed3a816fb1d101812f83e789f888322c34e291f894f19590dc310963e87a00" 251 | dependencies = [ 252 | "proc-macro2", 253 | "quote", 254 | "syn", 255 | ] 256 | 257 | [[package]] 258 | name = "serde_yaml" 259 | version = "0.9.11" 260 | source = "registry+https://github.com/rust-lang/crates.io-index" 261 | checksum = "89f31df3f50926cdf2855da5fd8812295c34752cb20438dae42a67f79e021ac3" 262 | dependencies = [ 263 | "indexmap", 264 | "itoa", 265 | "ryu", 266 | "serde", 267 | "unsafe-libyaml", 268 | ] 269 | 270 | [[package]] 271 | name = "shellwords" 272 | version = "1.1.0" 273 | source = "registry+https://github.com/rust-lang/crates.io-index" 274 | checksum = "89e515aa4699a88148ed5ef96413ceef0048ce95b43fbc955a33bde0a70fcae6" 275 | dependencies = [ 276 | "lazy_static", 277 | "regex", 278 | ] 279 | 280 | [[package]] 281 | name = "strsim" 282 | version = "0.10.0" 283 | source = "registry+https://github.com/rust-lang/crates.io-index" 284 | checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" 285 | 286 | [[package]] 287 | name = "swordfish-rs" 288 | version = "0.5.2" 289 | dependencies = [ 290 | "anyhow", 291 | "clap", 292 | "colored", 293 | "enum_dispatch", 294 | "serde", 295 | "serde_yaml", 296 | "shellwords", 297 | ] 298 | 299 | [[package]] 300 | name = "syn" 301 | version = "1.0.99" 302 | source = "registry+https://github.com/rust-lang/crates.io-index" 303 | checksum = "58dbef6ec655055e20b86b15a8cc6d439cca19b667537ac6a1369572d151ab13" 304 | dependencies = [ 305 | "proc-macro2", 306 | "quote", 307 | "unicode-ident", 308 | ] 309 | 310 | [[package]] 311 | name = "termcolor" 312 | version = "1.1.3" 313 | source = "registry+https://github.com/rust-lang/crates.io-index" 314 | checksum = "bab24d30b911b2376f3a13cc2cd443142f0c81dda04c118693e35b3835757755" 315 | dependencies = [ 316 | "winapi-util", 317 | ] 318 | 319 | [[package]] 320 | name = "textwrap" 321 | version = "0.15.0" 322 | source = "registry+https://github.com/rust-lang/crates.io-index" 323 | checksum = "b1141d4d61095b28419e22cb0bbf02755f5e54e0526f97f1e3d1d160e60885fb" 324 | 325 | [[package]] 326 | name = "unicode-ident" 327 | version = "1.0.3" 328 | source = "registry+https://github.com/rust-lang/crates.io-index" 329 | checksum = "c4f5b37a154999a8f3f98cc23a628d850e154479cd94decf3414696e12e31aaf" 330 | 331 | [[package]] 332 | name = "unsafe-libyaml" 333 | version = "0.2.2" 334 | source = "registry+https://github.com/rust-lang/crates.io-index" 335 | checksum = "931179334a56395bcf64ba5e0ff56781381c1a5832178280c7d7f91d1679aeb0" 336 | 337 | [[package]] 338 | name = "version_check" 339 | version = "0.9.4" 340 | source = "registry+https://github.com/rust-lang/crates.io-index" 341 | checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" 342 | 343 | [[package]] 344 | name = "winapi" 345 | version = "0.3.9" 346 | source = "registry+https://github.com/rust-lang/crates.io-index" 347 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 348 | dependencies = [ 349 | "winapi-i686-pc-windows-gnu", 350 | "winapi-x86_64-pc-windows-gnu", 351 | ] 352 | 353 | [[package]] 354 | name = "winapi-i686-pc-windows-gnu" 355 | version = "0.4.0" 356 | source = "registry+https://github.com/rust-lang/crates.io-index" 357 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 358 | 359 | [[package]] 360 | name = "winapi-util" 361 | version = "0.1.5" 362 | source = "registry+https://github.com/rust-lang/crates.io-index" 363 | checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" 364 | dependencies = [ 365 | "winapi", 366 | ] 367 | 368 | [[package]] 369 | name = "winapi-x86_64-pc-windows-gnu" 370 | version = "0.4.0" 371 | source = "registry+https://github.com/rust-lang/crates.io-index" 372 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 373 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "swordfish-rs" 3 | version = "0.6.0" 4 | edition = "2021" 5 | license-file = "LICENSE" 6 | description = "Cli tool for typing effect in Termainl for screencasts" 7 | readme = "README.md" 8 | repository = "https://github.com/vim-zz/swordfish/" 9 | exclude = ["/examples", "/tests", "*.gif"] 10 | 11 | [lib] 12 | name = "swordfishlib" 13 | path = "src/lib.rs" 14 | 15 | [[bin]] 16 | path = "src/main.rs" 17 | name = "swordfish" 18 | 19 | [dependencies] 20 | anyhow = "1" 21 | clap = { version = "3", features = ["derive"] } 22 | colored = "2" 23 | enum_dispatch = "0.3" 24 | serde = { version = "1", features = ["derive"] } 25 | serde_yaml = "0.9" 26 | shellwords = "1" 27 | 28 | [profile.release] 29 | strip = true 30 | lto = true 31 | codegen-units = 1 32 | opt-level = "z" # Optimize for size. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Ofer Affias 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 | # swordfish-rs 2 | 3 | > **Typing effect cli tool for screencasts and demos** 4 | 5 | [![Crates.io](https://img.shields.io/crates/v/swordfish-rs)](https://crates.io/crates/swordfish-rs) 6 | [![Crates.io](https://img.shields.io/crates/d/swordfish-rs)](https://crates.io/crates/swordfish-rs) 7 | 8 | 1. 💬 Describe what you are doing 9 | 2. ⚡️ Run any terminal command and get their outputs to screen 10 | 3. 🤖 Reproducible steps - iterate on the `screenplay` file till perfection 11 | 4. 😎 Mimics real person behavior with realtime typing into terminal 12 | 13 | ![Swordfish hack scene](swordfish_hack_scene.gif) 14 | 15 | ## Demo 16 | 17 | Example `screenplay.yaml` file: 18 | 19 | ```yaml 20 | - !clear 21 | # - !turbo {by: 3} 22 | - !write {msec: 0, color: green, text: "$ "} 23 | - !write {msec: 20, text: "i am going to list this dir"} 24 | - !wait {msec: 1000} 25 | - !erase {msec: 20, by_chars: xxxxxxxxxxxxxxxxxxxxxxxxxxx } 26 | - !wait {msec: 1000} 27 | - !write {msec: 20, text: ls} 28 | - !wait {msec: 1000} 29 | - !execute {line: ls -la} 30 | - !wait {msec: 3000} 31 | - !write {msec: 1000, color: green, text: "$ "} 32 | - !write {msec: 20, text: "bye, press any key..."} 33 | - !pause 34 | ``` 35 | 36 | Running `swordfish screenplay.yaml`: 37 | 38 | ![demo](demo.gif) 39 | 40 | ## Quick start 41 | 42 | 1. Install: 43 | 44 | ``` 45 | cargo install swordfish-rs 46 | ```` 47 | 48 | Requires Rust on your machine, get it from [https://rustup.rs](https://rustup.rs) 49 | 50 | or [download a binary](https://github.com/vim-zz/swordfish-rs/releases) 51 | 52 | 2. Create this getting started screenplay file as `getting_started.yaml`: 53 | 54 | ```yaml 55 | - !clear 56 | - !prompt {color: green, text: "$"} 57 | - !write {msec: 20, text: "swordfish reads screenplay files, in yaml format"} 58 | - !wait {msec: 2000} 59 | - !erase {msec: 20, amount: 1000 } 60 | - !wait {msec: 1000} 61 | - !write {msec: 20, text: "it contains a list of commands, each command can have parameters that control it"} 62 | - !wait {msec: 2000} 63 | - !new_line 64 | - !write {msec: 20, text: "that's it"} 65 | - !new_line 66 | ``` 67 | 68 | 3. Run swordfish 69 | 70 | ``` 71 | swordfish getting_started.yaml 72 | ``` 73 | 74 | ## Commands 75 | 76 | The following commands are available, written with `!` before the command name, for example `!clear`. 77 | 78 | #### `clear` 79 | 80 | Clear screen command. 81 | 82 | #### `erase` 83 | 84 | Erase characters to the left. 85 | 86 | | Argument | Type | Description | 87 | | - | - | - | 88 | |`amount` (optional)| String | the amount of backspaces | 89 | |`by_chars` (optional)| String | the amount of backspace is determined by the length of the provided text | 90 | |`msec`| Integer | delay between individual backspaces in millisecs | 91 | 92 | Use either `amount` or `by_chars` or both. 93 | 94 | #### `execute` 95 | 96 | Execute shell commands or other applications and show their output. 97 | 98 | | Argument | Type | Description | 99 | | - | - | - | 100 | |`line`| String | command line to execute, respects quoted arguments | 101 | 102 | The output is presented, while the executed command itself will not show. 103 | 104 | #### `new_line` 105 | 106 | Simulate user's `ENTER`. 107 | 108 | #### `pause` 109 | 110 | Pause before next command and wait for user input (any key...) 111 | 112 | #### `prompt` 113 | 114 | Prompt specify a constant text that is shown after every `execute` and cis not affected by `erase`. 115 | 116 | | Argument | Type | Description | 117 | | - | - | - | 118 | |`text`| String | the prompt text | 119 | |`color` (optional)| String | text's color: `black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, `white` or a brighter variant, for example `bright_red` | 120 | 121 | #### `title` 122 | 123 | Sets the terminal window title. 124 | 125 | | Argument | Type | Description | 126 | | - | - | - | 127 | |`text`| String | the text to use for the terminal window title | 128 | 129 | #### `turbo` 130 | 131 | Speed everything, useful when iterating over the screenplay. 132 | 133 | | Argument | Type | Description | 134 | | - | - | - | 135 | |`by`| Integer | Speed everything by this factor | 136 | 137 | #### `wait` 138 | 139 | Pauses execution for the specified time, then contrinues. 140 | 141 | | Argument | Type | Description | 142 | | - | - | - | 143 | |`msec`| Integer | delay before next command in millisecs | 144 | 145 | #### `write` 146 | 147 | Write text to the terminal. 148 | 149 | | Argument | Type | Description | 150 | | - | - | - | 151 | |`text`| String | the text to type in the terminal, each character will be entered one by one with some delay | 152 | |`msec`| Integer | delay between typed chars in millisecs | 153 | |`color` (optional)| String | text's color: `black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, `white` or a brighter variant, for example `bright_red` | 154 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vim-zz/swordfish-rs/abe092fc12e89ea6fe665f358acf86d342fcb856/demo.gif -------------------------------------------------------------------------------- /examples/intro.rs: -------------------------------------------------------------------------------- 1 | use swordfishlib; 2 | use std::fs; 3 | 4 | fn main() { 5 | let data = fs::read_to_string("examples/intro.yaml").expect("Unable to read screenplay file"); 6 | let commands = swordfishlib::from_yaml(&data).expect("Parsing errors in screenplay file"); 7 | swordfishlib::execute(commands).unwrap(); 8 | } 9 | -------------------------------------------------------------------------------- /examples/intro.yaml: -------------------------------------------------------------------------------- 1 | - !clear 2 | # - !turbo {by: 10} 3 | - !prompt {text: "$", color: green} 4 | - !write {msec: 20, text: "i am going to list this dir"} 5 | - !new_line 6 | - !wait {msec: 1000} 7 | - !erase {msec: 20, amount: 100} 8 | - !wait {msec: 1000} 9 | - !write {msec: 20, text: ls} 10 | - !wait {msec: 1000} 11 | - !execute {line: ls -la} 12 | - !wait {msec: 3000} 13 | - !write {msec: 20, text: "bye, press any key..."} 14 | - !pause 15 | -------------------------------------------------------------------------------- /examples/set_title.rs: -------------------------------------------------------------------------------- 1 | use swordfishlib; 2 | use std::fs; 3 | 4 | fn main() { 5 | let data = fs::read_to_string("examples/set_title.yaml").expect("Unable to read screenplay file"); 6 | let commands = swordfishlib::from_yaml(&data).expect("Parsing errors in screenplay file"); 7 | swordfishlib::execute(commands).unwrap(); 8 | } 9 | -------------------------------------------------------------------------------- /examples/set_title.yaml: -------------------------------------------------------------------------------- 1 | - !clear 2 | - !prompt {text: "$", color: green} 3 | - !write {msec: 20, text: "swordfish can set the terminal title, look up..."} 4 | - !wait {msec: 2000} 5 | - !new_line 6 | - !title {text: "swordfish demo"} 7 | - !write {msec: 20, text: "nice!"} 8 | - !wait {msec: 2000} 9 | - !erase {msec: 20, amount: 100} 10 | - !wait {msec: 1000} 11 | - !write {msec: 20, text: "super nice!"} 12 | - !wait {msec: 3000} -------------------------------------------------------------------------------- /src/commands.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use std::{cmp, process}; 3 | use std::io::{self, stdout, Write as IOWrite}; 4 | use serde::{Deserialize}; 5 | use enum_dispatch::enum_dispatch; 6 | use crate::{functions, terminal, state::State}; 7 | 8 | #[enum_dispatch] 9 | #[derive(Debug, Deserialize)] 10 | #[serde(rename_all = "snake_case")] 11 | pub enum Command<'a> { 12 | Clear(Clear), 13 | #[serde(borrow)] 14 | Erase(Erase<'a>), 15 | #[serde(borrow)] 16 | Execute(Execute<'a>), 17 | NewLine(NewLine), 18 | Pause(Pause), 19 | #[serde(borrow)] 20 | Prompt(Prompt<'a>), 21 | #[serde(borrow)] 22 | Title(Title<'a>), 23 | Turbo(Turbo), 24 | Wait(Wait), 25 | #[serde(borrow)] 26 | Write(Write<'a>), 27 | } 28 | 29 | #[enum_dispatch(Command)] 30 | pub trait Runnable { 31 | fn run(&self, state: &mut State) -> Result<()>; 32 | } 33 | 34 | /// `!clear` 35 | /// Clear screen command. 36 | #[derive(Debug, Deserialize)] 37 | pub struct Clear {} 38 | 39 | impl Runnable for Clear { 40 | fn run(&self, state: &mut State) -> Result<()> { 41 | print!("\x1B[2J\x1B[1;1H"); 42 | functions::show_prompt(&state.prompt)?; 43 | state.cursor = 0; 44 | Ok(()) 45 | } 46 | } 47 | 48 | /// `!erase` 49 | /// Erase characters to the left. 50 | #[derive(Debug, Deserialize)] 51 | pub struct Erase<'a> { 52 | pub msec: u32, 53 | pub by_chars: Option<&'a str>, 54 | pub amount: Option, 55 | } 56 | 57 | impl Runnable for Erase<'_> { 58 | fn run(&self, state: &mut State) -> Result<()> { 59 | let deletions = match (self.by_chars.as_ref(), self.amount) { 60 | (Some(by_chars), None) => by_chars.len(), 61 | (None, Some(amount)) => amount as usize, 62 | (Some(by_chars), Some(amount)) => amount as usize + by_chars.len(), 63 | (None, None) => 0, 64 | }; 65 | 66 | // Remove the deletions up till the cursor 67 | let deletions = cmp::min(deletions, state.cursor); 68 | state.cursor -= deletions; 69 | functions::erase(deletions, self.msec, state.speed_factor)?; 70 | Ok(()) 71 | } 72 | } 73 | 74 | /// `!execute` 75 | /// Execute shell commands or other applications and show their output. 76 | #[derive(Debug, Deserialize)] 77 | pub struct Execute<'a> { 78 | pub line: &'a str, 79 | } 80 | 81 | impl<'a> Runnable for Execute<'a> { 82 | fn run(&self, state: &mut State) -> Result<()> { 83 | println!(""); 84 | let words = shellwords::split(&self.line).unwrap(); 85 | 86 | if let Some((cmd, args)) = words.split_first() { 87 | process::Command::new(cmd).args(args).spawn()?; 88 | } 89 | functions::delay(crate::DELAY_AFTER_EXECUTE, state.speed_factor); 90 | functions::show_prompt(&state.prompt)?; 91 | state.cursor = 0; 92 | Ok(()) 93 | } 94 | } 95 | 96 | /// `!new_line` 97 | /// Simulate user's `ENTER`. 98 | #[derive(Debug, Deserialize)] 99 | pub struct NewLine {} 100 | 101 | impl Runnable for NewLine { 102 | fn run(&self, state: &mut State) -> Result<()> { 103 | print!("\n"); 104 | functions::show_prompt(&state.prompt)?; 105 | state.cursor = 0; 106 | Ok(()) 107 | } 108 | } 109 | 110 | /// `!pasue` 111 | /// Pause before next command and wait for user input (any key...) 112 | #[derive(Debug, Deserialize)] 113 | pub struct Pause {} 114 | 115 | impl Runnable for Pause { 116 | fn run(&self, _state: &mut State) -> Result<()> { 117 | let mut answer = String::new(); 118 | io::stdin().read_line(&mut answer)?; 119 | Ok(()) 120 | } 121 | } 122 | 123 | /// `!prompt` 124 | /// Prompt specify a constant text that is shown after every `execute` and cis not affected by `erase`. 125 | #[derive(Debug, Deserialize)] 126 | pub struct Prompt<'a> { 127 | pub text: &'a str, 128 | pub color: Option<&'a str>, 129 | } 130 | 131 | impl Runnable for Prompt<'_> { 132 | fn run(&self, state: &mut State) -> Result<()> { 133 | let ps1 = terminal::colorful(&self.text, self.color.as_deref()); 134 | state.prompt = Some(ps1); 135 | functions::show_prompt(&state.prompt)?; 136 | state.cursor = 0; 137 | Ok(()) 138 | } 139 | } 140 | 141 | /// `!title` 142 | /// Title specify a constant text that is shown after every `execute` and cis not affected by `erase`. 143 | #[derive(Debug, Deserialize)] 144 | pub struct Title<'a> { 145 | pub text: &'a str, 146 | } 147 | 148 | impl Runnable for Title<'_> { 149 | fn run(&self, _state: &mut State) -> Result<()> { 150 | functions::show_title(self.text)?; 151 | Ok(()) 152 | } 153 | } 154 | 155 | /// `!turbo` 156 | /// Speed everything, useful when iterating over the screenplay. 157 | #[derive(Debug, Deserialize)] 158 | pub struct Turbo { 159 | pub by: u32, 160 | } 161 | 162 | impl Runnable for Turbo { 163 | fn run(&self, state: &mut State) -> Result<()> { 164 | state.speed_factor = self.by; 165 | Ok(()) 166 | } 167 | } 168 | 169 | /// `!wait` 170 | /// Pauses execution for the specified time, then contrinues. 171 | #[derive(Debug, Deserialize)] 172 | pub struct Wait { 173 | pub msec: u32, 174 | } 175 | 176 | impl Runnable for Wait { 177 | fn run(&self, state: &mut State) -> Result<()> { 178 | functions::delay(self.msec, state.speed_factor); 179 | Ok(()) 180 | } 181 | } 182 | 183 | /// `!write` 184 | /// Write text to the terminal. 185 | #[derive(Debug, Deserialize)] 186 | pub struct Write<'a> { 187 | pub msec: u32, 188 | pub color: Option<&'a str>, 189 | pub text: &'a str, 190 | } 191 | 192 | impl Runnable for Write<'_> { 193 | fn run(&self, state: &mut State) -> Result<()> { 194 | for c in self.text.chars() { 195 | functions::delay(self.msec, state.speed_factor); 196 | print!("{}", terminal::colorful(&c.to_string(), self.color.as_deref())); 197 | stdout().flush()?; 198 | } 199 | state.cursor += self.text.len(); 200 | Ok(()) 201 | } 202 | } 203 | 204 | -------------------------------------------------------------------------------- /src/functions.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use std::{thread, time}; 3 | use std::io::{stdout, Write}; 4 | use crate::terminal::BACKSPACE; 5 | 6 | pub fn delay(msec: u32, speed_factor: u32) { 7 | let t = msec / speed_factor; 8 | thread::sleep(time::Duration::from_millis(t.into())); 9 | } 10 | 11 | pub fn erase(amount: usize, msec: u32, speed_factor: u32) -> Result<()> { 12 | for _ in 0..amount { 13 | delay(msec, speed_factor); 14 | print!("{} {}", BACKSPACE, BACKSPACE); 15 | stdout().flush()?; 16 | } 17 | Ok(()) 18 | } 19 | 20 | pub fn show_prompt(prompt: &Option) -> Result<()> { 21 | if let Some(ps1) = prompt { 22 | print!("{ps1} "); 23 | stdout().flush()?; 24 | } 25 | Ok(()) 26 | } 27 | 28 | pub fn show_title(text: &str) -> Result<()> { 29 | write!(stdout(), "\x1B]0;{text}\x07")?; 30 | Ok(()) 31 | } -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | mod commands; 2 | mod terminal; 3 | mod functions; 4 | mod state; 5 | 6 | use anyhow::Result; 7 | use commands::Command; 8 | use state::State; 9 | use commands::Runnable; 10 | 11 | pub fn from_yaml(data: &str) -> Result> { 12 | let yaml = serde_yaml::from_str(data)?; 13 | Ok(yaml) 14 | } 15 | 16 | const DELAY_AFTER_EXECUTE: u32 = 250; 17 | 18 | pub fn execute(commands: Vec) -> Result<()> { 19 | let mut state = State { 20 | prompt: None, 21 | cursor: 0, 22 | speed_factor: 1, 23 | }; 24 | 25 | for cmd in commands { 26 | cmd.run(&mut state)?; 27 | } 28 | 29 | Ok(()) 30 | } 31 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use clap::{Parser}; 2 | use std::fs; 3 | use std::path::PathBuf; 4 | 5 | #[derive(Parser)] 6 | #[clap(author = "Ofer Affias", version, about, long_about = None)] 7 | struct Cli { 8 | /// Screenplay file, YAML formatted list of commands and their args 9 | #[clap(value_parser, value_hint = clap::ValueHint::FilePath)] 10 | file: PathBuf, 11 | } 12 | 13 | fn main() { 14 | let cli = Cli::parse(); 15 | 16 | let data = fs::read_to_string(cli.file).expect("Unable to read screenplay file"); 17 | let commands = swordfishlib::from_yaml(&data).expect("Parsing errors in screenplay file"); 18 | swordfishlib::execute(commands).expect("Runtime error"); 19 | } 20 | -------------------------------------------------------------------------------- /src/state.rs: -------------------------------------------------------------------------------- 1 | pub struct State { 2 | pub prompt: Option, 3 | pub cursor: usize, 4 | pub speed_factor: u32, 5 | } -------------------------------------------------------------------------------- /src/terminal.rs: -------------------------------------------------------------------------------- 1 | use colored::Colorize; 2 | 3 | pub const BACKSPACE: char = 8u8 as char; 4 | 5 | pub fn colorful(c: &str, color: Option<&str>) -> String { 6 | match color { 7 | Some("black") => format!("{}", c.black()), 8 | Some("red") => format!("{}", c.red()), 9 | Some("green") => format!("{}", c.green()), 10 | Some("yellow") => format!("{}", c.yellow()), 11 | Some("blue") => format!("{}", c.blue()), 12 | Some("magenta") => format!("{}", c.magenta()), 13 | Some("cyan") => format!("{}", c.cyan()), 14 | Some("white") => format!("{}", c.white()), 15 | Some("bright_black") => format!("{}", c.bright_black()), 16 | Some("bright_red") => format!("{}", c.bright_red()), 17 | Some("bright_green") => format!("{}", c.bright_green()), 18 | Some("bright_yellow") => format!("{}", c.bright_yellow()), 19 | Some("bright_blue") => format!("{}", c.bright_blue()), 20 | Some("bright_magenta") => format!("{}", c.bright_magenta()), 21 | Some("bright_cyan") => format!("{}", c.bright_cyan()), 22 | Some("bright_white") => format!("{}", c.bright_white()), 23 | Some(_) | None => format!("{c}"), 24 | } 25 | } -------------------------------------------------------------------------------- /swordfish_hack_scene.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vim-zz/swordfish-rs/abe092fc12e89ea6fe665f358acf86d342fcb856/swordfish_hack_scene.gif -------------------------------------------------------------------------------- /tests/screenplay.rs: -------------------------------------------------------------------------------- 1 | use swordfishlib; 2 | 3 | #[test] 4 | fn play_commands() { 5 | let screenplay = r###" 6 | - !clear 7 | - !title {text: "title here"} 8 | - !prompt {color: bright_green, text: "$"} 9 | - !write {msec: 0, color: blue, text: "$ "} 10 | - !write {msec: 0, text: "i am going to list this dir"} 11 | - !wait {msec: 0} 12 | - !erase {msec: 0, by_chars: xxxxxxxxxxxxxxxxxxxxxxxxxxx, amount: 5 } 13 | - !wait {msec: 0} 14 | - !write {msec: 0, text: "echo swordfish"} 15 | - !wait {msec: 0} 16 | - !execute {line: echo swordfish} 17 | - !wait {msec: 0} 18 | - !new_line 19 | - !write {msec: 0, text: "bye, press any key..."} 20 | - !turbo {by: 1} 21 | "###; 22 | 23 | let commands = swordfishlib::from_yaml(&screenplay).unwrap(); 24 | let lines = screenplay.lines().collect::>().len(); 25 | assert_eq!(commands.len(), lines - 2); 26 | swordfishlib::execute(commands).unwrap(); 27 | } 28 | 29 | #[test] 30 | fn play_wrong_commands() { 31 | let screenplay = r###" 32 | - !no_command_like_this_one 33 | "###; 34 | 35 | let commands = swordfishlib::from_yaml(&screenplay); 36 | assert!(commands.is_err()); 37 | } 38 | 39 | #[test] 40 | fn play_wrong_command_arg() { 41 | let screenplay = r###" 42 | - !wait {no_arg_like_this_one: 0} 43 | "###; 44 | 45 | let commands = swordfishlib::from_yaml(&screenplay); 46 | assert!(commands.is_err()); 47 | } --------------------------------------------------------------------------------