├── .github └── workflows │ └── ci.yaml ├── .gitignore ├── LICENSE.md ├── README.md ├── assets ├── cli-usage.png ├── roc.png └── tui-main-menu.png ├── flake.lock ├── flake.nix ├── install.d ├── bash │ ├── .bashrc │ └── roc-start_completion.sh ├── setup_completion.sh └── zsh │ ├── .zshrc │ └── _roc-start ├── install.sh ├── sloc.py ├── src ├── ArgParser.roc ├── Dotfile.roc ├── ErrorHandlers.roc ├── Installer.roc ├── Logger.roc ├── PluginManager.roc ├── RocParser.roc ├── main.roc ├── repos │ ├── Manager.roc │ ├── Updater.roc │ └── main.roc ├── themes │ ├── Manager.roc │ ├── Theme.roc │ └── main.roc └── tui │ ├── AsciiArt.roc │ ├── BoxStyle.roc │ ├── Choices.roc │ ├── Controller.roc │ ├── InputHandlers.roc │ ├── Keys.roc │ ├── Model.roc │ ├── State.roc │ ├── StateTransitions.roc │ ├── UserAction.roc │ ├── Utils.roc │ ├── View.roc │ └── main.roc └── themes └── .rocstartthemes /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: Check and Test Package 2 | on: 3 | pull_request: 4 | branches: 5 | - main 6 | workflow_dispatch: 7 | push: 8 | branches: 9 | - main 10 | 11 | # this cancels workflows currently in progress if you start a new one 12 | concurrency: 13 | group: ${{ github.workflow }}-${{ github.ref }} 14 | cancel-in-progress: true 15 | 16 | jobs: 17 | 18 | build-and-test: 19 | runs-on: [ubuntu-20.04] 20 | steps: 21 | - name: Check out the repository 22 | uses: actions/checkout@v4 23 | - name: Install Roc 24 | uses: hasnep/setup-roc@v0.5.0 25 | with: 26 | roc-version: alpha3-rolling 27 | - name: Roc check on src/main.roc 28 | run: roc check src/main.roc 29 | # - name: Roc test on src/main.roc 30 | # run: roc test src/main.roc 31 | - name: Roc build on src/main.roc 32 | run: roc build src/main.roc -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # ingore all 2 | * 3 | 4 | # unignore files 5 | !*.* 6 | !*.roc 7 | !src/ 8 | !repos/ 9 | !tui/ 10 | !themes/ 11 | !assets 12 | !.github/workflows/ 13 | !install.d/bash/ 14 | !/install.d/zsh/ 15 | !install.d/zsh/_* 16 | 17 | # reignore files 18 | *.DS_Store 19 | progress.roc 20 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024 Ian McLerran and subsequent [authors](https://github.com/imclerran/roc-start/graphs/contributors) 2 | 3 | The Universal Permissive License (UPL), Version 1.0 4 | 5 | Subject to the condition set forth below, permission is hereby granted to any person obtaining a copy of this software, associated documentation and/or data (collectively the “Software”), free of charge and under any and all copyright rights in the Software, and any and all patent rights owned or freely licensable by each licensor hereunder covering either (i) the unmodified Software as contributed to or provided by such licensor, or (ii) the Larger Works (as defined below), to deal in both 6 | 7 | (a) the Software, and 8 | 9 | (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if one is included with the Software (each a “Larger Work” to which the Software is contributed by such licensors), 10 | 11 | without restriction, including without limitation the rights to copy, create derivative works of, display, perform, and distribute the Software and make, use, sell, offer for sale, import, export, have made, and have sold the Software and the Larger Work(s), and to sublicense the foregoing rights on either these or other terms. 12 | 13 | This license is subject to the following condition: 14 | 15 | The above copyright notice and either this complete permission notice or at a minimum a reference to the UPL must be included in all copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `roc-start` CLI tool 🚀 2 | 3 | [![Roc-Lang][roc_badge]][roc_link] 4 | [![GitHub last commit][last_commit_badge]][last_commit_link] 5 | [![CI status][ci_status_badge]][ci_status_link] 6 | 7 | ![CLI usage example](assets/roc.png) 8 | 9 | Roc-start is a CLI tool for generating dependency headers for a new roc application or package, or upgrading the dependencies in an existing roc file. Think of it as the other half of the Roc package manager. 10 | 11 | Starting a new roc app which requires multiple packages can be a bit cumbersome, due to the requirement for long urls which cannot be easily memorized. This typically requires opening previous projects which have some of the same dependencies, and copy/pasting from there, or visiting multiple github pages, finding the release page, and copying the url of the required assets. 12 | 13 | Roc-start is intended to streamline this process. 14 | 15 | Roc-start maintains a repository of package and platform git repos. From this list, it will fetch the URLs for all releases for each platform and package. Then with a simple command, you can generate a new roc application or pacakge file, or upgrade the dependencies in an existing one. Roc start even aids in platform and package discovery, by allowing you to browse and search for packages and platforms. 16 | 17 | 18 | ### New Features 19 | __As of 0.6.0:__ 20 | - Support for multiple packages or platforms with the same name, but different owners 21 | - Support for specifying the package or platform version, or default to latest if not specified 22 | - Specifying the filename and platform is optional, will default to main.roc and basic-cli:latest 23 | - Default platform and version can be configured to use the user's preferred platform and version 24 | - Packages and platforms whose names are not ambiguous can be specified without the owner 25 | - If the requested package or platform repo or version cannot be resolved, appropriate warnings will be given 26 | - Stub application code is generated dynamically based on the specified platform version 27 | - Progress bar displayed while updating local package, platform and script cache 28 | - TUI interface supports doing all actions available through the CLI 29 | 30 | __Post 0.6.0:__ 31 | - 0.6.3: User defined color themes in `$HOME/.rocstartthemes` 32 | - 0.6.5: Support for Vim motions for menu navigation 33 | - 0.6.6: Flags (like `force`) are selectable in the TUI 34 | - 0.6.6: Install script will install terminal autocompletion in zsh and bash 35 | - 0.7.0: Platform scripts renamed to plugins for better clarity on purpose 36 | - 0.7.3: Roc-start can install its own updates, if it was installed using the install script. 37 | 38 | ## Installing: 39 | If you already have the github cli installed (gh, not git), installing roc-start is as easy as running the install script in the root directory of the roc-start repository (install.sh). 40 | 41 | > __Important:__ 42 | > Roc-start depends on the github cli tool to get the latest releases, and cannot run at all without `gh` installed. 43 | > Go to https://cli.github.com to install the `gh` tool, and then run `gh auth login`. 44 | 45 | Once the github cli tool is installed and authenticated, chdir into the base roc-start repository directory and run: 46 | ```sh 47 | chmod +x install.sh && ./install.sh 48 | ``` 49 | Roc-start is now installed to `$HOME/.local/bin/`. The first time you run `roc-start`, it will download all release data for each platform and package in its repository, as well as the code generation scripts. You're all ready to go! 50 | 51 | > __Linux Users:__ 52 | > Due to ongoing problems with both the surgical and legacy linkers on Linux, roc-start may have issues on your machine. In my testing, building roc-start with the legacy linker causes a segfault when the executable is run, and building with the surgical linker with the `--optimize` flag fails entirely. 53 | > 54 | > For these reasons, the install script does not use the optimize flag when installing on Linux. The CLI app still works great, and you should be able to use it without issue. However, the TUI depends heavily on these optimizations, and it is likely you may encounter poor performance and even crashes in the TUI when built without optimization. 55 | 56 | ## Two workflows 57 | 58 | 1) Use the CLI with your prefered arguments: 59 | - `roc-start app roc-ansi roc-json roc-ai:0.10.0` 60 | 61 | ![CLI usage example](assets/cli-usage.png) 62 | 63 | 64 | 2) Launch the TUI app to browse and search for packages and platforms: 65 | - `roc-start` 66 | 67 | ![TUI main menu screen](assets/tui-main-menu.png) 68 | 69 | ## Usage 70 | 71 | Starting a new app is as easy as running `roc-start app`. This will use your default platform, with the filename "main.roc". 72 | Alternatively, you can: 73 | - Use `--platform`, to specify the platform. You may include the version tag or leave it off to use the latest release: 74 | - `--platform basic-cli` or `--platform basic-cli:0.19.0`. 75 | - Append as many packages as you want. These may include or ommit the version like the platform, but no option is required. 76 | - `roc-start app roc-json:0.12.0` 77 | - Specify the output filename, if you want something besides main.roc: 78 | - `roc-start app --out hello-world` or `roc-start app --out hello-world.roc` 79 | - Upgrade an existing app or package with `--upgrade`. In this case, specify the file to upgrade with `--in`, or leave it off to use "main.roc" 80 | - otherwise, the arguments will be the same as `app`. 81 | 82 | ## Updating platform/package urls 83 | 84 | The first time roc-start is run, it will automatically get the latest release urls for the platforms and packages in its repository. These can be updated again at any time by running: 85 | - `roc-start update`, which will update everything, or with any or all of `--packages`, `--platforms`, or `--scripts`, to update specific components. 86 | - `roc-start`, and selecting "update roc-start" from the main menu, and continuing on to confirmation" 87 | 88 | ## Getting your package or platform added to roc-start 89 | 90 | To make your package or platform available in roc-start, simply make a pull request to the https://github.com/imclerran/roc-repo, and add your repo to appropriate CSV file (packages.csv, or platforms.csv). 91 | 92 | ## Help pages 93 | ### roc-start --help 94 | ``` 95 | A simple CLI tool for starting or upgrading roc projects. Specify your platform and packages by name, and roc-start will create a new .roc file or update an existing one with the either the versions you specify, or the latest releases. If no arguments are specified, the TUI app will be launched instead. 96 | 97 | Usage: 98 | roc-start -v/--verbosity STR [options] 99 | roc-start 100 | 101 | Commands: 102 | update Update the platform and package repositories and plugins. Update all, or specify which to update. 103 | app Create a new roc app with the specified name, platform, and packages. 104 | package Create a new roc package main file with all specified packages dependencies. 105 | upgrade Upgrade the platform and/or packages in an app or package 106 | config Configure the default settings for the roc-start CLI tool. 107 | 108 | Options: 109 | -v STR, --verbosity STR Set the verbosity level to one of: verbose, quiet, or silent. 110 | -h, --help Show this help page. 111 | -V, --version Show the version. 112 | ``` 113 | 114 | ### roc-start app --help 115 | ``` 116 | Create a new roc app with the specified name, platform, and packages. 117 | 118 | Usage: 119 | roc-start app -o/--out STR -p/--platform STR [options] 120 | 121 | Arguments: 122 | Any packages to use. Set the version of the package with `:`. If version is not set packages will default to the latest version. 123 | 124 | Options: 125 | -f, --force Force overwrite of existing file. 126 | --no-plugin Force roc-start to use fallback generation insteaad of platform specific plugin. 127 | -o STR, --out STR The name of the output file (Defaults to `main.roc`). Extension is not required. 128 | -p STR, --platform STR The platform to use (Defaults to `basic-cli=latest` unless otherwise configured). Set the version with `--platform :`. 129 | -h, --help Show this help page. 130 | -V, --version Show the version. 131 | ``` 132 | 133 | ### roc-start upgrade --help 134 | ``` 135 | Upgrade the platform and/or packages in an app or package 136 | 137 | Usage: 138 | roc-start upgrade -i/--in STR -p/--platform STR [options] 139 | 140 | Arguments: 141 | List of packages upgrade. If ommitted, all will be upgraded. Version may be specified, or left out to upgrade to the latest version. 142 | 143 | Options: 144 | -i STR, --in STR The name of the input file who's platforms and/or packages should be upgraded. 145 | -p STR, --platform STR Specify the platform and version to upgrade to. If ommitted, the platform will not be upgraded. If the specified platform is different than the platform in the upgraded file, the platform will be replaced with the specified one. 146 | -h, --help Show this help page. 147 | -V, --version Show the version. 148 | ``` 149 | 150 | ### roc-start update --help 151 | ``` 152 | Update the platform and package repositories and plugins. Update all (excluding installation), or specify which to update. 153 | 154 | Usage: 155 | roc-start update [options] 156 | 157 | Options: 158 | -k, --packages Update the package repositories. 159 | -f, --platforms Update the platform repositories. 160 | -s, --plugins Update the platform plugins. 161 | -t, --themes Update the available color themes. 162 | -i, --install Install the latest version of roc-start. 163 | -h, --help Show this help page. 164 | -V, --version Show the version. 165 | ``` 166 | 167 | 168 | [roc_badge]: https://img.shields.io/endpoint?url=https%3A%2F%2Fpastebin.com%2Fraw%2FcFzuCCd7 169 | [roc_link]: https://github.com/roc-lang/roc 170 | 171 | [ci_status_badge]: https://img.shields.io/github/actions/workflow/status/imclerran/roc-start/ci.yaml?logo=github&logoColor=lightgrey 172 | [ci_status_link]: https://github.com/imclerran/roc-start/actions/workflows/ci.yaml 173 | [last_commit_badge]: https://img.shields.io/github/last-commit/imclerran/roc-start?logo=git&logoColor=lightgrey 174 | [last_commit_link]: https://github.com/imclerran/roc-start/commits/main/ 175 | -------------------------------------------------------------------------------- /assets/cli-usage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imclerran/roc-start/70786aea19124b437df2ec13ff7764bf0db7f58c/assets/cli-usage.png -------------------------------------------------------------------------------- /assets/roc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imclerran/roc-start/70786aea19124b437df2ec13ff7764bf0db7f58c/assets/roc.png -------------------------------------------------------------------------------- /assets/tui-main-menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imclerran/roc-start/70786aea19124b437df2ec13ff7764bf0db7f58c/assets/tui-main-menu.png -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-compat": { 4 | "flake": false, 5 | "locked": { 6 | "lastModified": 1733328505, 7 | "narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=", 8 | "owner": "edolstra", 9 | "repo": "flake-compat", 10 | "rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec", 11 | "type": "github" 12 | }, 13 | "original": { 14 | "owner": "edolstra", 15 | "repo": "flake-compat", 16 | "type": "github" 17 | } 18 | }, 19 | "flake-utils": { 20 | "inputs": { 21 | "systems": "systems" 22 | }, 23 | "locked": { 24 | "lastModified": 1731533236, 25 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 26 | "owner": "numtide", 27 | "repo": "flake-utils", 28 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 29 | "type": "github" 30 | }, 31 | "original": { 32 | "owner": "numtide", 33 | "repo": "flake-utils", 34 | "type": "github" 35 | } 36 | }, 37 | "flake-utils_2": { 38 | "inputs": { 39 | "systems": "systems_2" 40 | }, 41 | "locked": { 42 | "lastModified": 1731533236, 43 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 44 | "owner": "numtide", 45 | "repo": "flake-utils", 46 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 47 | "type": "github" 48 | }, 49 | "original": { 50 | "owner": "numtide", 51 | "repo": "flake-utils", 52 | "type": "github" 53 | } 54 | }, 55 | "nixpkgs": { 56 | "locked": { 57 | "lastModified": 1722403750, 58 | "narHash": "sha256-tRmn6UiFAPX0m9G1AVcEPjWEOc9BtGsxGcs7Bz3MpsM=", 59 | "owner": "nixos", 60 | "repo": "nixpkgs", 61 | "rev": "184957277e885c06a505db112b35dfbec7c60494", 62 | "type": "github" 63 | }, 64 | "original": { 65 | "owner": "nixos", 66 | "repo": "nixpkgs", 67 | "rev": "184957277e885c06a505db112b35dfbec7c60494", 68 | "type": "github" 69 | } 70 | }, 71 | "roc": { 72 | "inputs": { 73 | "flake-compat": "flake-compat", 74 | "flake-utils": "flake-utils_2", 75 | "nixpkgs": "nixpkgs", 76 | "rust-overlay": "rust-overlay" 77 | }, 78 | "locked": { 79 | "lastModified": 1743859145, 80 | "narHash": "sha256-4w2JNBNukoN78CHl3WRFB1Jy4V8ODMVxaFN1xbd2mw0=", 81 | "owner": "roc-lang", 82 | "repo": "roc", 83 | "rev": "5ba1c8e24b555b4c2c625e7d718bfcb223f00281", 84 | "type": "github" 85 | }, 86 | "original": { 87 | "owner": "roc-lang", 88 | "repo": "roc", 89 | "type": "github" 90 | } 91 | }, 92 | "root": { 93 | "inputs": { 94 | "flake-utils": "flake-utils", 95 | "nixpkgs": [ 96 | "roc", 97 | "nixpkgs" 98 | ], 99 | "roc": "roc" 100 | } 101 | }, 102 | "rust-overlay": { 103 | "inputs": { 104 | "nixpkgs": [ 105 | "roc", 106 | "nixpkgs" 107 | ] 108 | }, 109 | "locked": { 110 | "lastModified": 1736303309, 111 | "narHash": "sha256-IKrk7RL+Q/2NC6+Ql6dwwCNZI6T6JH2grTdJaVWHF0A=", 112 | "owner": "oxalica", 113 | "repo": "rust-overlay", 114 | "rev": "a0b81d4fa349d9af1765b0f0b4a899c13776f706", 115 | "type": "github" 116 | }, 117 | "original": { 118 | "owner": "oxalica", 119 | "repo": "rust-overlay", 120 | "type": "github" 121 | } 122 | }, 123 | "systems": { 124 | "locked": { 125 | "lastModified": 1681028828, 126 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 127 | "owner": "nix-systems", 128 | "repo": "default", 129 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 130 | "type": "github" 131 | }, 132 | "original": { 133 | "owner": "nix-systems", 134 | "repo": "default", 135 | "type": "github" 136 | } 137 | }, 138 | "systems_2": { 139 | "locked": { 140 | "lastModified": 1681028828, 141 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 142 | "owner": "nix-systems", 143 | "repo": "default", 144 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 145 | "type": "github" 146 | }, 147 | "original": { 148 | "owner": "nix-systems", 149 | "repo": "default", 150 | "type": "github" 151 | } 152 | } 153 | }, 154 | "root": "root", 155 | "version": 7 156 | } 157 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "roc-start"; 3 | 4 | inputs = { 5 | roc.url = "github:roc-lang/roc"; 6 | nixpkgs.follows = "roc/nixpkgs"; 7 | 8 | flake-utils.url = "github:numtide/flake-utils"; 9 | }; 10 | 11 | outputs = { nixpkgs, flake-utils, roc, ... }: 12 | let 13 | supportedSystems = 14 | [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ]; 15 | in flake-utils.lib.eachSystem supportedSystems (system: 16 | let 17 | pkgs = import nixpkgs { inherit system; }; 18 | roc-cli = roc.packages.${system}.cli; 19 | in { 20 | devShell = pkgs.mkShell { packages = [ roc-cli ]; }; 21 | 22 | packages = rec { 23 | default = roc-start; 24 | roc-start = roc.lib.buildRocPackage { 25 | inherit pkgs roc-cli; 26 | linker = "surgical"; 27 | name = "roc-start"; 28 | src = ./.; 29 | entryPoint = "src/main.roc"; 30 | outputHash = "sha256-R0LtKuWpsNQxB1OS3sRMkvYuDy0tTPScdMrhIBUGpRc="; 31 | }; 32 | 33 | formatter = pkgs.nixpkgs-fmt; 34 | }; 35 | }); 36 | 37 | nixConfig = { 38 | extra-trusted-public-keys = 39 | "roc-lang.cachix.org-1:6lZeqLP9SadjmUbskJAvcdGR2T5ViR57pDVkxJQb8R4="; 40 | extra-trusted-substituters = "https://roc-lang.cachix.org"; 41 | }; 42 | 43 | } 44 | -------------------------------------------------------------------------------- /install.d/bash/.bashrc: -------------------------------------------------------------------------------- 1 | # Added by roc-start installer 2 | source ~/.bash_completion.d/roc-start_completion.sh -------------------------------------------------------------------------------- /install.d/bash/roc-start_completion.sh: -------------------------------------------------------------------------------- 1 | _roc_start() { 2 | local cur prev words cword 3 | _init_completion || return 4 | 5 | local global_opts=( 6 | "-v --verbosity" 7 | "-h --help" 8 | "-V --version" 9 | ) 10 | 11 | local subcommands=(update upgrade app package config) 12 | 13 | case "${COMP_WORDS[1]}" in 14 | update) 15 | COMPREPLY=( $(compgen -W "-k --packages -f --platforms -s --plugins -t --themes ${global_opts[*]}" -- "$cur") ) 16 | ;; 17 | upgrade) 18 | COMPREPLY=( $(compgen -W "-i --in -p --platform ${global_opts[*]}" -- "$cur") ) 19 | ;; 20 | app) 21 | COMPREPLY=( $(compgen -W "-f --force -o --out -p --platform --no-plugin ${global_opts[*]}" -- "$cur") ) 22 | ;; 23 | package) 24 | COMPREPLY=( $(compgen -W "-f --force ${global_opts[*]}" -- "$cur") ) 25 | ;; 26 | config) 27 | COMPREPLY=( $(compgen -W "--set-theme --set-verbosity --set-default-platform ${global_opts[*]}" -- "$cur") ) 28 | ;; 29 | *) 30 | COMPREPLY=( $(compgen -W "${subcommands[*]} ${global_opts[*]}" -- "$cur") ) 31 | ;; 32 | esac 33 | } 34 | 35 | complete -F _roc_start roc-start 36 | -------------------------------------------------------------------------------- /install.d/setup_completion.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # Get the directory of the current script 4 | if [ -n "$ZSH_VERSION" ]; then 5 | SCRIPT_DIR="$(cd "$(dirname "${(%):-%N}")" && pwd)" 6 | else 7 | # POSIX-compliant way without using BASH_SOURCE 8 | # Use command line arg $0 instead 9 | SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" 10 | fi 11 | 12 | # Function to update .bashrc 13 | update_bashrc() { 14 | bashrc="$HOME/.bashrc" 15 | bash_completion_dir="$HOME/.bash_completion.d" 16 | completion_script="$SCRIPT_DIR/bash/roc-start_completion.sh" 17 | 18 | if [ -f "$SCRIPT_DIR/bash/.bashrc" ]; then 19 | bashrc_content=$(cat "$SCRIPT_DIR/bash/.bashrc") 20 | fi 21 | 22 | if [ -f "$bashrc" ]; then 23 | if ! grep -F "source ~/.bash_completion.d/roc-start_completion.sh" "$bashrc" > /dev/null 2>&1; then 24 | printf "\n%s\n" "$bashrc_content" >> "$bashrc" 25 | fi 26 | mkdir -p "$bash_completion_dir" 27 | cp "$completion_script" "$bash_completion_dir/roc-start_completion.sh" 28 | echo "Installed bash completions" 29 | fi 30 | } 31 | 32 | # Function to update .zshrc 33 | update_zshrc() { 34 | zshrc="$HOME/.zshrc" 35 | zsh_completion_dir="$HOME/.zsh/completions" 36 | completion_script="$SCRIPT_DIR/zsh/_roc-start" 37 | 38 | if [ -f "$SCRIPT_DIR/zsh/.zshrc" ]; then 39 | zshrc_content=$(cat "$SCRIPT_DIR/zsh/.zshrc") 40 | fi 41 | 42 | if [ -f "$zshrc" ]; then 43 | if ! grep -F "fpath+=(~/.zsh/completions)" "$zshrc" > /dev/null 2>&1 || 44 | ! grep -F "autoload -U compinit && compinit" "$zshrc" > /dev/null 2>&1; then 45 | printf "\n%s\n" "$zshrc_content" >> "$zshrc" 46 | fi 47 | mkdir -p "$zsh_completion_dir" 48 | cp "$completion_script" "$zsh_completion_dir/_roc-start" 49 | echo "Installed zsh completions" 50 | fi 51 | } 52 | 53 | # Run the update functions 54 | update_bashrc 55 | update_zshrc 56 | -------------------------------------------------------------------------------- /install.d/zsh/.zshrc: -------------------------------------------------------------------------------- 1 | # Added by roc-start installer 2 | fpath+=(~/.zsh/completions) 3 | autoload -U compinit && compinit -------------------------------------------------------------------------------- /install.d/zsh/_roc-start: -------------------------------------------------------------------------------- 1 | #compdef roc-start 2 | 3 | _roc_start() { 4 | local context="$words[2]" # Capture the subcommand 5 | 6 | # Global options available for all commands 7 | local -a global_opts=( 8 | '-v+[Set verbosity level]:verbosity:(verbose quiet silent)' 9 | '--verbosity=[Set verbosity level]:verbosity:(verbose quiet silent)' 10 | '-h[Show help]' 11 | '--help[Show help]' 12 | '-V[Show version]' 13 | '--version[Show version]' 14 | ) 15 | 16 | case "$context" in 17 | upgrade) 18 | _arguments \ 19 | "${global_opts[@]}" \ 20 | '-i+[Input file to upgrade]:input file:_files' \ 21 | '--in=[Input file to upgrade]:input file:_files' \ 22 | '-p+[Specify platform]:platform' \ 23 | '--platform=[Specify platform]:platform' \ 24 | '*:packages:' 25 | ;; 26 | app) 27 | _arguments \ 28 | "${global_opts[@]}" \ 29 | '-f[Force overwrite of existing file]' \ 30 | '--force[Force overwrite of existing file]' \ 31 | '-o+[Output file name]:output file:_files' \ 32 | '--out=[Output file name]:output file:_files' \ 33 | '-p+[Specify platform]:platform' \ 34 | '--platform=[Specify platform]:platform' \ 35 | '--no-plugin[Force the use of fallback generation]' \ 36 | '*:packages:' 37 | ;; 38 | package) 39 | _arguments \ 40 | "${global_opts[@]}" \ 41 | '-f[Force overwrite of existing file]' \ 42 | '--force[Force overwrite of existing file]' \ 43 | '*:packages:' 44 | ;; 45 | config) 46 | _arguments \ 47 | "${global_opts[@]}" \ 48 | '--set-theme=[Set default CLI color theme]:theme' \ 49 | '--set-verbosity=[Set default verbosity level]:verbosity:(verbose quiet silent)' \ 50 | '--set-default-platform=[Set default platform]:platform' 51 | ;; 52 | update) 53 | _arguments \ 54 | "${global_opts[@]}" \ 55 | '-k[Update the package repositories]' \ 56 | '--packages[Update the package repositories]' \ 57 | '-f[Update the platform repositories]' \ 58 | '--platforms[Update the platform repositories]' \ 59 | '-s[Update the platform plugins]' \ 60 | '--plugins[Update the platform plugins]' \ 61 | '-t[Update the available color themes]' \ 62 | '--themes[Update the available color themes]' 63 | ;; 64 | *) 65 | # Default subcommand suggestions with global options 66 | _arguments \ 67 | "${global_opts[@]}" \ 68 | "1: :( 69 | upgrade 70 | app 71 | package 72 | config 73 | update 74 | )" 75 | ;; 76 | esac 77 | } 78 | 79 | _roc_start "$@" -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # Get the directory of the current script 4 | SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" 5 | 6 | # Check if the script is running on macOS or Linux 7 | OS_TYPE=$(uname) 8 | if [ "$OS_TYPE" = "Darwin" ]; then 9 | LINKER="--linker=legacy" 10 | elif [ "$OS_TYPE" = "Linux" ]; then 11 | LINKER="--linker=surgical" 12 | else 13 | echo "Unsupported OS: $OS_TYPE" 14 | exit 1 15 | fi 16 | 17 | LOCAL_BIN="$HOME/.local/bin" 18 | 19 | YELLOW="\033[33m" 20 | RED="\033[31m" 21 | MAGENTA="\033[35m" 22 | CYAN="\033[36m" 23 | RESET="\033[0m" 24 | 25 | # Check if $LOCAL_BIN exists and create it if it does not 26 | [ ! -d "$LOCAL_BIN" ] && mkdir -p "$LOCAL_BIN" 27 | 28 | OPTIMIZE="--optimize" 29 | AUTO_YES=false 30 | 31 | # Parse command line arguments 32 | for arg in "$@"; do 33 | if [ "$arg" = "-f" ] || [ "$arg" = "--fast" ] || [ "$arg" = "--dev" ] || [ "$arg" = "--no-optimize" ]; then 34 | OPTIMIZE="" 35 | elif [ "$arg" = "-y" ] || [ "$arg" = "--yes" ]; then 36 | AUTO_YES=true 37 | fi 38 | done 39 | 40 | # Also disable optimization on Linux 41 | [ "$OS_TYPE" = "Linux" ] && OPTIMIZE="" 42 | 43 | SRC_DIR="$SCRIPT_DIR/src" 44 | 45 | # Notify user that roc-start build process is starting 46 | printf "Building ${MAGENTA}roc-start${RESET}..." 47 | printf "${OPTIMIZE:+ (please be patient, this may take a minute or two)}\n" 48 | [ -z "$OPTIMIZE" ] && printf "${YELLOW}WARNING:${RESET} using dev build is not recommended for general use\n" 49 | 50 | /usr/bin/env roc build $SRC_DIR/main.roc --output roc-start $LINKER $OPTIMIZE > /dev/null 2>&1 51 | # If build succeeded, copy the executable to $LOCAL_BIN and notify user 52 | if [ -f "./roc-start" ]; then 53 | chmod +x ./roc-start 54 | mv ./roc-start $LOCAL_BIN 55 | 56 | # Check for the existence of $HOME/.cache/roc-start/scripts/ and rename it to plugins 57 | SCRIPTS_DIR="$HOME/.cache/roc-start/scripts" 58 | PLUGINS_DIR="$HOME/.cache/roc-start/plugins" 59 | if [ -d "$SCRIPTS_DIR" ]; then 60 | mv "$SCRIPTS_DIR" "$PLUGINS_DIR" 61 | fi 62 | 63 | printf "Installed ${MAGENTA}roc-start${RESET} to $LOCAL_BIN\n" 64 | 65 | # Handle shell completions based on AUTO_YES flag 66 | if [ "$AUTO_YES" = true ]; then 67 | . "$SCRIPT_DIR/install.d/setup_completion.sh" 68 | printf "Shell auto completions installed automatically\n" 69 | else 70 | # Prompt the user to install shell completions 71 | printf "Do you want to install shell auto completions? (Y/n): " 72 | read install_completions 73 | case "$install_completions" in 74 | [Yy]|"") . "$SCRIPT_DIR/install.d/setup_completion.sh" ;; 75 | esac 76 | fi 77 | else 78 | printf "${RED}ERROR: ${MAGENTA}roc-start${RESET} build failed.\n" >&2 79 | exit 1 80 | fi 81 | 82 | # Check if the GitHub CLI (gh) is installed 83 | if ! command -v gh > /dev/null 2>&1; then 84 | printf "${YELLOW}- NOTE: ${MAGENTA}roc-start${RESET} requires ${CYAN}gh${RESET} to be installed. Please install the GitHub CLI: https://cli.github.com\n" 85 | exit 1 86 | fi 87 | 88 | # Check if $LOCAL_BIN or ~/.local/bin is in the PATH 89 | case ":$PATH:" in 90 | *":$LOCAL_BIN:"*|*":$HOME/.local/bin:"*) ;; 91 | *) printf "${YELLOW}- NOTE:${RESET} $LOCAL_BIN is not in your PATH. Please make sure to add it to your shell's configuration file (e.g. ~/.zshrc, ~/.bashrc, etc.)\n" ;; 92 | esac 93 | -------------------------------------------------------------------------------- /sloc.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # filepath: count_roc_lines.py 3 | 4 | import os 5 | import sys 6 | from pathlib import Path 7 | 8 | def count_lines_in_file(file_path): 9 | """Count the number of lines in a file.""" 10 | try: 11 | with open(file_path, 'r', encoding='utf-8') as file: 12 | return len(file.readlines()) 13 | except Exception as e: 14 | print(f"Error reading {file_path}: {e}", file=sys.stderr) 15 | return 0 16 | 17 | def main(): 18 | current_dir = Path('.') 19 | roc_files = list(current_dir.glob('**/*.roc')) 20 | 21 | if not roc_files: 22 | print("No .roc files found in the current directory or subdirectories.") 23 | return 24 | 25 | total_lines = 0 26 | results = [] 27 | 28 | for file_path in roc_files: 29 | line_count = count_lines_in_file(file_path) 30 | total_lines += line_count 31 | results.append((str(file_path), line_count)) 32 | 33 | # Sort by line count (descending) 34 | results.sort(key=lambda x: x[1], reverse=True) 35 | 36 | # Print results in a table format 37 | print("\nLines of code in .roc files:") 38 | print("-" * 60) 39 | print(f"{'File':<45} | {'Lines':<10}") 40 | print("-" * 60) 41 | 42 | for file_path, line_count in results: 43 | print(f"{file_path:<45} | {line_count:<10}") 44 | 45 | print("-" * 60) 46 | print(f"{'Total':<45} | {total_lines:<10}") 47 | print(f"\nFound {len(roc_files)} .roc files with a total of {total_lines} lines") 48 | 49 | if __name__ == "__main__": 50 | main() -------------------------------------------------------------------------------- /src/ArgParser.roc: -------------------------------------------------------------------------------- 1 | module [parse_or_display_message, base_usage, extended_usage] 2 | 3 | import weaver.Cli 4 | import weaver.Help 5 | import weaver.Opt 6 | import weaver.Param 7 | import weaver.SubCmd 8 | import rtils.StrUtils 9 | 10 | ## Usage messages 11 | # ----------------------------------------------------------------------------- 12 | 13 | parse_or_display_message = |args, to_os| Cli.parse_or_display_message(cli_parser, args, to_os) 14 | 15 | base_usage = Help.usage_help(cli_parser.config, ["roc-start"], cli_parser.text_style) 16 | 17 | extended_usage = 18 | ansi_code = 19 | when cli_parser.text_style is 20 | Color -> "\u(001b)[1m\u(001b)[4m" 21 | Plain -> "" 22 | usage_help_str = Help.usage_help(cli_parser.config, ["roc-start"], cli_parser.text_style) 23 | extended_usage_str = 24 | when 25 | Help.help_text(cli_parser.config, ["roc-start"], cli_parser.text_style) 26 | |> Str.split_first("${ansi_code}Commands:") 27 | is 28 | Ok({ after }) -> "${ansi_code}Commands:${after}" 29 | Err(NotFound) -> "" 30 | Str.join_with([usage_help_str, extended_usage_str], "\n\n") 31 | 32 | # Base CLI parser 33 | # ----------------------------------------------------------------------------- 34 | 35 | cli_parser = 36 | { Cli.weave <- 37 | verbosity: Opt.maybe_str({ short: "v", long: "verbosity", help: "Set the verbosity level to one of: verbose, quiet, or silent." }) |> Cli.map(verbosity_to_log_level), 38 | subcommand: SubCmd.optional([update_subcommand, app_subcommand, package_subcommand, upgrade_subcommand, config_subcommand]), 39 | } 40 | |> Cli.finish( 41 | { 42 | name: "roc-start", 43 | version: "v0.7.3", 44 | authors: ["Ian McLerran "], 45 | description: "A simple CLI tool for starting or upgrading roc projects. Specify your platform and packages by name, and roc-start will create a new .roc file or update an existing one with the either the versions you specify, or the latest releases. If no arguments are specified, the TUI app will be launched instead.", 46 | text_style: Color, 47 | }, 48 | ) 49 | |> Cli.assert_valid 50 | 51 | verbosity_to_log_level = |verbosity| 52 | when verbosity is 53 | Ok("verbose") -> Ok(Verbose) 54 | Ok("quiet") -> Ok(Quiet) 55 | Ok("silent") -> Ok(Silent) 56 | _ -> Err(NoLogLevel) 57 | 58 | # App, package and upgrade subcommands 59 | # ----------------------------------------------------------------------------- 60 | 61 | app_subcommand = 62 | { Cli.weave <- 63 | force: Opt.flag({ short: "f", long: "force", help: "Force overwrite of existing file." }), 64 | no_plugin: Opt.flag({ long: "no-plugin", help: "Force roc-start to use fallback generation insteaad of platform specific plugin." }), 65 | filename: Opt.maybe_str({ short: "o", long: "out", help: "The name of the output file (Defaults to `main.roc`). Extension is not required." }) 66 | |> Cli.map(default_filename) 67 | |> Cli.map(with_extension), 68 | platform: Opt.maybe_str({ short: "p", long: "platform", help: "The platform to use (Defaults to `basic-cli=latest` unless otherwise configured). Set the version with `--platform :`." }) 69 | |> Cli.map(platform_name_and_version_with_default), 70 | packages: Param.str_list({ name: "packages", help: "Any packages to use. Set the version of the package with `:`. If version is not set packages will default to the latest version." }) 71 | |> Cli.map(package_names_and_versions), 72 | } 73 | |> SubCmd.finish( 74 | { 75 | name: "app", 76 | description: "Create a new roc app with the specified name, platform, and packages.", 77 | mapper: App, 78 | }, 79 | ) 80 | default_filename = |filename_res| Result.with_default(filename_res, "main.roc") 81 | with_extension = |filename| if Str.ends_with(filename, ".roc") then filename else "${filename}.roc" 82 | 83 | platform_name_and_version_with_default = |platform_res| 84 | when platform_res is 85 | Ok(platform) -> 86 | { before: name, after: version } = 87 | StrUtils.split_first_if(platform, |c| List.contains([':', '='], c)) 88 | |> Result.with_default({ before: platform, after: "latest" }) 89 | { name, version } 90 | 91 | Err(_) -> { name: "", version: "" } 92 | 93 | maybe_platform_name_and_version = |platform_res| 94 | when platform_res is 95 | Ok(platform) -> 96 | { before: name, after: version } = 97 | StrUtils.split_first_if(platform, |c| List.contains([':', '='], c)) 98 | |> Result.with_default({ before: platform, after: "latest" }) 99 | Ok({ name, version }) 100 | 101 | Err(_) -> Err(NoPLatformSpecified) 102 | 103 | package_names_and_versions = |packages| 104 | List.map( 105 | packages, 106 | |package| 107 | { before: name, after: version } = 108 | StrUtils.split_first_if(package, |c| List.contains([':', '='], c)) 109 | |> Result.with_default({ before: package, after: "latest" }) 110 | { name, version }, 111 | ) 112 | 113 | upgrade_subcommand = 114 | { Cli.weave <- 115 | filename: Opt.maybe_str({ short: "i", long: "in", help: "The name of the input file who's platforms and/or packages should be upgraded." }) 116 | |> Cli.map(default_filename) 117 | |> Cli.map(with_extension), 118 | platform: Opt.maybe_str({ short: "p", long: "platform", help: "Specify the platform and version to upgrade to. If ommitted, the platform will not be upgraded. If the specified platform is different than the platform in the upgraded file, the platform will be replaced with the specified one." }) 119 | |> Cli.map(maybe_platform_name_and_version), 120 | packages: Param.str_list({ name: "packages", help: "List of packages upgrade. If ommitted, all will be upgraded. Version may be specified, or left out to upgrade to the latest version." }) 121 | |> Cli.map(package_names_and_versions), 122 | } 123 | |> SubCmd.finish( 124 | { 125 | name: "upgrade", 126 | description: "Upgrade the platform and/or packages in an app or package", 127 | mapper: Upgrade, 128 | }, 129 | ) 130 | 131 | package_subcommand = 132 | { Cli.weave <- 133 | force: Opt.flag({ short: "f", long: "force", help: "Force overwrite of existing file." }), 134 | packages: Param.str_list({ name: "packages", help: "Any packages to use. Set the version of the package with `:`. If version is not set packages will default to the latest version." }) 135 | |> Cli.map(package_names_and_versions), 136 | } 137 | |> SubCmd.finish( 138 | { 139 | name: "package", 140 | description: "Create a new roc package main file with all specified packages dependencies.", 141 | mapper: Package, 142 | }, 143 | ) 144 | 145 | # Roc-start management subcommands 146 | # ----------------------------------------------------------------------------- 147 | 148 | update_subcommand = 149 | { Cli.weave <- 150 | do_packages: Opt.flag({ short: "k", long: "packages", help: "Update the package repositories." }), 151 | do_platforms: Opt.flag({ short: "f", long: "platforms", help: "Update the platform repositories." }), 152 | do_plugins: Opt.flag({ short: "s", long: "plugins", help: "Update the platform plugins." }), 153 | do_themes: Opt.flag({ short: "t", long: "themes", help: "Update the available color themes." }), 154 | do_install: Opt.flag({ short: "i", long: "install", help: "Install the latest version of roc-start." }), 155 | } 156 | |> SubCmd.finish( 157 | { 158 | name: "update", 159 | description: "Update the platform and package repositories and plugins. Update all (excluding installation), or specify which to update.", 160 | mapper: Update, 161 | }, 162 | ) 163 | 164 | config_subcommand = 165 | { Cli.weave <- 166 | theme: Opt.maybe_str({ long: "set-theme", help: "Set the default color theme to use in the CLI." }), 167 | verbosity: Opt.maybe_str({ long: "set-verbosity", help: "Set the default verbosity level to use in the CLI." }), 168 | platform: Opt.maybe_str({ long: "set-default-platform", help: "Set the default platform to use when initializing a new app." }), 169 | } 170 | |> SubCmd.finish( 171 | { 172 | name: "config", 173 | description: "Configure the default settings for the roc-start CLI tool.", 174 | mapper: Config, 175 | }, 176 | ) 177 | -------------------------------------------------------------------------------- /src/Dotfile.roc: -------------------------------------------------------------------------------- 1 | module 2 | { 3 | env_var!, 4 | is_file!, 5 | read_utf8!, 6 | write_utf8!, 7 | load_themes!, 8 | } -> [Config, get_config!, load_dotfile!, load_custom_themes!, create_default_dotfile!, default_config, save_to_dotfile!, save_config!] 9 | 10 | import rtils.StrUtils 11 | import rtils.ListUtils 12 | import parse.Parse as P 13 | import json.Json 14 | import themes.Theme exposing [Theme] 15 | LogLevel : [Silent, Quiet, Verbose] 16 | 17 | Config : { verbosity : LogLevel, theme : Theme, platform : { name : Str, version : Str } } 18 | 19 | get_config! : {} => Config 20 | get_config! = |{}| 21 | when load_dotfile!({}) is 22 | Ok(config) -> config 23 | Err(NoDotFileFound) -> 24 | new = create_default_dotfile!({}) 25 | when new is 26 | Ok(config) -> config 27 | Err(_) -> default_config 28 | 29 | Err(_) -> default_config 30 | 31 | config_to_str : Config -> Str 32 | config_to_str = |config| 33 | verbosity = 34 | when config.verbosity is 35 | Verbose -> "verbose" 36 | Quiet -> "quiet" 37 | Silent -> "silent" 38 | platform_str = 39 | if config.platform.version == "" then 40 | config.platform.name 41 | else 42 | "${config.platform.name}=${config.platform.version}" 43 | """ 44 | verbosity: ${verbosity} 45 | theme: ${config.theme.name} 46 | platform: ${platform_str}\n 47 | """ 48 | 49 | load_dotfile! : {} => Result Config [HomeVarNotSet, NoDotFileFound, InvalidDotFile, FileReadError] 50 | load_dotfile! = |{}| 51 | home = 52 | env_var!("HOME") 53 | ? |_| HomeVarNotSet 54 | |> Str.drop_suffix("/") 55 | file_path = "${home}/.rocstartconfig" 56 | if file_exists!(file_path) then 57 | file_contents = read_utf8!(file_path) ? |_| FileReadError 58 | theme_list = load_themes!({}) 59 | parse_dotfile(file_contents, theme_list) |> Result.map_err(|_| InvalidDotFile) 60 | else 61 | Err(NoDotFileFound) 62 | 63 | file_exists! = |path| is_file!(path) |> Result.with_default(Bool.false) 64 | 65 | parse_dotfile : Str, List Theme -> Result Config [InvalidDotFile] 66 | parse_dotfile = |str, theme_list| 67 | lines = Str.to_utf8(str) |> ListUtils.split_with_delims_tail(|c| c == '\n') |> List.map(Str.from_utf8_lossy) 68 | verbosity = 69 | when 70 | lines |> List.keep_oks(parse_verbosity) 71 | is 72 | [level, ..] -> level 73 | _ -> default_config.verbosity 74 | theme = 75 | when 76 | lines |> List.keep_oks(parse_theme(theme_list)) 77 | is 78 | [colors, ..] -> colors 79 | _ -> default_config.theme 80 | platform = 81 | when 82 | lines |> List.keep_oks(parse_platform) 83 | is 84 | [pf, ..] -> pf 85 | _ -> default_config.platform 86 | Ok({ verbosity, theme, platform }) 87 | 88 | parse_theme = |theme_list| 89 | |str| 90 | theme_names = theme_list |> List.map(|t| t.name) 91 | themes = theme_names |> List.map(|s| P.string(s) |> P.lhs(newline)) 92 | pattern = P.string("theme:") |> P.rhs(P.maybe(P.whitespace)) |> P.rhs(P.one_of(themes)) 93 | parser = pattern |> P.map(|name| Theme.from_name(theme_list, name)) 94 | parser(str) |> P.finalize |> Result.map_err(|_| InvalidTheme) 95 | 96 | parse_verbosity = |str| 97 | verbosity_levels = ["silent", "quiet", "verbose"] |> List.map(|s| P.string(s)) 98 | pattern = P.string("verbosity:") |> P.rhs(P.maybe(P.whitespace)) |> P.rhs(P.one_of(verbosity_levels)) |> P.lhs(newline) 99 | parser = 100 | pattern 101 | |> P.map( 102 | |s| 103 | when s is 104 | "silent" -> Ok(Silent) 105 | "quiet" -> Ok(Quiet) 106 | "verbose" -> Ok(Verbose) 107 | _ -> Err(InvalidLogLevel), 108 | ) 109 | parser(str) |> P.finalize |> Result.map_err(|_| InvalidLogLevel) 110 | 111 | parse_platform = |str| 112 | pattern = P.string("platform:") |> P.rhs(P.maybe(P.whitespace)) |> P.rhs(platform_string) |> P.lhs(newline) 113 | parser = 114 | pattern 115 | |> P.map( 116 | |s| 117 | when s |> StrUtils.split_first_if(|c| List.contains([':', '='], c)) is 118 | Ok({ before: name, after: version }) -> Ok({ name, version }) 119 | _ -> Ok({ name: s, version: "latest" }), 120 | ) 121 | parser(str) |> P.finalize |> Result.map_err(|_| InvalidPlatform) 122 | 123 | platform_string = P.one_or_more(platform_chars) |> P.map(|chars| Str.from_utf8_lossy(chars) |> Ok) 124 | 125 | platform_chars = 126 | P.char 127 | |> P.filter( 128 | |c| 129 | (c >= 'a' and c <= 'z') 130 | or 131 | (c >= 'A' and c <= 'Z') 132 | or 133 | (c >= '0' and c <= '9') 134 | or 135 | (List.contains(['-', '_', '/', '=', ':', '-', '+', '.'], c)), 136 | ) 137 | 138 | newline = P.char |> P.filter(|c| c == '\n') 139 | 140 | create_default_dotfile! : {} => Result Config [HomeVarNotSet, FileWriteError] 141 | create_default_dotfile! = |{}| 142 | save_config!(default_config)? 143 | Ok(default_config) 144 | 145 | save_config! : Config => Result {} [HomeVarNotSet, FileWriteError] 146 | save_config! = |config| 147 | home = 148 | env_var!("HOME") 149 | ? |_| HomeVarNotSet 150 | |> Str.drop_suffix("/") 151 | file_path = "${home}/.rocstartconfig" 152 | contents = config_to_str(config) 153 | write_utf8!(contents, file_path) |> Result.map_err(|_| FileWriteError) 154 | 155 | default_config = { verbosity: Verbose, theme: Theme.default, platform: { name: "basic-cli", version: "latest" } } 156 | 157 | save_to_dotfile! : { key : Str, value : Str } => Result {} [HomeVarNotSet, FileWriteError, FileReadError] 158 | save_to_dotfile! = |{ key, value }| 159 | home = 160 | env_var!("HOME") 161 | ? |_| HomeVarNotSet 162 | |> Str.drop_suffix("/") 163 | file_path = "${home}/.rocstartconfig" 164 | if file_exists!(file_path) then 165 | file_contents = read_utf8!(file_path) ? |_| FileReadError 166 | Str.split_on(file_contents, "\n") 167 | |> List.map( 168 | |line| 169 | if Str.starts_with(line, key) then 170 | "${key}: ${value}" 171 | else 172 | line, 173 | ) 174 | |> |lines| 175 | if List.contains(lines, "${key}: ${value}") then 176 | lines 177 | else 178 | List.append(lines, "${key}: ${value}\n") 179 | |> Str.join_with("\n") 180 | |> write_utf8!(file_path) 181 | |> Result.map_err(|_| FileWriteError) 182 | else 183 | "${key}: ${value}\n" 184 | |> write_utf8!(file_path) 185 | |> Result.map_err(|_| FileWriteError) 186 | 187 | load_custom_themes! : {} => Result (List Theme) [HomeVarNotSet, NoDotFileFound, InvalidThemeFile, FileReadError] 188 | load_custom_themes! = |{}| 189 | home_res = env_var!("HOME") |> Result.map_err(|_| HomeVarNotSet) 190 | home = 191 | if home_res == Err(HomeVarNotSet) then 192 | return Ok([]) 193 | else 194 | home_res |> Result.with_default("") |> Str.drop_suffix("/") 195 | 196 | file_path = "${home}/.rocstartthemes" 197 | if file_exists!(file_path) then 198 | file_contents = 199 | read_utf8!(file_path) 200 | |> Result.with_default("") 201 | |> Str.to_utf8 202 | themes = Decode.from_bytes_partial(file_contents, Json.utf8) |> .result |> Result.with_default([]) |> List.map(json_to_theme) 203 | Ok(themes) 204 | else 205 | Ok([]) 206 | 207 | JsonTheme : { 208 | name : Str, 209 | primary : Str, 210 | secondary : Str, 211 | tertiary : Str, 212 | okay : Str, 213 | warn : Str, 214 | error : Str, 215 | } 216 | 217 | json_to_theme : JsonTheme -> Theme 218 | json_to_theme = |json_theme| { 219 | name: json_theme.name, 220 | primary: parse_hex_color(json_theme.primary) |> Result.with_default(Default), 221 | secondary: parse_hex_color(json_theme.secondary) |> Result.with_default(Default), 222 | tertiary: parse_hex_color(json_theme.tertiary) |> Result.with_default(Default), 223 | okay: parse_hex_color(json_theme.okay) |> Result.with_default(Default), 224 | warn: parse_hex_color(json_theme.warn) |> Result.with_default(Default), 225 | error: parse_hex_color(json_theme.error) |> Result.with_default(Default), 226 | } 227 | 228 | parse_hex_color = |str| hex_color(str) |> P.finalize |> Result.map_err(|_| InvalidHexColor) 229 | 230 | hex_color = pound_sign |> P.rhs(P.zip_3(hex_pair, hex_pair, hex_pair)) |> P.map(|(r, g, b)| Rgb((r, g, b)) |> Ok) 231 | 232 | pound_sign = P.char |> P.filter(|c| c == '#') 233 | 234 | hex_char = P.char |> P.filter(is_hex_digit) 235 | is_hex_digit = |c| (c >= '0' and c <= '9') or (c >= 'a' and c <= 'f') or (c >= 'A' and c <= 'F') 236 | 237 | hex_pair = P.zip(hex_char, hex_char) |> P.map(|(a, b)| num_from_hex_char(a) * 16 + num_from_hex_char(b) |> Ok) 238 | 239 | num_from_hex_char = |c| 240 | if c >= '0' and c <= '9' then 241 | c - 48 |> Num.to_u8 242 | else if c >= 'a' and c <= 'f' then 243 | c - 87 |> Num.to_u8 244 | else if c >= 'A' and c <= 'F' then 245 | c - 55 |> Num.to_u8 246 | else 247 | 0 248 | 249 | expect num_from_hex_char('0') == 0 250 | expect num_from_hex_char('9') == 9 251 | expect num_from_hex_char('a') == 10 252 | expect num_from_hex_char('f') == 15 253 | expect num_from_hex_char('A') == 10 254 | expect num_from_hex_char('F') == 15 255 | -------------------------------------------------------------------------------- /src/ErrorHandlers.roc: -------------------------------------------------------------------------------- 1 | module [ 2 | handle_platform_repo_error, 3 | handle_upgrade_platform_repo_error, 4 | handle_package_repo_error, 5 | handle_get_repositories_error, 6 | handle_package_release_error, 7 | handle_platform_release_error, 8 | handle_upgrade_platform_release_error, 9 | handle_upgrade_file_read_error, 10 | handle_upgrade_split_file_error, 11 | handle_cache_plugins_error, 12 | handle_update_local_repos_error, 13 | ] 14 | 15 | import repos.Manager as RM 16 | 17 | handle_platform_repo_error = |name, { log_level, theme, log!, colorize }| 18 | |err| 19 | when err is 20 | RepoNotFound -> 21 | log_strs = ["Platform: ", name, " : repo not found - valid platform is required\n"] 22 | message = 23 | when log_level is 24 | Verbose -> log_strs |> colorize([theme.primary, theme.secondary, theme.error]) |> Verbose 25 | Quiet -> log_strs |> colorize([theme.error]) |> Quiet 26 | _ -> Verbose("") 27 | log!(message, log_level) 28 | Err(Handled) 29 | 30 | RepoNotFoundButMaybe(suggestion) -> 31 | log_strs = ["Platform: ", name, " : repo not found; did you mean ${suggestion}? - valid platform is required\n"] 32 | message = 33 | when log_level is 34 | Verbose -> log_strs |> colorize([theme.primary, theme.secondary, theme.error]) |> Verbose 35 | Quiet -> log_strs |> colorize([theme.error]) |> Quiet 36 | _ -> Verbose("") 37 | log!(message, log_level) 38 | Err(Handled) 39 | 40 | AmbiguousName -> 41 | log_strs = ["Platform: ", name, " : ambiguous; use /${name} - valid platform is required\n"] 42 | message = 43 | when log_level is 44 | Verbose -> log_strs |> colorize([theme.primary, theme.secondary, theme.error]) |> Verbose 45 | Quiet -> log_strs |> colorize([theme.error]) |> Quiet 46 | _ -> Verbose("") 47 | log!(message, log_level) 48 | Err(Handled) 49 | 50 | handle_upgrade_platform_repo_error = |name, { log_level, theme, log!, colorize }| 51 | |err| 52 | when err is 53 | RepoNotFound -> 54 | log_strs = ["Platform: ", name, " : repo not found - skipping\n"] 55 | log_strs |> colorize([theme.primary, theme.secondary, theme.warn]) |> Verbose |> log!(log_level) 56 | Err(Handled) 57 | 58 | RepoNotFoundButMaybe(suggestion) -> 59 | log_strs = ["Platform: ", name, " : repo not found; did you mean ${suggestion}? - skipping\n"] 60 | log_strs |> colorize([theme.primary, theme.secondary, theme.warn]) |> Verbose |> log!(log_level) 61 | Err(Handled) 62 | 63 | AmbiguousName -> 64 | log_strs = ["Platform: ", name, " : ambiguous; use /${name} - skipping\n"] 65 | log_strs |> colorize([theme.primary, theme.secondary, theme.warn]) |> Verbose |> log!(log_level) 66 | Err(Handled) 67 | 68 | handle_package_repo_error = |name, { log_level, theme, log!, colorize }| 69 | |err| 70 | when err is 71 | RepoNotFound -> 72 | _ = 73 | ["| ", name, " : package repo not found - skipping\n"] 74 | |> colorize([theme.warn, theme.secondary, theme.warn]) 75 | |> Verbose 76 | |> log!(log_level) 77 | Err(Handled) 78 | 79 | RepoNotFoundButMaybe(suggestion) -> 80 | _ = 81 | ["| ", name, " : package repo not found; did you mean ${suggestion}? - skipping\n"] 82 | |> colorize([theme.warn, theme.secondary, theme.warn]) 83 | |> Verbose 84 | |> log!(log_level) 85 | Err(Handled) 86 | 87 | AmbiguousName -> 88 | _ = 89 | ["| ", name, " : ambiguous; use /${name} - skipping\n"] 90 | |> colorize([theme.warn, theme.secondary, theme.warn]) 91 | |> Verbose 92 | |> log!(log_level) 93 | Err(Handled) 94 | 95 | handle_package_release_error = |packages, repo, name, version, { log_level, theme, log!, colorize }| 96 | |err| 97 | when err is 98 | RepoNotFound -> 99 | _ = 100 | ["| ", name, " : package not found - skipping\n"] 101 | |> colorize([theme.error, theme.secondary, theme.warn]) 102 | |> Verbose 103 | |> log!(log_level) 104 | Err(Handled) 105 | 106 | RepoNotFoundButMaybe(suggestion) -> 107 | _ = 108 | ["| ", name, " : package not found; did you mean ${suggestion}? - skipping\n"] 109 | |> colorize([theme.warn, theme.secondary, theme.warn]) 110 | |> Verbose 111 | |> log!(log_level) 112 | Err(Handled) 113 | 114 | VersionNotFound -> 115 | when RM.get_repo_release(packages, repo, "latest", Package) is 116 | Ok(suggestion) -> 117 | _ = 118 | ["| ", name, " : version not found; latest is ${suggestion.tag} - skipping\n"] 119 | |> colorize([theme.warn, theme.secondary, theme.warn]) 120 | |> Verbose 121 | |> log!(log_level) 122 | Err(Handled) 123 | 124 | Err(_) -> 125 | _ = 126 | ["| ", name, " : version ${version} not found - skipping\n"] 127 | |> colorize([theme.warn, theme.secondary, theme.warn]) 128 | |> Verbose 129 | |> log!(log_level) 130 | Err(Handled) 131 | 132 | handle_platform_release_error = |platforms, repo, name, version, { log_level, theme, log!, colorize }| 133 | |err| 134 | when err is 135 | RepoNotFound -> 136 | log_strs = ["Platform: ", name, " : repo not found - valid platform is required\n"] 137 | message = 138 | when log_level is 139 | Verbose -> log_strs |> colorize([theme.primary, theme.secondary, theme.error]) |> Verbose 140 | Quiet -> log_strs |> colorize([theme.error]) |> Quiet 141 | _ -> Verbose("") 142 | log!(message, log_level) 143 | Err(Handled) 144 | 145 | RepoNotFoundButMaybe(suggestion) -> 146 | log_strs = ["Platform: ", name, " : repo not found; did you mean ${suggestion}? - valid platform is required\n"] 147 | message = 148 | when log_level is 149 | Verbose -> log_strs |> colorize([theme.primary, theme.secondary, theme.error]) |> Verbose 150 | Quiet -> log_strs |> colorize([theme.error]) |> Quiet 151 | _ -> Verbose("") 152 | log!(message, log_level) 153 | Err(Handled) 154 | 155 | VersionNotFound -> 156 | when RM.get_repo_release(platforms, repo, "latest", Platform) is 157 | Ok(suggestion) -> 158 | log_strs = ["Platform: ", name, " : version not found; latest is ${suggestion.tag} - valid platform is required\n"] 159 | message = 160 | when log_level is 161 | Verbose -> log_strs |> colorize([theme.primary, theme.secondary, theme.error]) |> Verbose 162 | Quiet -> log_strs |> colorize([theme.error]) |> Quiet 163 | _ -> Verbose("") 164 | log!(message, log_level) 165 | Err(Handled) 166 | 167 | Err(_) -> 168 | log_strs = ["Platform: ", name, " : version ${version} not found - valid platform is required\n"] 169 | message = 170 | when log_level is 171 | Verbose -> log_strs |> colorize([theme.primary, theme.secondary, theme.error]) |> Verbose 172 | Quiet -> log_strs |> colorize([theme.error]) |> Quiet 173 | _ -> Verbose("") 174 | log!(message, log_level) 175 | Err(Handled) 176 | 177 | handle_upgrade_platform_release_error = |platforms, repo, name, version, { log_level, theme, log!, colorize }| 178 | |err| 179 | when err is 180 | RepoNotFound -> 181 | log_strs = ["Platform: ", name, " : repo not found - skipping\n"] 182 | log_strs |> colorize([theme.primary, theme.secondary, theme.warn]) |> Verbose |> log!(log_level) 183 | Err(Handled) 184 | 185 | RepoNotFoundButMaybe(suggestion) -> 186 | log_strs = ["Platform: ", name, " : repo not found; did you mean ${suggestion}? - skipping\n"] 187 | log_strs |> colorize([theme.primary, theme.secondary, theme.warn]) |> Verbose |> log!(log_level) 188 | Err(Handled) 189 | 190 | VersionNotFound -> 191 | when RM.get_repo_release(platforms, repo, "latest", Platform) is 192 | Ok(suggestion) -> 193 | log_strs = ["Platform: ", name, " : version not found; latest is ${suggestion.tag} - skipping\n"] 194 | log_strs |> colorize([theme.primary, theme.secondary, theme.warn]) |> Verbose |> log!(log_level) 195 | Err(Handled) 196 | 197 | Err(_) -> 198 | log_strs = ["Platform: ", name, " : version ${version} not found - skipping\n"] 199 | log_strs |> colorize([theme.primary, theme.secondary, theme.warn]) |> Verbose |> log!(log_level) 200 | Err(Handled) 201 | 202 | handle_upgrade_file_read_error = |filename, { log_level, theme, log!, colorize }| 203 | |err| 204 | when err is 205 | FileReadErr _path NotFound -> 206 | ["Target file not found: ", filename, "\n"] |> colorize([theme.error]) |> Quiet |> log!(log_level) 207 | Err(Handled) 208 | 209 | FileReadErr _path PermissionDenied -> 210 | ["Permission denied reading file: ", filename, "\n"] |> colorize([theme.error]) |> Quiet |> log!(log_level) 211 | Err(Handled) 212 | 213 | FileReadErr _path _ioerr -> 214 | ["Error reading file: ", filename, "\n"] |> colorize([theme.error]) |> Quiet |> log!(log_level) 215 | Err(Handled) 216 | 217 | FileReadUtf8Err _path BadUtf8(_) -> 218 | ["Error reading file: ", filename, " - invalid utf8\n"] |> colorize([theme.error]) |> Quiet |> log!(log_level) 219 | Err(Handled) 220 | 221 | handle_upgrade_split_file_error = |filename, { log_level, theme, colorize }| 222 | |_err| 223 | if log_level == Silent then 224 | Err(Exit(1, "")) 225 | else 226 | err_message = "Invalid roc file: ${filename}" 227 | Err(Exit(1, [err_message] |> colorize([theme.error]))) 228 | 229 | handle_get_repositories_error = |{ log_level, theme, colorize }| 230 | |err| 231 | if log_level == Silent then 232 | Err(Exit(1, "")) 233 | else 234 | err_message = 235 | when err is 236 | Exit(_, s) -> s 237 | BadRepoReleasesData -> "Error getting repositories: bad release data - running an update may fix this." 238 | FileReadError -> "Error getting repositories: file read error - running an update may fix this." 239 | FileWriteError -> "Error getting repositories: file write error while saving repo data." 240 | GhAuthError -> "Error getting repositories: gh cli tool not authenticated - run `gh auth login` to fix." 241 | GhNotInstalled -> "Error getting repositories: gh cli tool not installed - install it from https://cli.github.com." 242 | HomeVarNotSet -> "Error getting repositories: HOME environment variable not set." 243 | NetworkError -> "Error getting repositories: network error - check your connection." 244 | ParsingError -> "Error getting repositories: error parsing remote data - please file an issue at https://github.com/imclerran/roc-start." 245 | Err(Exit(1, [err_message] |> colorize([theme.error]))) 246 | 247 | handle_cache_plugins_error = |{ log_level, theme, colorize }| 248 | |err| 249 | if log_level == Silent then 250 | Err(Exit(1, "")) 251 | else 252 | err_message = 253 | when err is 254 | FileWriteError -> "Error caching plugins: file write error." 255 | NetworkError -> "Error caching plugins: network error - check your connection." 256 | Err(Exit(1, [err_message] |> colorize([theme.error]))) 257 | 258 | handle_update_local_repos_error = |{ log_level, theme, colorize }| 259 | |err| 260 | if log_level == Silent then 261 | Err(Exit(1, "")) 262 | else 263 | err_message = 264 | when err is 265 | FileWriteError -> "Error updating local repositories: file write error." 266 | GhAuthError -> "Error updating local repositories: gh cli tool not authenticated - run `gh auth login` to fix." 267 | GhNotInstalled -> "Error updating local repositories: gh cli tool not installed - install it from https://cli.github.com." 268 | GhError(e) -> "Error updating local repositories: when calling `gh` cmd: ${Inspect.to_str(e)}" 269 | ParsingError -> "Error updating local repositories: error parsing remote data - please file an issue at https://github.com/imclerran/roc-start" 270 | 271 | Err(Exit(1, [err_message] |> colorize([theme.error]))) 272 | -------------------------------------------------------------------------------- /src/Installer.roc: -------------------------------------------------------------------------------- 1 | module 2 | { 3 | cmd_new, 4 | cmd_args, 5 | cmd_output!, 6 | delete_dirs!, 7 | } -> [install!] 8 | 9 | install! = |cache_dir| 10 | roc_version_cmd({}) 11 | |> cmd_output! 12 | |> .status 13 | |> Result.map_err(|_| Exit(1, "Roc binary could not be found.")) 14 | |> Result.map_ok(|_| {})? 15 | delete_repo!(cache_dir) 16 | |> Result.on_err(|DirErr(e)| if e == NotFound then Ok({}) else Err(DirErr(e))) 17 | |> Result.map_err(|e| Exit(1, "Could not delete previous repository at ${cache_dir}/repo: ${Inspect.to_str(e)}"))? 18 | clone_cmd(cache_dir) 19 | |> cmd_output! 20 | |> .status 21 | |> Result.map_err(|e| Exit(1, "Could not clone roc-start repository: ${Inspect.to_str(e)}")) 22 | |> Result.map_ok(|_| {})? 23 | chmod_cmd(cache_dir) 24 | |> cmd_output! 25 | |> .status 26 | |> Result.map_err(|e| Exit(1, "Could not make install script executable: ${Inspect.to_str(e)}")) 27 | |> Result.map_ok(|_| {})? 28 | output = 29 | install_cmd(cache_dir) 30 | |> cmd_output! 31 | output.status 32 | |> Result.on_err!( 33 | |err| 34 | _ = 35 | delete_repo!(cache_dir) 36 | |> Result.map_ok(|_| 0) 37 | Err(err), 38 | ) 39 | |> Result.map_err( 40 | |e| 41 | prefix = "ERROR: " 42 | sterr = output.stderr |> strip_ansi_control |> Str.from_utf8_lossy |> Str.trim_end |> Str.drop_prefix(prefix) 43 | when e is 44 | Other(_) -> Exit(1, "Error installing update: ${sterr}") 45 | _ -> Exit(1, "Error running install script: ${Inspect.to_str(e)}"), 46 | ) 47 | |> Result.map_ok(|_| {})? 48 | delete_repo!(cache_dir) 49 | |> Result.map_err(|e| Exit(1, "Could not delete repository at ${cache_dir}/repo: ${Inspect.to_str(e)}")) 50 | 51 | roc_version_cmd = |{}| 52 | cmd_new("/usr/bin/env") 53 | |> cmd_args(["roc", "version"]) 54 | 55 | clone_cmd = |cache_dir| 56 | cmd_new("gh") 57 | |> cmd_args(["repo", "clone", "imclerran/roc-start", "${cache_dir}/repo"]) 58 | 59 | chmod_cmd = |cache_dir| 60 | cmd_new("chmod") 61 | |> cmd_args(["+x", "${cache_dir}/repo/install.sh"]) 62 | 63 | install_cmd = |cache_dir| 64 | cmd_new("${cache_dir}/repo/install.sh") 65 | |> cmd_args(["-y"]) 66 | 67 | delete_repo! = |cache_dir| 68 | delete_dirs!("${cache_dir}/repo") 69 | 70 | ## Strip ANSI control sequences from a list of bytes. 71 | strip_ansi_control : List U8 -> List U8 72 | strip_ansi_control = |bytes| 73 | when List.find_first_index(bytes, |b| b == 27) is 74 | Ok(escape_index) -> 75 | { before: lhs, others: remainder } = List.split_at(bytes, escape_index) 76 | when List.find_first_index(remainder, |b| (b >= 'A' and b <= 'Z') or (b >= 'a' and b <= 'z')) is 77 | Ok(control_index) -> 78 | { before: _, others: rhs } = List.split_at(remainder, (control_index + 1)) 79 | List.concat(lhs, strip_ansi_control(rhs)) 80 | 81 | Err(_) -> bytes 82 | 83 | Err(_) -> bytes 84 | -------------------------------------------------------------------------------- /src/Logger.roc: -------------------------------------------------------------------------------- 1 | module { write! } -> [LogLevel, LogStr, log!, colorize] 2 | 3 | import ansi.Color 4 | import ansi.ANSI 5 | 6 | LogLevel : [Silent, Quiet, Verbose] 7 | LogStr : [Quiet Str, Verbose Str] 8 | Color : Color.Color 9 | 10 | log! : LogStr, LogLevel => {} 11 | log! = |log_str, level| 12 | _ = 13 | when (log_str, level) is 14 | (Verbose(str), Verbose) -> write!(str) 15 | (Quiet(str), Verbose) -> write!(str) 16 | (Quiet(str), Quiet) -> write!(str) 17 | _ -> Ok({}) 18 | {} 19 | 20 | colorize : List Str, List Color -> Str 21 | colorize = |parts, colors| 22 | if List.len(parts) <= List.len(colors) then 23 | List.map2(parts, colors, |part, color| ANSI.color(part, { fg: color })) |> Str.join_with("") 24 | else 25 | rest = 26 | List.split_at(parts, List.len(colors)) 27 | |> .others 28 | |> Str.join_with("") 29 | |> ANSI.color({ fg: List.last(colors) |> Result.with_default(Default) }) 30 | List.map2(parts, colors, |part, color| ANSI.color(part, { fg: color })) 31 | |> Str.join_with("") 32 | |> Str.concat(rest) 33 | -------------------------------------------------------------------------------- /src/PluginManager.roc: -------------------------------------------------------------------------------- 1 | module 2 | { 3 | http_send!, 4 | file_write_utf8!, 5 | create_all_dirs!, 6 | list_dir!, 7 | path_to_str, 8 | } -> [ 9 | cache_plugins!, 10 | choose_plugin, 11 | get_available_plugins!, 12 | ] 13 | 14 | import semver.Types exposing [Semver] 15 | import semver.Semver 16 | 17 | PlatformDict : Dict Str (List PlatformRelease) 18 | PlatformRelease : { repo : Str, alias : Str, tag : Str, url : Str, semver : Semver } 19 | 20 | # Downloading and caching 21 | # ----------------------------------------------------------------------------- 22 | 23 | plugin_url : Str, Str -> Str 24 | plugin_url = |repo, tag| 25 | "https://raw.githubusercontent.com/imclerran/roc-repo/refs/heads/main/plugins/${repo}/${tag}.sh" 26 | 27 | cache_plugins! : PlatformDict, Str, (Str => {}) => Result {} [FileWriteError, NetworkError] 28 | cache_plugins! = |platforms, cache_dir, logger!| 29 | create_all_dirs!(cache_dir) ? |_| FileWriteError 30 | release_list = 31 | Dict.to_list(platforms) 32 | |> List.map(|(_, rs)| rs) 33 | |> List.join 34 | num_releases = List.len(release_list) 35 | logger!("[") 36 | logger!(Str.repeat("=", Num.sub_saturated(5, num_releases))) 37 | res = List.walk_try!( 38 | release_list, 39 | (0, 0), 40 | |(n, last_fifth), release| 41 | next_n = n + 1 42 | current_fifth = Num.div_trunc(next_n * 5, num_releases) 43 | next_fifth = if current_fifth > last_fifth then current_fifth else last_fifth 44 | url = plugin_url(release.repo, release.tag) 45 | dir_no_slash = cache_dir |> Str.drop_suffix("/") 46 | dir_path = "${dir_no_slash}/${release.repo}" 47 | filename = "${release.tag}.sh" 48 | download_plugin!(url, dir_path, filename) 49 | |> Result.on_err( 50 | |e| 51 | when e is 52 | PluginNotFound -> Ok({}) 53 | FileWriteError -> Err(FileWriteError) 54 | NetworkError -> Err(NetworkError), 55 | )? 56 | if current_fifth > last_fifth then logger!("=") else {} 57 | Ok((next_n, next_fifth)), 58 | ) 59 | logger!("] ") 60 | res 61 | |> Result.map_ok(|_| {}) 62 | 63 | download_plugin! : Str, Str, Str => Result {} [FileWriteError, NetworkError, PluginNotFound] 64 | download_plugin! = |url, dir_path, filename| 65 | req = { 66 | method: GET, 67 | headers: [], 68 | uri: url, 69 | body: [], 70 | timeout_ms: NoTimeout, 71 | } 72 | resp = http_send!(req) ? |_| NetworkError 73 | if resp.status == 404 then 74 | Err(PluginNotFound) 75 | else if resp.status != 200 then 76 | Err(NetworkError) 77 | else 78 | text = resp.body |> Str.from_utf8_lossy 79 | create_all_dirs!("${dir_path}") ? |_| FileWriteError 80 | file_path = dir_path |> Str.drop_suffix("/") |> |path| "${path}/${filename}" 81 | file_write_utf8!(text, file_path) |> Result.map_err(|_| FileWriteError) 82 | 83 | # Plugin selection 84 | # ----------------------------------------------------------------------------- 85 | 86 | choose_plugin : Str, List Str -> Result Str [NoMatch] 87 | choose_plugin = |tag, plugins| 88 | tag_sv = semver_with_default(tag) 89 | plugin_svs = 90 | plugins 91 | |> List.map(|plugin| (plugin, semver_with_default(Str.drop_suffix(plugin, ".sh")))) 92 | |> List.sort_with(|(_, sv1), (_, sv2)| Semver.compare(sv2, sv1)) 93 | List.find_first( 94 | plugin_svs, 95 | |(_tag, sv)| 96 | when Semver.compare(tag_sv, sv) is 97 | GT | EQ -> Bool.true 98 | _ -> Bool.false, 99 | ) 100 | |> Result.map_err(|_| NoMatch) 101 | |> Result.map_ok(.0) 102 | 103 | semver_with_default = |s| Semver.parse(Str.drop_prefix(s, "v")) |> Result.with_default({ major: 0, minor: 0, patch: 0, pre_release: [s], build: [] }) 104 | 105 | get_available_plugins! : Str, Str => List Str 106 | get_available_plugins! = |cache_dir, repo| 107 | dir_path = cache_dir |> Str.drop_suffix("/") 108 | list_dir!("${dir_path}/${repo}") 109 | |> Result.with_default([]) 110 | |> List.map( 111 | |path| 112 | f = path_to_str(path) 113 | Str.split_last(f, "/") 114 | |> Result.map_ok(|{ after }| after) 115 | |> Result.with_default(f), 116 | ) 117 | |> List.keep_if(|f| Str.ends_with(f, ".sh")) 118 | -------------------------------------------------------------------------------- /src/RocParser.roc: -------------------------------------------------------------------------------- 1 | module [parse_platform_line, parse_package_line, parse_repo_owner_name] 2 | 3 | import parse.Parse as P 4 | 5 | parse_platform_line = |str| 6 | pattern = P.maybe(P.whitespace) |> P.rhs(alias) |> P.lhs(colon) |> P.lhs(P.whitespace) |> P.lhs(P.string("platform")) |> P.lhs(P.whitespace) |> P.lhs(double_quote) |> P.both(path) |> P.lhs(double_quote) |> P.lhs(P.maybe(comma)) |> P.lhs(P.maybe(P.whitespace)) 7 | parser = pattern |> P.map(|(a, p)| Ok({ alias: a, path: p })) 8 | parser(str) |> P.finalize |> Result.map_err(|_| InvalidPlatformLine) 9 | 10 | expect 11 | res = parse_platform_line(" cli: platform \"../basic-cli/platform/main.roc\",") 12 | res == Ok({ alias: "cli", path: "../basic-cli/platform/main.roc" }) 13 | 14 | expect 15 | res = parse_platform_line(" cli: platform \"https://github.com/roc-lang/basic-cli/releases/download/0.19.0/Hj-J_zxz7V9YurCSTFcFdu6cQJie4guzsPMUi5kBYUk.tar.br\",") 16 | res == Ok({ alias: "cli", path: "https://github.com/roc-lang/basic-cli/releases/download/0.19.0/Hj-J_zxz7V9YurCSTFcFdu6cQJie4guzsPMUi5kBYUk.tar.br" }) 17 | 18 | parse_package_line = |str| 19 | pattern = P.maybe(P.whitespace) |> P.rhs(alias) |> P.lhs(colon) |> P.lhs(P.whitespace) |> P.lhs(double_quote) |> P.both(path) |> P.lhs(double_quote) |> P.lhs(P.maybe(comma)) |> P.lhs(P.maybe(P.whitespace)) 20 | parser = pattern |> P.map(|(a, p)| Ok({ alias: a, path: p })) 21 | parser(str) |> P.finalize |> Result.map_err(|_| InvalidPackageLine) 22 | 23 | expect 24 | res = parse_package_line(" parse: \"../roc-tinyparse/package/main.roc\",") 25 | res == Ok({ alias: "parse", path: "../roc-tinyparse/package/main.roc" }) 26 | 27 | expect 28 | res = parse_package_line(" parse: \"https://github.com/imclerran/roc-tinyparse/releases/download/v0.3.3/kKiVNqjpbgYFhE-aFB7FfxNmkXQiIo2f_mGUwUlZ3O0.tar.br\",") 29 | res == Ok({ alias: "parse", path: "https://github.com/imclerran/roc-tinyparse/releases/download/v0.3.3/kKiVNqjpbgYFhE-aFB7FfxNmkXQiIo2f_mGUwUlZ3O0.tar.br" }) 30 | 31 | parse_repo_owner_name = |str| 32 | pattern = P.string("https://github.com/") |> P.rhs(slug) |> P.lhs(forward_slash) |> P.both(slug) |> P.lhs(forward_slash) 33 | parser = pattern |> P.map(|(owner, name)| Ok({ owner, name })) 34 | parser(str) |> P.finalize_lazy |> Result.map_err(|_| InvalidRepoUrl) 35 | 36 | expect 37 | res = parse_repo_owner_name("https://github.com/roc-lang/basic-cli/releases/download/0.19.0/Hj-J_zxz7V9YurCSTFcFdu6cQJie4guzsPMUi5kBYUk.tar.br") 38 | res == Ok({ owner: "roc-lang", name: "basic-cli" }) 39 | 40 | colon = P.char |> P.filter(|c| c == ':') 41 | comma = P.char |> P.filter(|c| c == ',') 42 | double_quote = P.char |> P.filter(|c| c == '"') 43 | forward_slash = P.char |> P.filter(|c| c == '/') 44 | 45 | alias_char = P.char |> P.filter(|c| (c >= 'a' and c <= 'z') or (c >= 'A' and c <= 'Z') or (c >= '0' and c <= '9') or c == '_') 46 | path_char = P.char |> P.filter(|c| (c >= 'a' and c <= 'z') or (c >= 'A' and c <= 'Z') or (c >= '0' and c <= '9') or (List.contains(['/', '.', '-', '_', ':'], c))) 47 | slug_char = P.char |> P.filter(|c| (c >= 'a' and c <= 'z') or (c >= 'A' and c <= 'Z') or (c >= '0' and c <= '9') or c == '-' or c == '_') 48 | 49 | alias = P.one_or_more(alias_char) |> P.map(Str.from_utf8) 50 | path = P.one_or_more(path_char) |> P.map(Str.from_utf8) 51 | slug = P.one_or_more(slug_char) |> P.map(Str.from_utf8) 52 | 53 | -------------------------------------------------------------------------------- /src/repos/Manager.roc: -------------------------------------------------------------------------------- 1 | module [ 2 | RepositoryRelease, 3 | RepositoryDict, 4 | RepositoryReleaseSerialized, 5 | RepoNameMap, 6 | get_repos_from_json_bytes, 7 | get_repo_release, 8 | build_repo_name_map, 9 | get_full_repo_name, 10 | ] 11 | 12 | import json.Json 13 | import semver.Semver 14 | import semver.Types exposing [Semver] 15 | 16 | RepositoryDict : Dict Str (List RepositoryRelease) 17 | RepositoryRelease : { repo : Str, alias : Str, tag : Str, url : Str, semver : Semver } 18 | RepositoryReleaseSerialized : { repo : Str, alias : Str, tag : Str, url : Str } 19 | 20 | # Get packages and platforms from csv text 21 | # ------------------------------------------------------------------------------ 22 | 23 | get_repos_from_json_bytes : List U8 -> Result RepositoryDict [BadRepoReleasesData] 24 | get_repos_from_json_bytes = |bytes| 25 | decode_repo_releases(bytes)? 26 | |> build_repo_dict 27 | |> Ok 28 | 29 | # decode releases 30 | # ------------------------------------------------------------------------------ 31 | 32 | decode_repo_releases : List U8 -> Result (List RepositoryReleaseSerialized) [BadRepoReleasesData] 33 | decode_repo_releases = |bytes| 34 | decoder = Json.utf8_with({ field_name_mapping: SnakeCase }) 35 | decoded : Decode.DecodeResult (List RepositoryReleaseSerialized) 36 | decoded = Decode.from_bytes_partial(bytes, decoder) 37 | decoded.result |> Result.map_err(|_| BadRepoReleasesData) 38 | 39 | # Build dictionaries from csv data 40 | # ----------------------------------------------------------------------------- 41 | 42 | build_repo_dict : List RepositoryReleaseSerialized -> RepositoryDict 43 | build_repo_dict = |repo_list| 44 | List.walk( 45 | repo_list, 46 | Dict.empty {}, 47 | |dict, { repo, alias, tag, url }| 48 | when Dict.get(dict, repo) is 49 | Ok(releases) -> 50 | Dict.insert( 51 | dict, 52 | repo, 53 | List.append(releases, { repo, alias, tag, url, semver: semver_with_default(tag) }) 54 | |> List.sort_with(|{ semver: a }, { semver: b }| Semver.compare(b, a)), 55 | ) 56 | 57 | Err(KeyNotFound) -> 58 | Dict.insert(dict, repo, [{ repo, alias, tag, url, semver: semver_with_default(tag) }]), 59 | ) 60 | 61 | semver_with_default = |s| Semver.parse(Str.drop_prefix(s, "v")) |> Result.with_default({ major: 0, minor: 0, patch: 0, pre_release: [s], build: [] }) 62 | 63 | # Release Lookup 64 | # ----------------------------------------------------------------------------- 65 | 66 | get_repo_release : RepositoryDict, Str, Str, [Package, Platform] -> Result RepositoryRelease [RepoNotFound, VersionNotFound, RepoNotFoundButMaybe Str] 67 | get_repo_release = |dict, repo, version, type| 68 | when type is 69 | Package -> get_repo_release_help(dict, repo, version, "roc-") 70 | Platform -> get_repo_release_help(dict, repo, version, "basic-") 71 | 72 | get_repo_release_help : RepositoryDict, Str, Str, Str -> Result RepositoryRelease [RepoNotFound, VersionNotFound, RepoNotFoundButMaybe Str] 73 | get_repo_release_help = |dict, repo, version, try_prefix| 74 | when Dict.get(dict, repo) is 75 | Ok(releases) -> 76 | if version == "latest" or version == "" then 77 | sorted = List.sort_with(releases, |{ semver: a }, { semver: b }| Semver.compare(a, b)) 78 | release = List.last(sorted) ? |_| RepoNotFound 79 | Ok(release) 80 | else 81 | when Semver.parse(Str.drop_prefix(version, "v")) is 82 | Ok(sv) -> 83 | release = List.find_first(releases, |{ semver }| Semver.compare(semver, sv) == EQ) ? |_| VersionNotFound 84 | Ok(release) 85 | 86 | Err(_) -> 87 | sorted = List.sort_with(releases, |{ semver: a }, { semver: b }| Semver.compare(a, b)) 88 | release = List.find_last(sorted, |{ tag }| tag == version) ? |_| VersionNotFound 89 | Ok(release) 90 | 91 | Err(KeyNotFound) -> 92 | when Str.split_first(repo, "/") is 93 | Ok({ before: owner, after: name }) -> 94 | if !Str.starts_with(name, try_prefix) and Dict.contains(dict, "${owner}/${try_prefix}${name}") then 95 | Err(RepoNotFoundButMaybe("${owner}/${try_prefix}${name}")) 96 | else 97 | Err(RepoNotFound) 98 | 99 | Err(NotFound) -> Err(RepoNotFound) 100 | 101 | # Repository onwer/name lookup 102 | # ----------------------------------------------------------------------------- 103 | 104 | RepoNameMap : Dict Str (List Str) 105 | 106 | build_repo_name_map : List Str -> Dict Str (List Str) 107 | build_repo_name_map = |repos| 108 | List.walk( 109 | repos, 110 | Dict.empty {}, 111 | |dict, repo| 112 | { before: owner, after: name } = Str.split_first(repo, "/") |> Result.with_default { before: repo, after: repo } 113 | when Dict.get(dict, name) is 114 | Ok(names) -> Dict.insert(dict, name, List.append(names, owner)) 115 | Err(KeyNotFound) -> Dict.insert(dict, name, [owner]), 116 | ) 117 | 118 | get_full_repo_name : RepoNameMap, Str, [Package, Platform] -> Result Str [RepoNotFound, AmbiguousName, RepoNotFoundButMaybe Str] 119 | get_full_repo_name = |dict, name, type| 120 | when type is 121 | Package -> get_full_repo_name_help(dict, name, "roc-") 122 | Platform -> get_full_repo_name_help(dict, name, "basic-") 123 | 124 | get_full_repo_name_help = |dict, name, try_prefix| 125 | if Str.contains(name, "/") then 126 | Ok(name) 127 | else 128 | when Dict.get(dict, name) is 129 | Ok([owner]) -> Ok("${owner}/${name}") 130 | Ok(_) -> Err(AmbiguousName) 131 | Err(KeyNotFound) -> 132 | if !Str.starts_with(name, try_prefix) then 133 | if Dict.contains(dict, "${try_prefix}${name}") then 134 | Err(RepoNotFoundButMaybe("${try_prefix}${name}")) 135 | else 136 | Err(RepoNotFound) 137 | else 138 | Err(RepoNotFound) 139 | -------------------------------------------------------------------------------- /src/repos/Updater.roc: -------------------------------------------------------------------------------- 1 | module { write_bytes!, cmd_output!, cmd_new, cmd_args } -> [ 2 | update_local_repos!, 3 | ] 4 | 5 | import json.Json 6 | import parse.CSV exposing [csv_string] 7 | import parse.Parse exposing [one_or_more, maybe, string, lhs, rhs, map, zip_3, zip_4, whitespace, finalize] 8 | import semver.Semver 9 | 10 | import Manager exposing [RepositoryDict, RepositoryReleaseSerialized] 11 | 12 | # Run updates 13 | # ------------------------------------------------------------------------------ 14 | 15 | update_local_repos! : Str, Str, (Str => {}) => Result RepositoryDict [FileWriteError, GhAuthError, GhNotInstalled, GhError _, ParsingError] 16 | update_local_repos! = |known_repos_csv_text, save_path, logger!| 17 | parsed_repos = parse_known_repos(known_repos_csv_text) ? |_| ParsingError 18 | num_repos = List.len(parsed_repos) 19 | logger!("[") 20 | logger!(Str.repeat("=", Num.sub_saturated(5, num_repos))) 21 | release_list = 22 | List.walk_try!( 23 | parsed_repos, 24 | ([], 0, 0), 25 | |(releases, n, last_fifth), { repo, alias }| 26 | next_n = n + 1 27 | current_fifth = (next_n * 5) // num_repos 28 | next_fifth = if current_fifth > last_fifth then current_fifth else last_fifth 29 | new_releases_str = 30 | get_releases_cmd(repo, alias) 31 | |> cmd_output! 32 | |> get_gh_cmd_stdout? 33 | if current_fifth > last_fifth then logger!("=") else {} 34 | when new_releases_str is 35 | "" -> 36 | Ok((releases, next_n, next_fifth)) 37 | 38 | _ -> 39 | when parse_repo_releases(new_releases_str) is 40 | Ok(new_releases) -> 41 | Ok((List.join([releases, new_releases]), next_n, next_fifth)) 42 | 43 | _ -> Ok((releases, next_n, next_fifth)), 44 | )? 45 | |> .0 46 | logger!("] ") 47 | save_repo_releases!(release_list, save_path)? 48 | release_list 49 | |> build_repo_dict 50 | |> Ok 51 | 52 | save_repo_releases! : List RepositoryReleaseSerialized, Str => Result {} [FileWriteError] 53 | save_repo_releases! = |releases, save_path| 54 | releases 55 | |> encode_repo_releases 56 | |> write_bytes!(save_path) 57 | |> Result.map_err(|_| FileWriteError) 58 | 59 | # GitHub API Commands 60 | # ------------------------------------------------------------------------------ 61 | 62 | get_releases_cmd = |repo, alias| 63 | cmd_new("gh") 64 | |> cmd_args(["api", "repos/${repo}/releases?per_page=100", "--paginate", "--jq", ".[] | . as \$release | .assets[]? | select(.name|(endswith(\".tar.br\") or endswith(\".tar.gz\"))) | select(.name | split(\".\")[0] | length == 43) | [\"${repo}\", \"${alias}\", \$release.tag_name, .browser_download_url] | @csv"]) 65 | 66 | get_gh_cmd_stdout = |output| 67 | stdout = output.stdout |> Str.from_utf8_lossy 68 | stderr = output.stderr |> Str.from_utf8_lossy 69 | when output.status is 70 | Ok(0) -> Ok(stdout) 71 | Ok(4) -> Err(GhAuthError) 72 | Ok(_) -> Err(GhNotInstalled) 73 | Err(Other(s)) -> 74 | if Str.contains(stderr, "gh auth login") or Str.contains(stdout, "gh auth login") then 75 | Err(GhAuthError) 76 | else 77 | Err(GhError(Other(s))) 78 | 79 | Err(NotFound) -> Err(GhNotInstalled) 80 | Err(e) -> Err(GhError(e)) 81 | 82 | # Parse known repositories csv 83 | # ------------------------------------------------------------------------------ 84 | 85 | parse_known_repos = |csv_text| 86 | parser = parse_known_repos_header |> rhs(one_or_more(parse_known_repos_line)) |> lhs(maybe(whitespace)) 87 | parser(csv_text) |> finalize |> Result.map_err(|_| BadKnownReposCSV) 88 | 89 | parse_known_repos_header = |line| 90 | parser = maybe(string("repo,alias,remote") |> lhs(maybe(string(",")) |> lhs(string("\n")))) 91 | parser(line) |> Result.map_err(|_| MaybeShouldNotFail) 92 | 93 | parse_known_repos_line = |line| 94 | pattern = 95 | zip_3( 96 | csv_string |> lhs(string(",")), 97 | csv_string |> lhs(string(",")), 98 | string("github") |> lhs(maybe(string(","))), 99 | ) 100 | |> lhs(maybe(string("\n"))) 101 | parser = pattern |> map(|(repo, alias, _remote)| Ok({ repo, alias })) 102 | parser(line) |> Result.map_err(|_| KnownReposLineNotFound) 103 | 104 | # encode and decode releases 105 | # ------------------------------------------------------------------------------ 106 | 107 | encode_repo_releases : List RepositoryReleaseSerialized -> List U8 108 | encode_repo_releases = |releases| 109 | encoder = Json.utf8_with({ field_name_mapping: SnakeCase }) 110 | Encode.to_bytes(releases, encoder) 111 | 112 | # Parse package releases 113 | # ------------------------------------------------------------------------------ 114 | 115 | parse_repo_releases : Str -> Result (List { repo : Str, alias : Str, tag : Str, url : Str }) [BadRepoReleasesCSV] 116 | parse_repo_releases = |csv_text| 117 | parser = parse_repo_release_header |> rhs(one_or_more(parse_repo_releases_line)) |> lhs(maybe(whitespace)) 118 | parser(csv_text) |> finalize |> Result.map_err(|_| BadRepoReleasesCSV) 119 | 120 | parse_repo_release_header = |line| 121 | parser = maybe(string("repo,alias,tag,url") |> lhs(maybe(string(",")) |> lhs(string("\n")))) 122 | parser(line) |> Result.map_err(|_| MaybeShouldNotFail) 123 | 124 | parse_repo_releases_line = |line| 125 | pattern = 126 | zip_4( 127 | csv_string |> lhs(string(",")), 128 | csv_string |> lhs(string(",")), 129 | csv_string |> lhs(string(",")), 130 | csv_string |> lhs(maybe(string(","))), 131 | ) 132 | |> lhs(maybe(string("\n"))) 133 | parser = pattern |> map(|(repo, alias, tag, url)| Ok({ repo, alias, tag, url })) 134 | parser(line) |> Result.map_err(|_| RepoReleaseLineNotFound) 135 | 136 | # Build dictionaries from csv data 137 | # ----------------------------------------------------------------------------- 138 | 139 | build_repo_dict : List RepositoryReleaseSerialized -> RepositoryDict 140 | build_repo_dict = |repo_list| 141 | List.walk( 142 | repo_list, 143 | Dict.empty {}, 144 | |dict, { repo, alias, tag, url }| 145 | when Dict.get(dict, repo) is 146 | Ok(releases) -> 147 | Dict.insert( 148 | dict, 149 | repo, 150 | List.append(releases, { repo, alias, tag, url, semver: semver_with_default(tag) }) 151 | |> List.sort_with(|{ semver: a }, { semver: b }| Semver.compare(b, a)), 152 | ) 153 | 154 | Err(KeyNotFound) -> 155 | Dict.insert(dict, repo, [{ repo, alias, tag, url, semver: semver_with_default(tag) }]), 156 | ) 157 | 158 | semver_with_default = |s| Semver.parse(Str.drop_prefix(s, "v")) |> Result.with_default({ major: 0, minor: 0, patch: 0, pre_release: [s], build: [] }) 159 | -------------------------------------------------------------------------------- /src/repos/main.roc: -------------------------------------------------------------------------------- 1 | package [Manager, Updater] { 2 | semver: "https://github.com/imclerran/roc-semver/releases/download/v0.2.0%2Bimclerran/ePmzscvLvhwfllSFZGgTp77uiTFIwZQPgK_TiM6k_1s.tar.br", 3 | json: "https://github.com/lukewilliamboswell/roc-json/releases/download/0.12.0/1trwx8sltQ-e9Y2rOB4LWUWLS_sFVyETK8Twl0i9qpw.tar.gz", 4 | parse: "https://github.com/imclerran/roc-tinyparse/releases/download/v0.3.3/kKiVNqjpbgYFhE-aFB7FfxNmkXQiIo2f_mGUwUlZ3O0.tar.br", 5 | } 6 | -------------------------------------------------------------------------------- /src/themes/Manager.roc: -------------------------------------------------------------------------------- 1 | module 2 | { 3 | read_bytes!, 4 | write_bytes!, 5 | env_var!, 6 | is_file!, 7 | http_send!, 8 | } -> [load_themes!, update_themes!] 9 | 10 | import parse.Parse as P 11 | import json.Json 12 | import Theme exposing [Theme] 13 | 14 | themes_file_url = "https://raw.githubusercontent.com/imclerran/roc-start/refs/heads/main/themes/.rocstartthemes" 15 | 16 | update_themes! = |save_path| 17 | file_contents = 18 | http_send!( 19 | { 20 | uri: themes_file_url, 21 | method: GET, 22 | body: [], 23 | headers: [], 24 | timeout_ms: TimeoutMilliseconds(3000), 25 | }, 26 | ) 27 | |> Result.map_ok(|resp| if resp.status == 200 then resp.body else []) 28 | |> Result.map_err(|e| Exit(1, "Error updating themes: ${Inspect.to_str(e)}"))? 29 | if !List.is_empty(file_contents) then 30 | write_bytes!(file_contents, save_path) 31 | |> Result.map_err(|_| Exit(1, "Error updating themes: could not write to filesystem")) 32 | else 33 | Err(Exit(1, "Error updating themes: got an empty themes file")) 34 | 35 | file_exists! = |path| is_file!(path) |> Result.with_default(Bool.false) 36 | 37 | load_themes! : {} => List Theme 38 | load_themes! = |{}| 39 | home_res = env_var!("HOME") 40 | home = 41 | if home_res == Err(HomeVarNotSet) then 42 | return [Theme.default] 43 | else 44 | home_res |> Result.with_default("") 45 | 46 | file_path = "${home}/.rocstartthemes" 47 | if file_exists!(file_path) then 48 | themes = read_theme_file!(file_path) |> List.map(json_to_theme) 49 | List.append(themes, Theme.default) 50 | else 51 | update_res = update_themes!(file_path) 52 | when update_res is 53 | Ok(_) -> 54 | themes = read_theme_file!(file_path) |> List.map(json_to_theme) 55 | List.append(themes, Theme.default) 56 | 57 | Err(_) -> 58 | [Theme.default] 59 | 60 | read_theme_file! = |file_path| 61 | file_contents = 62 | read_bytes!(file_path) 63 | |> Result.with_default([]) 64 | decoded : Decode.DecodeResult (List JsonTheme) 65 | decoded = Decode.from_bytes_partial(file_contents, Json.utf8) 66 | decoded |> .result |> Result.with_default([]) 67 | 68 | JsonTheme : { 69 | name : Str, 70 | primary : Str, 71 | secondary : Str, 72 | tertiary : Str, 73 | okay : Str, 74 | warn : Str, 75 | error : Str, 76 | } 77 | 78 | json_to_theme : JsonTheme -> Theme 79 | json_to_theme = |json_theme| { 80 | name: json_theme.name, 81 | primary: parse_hex_color(json_theme.primary) |> Result.with_default(Default), 82 | secondary: parse_hex_color(json_theme.secondary) |> Result.with_default(Default), 83 | tertiary: parse_hex_color(json_theme.tertiary) |> Result.with_default(Default), 84 | okay: parse_hex_color(json_theme.okay) |> Result.with_default(Default), 85 | warn: parse_hex_color(json_theme.warn) |> Result.with_default(Default), 86 | error: parse_hex_color(json_theme.error) |> Result.with_default(Default), 87 | } 88 | 89 | parse_hex_color = |str| hex_color(str) |> P.finalize |> Result.map_err(|_| InvalidHexColor) 90 | 91 | hex_color = pound_sign |> P.rhs(P.zip_3(hex_pair, hex_pair, hex_pair)) |> P.map(|(r, g, b)| Rgb((r, g, b)) |> Ok) 92 | 93 | pound_sign = P.char |> P.filter(|c| c == '#') 94 | 95 | hex_char = P.char |> P.filter(is_hex_digit) 96 | is_hex_digit = |c| (c >= '0' and c <= '9') or (c >= 'a' and c <= 'f') or (c >= 'A' and c <= 'F') 97 | 98 | hex_pair = P.zip(hex_char, hex_char) |> P.map(|(a, b)| num_from_hex_char(a) * 16 + num_from_hex_char(b) |> Ok) 99 | 100 | num_from_hex_char = |c| 101 | if c >= '0' and c <= '9' then 102 | c - 48 |> Num.to_u8 103 | else if c >= 'a' and c <= 'f' then 104 | c - 87 |> Num.to_u8 105 | else if c >= 'A' and c <= 'F' then 106 | c - 55 |> Num.to_u8 107 | else 108 | 0 109 | 110 | expect num_from_hex_char('0') == 0 111 | expect num_from_hex_char('9') == 9 112 | expect num_from_hex_char('a') == 10 113 | expect num_from_hex_char('f') == 15 114 | expect num_from_hex_char('A') == 10 115 | expect num_from_hex_char('F') == 15 116 | -------------------------------------------------------------------------------- /src/themes/Theme.roc: -------------------------------------------------------------------------------- 1 | module [ 2 | Theme, 3 | theme_names, 4 | from_name, 5 | default, 6 | ] 7 | 8 | import ansi.Color 9 | Color : Color.Color 10 | 11 | Theme : { 12 | name : Str, 13 | primary : Color, 14 | secondary : Color, 15 | tertiary : Color, 16 | okay : Color, 17 | error : Color, 18 | warn : Color, 19 | } 20 | 21 | theme_names = |themes| themes |> List.map(.name) 22 | 23 | from_name = |themes, name| 24 | List.keep_if(themes, |th| th.name == name) |> List.first |> Result.map_err(|_| InvalidTheme) 25 | 26 | default : Theme 27 | default = { 28 | name: "default", 29 | primary: Standard Magenta, 30 | secondary: Standard Cyan, 31 | tertiary: Default, 32 | okay: Standard Green, 33 | warn: Standard Yellow, 34 | error: Standard Red, 35 | } 36 | -------------------------------------------------------------------------------- /src/themes/main.roc: -------------------------------------------------------------------------------- 1 | package [Theme, Manager] { 2 | json: "https://github.com/lukewilliamboswell/roc-json/releases/download/0.12.0/1trwx8sltQ-e9Y2rOB4LWUWLS_sFVyETK8Twl0i9qpw.tar.gz", 3 | parse: "https://github.com/imclerran/roc-tinyparse/releases/download/v0.3.3/kKiVNqjpbgYFhE-aFB7FfxNmkXQiIo2f_mGUwUlZ3O0.tar.br", 4 | ansi: "https://github.com/lukewilliamboswell/roc-ansi/releases/download/0.8.0/RQlGWlkQEfxtkSYKl0nHNQaOFT0-Jh7NNFEX2IPXlec.tar.br", 5 | } 6 | -------------------------------------------------------------------------------- /src/tui/AsciiArt.roc: -------------------------------------------------------------------------------- 1 | module [Art, size, width, height, roc_start, roc_small, roc_large, roc_start_colored, roc_large_colored, roc_small_colored] 2 | 3 | import ansi.ANSI 4 | 5 | Art : { 6 | height : U16, 7 | width : U16, 8 | art : List { text : Str, r : U16, c : U16, color : ANSI.Color }, 9 | } 10 | 11 | size : List Str -> { width : I32, height : I32 } 12 | size = |art| { 13 | width: width(art), 14 | height: height(art), 15 | } 16 | 17 | height : List Str -> I32 18 | height = |art| List.len(art) |> Num.to_i32 19 | 20 | width : List Str -> I32 21 | width = |art| List.first(art) |> Result.with_default("") |> Str.count_utf8_bytes |> Num.to_i32 22 | 23 | roc_start = [ 24 | " _ _ ", 25 | " _ __ ___ ___ ____| |_ __ _ _ __| |_ ", 26 | "| '__/ _ \\ / __|____/ ___| __/ _` | '__| __|", 27 | "| | | (_) | (_|_____\\___ \\ || (_| | | | |_ ", 28 | "|_| \\___/ \\___| |____/\\__\\__,_|_| \\__|", 29 | " quick start cli ", 30 | ] 31 | 32 | roc_start_colored = { 33 | width: 45, 34 | height: 6, 35 | art: [ 36 | { text: "_", r: 0, c: 26, color: C256(93) }, 37 | { text: "_", r: 0, c: 40, color: C256(93) }, 38 | { text: "_", r: 1, c: 1, color: C256(93) }, 39 | { text: "__", r: 1, c: 3, color: C256(93) }, 40 | { text: "___", r: 1, c: 6, color: C256(93) }, 41 | { text: "___", r: 1, c: 12, color: C256(93) }, 42 | { text: "____| |_ __ _ _ __| |_", r: 1, c: 21, color: C256(93) }, 43 | { text: "| '__/ _ \\ / __|", r: 2, c: 0, color: C256(93) }, 44 | { text: "____", r: 2, c: 16, color: C256(177) }, 45 | { text: "/ ___| __/ _` | '__| __|", r: 2, c: 20, color: C256(93) }, 46 | { text: "| | | (_) | (_", r: 3, c: 0, color: C256(93) }, 47 | { text: "|_____", r: 3, c: 14, color: C256(177) }, 48 | { text: "\\___ \\ || (_| | | | |_", r: 3, c: 20, color: C256(93) }, 49 | { text: "|_| \\___/ \\___|", r: 4, c: 0, color: C256(93) }, 50 | { text: "|____/\\__\\__,_|_| \\__|", r: 4, c: 20, color: C256(93) }, 51 | ], 52 | } 53 | 54 | roc_small = [ 55 | "___ ", 56 | "\\ ---___ ", 57 | " \\ ---___ ", 58 | " \\ |\\ ", 59 | " \\ | \\ __-\\ ", 60 | " \\ | \\--' /_\\ ", 61 | " \\ | \\ / ", 62 | " \\|---______\\ ", 63 | " /| __/ ", 64 | " | | __/ ", 65 | " | |_/ ", 66 | " / | ", 67 | " | | ", 68 | " | _/ ", 69 | " |/ ", 70 | ] 71 | 72 | roc_small_colored = { 73 | width: 31, 74 | height: 15, 75 | art: [ 76 | { text: "___", r: 0, c: 0, color: C256(177) }, 77 | { text: "\\ ---___", r: 1, c: 0, color: C256(177) }, 78 | { text: "\\ ---___", r: 2, c: 2, color: C256(177) }, 79 | { text: "\\ ", r: 3, c: 4, color: C256(177) }, 80 | { text: "|\\", r: 3, c: 14, color: C256(93) }, 81 | { text: "\\ ", r: 4, c: 6, color: C256(177) }, 82 | { text: "| \\", r: 4, c: 14, color: C256(93) }, 83 | { text: "__-", r: 4, c: 23, color: C256(177) }, 84 | { text: "\\", r: 4, c: 26, color: C256(93) }, 85 | { text: "\\ ", r: 5, c: 8, color: C256(177) }, 86 | { text: "| \\", r: 5, c: 13, color: C256(93) }, 87 | { text: "--' /", r: 5, c: 20, color: C256(177) }, 88 | { text: "_\\", r: 5, c: 26, color: C256(93) }, 89 | { text: "\\ ", r: 6, c: 10, color: C256(177) }, 90 | { text: "| \\", r: 6, c: 13, color: C256(93) }, 91 | { text: " /", r: 6, c: 22, color: C256(177) }, 92 | { text: "\\", r: 7, c: 12, color: C256(177) }, 93 | { text: "|---______\\", r: 7, c: 13, color: C256(93) }, 94 | { text: "/|", r: 8, c: 12, color: C256(93) }, 95 | { text: " __/", r: 8, c: 14, color: C256(177) }, 96 | { text: "| |", r: 9, c: 11, color: C256(93) }, 97 | { text: " __/", r: 9, c: 14, color: C256(177) }, 98 | { text: "| |", r: 10, c: 11, color: C256(93) }, 99 | { text: "_/", r: 10, c: 15, color: C256(177) }, 100 | { text: "/ |", r: 11, c: 11, color: C256(93) }, 101 | { text: "| |", r: 12, c: 10, color: C256(93) }, 102 | { text: "| _/", r: 13, c: 10, color: C256(93) }, 103 | { text: "|/", r: 14, c: 10, color: C256(93) }, 104 | ], 105 | } 106 | 107 | roc_large = [ 108 | "............ ", 109 | " ............................ ", 110 | " .................................... ", 111 | " ..................................-- ", 112 | " ................................---- ", 113 | " ..............................------ ", 114 | " ............................------- ", 115 | " .........................--------- ", 116 | " .......................----------- ", 117 | " .....................------------- ", 118 | " ..................-------------- ", 119 | " ................:---------------- ", 120 | " .............:------------------ ", 121 | " ...........:-------------------- ......- ", 122 | " .........:---------------------- ...........:--- ", 123 | " .......:----------------------..............------ ", 124 | " ....:------------------------............------- ", 125 | " ..:--------------------------.........--------- ", 126 | " .........:--------------------....... ", 127 | " -....................:----------.... ", 128 | " --..............................:--.. ", 129 | " ---................................. ", 130 | " ---.............................. ", 131 | " ----:.......................... ", 132 | " -----....................... ", 133 | " ------.................... ", 134 | " -------................ ", 135 | " --------............. ", 136 | " --------:......... ", 137 | " ----------...... ", 138 | " ----------.... ", 139 | " ------------ ", 140 | " ------------- ", 141 | " ------------- ", 142 | " -------------- ", 143 | " --------------- ", 144 | " -------------- ", 145 | " ------------ ", 146 | " --------- ", 147 | " ------- ", 148 | " ----- ", 149 | " --- ", 150 | ] 151 | 152 | roc_large_colored : Art 153 | roc_large_colored = { 154 | width: 94, 155 | height: 42, 156 | art: [ 157 | { text: "............", r: 0, c: 0, color: C256(177) }, 158 | { text: "............................", r: 1, c: 2, color: C256(177) }, 159 | { text: "....................................", r: 2, c: 5, color: C256(177) }, 160 | { text: "..................................", r: 3, c: 7, color: C256(177) }, 161 | { text: "--", r: 3, c: 41, color: C256(93) }, 162 | { text: "................................", r: 4, c: 9, color: C256(177) }, 163 | { text: "----", r: 4, c: 41, color: C256(93) }, 164 | { text: "..............................", r: 5, c: 11, color: C256(177) }, 165 | { text: "------", r: 5, c: 41, color: C256(93) }, 166 | { text: "............................", r: 6, c: 13, color: C256(177) }, 167 | { text: "-------", r: 6, c: 41, color: C256(93) }, 168 | { text: ".........................", r: 7, c: 16, color: C256(177) }, 169 | { text: "---------", r: 7, c: 41, color: C256(93) }, 170 | { text: ".......................", r: 8, c: 18, color: C256(177) }, 171 | { text: "-----------", r: 8, c: 41, color: C256(93) }, 172 | { text: ".....................", r: 9, c: 20, color: C256(177) }, 173 | { text: "-------------", r: 9, c: 41, color: C256(93) }, 174 | { text: "..................", r: 10, c: 23, color: C256(177) }, 175 | { text: "--------------", r: 10, c: 41, color: C256(93) }, 176 | { text: "...............", r: 11, c: 25, color: C256(177) }, 177 | { text: ":----------------", r: 11, c: 40, color: C256(93) }, 178 | { text: ".............", r: 12, c: 27, color: C256(177) }, 179 | { text: ":------------------", r: 12, c: 40, color: C256(93) }, 180 | { text: "...........", r: 13, c: 29, color: C256(177) }, 181 | { text: ":--------------------", r: 13, c: 40, color: C256(93) }, 182 | { text: "......", r: 13, c: 72, color: C256(177) }, 183 | { text: "-", r: 13, c: 78, color: C256(93) }, 184 | { text: ".........", r: 14, c: 31, color: C256(177) }, 185 | { text: ":----------------------", r: 14, c: 40, color: C256(93) }, 186 | { text: "...........", r: 14, c: 66, color: C256(177) }, 187 | { text: ":---", r: 14, c: 77, color: C256(93) }, 188 | { text: ".......", r: 15, c: 33, color: C256(177) }, 189 | { text: ":----------------------", r: 15, c: 40, color: C256(93) }, 190 | { text: "..............", r: 15, c: 63, color: C256(177) }, 191 | { text: "------", r: 15, c: 77, color: C256(93) }, 192 | { text: "....", r: 16, c: 36, color: C256(177) }, 193 | { text: ":------------------------", r: 16, c: 40, color: C256(93) }, 194 | { text: "............", r: 16, c: 65, color: C256(177) }, 195 | { text: "-------", r: 16, c: 77, color: C256(93) }, 196 | { text: "..", r: 17, c: 38, color: C256(177) }, 197 | { text: ":--------------------------", r: 17, c: 40, color: C256(93) }, 198 | { text: ".........", r: 17, c: 67, color: C256(177) }, 199 | { text: "---------", r: 17, c: 76, color: C256(93) }, 200 | { text: ".........", r: 18, c: 39, color: C256(177) }, 201 | { text: ":--------------------", r: 18, c: 48, color: C256(93) }, 202 | { text: ".......", r: 18, c: 69, color: C256(177) }, 203 | { text: "-", r: 19, c: 39, color: C256(93) }, 204 | { text: "....................", r: 19, c: 40, color: C256(177) }, 205 | { text: ":----------", r: 19, c: 60, color: C256(93) }, 206 | { text: "....", r: 19, c: 71, color: C256(177) }, 207 | { text: "--", r: 20, c: 38, color: C256(93) }, 208 | { text: "..............................", r: 20, c: 40, color: C256(177) }, 209 | { text: ":--", r: 20, c: 70, color: C256(93) }, 210 | { text: "..", r: 20, c: 73, color: C256(177) }, 211 | { text: "---", r: 21, c: 38, color: C256(93) }, 212 | { text: ".................................", r: 21, c: 41, color: C256(177) }, 213 | { text: "---", r: 22, c: 38, color: C256(93) }, 214 | { text: "..............................", r: 22, c: 41, color: C256(177) }, 215 | { text: "----:", r: 23, c: 37, color: C256(93) }, 216 | { text: "..........................", r: 23, c: 42, color: C256(177) }, 217 | { text: "-----", r: 24, c: 37, color: C256(93) }, 218 | { text: ".......................", r: 24, c: 42, color: C256(177) }, 219 | { text: "------", r: 25, c: 36, color: C256(93) }, 220 | { text: "....................", r: 25, c: 42, color: C256(177) }, 221 | { text: "-------", r: 26, c: 36, color: C256(93) }, 222 | { text: "................", r: 26, c: 43, color: C256(177) }, 223 | { text: "--------", r: 27, c: 35, color: C256(93) }, 224 | { text: ".............", r: 27, c: 43, color: C256(177) }, 225 | { text: "--------", r: 28, c: 35, color: C256(93) }, 226 | { text: ":.........", r: 28, c: 43, color: C256(177) }, 227 | { text: "----------", r: 29, c: 34, color: C256(93) }, 228 | { text: "......", r: 29, c: 44, color: C256(177) }, 229 | { text: "----------", r: 30, c: 34, color: C256(93) }, 230 | { text: "....", r: 30, c: 44, color: C256(177) }, 231 | { text: "------------", r: 31, c: 33, color: C256(93) }, 232 | { text: "-------------", r: 32, c: 33, color: C256(93) }, 233 | { text: "-------------", r: 33, c: 33, color: C256(93) }, 234 | { text: "--------------", r: 34, c: 33, color: C256(93) }, 235 | { text: "---------------", r: 35, c: 32, color: C256(93) }, 236 | { text: "--------------", r: 36, c: 32, color: C256(93) }, 237 | { text: "------------", r: 37, c: 31, color: C256(93) }, 238 | { text: "---------", r: 38, c: 31, color: C256(93) }, 239 | { text: "-------", r: 39, c: 30, color: C256(93) }, 240 | { text: "-----", r: 40, c: 30, color: C256(93) }, 241 | { text: "---", r: 41, c: 29, color: C256(93) }, 242 | ], 243 | } 244 | -------------------------------------------------------------------------------- /src/tui/BoxStyle.roc: -------------------------------------------------------------------------------- 1 | module [ 2 | BoxStyle, 3 | BoxElement, 4 | border, 5 | ] 6 | 7 | BoxStyle : [ 8 | SingleWall, 9 | DoubleWall, 10 | OneChar Str, 11 | CustomBorder { tl ?? Str, t ?? Str, tr ?? Str, l ?? Str, r ?? Str, bl ?? Str, b ?? Str, br ?? Str }, 12 | ] 13 | 14 | BoxElement : [ 15 | TopLeft, 16 | Top, 17 | TopRight, 18 | Left, 19 | Right, 20 | BotLeft, 21 | Bot, 22 | BotRight, 23 | ] 24 | 25 | border : BoxElement, BoxStyle -> Str 26 | border = |pos, style| 27 | when style is 28 | SingleWall -> 29 | when pos is 30 | TopLeft -> "┌" 31 | Top -> "─" 32 | TopRight -> "┐" 33 | Left -> "│" 34 | Right -> "│" 35 | BotLeft -> "└" 36 | Bot -> "─" 37 | BotRight -> "┘" 38 | 39 | DoubleWall -> 40 | when pos is 41 | TopLeft -> "╔" 42 | Top -> "═" 43 | TopRight -> "╗" 44 | Left -> "║" 45 | Right -> "║" 46 | BotLeft -> "╚" 47 | Bot -> "═" 48 | BotRight -> "╝" 49 | 50 | OneChar(char) -> 51 | when pos is 52 | TopLeft -> char 53 | Top -> char 54 | TopRight -> char 55 | Left -> char 56 | Right -> char 57 | BotLeft -> char 58 | Bot -> char 59 | BotRight -> char 60 | 61 | CustomBorder(chars) -> 62 | box_chars_with_defaults = |{ tl ?? "┌", t ?? "─", tr ?? "┐", l ?? "│", r ?? "│", bl ?? "└", b ?? "─", br ?? "┘" }| { tl, t, tr, l, r, bl, b, br } 63 | box_chars = box_chars_with_defaults(chars) 64 | when pos is 65 | TopLeft -> box_chars.tl 66 | Top -> box_chars.t 67 | TopRight -> box_chars.tr 68 | Left -> box_chars.l 69 | Right -> box_chars.r 70 | BotLeft -> box_chars.bl 71 | Bot -> box_chars.b 72 | BotRight -> box_chars.br 73 | -------------------------------------------------------------------------------- /src/tui/Choices.roc: -------------------------------------------------------------------------------- 1 | module [ 2 | Choices, 3 | set_filename, 4 | get_filename, 5 | set_force, 6 | get_force, 7 | set_no_plugin, 8 | get_no_plugin, 9 | set_flags, 10 | get_flags, 11 | set_packages, 12 | get_packages, 13 | set_platform, 14 | get_platform, 15 | set_updates, 16 | get_updates, 17 | set_config_theme, 18 | get_config_theme, 19 | set_config_verbosity, 20 | get_config_verbosity, 21 | set_config_platform, 22 | get_config_platform, 23 | to_app, 24 | to_package, 25 | to_upgrade, 26 | to_update, 27 | to_config, 28 | ] 29 | 30 | import rtils.StrUtils 31 | import heck.Heck 32 | 33 | Choices : [ 34 | App { filename : Str, force : Bool, no_plugin : Bool, packages : List { name : Str, version : Str }, platform : { name : Str, version : Str } }, 35 | Package { force : Bool, packages : List { name : Str, version : Str } }, 36 | Upgrade { filename : Str, packages : List { name : Str, version : Str }, platform : [Err [NoPLatformSpecified], Ok { name : Str, version : Str }] }, 37 | Config { theme : Result Str [NoValue], platform : Result Str [NoValue], verbosity : Result Str [NoValue] }, 38 | Update { do_packages : Bool, do_platforms : Bool, do_plugins : Bool, do_themes : Bool, do_install : Bool }, 39 | NothingToDo, 40 | ] 41 | 42 | to_app : Choices -> Choices 43 | to_app = |choices| 44 | when choices is 45 | App(config) -> 46 | App(config) 47 | 48 | Package({ force, packages }) -> 49 | App({ filename: "main.roc", force, packages, platform: { name: "", version: "" }, no_plugin: Bool.false }) 50 | 51 | Upgrade({ filename, packages, platform: maybe_pf }) -> 52 | platform = 53 | when maybe_pf is 54 | Ok(pf) -> pf 55 | Err(_) -> { name: "", version: "" } 56 | App({ filename, force: Bool.false, packages, platform, no_plugin: Bool.false }) 57 | 58 | _ -> 59 | App({ filename: "main.roc", force: Bool.false, packages: [], platform: { name: "", version: "" }, no_plugin: Bool.false }) 60 | 61 | to_package : Choices -> Choices 62 | to_package = |choices| 63 | when choices is 64 | App({ force, packages }) -> 65 | Package({ force, packages }) 66 | 67 | Package(config) -> 68 | Package(config) 69 | 70 | Upgrade({ packages }) -> 71 | Package({ force: Bool.false, packages }) 72 | 73 | _ -> 74 | Package({ force: Bool.false, packages: [] }) 75 | 76 | to_upgrade : Choices -> Choices 77 | to_upgrade = |choices| 78 | when choices is 79 | App({ filename, packages, platform }) -> 80 | Upgrade({ filename, packages, platform: Ok(platform) }) 81 | 82 | Package({ packages }) -> 83 | Upgrade({ filename: "main.roc", packages, platform: Err(NoPLatformSpecified) }) 84 | 85 | Upgrade(config) -> 86 | Upgrade(config) 87 | 88 | _ -> 89 | Upgrade({ filename: "main.roc", packages: [], platform: Err(NoPLatformSpecified) }) 90 | 91 | to_update : Choices -> Choices 92 | to_update = |choices| 93 | when choices is 94 | Update(config) -> 95 | Update(config) 96 | 97 | _ -> 98 | Update( 99 | { 100 | do_packages: Bool.false, 101 | do_platforms: Bool.false, 102 | do_plugins: Bool.false, 103 | do_themes: Bool.false, 104 | do_install: Bool.false, 105 | }, 106 | ) 107 | 108 | to_config : Choices -> Choices 109 | to_config = |choices| 110 | when choices is 111 | Config(config) -> 112 | Config(config) 113 | 114 | _ -> 115 | Config({ theme: Err(NoValue), platform: Err(NoValue), verbosity: Err(NoValue) }) 116 | 117 | set_filename : Choices, Str -> Choices 118 | set_filename = |choices, f| 119 | filename = f |> default_filename |> with_extension 120 | when choices is 121 | App(config) -> App({ config & filename }) 122 | Upgrade(config) -> Upgrade({ config & filename }) 123 | _ -> choices 124 | 125 | get_filename : Choices -> Str 126 | get_filename = |choices| 127 | when choices is 128 | App(config) -> config.filename 129 | Upgrade(config) -> config.filename 130 | _ -> "" 131 | 132 | set_force : Choices, Bool -> Choices 133 | set_force = |choices, force| 134 | when choices is 135 | App(config) -> App({ config & force }) 136 | Package(config) -> Package({ config & force }) 137 | _ -> choices 138 | 139 | get_force : Choices -> Bool 140 | get_force = |choices| 141 | when choices is 142 | App(config) -> config.force 143 | Package(config) -> config.force 144 | _ -> Bool.false 145 | 146 | set_no_plugin : Choices, Bool -> Choices 147 | set_no_plugin = |choices, no_plugin| 148 | when choices is 149 | App(config) -> App({ config & no_plugin }) 150 | _ -> choices 151 | 152 | get_no_plugin : Choices -> Bool 153 | get_no_plugin = |choices| 154 | when choices is 155 | App(config) -> config.no_plugin 156 | _ -> Bool.false 157 | 158 | set_flags : Choices, List Str -> Choices 159 | set_flags = |choices, flags| 160 | kebab_flags = List.map(flags, |f| Heck.to_kebab_case(f)) 161 | when choices is 162 | App(config) -> 163 | App( 164 | { config & 165 | force: List.contains(kebab_flags, "force"), 166 | no_plugin: List.contains(kebab_flags, "no-plugin"), 167 | }, 168 | ) 169 | 170 | Package(config) -> 171 | Package( 172 | { config & 173 | force: List.contains(kebab_flags, "force"), 174 | }, 175 | ) 176 | 177 | _ -> choices 178 | 179 | get_flags : Choices -> List Str 180 | get_flags = |choices| 181 | when choices is 182 | App(config) -> 183 | [] 184 | |> |ul| if config.force then List.append(ul, "force") else ul 185 | |> |ul| if config.no_plugin then List.append(ul, "no-plugin") else ul 186 | 187 | Package(config) -> 188 | if config.force then ["force"] else [] 189 | 190 | _ -> [] 191 | 192 | set_packages : Choices, List Str -> Choices 193 | set_packages = |choices, packages| 194 | when choices is 195 | App(config) -> App({ config & packages: package_names_and_versions(packages) }) 196 | Package(config) -> Package({ config & packages: package_names_and_versions(packages) }) 197 | Upgrade(config) -> Upgrade({ config & packages: package_names_and_versions(packages) }) 198 | _ -> choices 199 | 200 | get_packages : Choices -> List { name : Str, version : Str } 201 | get_packages = |choices| 202 | when choices is 203 | App(config) -> config.packages 204 | Package(config) -> config.packages 205 | Upgrade(config) -> config.packages 206 | _ -> [] 207 | 208 | set_platform : Choices, Str -> Choices 209 | set_platform = |choices, platform| 210 | when choices is 211 | Upgrade(config) -> 212 | if Str.is_empty(platform) then 213 | Upgrade({ config & platform: Err(NoPLatformSpecified) }) 214 | else 215 | Upgrade({ config & platform: Ok(platform_name_and_version_with_default(platform)) }) 216 | 217 | App(config) -> 218 | App({ config & platform: platform_name_and_version_with_default(platform) }) 219 | 220 | _ -> choices 221 | 222 | get_platform : Choices -> { name : Str, version : Str } 223 | get_platform = |choices| 224 | when choices is 225 | App(config) -> config.platform 226 | Upgrade(config) -> 227 | when config.platform is 228 | Ok(platform) -> platform 229 | Err(_) -> { name: "", version: "" } 230 | 231 | _ -> { name: "", version: "" } 232 | 233 | set_updates : Choices, List Str -> Choices 234 | set_updates = |choices, updates| 235 | when choices is 236 | Update(_) -> 237 | Update( 238 | { 239 | do_platforms: List.contains(updates, "Platforms"), 240 | do_packages: List.contains(updates, "Packages"), 241 | do_plugins: List.contains(updates, "Plugins"), 242 | do_themes: List.contains(updates, "Themes"), 243 | do_install: List.contains(updates, "Installation"), 244 | }, 245 | ) 246 | 247 | _ -> choices 248 | 249 | get_updates : Choices -> List Str 250 | get_updates = |choices| 251 | when choices is 252 | Update({ do_platforms, do_packages, do_plugins, do_themes, do_install }) -> 253 | [] 254 | |> |ul| if do_platforms then List.append(ul, "Platforms") else ul 255 | |> |ul| if do_packages then List.append(ul, "Packages") else ul 256 | |> |ul| if do_plugins then List.append(ul, "Plugins") else ul 257 | |> |ul| if do_themes then List.append(ul, "Themes") else ul 258 | |> |ul| if do_install then List.append(ul, "Installation") else ul 259 | 260 | _ -> [] 261 | 262 | set_config_theme : Choices, Str -> Choices 263 | set_config_theme = |choices, theme| 264 | when choices is 265 | Config(config) -> Config({ config & theme: Ok(theme) }) 266 | _ -> choices 267 | 268 | get_config_theme : Choices -> Result Str [NoValue] 269 | get_config_theme = |choices| 270 | when choices is 271 | Config(config) -> config.theme 272 | _ -> Err(NoValue) 273 | 274 | set_config_verbosity : Choices, Str -> Choices 275 | set_config_verbosity = |choices, verbosity| 276 | when choices is 277 | Config(config) -> Config({ config & verbosity: Ok(verbosity) }) 278 | _ -> choices 279 | 280 | get_config_verbosity : Choices -> Result Str [NoValue] 281 | get_config_verbosity = |choices| 282 | when choices is 283 | Config(config) -> config.verbosity 284 | _ -> Err(NoValue) 285 | 286 | set_config_platform : Choices, Str -> Choices 287 | set_config_platform = |choices, platform| 288 | when choices is 289 | Config(config) -> 290 | if Str.is_empty(platform) then 291 | Config({ config & platform: Err(NoValue) }) 292 | else 293 | Config({ config & platform: Ok(platform) }) 294 | 295 | _ -> choices 296 | 297 | get_config_platform : Choices -> Result Str [NoValue] 298 | get_config_platform = |choices| 299 | when choices is 300 | Config(config) -> config.platform 301 | _ -> Err(NoValue) 302 | 303 | # Helper functions 304 | 305 | default_filename = |filename| if Str.is_empty(filename) then "main.roc" else filename 306 | with_extension = |filename| if Str.ends_with(filename, ".roc") then filename else "${filename}.roc" 307 | 308 | package_names_and_versions = |packages| 309 | List.map( 310 | packages, 311 | |package| 312 | { before: name, after: version } = 313 | StrUtils.split_first_if(package, |c| List.contains([':', '='], c)) 314 | |> Result.with_default({ before: package, after: "" }) 315 | { name, version }, 316 | ) 317 | 318 | platform_name_and_version_with_default = |platform| 319 | { before: name, after: version } = 320 | StrUtils.split_first_if(platform, |c| List.contains([':', '='], c)) 321 | |> Result.with_default({ before: platform, after: "" }) 322 | { name, version } 323 | -------------------------------------------------------------------------------- /src/tui/Controller.roc: -------------------------------------------------------------------------------- 1 | module [apply_action] 2 | 3 | import Keys 4 | import Model exposing [Model] 5 | import UserAction exposing [UserAction] 6 | import StateTransitions as ST 7 | 8 | ## Translate the user action into a state transition by dispatching to the appropriate handler 9 | apply_action : Model, UserAction -> [Step Model, Done Model] 10 | apply_action = |model, action| 11 | if action == Exit then 12 | Done(ST.to_user_exited_state(model)) 13 | else if Model.action_is_available(model, action) then 14 | when model.state is 15 | MainMenu(_) -> main_menu_handler(model, action) 16 | SettingsMenu(_) -> settings_menu_handler(model, action) 17 | SettingsSubmenu(_) -> settings_submenu_handler(model, action) 18 | InputAppName(_) -> input_app_name_handler(model, action) 19 | PlatformSelect(_) -> platform_select_handler(model, action) 20 | PackageSelect(_) -> package_select_handler(model, action) 21 | VersionSelect(_) -> version_select_handler(model, action) 22 | UpdateSelect(_) -> update_select_handler(model, action) 23 | Confirmation(_) -> confirmation_handler(model, action) 24 | ChooseFlags(_) -> choose_flags_handler(model, action) 25 | Search(_) -> search_handler(model, action) 26 | Splash(_) -> splash_handler(model, action) 27 | _ -> default_handler(model, action) 28 | else 29 | Step(model) 30 | 31 | ## Default handler ensures program can always be exited 32 | default_handler : Model, UserAction -> [Step Model, Done Model] 33 | default_handler = |model, action| 34 | when action is 35 | CursorUp -> Step(Model.move_cursor(model, Up)) 36 | CursorDown -> Step(Model.move_cursor(model, Down)) 37 | NextPage -> Step(Model.next_page(model)) 38 | PrevPage -> Step(Model.prev_page(model)) 39 | _ -> Step(model) 40 | 41 | ## Map the user action to the appropriate state transition from the MainMenu state 42 | main_menu_handler : Model, UserAction -> [Step Model, Done Model] 43 | main_menu_handler = |model, action| 44 | when action is 45 | SingleSelect -> 46 | selected = Model.get_highlighted_item(model) 47 | if Str.contains(selected, "Start app") then 48 | Step(ST.to_input_app_name_state(model)) 49 | else if selected == "Start package" then 50 | Step(ST.to_package_select_state(model)) 51 | else if selected == "Upgrade app" then 52 | Step(ST.to_input_app_name_state(model)) 53 | else if selected == "Upgrade package" then 54 | Step(ST.to_package_select_state(model)) 55 | else if selected == "Update roc-start" then 56 | Step(ST.to_update_select_state(model)) 57 | else if selected == "Settings" then 58 | Step(ST.to_settings_menu_state(model)) 59 | else if selected == "Exit" then 60 | Done(ST.to_user_exited_state(model)) 61 | else 62 | Step(model) 63 | 64 | CursorUp -> Step(Model.move_cursor(model, Up)) 65 | CursorDown -> Step(Model.move_cursor(model, Down)) 66 | NextPage -> Step(Model.next_page(model)) 67 | PrevPage -> Step(Model.prev_page(model)) 68 | Secret -> Step(ST.to_splash_state(model)) 69 | _ -> Step(model) 70 | 71 | settings_menu_handler : Model, UserAction -> [Step Model, Done Model] 72 | settings_menu_handler = |model, action| 73 | when action is 74 | SingleSelect -> 75 | selected = Model.get_highlighted_item(model) 76 | if selected == "Theme" then 77 | Step(ST.to_settings_submenu_state(model, Theme)) 78 | else if selected == "Verbosity" then 79 | Step(ST.to_settings_submenu_state(model, Verbosity)) 80 | else if Str.contains(selected, "platform") then 81 | Step(ST.to_platform_select_state(model)) 82 | else 83 | Step(ST.to_confirmation_state(model)) 84 | 85 | CursorUp -> Step(Model.move_cursor(model, Up)) 86 | CursorDown -> Step(Model.move_cursor(model, Down)) 87 | NextPage -> Step(Model.next_page(model)) 88 | PrevPage -> Step(Model.prev_page(model)) 89 | GoBack -> Step(ST.to_main_menu_state(model)) 90 | _ -> Step(model) 91 | 92 | settings_submenu_handler : Model, UserAction -> [Step Model, Done Model] 93 | settings_submenu_handler = |model, action| 94 | when action is 95 | SingleSelect -> Step(ST.to_settings_menu_state(model)) 96 | CursorUp -> Step(Model.move_cursor(model, Up)) 97 | CursorDown -> Step(Model.move_cursor(model, Down)) 98 | NextPage -> Step(Model.next_page(model)) 99 | PrevPage -> Step(Model.prev_page(model)) 100 | GoBack -> Step(ST.to_settings_menu_state({ model & cursor: { row: 0, col: 2 } })) 101 | _ -> Step(model) 102 | 103 | ## Map the user action to the appropriate state transition from the PlatformSelect state 104 | platform_select_handler : Model, UserAction -> [Step Model, Done Model] 105 | platform_select_handler = |model, action| 106 | when action is 107 | Search -> Step(ST.to_search_state(model)) 108 | SingleSelect -> 109 | when model.sender is 110 | SettingsMenu(_) -> Step(ST.to_settings_menu_state(model)) 111 | _ -> Step(ST.to_package_select_state(model)) 112 | 113 | VersionSelect -> Step(ST.to_version_select_state(model)) 114 | CursorUp -> Step(Model.move_cursor(model, Up)) 115 | CursorDown -> Step(Model.move_cursor(model, Down)) 116 | GoBack -> 117 | if Model.menu_is_filtered(model) then 118 | Step(Model.clear_search_filter(model)) 119 | else 120 | when model.sender is 121 | InputAppName(_) -> 122 | Step(ST.to_input_app_name_state(model)) 123 | 124 | SettingsMenu(_) -> 125 | Step(ST.to_settings_menu_state({ model & cursor: { row: 0, col: 2 } })) 126 | 127 | VersionSelect({ choices }) -> 128 | when choices is 129 | App(_) -> Step(ST.to_input_app_name_state(model)) 130 | Config(_) -> Step(ST.to_settings_menu_state(model)) 131 | Upgrade(_) -> Step(ST.to_input_app_name_state(model)) 132 | _ -> Step(model) 133 | 134 | Search({ choices }) -> 135 | when choices is 136 | App(_) -> Step(ST.to_input_app_name_state(model)) 137 | Config(_) -> Step(ST.to_settings_menu_state(model)) 138 | Upgrade(_) -> Step(ST.to_input_app_name_state(model)) 139 | _ -> Step(model) 140 | 141 | PackageSelect(_) -> 142 | Step(ST.to_input_app_name_state(model)) 143 | 144 | _ -> Step(model) 145 | 146 | ClearFilter -> Step(Model.clear_search_filter(model)) 147 | NextPage -> Step(Model.next_page(model)) 148 | PrevPage -> Step(Model.prev_page(model)) 149 | _ -> Step(model) 150 | 151 | ## Map the user action to the appropriate state transition from the PackageSelect state 152 | package_select_handler : Model, UserAction -> [Step Model, Done Model] 153 | package_select_handler = |model, action| 154 | when action is 155 | Search -> Step(ST.to_search_state(model)) 156 | MultiConfirm -> Step(ST.to_confirmation_state(model)) 157 | MultiSelect -> Step(Model.toggle_selected(model)) 158 | VersionSelect -> Step(ST.to_version_select_state(model)) 159 | CursorUp -> Step(Model.move_cursor(model, Up)) 160 | CursorDown -> Step(Model.move_cursor(model, Down)) 161 | GoBack -> 162 | if Model.menu_is_filtered(model) then 163 | Step(Model.clear_search_filter(model)) 164 | else 165 | when model.sender is 166 | PlatformSelect(_) -> Step(ST.to_platform_select_state(model)) 167 | MainMenu(_) -> Step(ST.to_main_menu_state(model)) 168 | Confirmation({ choices }) -> 169 | when choices is 170 | App(_) -> Step(ST.to_platform_select_state(model)) 171 | Package(_) -> Step(ST.to_main_menu_state(model)) 172 | Upgrade({ platform, filename }) -> 173 | when platform is 174 | Ok(_) -> Step(ST.to_platform_select_state(model)) 175 | Err(_) if filename != "main.roc" -> Step(ST.to_platform_select_state(model)) 176 | _ -> Step(ST.to_main_menu_state(model)) 177 | 178 | _ -> Step(model) 179 | 180 | _ -> Step(model) 181 | 182 | ClearFilter -> Step(Model.clear_search_filter(model)) 183 | NextPage -> Step(Model.next_page(model)) 184 | PrevPage -> Step(Model.prev_page(model)) 185 | _ -> Step(model) 186 | 187 | version_select_handler : Model, UserAction -> [Step Model, Done Model] 188 | version_select_handler = |model, action| 189 | when action is 190 | SingleSelect -> 191 | when model.sender is 192 | PackageSelect(_) -> Step(ST.to_package_select_state(model)) 193 | PlatformSelect({ choices }) -> 194 | when choices is 195 | Config(_) -> Step(ST.to_settings_menu_state(model)) 196 | App(_) -> Step(ST.to_package_select_state(model)) 197 | Upgrade(_) -> Step(ST.to_package_select_state(model)) 198 | _ -> Step(model) 199 | 200 | _ -> Step(model) 201 | 202 | CursorUp -> Step(Model.move_cursor(model, Up)) 203 | CursorDown -> Step(Model.move_cursor(model, Down)) 204 | GoBack -> 205 | when model.sender is 206 | PackageSelect(_) -> Step(ST.to_package_select_state({ model & cursor: { row: 0, col: 2 } })) 207 | PlatformSelect(_) -> Step(ST.to_platform_select_state({ model & cursor: { row: 0, col: 2 } })) 208 | _ -> Step(model) 209 | 210 | NextPage -> Step(Model.next_page(model)) 211 | PrevPage -> Step(Model.prev_page(model)) 212 | _ -> Step(model) 213 | 214 | update_select_handler : Model, UserAction -> [Step Model, Done Model] 215 | update_select_handler = |model, action| 216 | when action is 217 | MultiSelect -> Step(Model.toggle_selected(model)) 218 | MultiConfirm -> Step(ST.to_confirmation_state(model)) 219 | CursorUp -> Step(Model.move_cursor(model, Up)) 220 | CursorDown -> Step(Model.move_cursor(model, Down)) 221 | GoBack -> Step(ST.to_main_menu_state(model)) 222 | NextPage -> Step(Model.next_page(model)) 223 | PrevPage -> Step(Model.prev_page(model)) 224 | _ -> Step(model) 225 | 226 | ## Map the user action to the appropriate state transition from the Search state 227 | search_handler : Model, UserAction -> [Step Model, Done Model] 228 | search_handler = |model, action| 229 | when action is 230 | SearchGo -> 231 | when model.sender is 232 | PlatformSelect(_) -> Step(ST.to_platform_select_state(model)) 233 | PackageSelect(_) -> Step(ST.to_package_select_state(model)) 234 | _ -> Step(model) 235 | 236 | TextBackspace -> Step(Model.backspace_buffer(model)) 237 | TextInput(key) -> 238 | ch = key |> Keys.key_to_str |> |str| if Str.is_empty(str) then None else Char(str) 239 | when ch is 240 | Char(c) -> Step(Model.append_to_buffer(model, c)) 241 | None -> Step(model) 242 | 243 | Cancel -> 244 | when model.sender is 245 | PlatformSelect(_) -> Step(model |> Model.clear_buffer |> ST.to_platform_select_state) 246 | PackageSelect(_) -> Step(model |> Model.clear_buffer |> ST.to_package_select_state) 247 | _ -> Step(model) 248 | 249 | GoBack -> 250 | when model.sender is 251 | PlatformSelect(_) -> Step(ST.to_platform_select_state(model)) 252 | PackageSelect(_) -> Step(ST.to_package_select_state(model)) 253 | _ -> Step(model) 254 | 255 | _ -> Step(model) 256 | 257 | ## Map the user action to the appropriate state transition from the InputAppName state 258 | input_app_name_handler : Model, UserAction -> [Step Model, Done Model] 259 | input_app_name_handler = |model, action| 260 | when action is 261 | TextSubmit -> Step(ST.to_platform_select_state(model)) 262 | TextInput(key) -> 263 | ch = key |> Keys.key_to_str |> |str| if Str.is_empty(str) then None else Char(str) 264 | when ch is 265 | Char(c) -> Step(Model.append_to_buffer(model, c)) 266 | None -> Step(model) 267 | 268 | TextBackspace -> Step(Model.backspace_buffer(model)) 269 | GoBack -> Step(ST.to_main_menu_state(model)) 270 | _ -> Step(model) 271 | 272 | ## Map the user action to the appropriate state transition from the Confirmation state 273 | confirmation_handler : Model, UserAction -> [Step Model, Done Model] 274 | confirmation_handler = |model, action| 275 | when action is 276 | Finish -> Done(ST.to_finished_state(model)) 277 | GoBack -> 278 | when model.sender is 279 | PackageSelect(_) -> Step(ST.to_package_select_state(model)) 280 | UpdateSelect(_) -> Step(ST.to_update_select_state(model)) 281 | SettingsMenu(_) -> Step(ST.to_settings_menu_state(model)) 282 | ChooseFlags({ choices }) -> 283 | when choices is 284 | App(_) | Package(_) -> Step(ST.to_package_select_state(model)) 285 | _ -> Step(model) 286 | 287 | _ -> Step(model) 288 | 289 | SetFlags -> Step(ST.to_choose_flags_state(model)) 290 | _ -> Step(model) 291 | 292 | choose_flags_handler : Model, UserAction -> [Step Model, Done Model] 293 | choose_flags_handler = |model, action| 294 | when action is 295 | MultiSelect -> Step(Model.toggle_selected(model)) 296 | MultiConfirm -> Step(ST.to_confirmation_state(model)) 297 | CursorUp -> Step(Model.move_cursor(model, Up)) 298 | CursorDown -> Step(Model.move_cursor(model, Down)) 299 | NextPage -> Step(Model.next_page(model)) 300 | PrevPage -> Step(Model.prev_page(model)) 301 | GoBack -> Step(ST.to_confirmation_state(model)) 302 | _ -> Step(model) 303 | 304 | ## Map the user action to the appropriate state transition from the Splash state 305 | splash_handler : Model, UserAction -> [Step Model, Done Model] 306 | splash_handler = |model, action| 307 | when action is 308 | Continue -> Step(ST.to_main_menu_state(model)) 309 | _ -> Step(model) 310 | -------------------------------------------------------------------------------- /src/tui/InputHandlers.roc: -------------------------------------------------------------------------------- 1 | module [handle_input] 2 | 3 | import ansi.ANSI exposing [Input] 4 | import Model exposing [Model] 5 | import UserAction exposing [UserAction] 6 | import Controller 7 | 8 | handle_input : Model, ANSI.Input -> [Step Model, Done Model] 9 | handle_input = |model, input| 10 | input_handlers = Model.get_actions(model) |> List.map(action_to_input_handler) 11 | action = List.walk_until( 12 | input_handlers, 13 | None, 14 | |_, handler| 15 | when handler(model, input) is 16 | Ok(act) -> Break(act) 17 | Err(Unhandled) -> Continue(None), 18 | ) 19 | Controller.apply_action(model, action) 20 | 21 | action_to_input_handler = |action| 22 | when action is 23 | Cancel -> handle_cancel 24 | ClearFilter -> handle_clear_filter 25 | CursorDown -> handle_cursor_down 26 | CursorUp -> handle_cursor_up 27 | Exit -> handle_exit 28 | Finish -> handle_finish 29 | GoBack -> handle_go_back 30 | MultiConfirm -> handle_multi_confirm 31 | MultiSelect -> handle_multi_select 32 | VersionSelect -> handle_version_select 33 | NextPage -> handle_next_page 34 | PrevPage -> handle_prev_page 35 | Search -> handle_search 36 | SearchGo -> handle_search_go 37 | SingleSelect -> handle_single_select 38 | TextInput(None) -> handle_text_input 39 | TextBackspace -> handle_text_backspace 40 | TextSubmit -> handle_text_submit 41 | SetFlags -> handle_set_flags 42 | Secret -> handle_secret 43 | Continue -> handle_continue 44 | _ -> unhandled 45 | 46 | handle_cancel : Model, Input -> Result UserAction [Unhandled] 47 | handle_cancel = |_model, input| 48 | when input is 49 | Action(Escape) -> Ok(Cancel) 50 | _ -> Err(Unhandled) 51 | 52 | handle_clear_filter : Model, Input -> Result UserAction [Unhandled] 53 | handle_clear_filter = |_model, input| 54 | when input is 55 | Action(Escape) -> Ok(ClearFilter) 56 | _ -> Err(Unhandled) 57 | 58 | handle_cursor_down : Model, Input -> Result UserAction [Unhandled] 59 | handle_cursor_down = |_model, input| 60 | when input is 61 | Arrow(Down) | Lower(J) -> Ok(CursorDown) 62 | _ -> Err(Unhandled) 63 | 64 | handle_cursor_up : Model, Input -> Result UserAction [Unhandled] 65 | handle_cursor_up = |_model, input| 66 | when input is 67 | Arrow(Up) | Lower(K) -> Ok(CursorUp) 68 | _ -> Err(Unhandled) 69 | 70 | handle_exit : Model, Input -> Result UserAction [Unhandled] 71 | handle_exit = |_model, input| 72 | when input is 73 | Ctrl(C) -> Ok(Exit) 74 | _ -> Err(Unhandled) 75 | 76 | handle_finish : Model, Input -> Result UserAction [Unhandled] 77 | handle_finish = |_model, input| 78 | when input is 79 | Action(Enter) -> Ok(Finish) 80 | _ -> Err(Unhandled) 81 | 82 | handle_go_back : Model, Input -> Result UserAction [Unhandled] 83 | handle_go_back = |model, input| 84 | buffer_len = Model.get_buffer_len(model) 85 | when input is 86 | Action(Delete) | Ctrl(H) if buffer_len == 0 -> Ok(GoBack) 87 | _ -> Err(Unhandled) 88 | 89 | handle_multi_confirm : Model, Input -> Result UserAction [Unhandled] 90 | handle_multi_confirm = |_model, input| 91 | when input is 92 | Action(Enter) -> Ok(MultiConfirm) 93 | _ -> Err(Unhandled) 94 | 95 | handle_multi_select : Model, Input -> Result UserAction [Unhandled] 96 | handle_multi_select = |_model, input| 97 | when input is 98 | Action(Space) -> Ok(MultiSelect) 99 | _ -> Err(Unhandled) 100 | 101 | handle_version_select : Model, Input -> Result UserAction [Unhandled] 102 | handle_version_select = |_model, input| 103 | when input is 104 | Upper(V) | Lower(V) -> Ok(VersionSelect) 105 | _ -> Err(Unhandled) 106 | 107 | handle_next_page : Model, Input -> Result UserAction [Unhandled] 108 | handle_next_page = |_model, input| 109 | when input is 110 | Arrow(Right) | Symbol(GreaterThanSign) | Symbol(FullStop) | Lower(L) -> Ok(NextPage) 111 | _ -> Err(Unhandled) 112 | 113 | handle_prev_page : Model, Input -> Result UserAction [Unhandled] 114 | handle_prev_page = |_model, input| 115 | when input is 116 | Arrow(Left) | Symbol(LessThanSign) | Symbol(Comma) | Lower(H) -> Ok(PrevPage) 117 | _ -> Err(Unhandled) 118 | 119 | handle_search : Model, Input -> Result UserAction [Unhandled] 120 | handle_search = |_model, input| 121 | when input is 122 | Lower(S) | Upper(S) -> Ok(Search) 123 | _ -> Err(Unhandled) 124 | 125 | handle_search_go : Model, Input -> Result UserAction [Unhandled] 126 | handle_search_go = |_model, input| 127 | when input is 128 | Action(Enter) -> Ok(SearchGo) 129 | _ -> Err(Unhandled) 130 | 131 | handle_single_select : Model, Input -> Result UserAction [Unhandled] 132 | handle_single_select = |_model, input| 133 | when input is 134 | Action(Enter) -> Ok(SingleSelect) 135 | _ -> Err(Unhandled) 136 | 137 | handle_text_input : Model, Input -> Result UserAction [Unhandled] 138 | handle_text_input = |_model, input| 139 | when input is 140 | Action(Space) -> Ok(TextInput(Action(Space))) 141 | Symbol(s) -> Ok(TextInput(Symbol(s))) 142 | Number(n) -> Ok(TextInput(Number(n))) 143 | Lower(l) | Upper(l) -> Ok(TextInput(Lower(l))) 144 | _ -> Err(Unhandled) 145 | 146 | handle_text_backspace : Model, Input -> Result UserAction [Unhandled] 147 | handle_text_backspace = |model, input| 148 | buffer_len = Model.get_buffer_len(model) 149 | when input is 150 | Ctrl(H) if buffer_len > 0 -> Ok(TextBackspace) 151 | Action(Delete) if buffer_len > 0 -> Ok(TextBackspace) 152 | _ -> Err(Unhandled) 153 | 154 | handle_text_submit : Model, Input -> Result UserAction [Unhandled] 155 | handle_text_submit = |_model, input| 156 | when input is 157 | Action(Enter) -> Ok(TextSubmit) 158 | _ -> Err(Unhandled) 159 | 160 | handle_set_flags : Model, Input -> Result UserAction [Unhandled] 161 | handle_set_flags = |_model, input| 162 | when input is 163 | Lower(F) | Upper(F) -> Ok(SetFlags) 164 | _ -> Err(Unhandled) 165 | 166 | handle_secret : Model, Input -> Result UserAction [Unhandled] 167 | handle_secret = |_model, input| 168 | when input is 169 | Symbol(GraveAccent) -> Ok(Secret) 170 | _ -> Err(Unhandled) 171 | 172 | handle_continue : Model, Input -> Result UserAction [Unhandled] 173 | handle_continue = |_model, input| 174 | when input is 175 | Action(Enter) -> Ok(Continue) 176 | _ -> Err(Unhandled) 177 | 178 | unhandled : Model, Input -> Result UserAction [Unhandled] 179 | unhandled = |_model, _input| 180 | Err(Unhandled) 181 | -------------------------------------------------------------------------------- /src/tui/Keys.roc: -------------------------------------------------------------------------------- 1 | module [Key, key_to_str, key_to_slug_str] 2 | 3 | import Utils 4 | 5 | Key : [ 6 | Action [Space], 7 | Symbol Symbol, 8 | Number Number, 9 | Upper Letter, 10 | Lower Letter, 11 | None, 12 | ] 13 | 14 | Symbol : [ 15 | ExclamationMark, 16 | QuotationMark, 17 | NumberSign, 18 | DollarSign, 19 | PercentSign, 20 | Ampersand, 21 | Apostrophe, 22 | RoundOpenBracket, 23 | RoundCloseBracket, 24 | Asterisk, 25 | PlusSign, 26 | Comma, 27 | Hyphen, 28 | FullStop, 29 | ForwardSlash, 30 | Colon, 31 | SemiColon, 32 | LessThanSign, 33 | EqualsSign, 34 | GreaterThanSign, 35 | QuestionMark, 36 | AtSign, 37 | SquareOpenBracket, 38 | Backslash, 39 | SquareCloseBracket, 40 | Caret, 41 | Underscore, 42 | GraveAccent, 43 | CurlyOpenBrace, 44 | VerticalBar, 45 | CurlyCloseBrace, 46 | Tilde, 47 | ] 48 | Number : [N0, N1, N2, N3, N4, N5, N6, N7, N8, N9] 49 | Letter : [A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V, W, X, Y, Z] 50 | 51 | symbol_to_str : Symbol -> Str 52 | symbol_to_str = |symbol| 53 | when symbol is 54 | ExclamationMark -> "!" 55 | QuotationMark -> "\"" 56 | NumberSign -> "#" 57 | DollarSign -> "$" 58 | PercentSign -> "%" 59 | Ampersand -> "&" 60 | Apostrophe -> "'" 61 | RoundOpenBracket -> "(" 62 | RoundCloseBracket -> ")" 63 | Asterisk -> "*" 64 | PlusSign -> "+" 65 | Comma -> "," 66 | Hyphen -> "-" 67 | FullStop -> "." 68 | ForwardSlash -> "/" 69 | Colon -> ":" 70 | SemiColon -> ";" 71 | LessThanSign -> "<" 72 | EqualsSign -> "=" 73 | GreaterThanSign -> ">" 74 | QuestionMark -> "?" 75 | AtSign -> "@" 76 | SquareOpenBracket -> "[" 77 | Backslash -> "\\" 78 | SquareCloseBracket -> "]" 79 | Caret -> "^" 80 | Underscore -> "_" 81 | GraveAccent -> "`" 82 | CurlyOpenBrace -> "{" 83 | VerticalBar -> "|" 84 | CurlyCloseBrace -> "}" 85 | Tilde -> "~" 86 | 87 | number_to_str : Number -> Str 88 | number_to_str = |number| 89 | when number is 90 | N0 -> "0" 91 | N1 -> "1" 92 | N2 -> "2" 93 | N3 -> "3" 94 | N4 -> "4" 95 | N5 -> "5" 96 | N6 -> "6" 97 | N7 -> "7" 98 | N8 -> "8" 99 | N9 -> "9" 100 | 101 | letter_to_str : Letter -> Str 102 | letter_to_str = |letter| 103 | when letter is 104 | A -> "A" 105 | B -> "B" 106 | C -> "C" 107 | D -> "D" 108 | E -> "E" 109 | F -> "F" 110 | G -> "G" 111 | H -> "H" 112 | I -> "I" 113 | J -> "J" 114 | K -> "K" 115 | L -> "L" 116 | M -> "M" 117 | N -> "N" 118 | O -> "O" 119 | P -> "P" 120 | Q -> "Q" 121 | R -> "R" 122 | S -> "S" 123 | T -> "T" 124 | U -> "U" 125 | V -> "V" 126 | W -> "W" 127 | X -> "X" 128 | Y -> "Y" 129 | Z -> "Z" 130 | 131 | key_to_str : Key -> Str 132 | key_to_str = |key| 133 | when key is 134 | Action(Space) -> " " 135 | Symbol(symbol) -> 136 | symbol 137 | |> symbol_to_str 138 | 139 | Number(number) -> 140 | number 141 | |> number_to_str 142 | 143 | Upper(letter) -> 144 | letter 145 | |> letter_to_str 146 | 147 | Lower(letter) -> 148 | letter 149 | |> letter_to_str 150 | |> Utils.str_to_lower 151 | 152 | None -> "" 153 | 154 | key_to_slug_str : Key -> Str 155 | key_to_slug_str = |key| 156 | when key is 157 | Action(Space) -> "_" 158 | Symbol(Hyphen) -> "-" 159 | Symbol(Underscore) -> "_" 160 | Number(number) -> 161 | number 162 | |> number_to_str 163 | 164 | Lower(letter) -> 165 | letter 166 | |> letter_to_str 167 | |> Utils.str_to_lower 168 | 169 | Upper(letter) -> 170 | letter 171 | |> letter_to_str 172 | 173 | _ -> "" 174 | -------------------------------------------------------------------------------- /src/tui/Model.roc: -------------------------------------------------------------------------------- 1 | module [ 2 | Model, 3 | init, 4 | main_menu, 5 | get_actions, 6 | action_is_available, 7 | is_not_first_page, 8 | is_not_last_page, 9 | get_highlighted_index, 10 | get_highlighted_item, 11 | get_selected_items, 12 | menu_is_filtered, 13 | get_choices, 14 | get_buffer_len, 15 | paginate, 16 | add_selected_packages_to_config, 17 | clear_search_filter, 18 | append_to_buffer, 19 | backspace_buffer, 20 | clear_buffer, 21 | toggle_selected, 22 | next_page, 23 | prev_page, 24 | move_cursor, 25 | ] 26 | 27 | import Utils 28 | import Choices exposing [Choices] 29 | import State exposing [State] 30 | import UserAction exposing [UserAction] 31 | import ansi.ANSI 32 | import repos.Manager as RM exposing [RepositoryRelease] 33 | import rtils.Compare 34 | 35 | Model : { 36 | screen : ANSI.ScreenSize, 37 | cursor : ANSI.CursorPosition, 38 | menu_row : U16, 39 | page_first_item : U64, 40 | menu : List Str, 41 | full_menu : List Str, 42 | selected : List Str, 43 | # inputs : List ANSI.Input, 44 | theme_names : List Str, 45 | platforms : Dict Str (List RepositoryRelease), 46 | packages : Dict Str (List RepositoryRelease), 47 | package_name_map : Dict Str (List Str), 48 | platform_name_map : Dict Str (List Str), 49 | package_menu : List Str, 50 | platform_menu : List Str, 51 | state : State, 52 | sender : State, 53 | # theme : Theme, 54 | # including theme in model, whether imported from theme module, or redefined internally using Color causes compiler crash 55 | } 56 | 57 | ## Get the available actions for the current state 58 | get_actions : Model -> List UserAction 59 | get_actions = |model| 60 | when model.state is 61 | PlatformSelect(_) -> 62 | [Exit, SingleSelect, CursorUp, CursorDown] 63 | |> with_platform_version(model) 64 | |> with_search_or_clear_filter(model) 65 | |> List.append(GoBack) 66 | |> with_prev_page(model) 67 | |> with_next_page(model) 68 | 69 | PackageSelect(_) -> 70 | [Exit, MultiSelect, VersionSelect, MultiConfirm, CursorUp, CursorDown] 71 | |> with_search_or_clear_filter(model) 72 | |> List.append(GoBack) 73 | |> with_prev_page(model) 74 | |> with_next_page(model) 75 | 76 | MainMenu(_) -> 77 | [Exit, SingleSelect, CursorUp, CursorDown, Secret] 78 | |> with_prev_page(model) 79 | |> with_next_page(model) 80 | 81 | InputAppName(_) -> 82 | [Exit, TextSubmit, TextInput(None)] 83 | |> with_go_back_or_backspace(model) 84 | 85 | VersionSelect(_) -> 86 | [Exit, SingleSelect, CursorUp, CursorDown, GoBack] 87 | |> with_prev_page(model) 88 | |> with_next_page(model) 89 | 90 | UpdateSelect(_) -> 91 | [Exit, MultiSelect, MultiConfirm, CursorUp, CursorDown, GoBack] 92 | |> with_prev_page(model) 93 | |> with_next_page(model) 94 | 95 | SettingsMenu(_) -> 96 | [Exit, SingleSelect, CursorUp, CursorDown, GoBack] 97 | |> with_prev_page(model) 98 | |> with_next_page(model) 99 | 100 | SettingsSubmenu(_) -> 101 | [Exit, SingleSelect, CursorUp, CursorDown, GoBack] 102 | |> with_prev_page(model) 103 | |> with_next_page(model) 104 | 105 | Confirmation(_) -> 106 | [Exit, Finish] 107 | |> with_set_flags(model) 108 | |> List.append(GoBack) 109 | 110 | ChooseFlags(_) -> 111 | [Exit, MultiSelect, MultiConfirm, CursorUp, CursorDown] 112 | |> with_prev_page(model) 113 | |> with_next_page(model) 114 | 115 | Search(_) -> 116 | [Exit, SearchGo, Cancel, TextInput(None)] 117 | |> with_go_back_or_backspace(model) 118 | 119 | Splash(_) -> [Exit, Continue] 120 | _ -> [Exit] 121 | 122 | with_search_or_clear_filter = |actions, model| List.append(actions, (if Model.menu_is_filtered(model) then ClearFilter else Search)) 123 | with_go_back_or_backspace = |actions, model| List.append(actions, (if Model.get_buffer_len(model) > 0 then TextBackspace else GoBack)) 124 | with_platform_version = |actions, model| if Model.get_highlighted_item(model) == "No change" then actions else List.append(actions, VersionSelect) 125 | with_prev_page = |actions, model| if Model.is_not_first_page(model) then List.append(actions, PrevPage) else actions 126 | with_next_page = |actions, model| if Model.is_not_last_page(model) then List.append(actions, NextPage) else actions 127 | with_set_flags = |actions, model| 128 | when Model.get_choices(model) is 129 | App(_) | Package(_) -> List.append(actions, SetFlags) 130 | _ -> actions 131 | 132 | ## Check if the user action is available in the current state 133 | action_is_available : Model, UserAction -> Bool 134 | action_is_available = |model, action| 135 | actions = get_actions(model) 136 | when action is 137 | TextInput(_) -> List.contains(actions, TextInput(None)) 138 | _ -> List.contains(actions, action) 139 | 140 | main_menu = ["Start app", "Start package", "Upgrade app", "Upgrade package", "Update roc-start", "Settings", "Exit"] 141 | 142 | ## Initialize the model 143 | init : Dict Str (List RepositoryRelease), Dict Str (List RepositoryRelease), { state ?? State, theme_names ?? List Str } -> Model 144 | init = |platforms, packages, { state ?? MainMenu({ choices: NothingToDo }), theme_names ?? ["default"] }| 145 | package_name_map = RM.build_repo_name_map(Dict.keys(packages)) 146 | platform_name_map = RM.build_repo_name_map(Dict.keys(platforms)) 147 | package_menu = build_repo_menu(package_name_map) 148 | platform_menu = build_repo_menu(platform_name_map) 149 | { 150 | screen: { width: 0, height: 0 }, 151 | cursor: { row: 2, col: 2 }, 152 | menu_row: 2, 153 | page_first_item: 0, 154 | menu: main_menu, 155 | full_menu: main_menu, 156 | theme_names, 157 | platforms, 158 | packages, 159 | package_name_map, 160 | platform_name_map, 161 | platform_menu: platform_menu, 162 | package_menu: package_menu, 163 | selected: [], 164 | state, 165 | sender: state, 166 | } 167 | 168 | build_repo_menu : Dict Str (List Str) -> List Str 169 | build_repo_menu = |name_map| 170 | Dict.to_list(name_map) 171 | |> List.sort_with(|(a, _), (b, _)| Compare.str(a, b)) 172 | |> List.map( 173 | |(name, owners)| 174 | when owners is 175 | [_] -> [name] 176 | _ -> List.map(owners, |owner| "${name} (${owner})") |> List.sort_with(Compare.str), 177 | ) 178 | |> List.join 179 | 180 | ## Check if the current page is not the first page 181 | is_not_first_page : Model -> Bool 182 | is_not_first_page = |model| model.page_first_item > 0 183 | 184 | ## Check if the current page is not the last page 185 | is_not_last_page : Model -> Bool 186 | is_not_last_page = |model| 187 | max_items = 188 | Num.sub_checked(model.screen.height, (model.menu_row + 1)) 189 | |> Result.with_default(0) 190 | |> Num.to_u64 191 | model.page_first_item + max_items < List.len(model.full_menu) 192 | 193 | ## Get the index of the highlighted item 194 | get_highlighted_index : Model -> U64 195 | get_highlighted_index = |model| Num.to_u64(model.cursor.row) - Num.to_u64(model.menu_row) 196 | 197 | ## Get the highlighted item 198 | get_highlighted_item : Model -> Str 199 | get_highlighted_item = |model| List.get(model.menu, get_highlighted_index(model)) |> Result.with_default("") 200 | 201 | ## Get the selected items in a multi-select menu 202 | get_selected_items : Model -> List Str 203 | get_selected_items = |model| model.selected 204 | 205 | ## Check if the menu is currently filtered 206 | menu_is_filtered : Model -> Bool 207 | menu_is_filtered = |model| 208 | when model.state is 209 | PlatformSelect(_) -> List.len(model.full_menu) < List.len(model.platform_menu) 210 | PackageSelect(_) -> List.len(model.full_menu) < List.len(model.package_menu) 211 | _ -> Bool.false 212 | 213 | get_choices : Model -> Choices 214 | get_choices = |model| 215 | when model.state is 216 | MainMenu({ choices }) -> choices 217 | InputAppName({ choices }) -> choices 218 | SettingsMenu({ choices }) -> choices 219 | SettingsSubmenu({ choices }) -> choices 220 | Search({ choices }) -> choices 221 | PlatformSelect({ choices }) -> choices 222 | PackageSelect({ choices }) -> choices 223 | VersionSelect({ choices }) -> choices 224 | UpdateSelect({ choices }) -> choices 225 | Confirmation({ choices }) -> choices 226 | Finished({ choices }) -> choices 227 | Splash({ choices }) -> choices 228 | _ -> NothingToDo 229 | 230 | get_buffer_len : Model -> U64 231 | get_buffer_len = |model| 232 | when model.state is 233 | InputAppName({ name_buffer }) -> List.len(name_buffer) 234 | Search({ search_buffer }) -> List.len(search_buffer) 235 | _ -> 0 236 | 237 | ## Split the menu into pages, and adjust the cursor position if necessary 238 | paginate : Model -> Model 239 | paginate = |model| 240 | max_items = 241 | Num.sub_checked(model.screen.height, (model.menu_row + 1)) 242 | |> Result.with_default(0) 243 | |> Num.to_u64 244 | page_first_item = 245 | if List.len(model.menu) < max_items and model.page_first_item > 0 then 246 | idx = Num.to_i64(List.len(model.full_menu)) - Num.to_i64(max_items) 247 | if idx >= 0 then Num.to_u64(idx) else 0 248 | else 249 | model.page_first_item 250 | menu = List.sublist(model.full_menu, { start: page_first_item, len: max_items }) 251 | cursor_row = 252 | if model.cursor.row >= model.menu_row + Num.to_u16(List.len(menu)) and List.len(menu) > 0 then 253 | model.menu_row + Num.to_u16(List.len(menu)) - 1 254 | else 255 | model.cursor.row 256 | cursor = { row: cursor_row, col: model.cursor.col } 257 | { model & menu, page_first_item, cursor } 258 | 259 | ## Add the selected packages to the configuration 260 | add_selected_packages_to_config : Model -> Model 261 | add_selected_packages_to_config = |model| 262 | when model.state is 263 | PackageSelect(data) -> 264 | package_repos = Model.get_selected_items(model) |> List.map(Utils.menu_item_to_repo) 265 | new_choices = data.choices |> Choices.set_packages(package_repos) 266 | { model & state: PackageSelect({ data & choices: new_choices }) } 267 | 268 | _ -> model 269 | 270 | ## Clear the search filter 271 | clear_search_filter : Model -> Model 272 | clear_search_filter = |model| 273 | when model.state is 274 | PackageSelect(_) -> 275 | { model & 276 | full_menu: model.package_menu, 277 | cursor: { row: 2, col: 2 }, 278 | } 279 | 280 | PlatformSelect({ choices }) -> 281 | menu = 282 | when choices is 283 | Upgrade(_) -> [["No change"], model.platform_menu] |> List.join 284 | _ -> model.platform_menu 285 | { model & 286 | full_menu: menu, 287 | cursor: { row: 2, col: 2 }, 288 | } 289 | 290 | _ -> model 291 | 292 | ## Append a key to the name or search buffer 293 | append_to_buffer : Model, Str -> Model 294 | append_to_buffer = |model, str| 295 | when model.state is 296 | Search({ search_buffer, choices, prior_sender }) -> 297 | new_buffer = List.concat(search_buffer, (Utils.str_to_slug(str) |> Str.to_utf8)) 298 | { model & state: Search({ choices, search_buffer: new_buffer, prior_sender }) } 299 | 300 | InputAppName({ name_buffer, choices }) -> 301 | new_buffer = List.concat(name_buffer, (Utils.str_to_slug(str) |> Str.to_utf8)) 302 | { model & state: InputAppName({ choices, name_buffer: new_buffer }) } 303 | 304 | _ -> model 305 | 306 | ## Remove the last character from the name or search buffer 307 | backspace_buffer : Model -> Model 308 | backspace_buffer = |model| 309 | when model.state is 310 | Search({ search_buffer, choices, prior_sender }) -> 311 | new_buffer = List.drop_last(search_buffer, 1) 312 | { model & state: Search({ choices, search_buffer: new_buffer, prior_sender }) } 313 | 314 | InputAppName({ name_buffer, choices }) -> 315 | new_buffer = List.drop_last(name_buffer, 1) 316 | { model & state: InputAppName({ choices, name_buffer: new_buffer }) } 317 | 318 | _ -> model 319 | 320 | ## Clear the search buffer 321 | clear_buffer : Model -> Model 322 | clear_buffer = |model| 323 | when model.state is 324 | Search({ choices, prior_sender }) -> 325 | { model & state: Search({ choices, search_buffer: [], prior_sender }) } 326 | 327 | InputAppName({ choices }) -> 328 | { model & state: InputAppName({ choices, name_buffer: [] }) } 329 | 330 | _ -> model 331 | 332 | ## Toggle the selected state of an item in a multi-select menu 333 | toggle_selected : Model -> Model 334 | toggle_selected = |model| 335 | item = Model.get_highlighted_item(model) 336 | if List.contains(model.selected, item) then 337 | { model & selected: List.drop_if(model.selected, |i| i == item) } 338 | else 339 | { model & selected: List.append(model.selected, item) } 340 | 341 | ## Move to the next page if possible 342 | next_page : Model -> Model 343 | next_page = |model| 344 | max_items = model.screen.height - (model.menu_row + 1) |> Num.to_u64 345 | if Model.is_not_last_page(model) then 346 | page_first_item = model.page_first_item + max_items 347 | menu = List.sublist(model.full_menu, { start: page_first_item, len: max_items }) 348 | cursor = { row: model.menu_row, col: model.cursor.col } 349 | { model & menu, page_first_item, cursor } 350 | else 351 | model 352 | 353 | ## Move to the previous page if possible 354 | prev_page : Model -> Model 355 | prev_page = |model| 356 | max_items = model.screen.height - (model.menu_row + 1) |> Num.to_u64 357 | if Model.is_not_first_page(model) then 358 | page_first_item = if (Num.to_i64(model.page_first_item) - Num.to_i64(max_items)) > 0 then model.page_first_item - max_items else 0 359 | menu = List.sublist(model.full_menu, { start: page_first_item, len: max_items }) 360 | cursor = { row: model.menu_row, col: model.cursor.col } 361 | { model & menu, page_first_item, cursor } 362 | else 363 | model 364 | 365 | ## Move the cursor up or down 366 | move_cursor : Model, [Up, Down] -> Model 367 | move_cursor = |model, direction| 368 | if List.len(model.menu) > 0 then 369 | when direction is 370 | Up -> 371 | if model.cursor.row <= Num.to_u16(model.menu_row) then 372 | { model & cursor: { row: Num.to_u16(List.len(model.menu)) + model.menu_row - 1, col: model.cursor.col } } 373 | else 374 | { model & cursor: { row: model.cursor.row - 1, col: model.cursor.col } } 375 | 376 | Down -> 377 | if model.cursor.row >= Num.to_u16((List.len(model.menu) - 1)) + Num.to_u16(model.menu_row) then 378 | { model & cursor: { row: Num.to_u16(model.menu_row), col: model.cursor.col } } 379 | else 380 | { model & cursor: { row: model.cursor.row + 1, col: model.cursor.col } } 381 | else 382 | model 383 | -------------------------------------------------------------------------------- /src/tui/State.roc: -------------------------------------------------------------------------------- 1 | module [State] 2 | 3 | import Choices exposing [Choices] 4 | 5 | State : [ 6 | MainMenu { choices : Choices }, 7 | SettingsMenu { choices : Choices }, 8 | SettingsSubmenu { choices : Choices, submenu : [Theme, Verbosity] }, 9 | InputAppName { name_buffer : List U8, choices : Choices }, 10 | Search { search_buffer : List U8, choices : Choices, prior_sender : State }, 11 | PlatformSelect { choices : Choices }, 12 | PackageSelect { choices : Choices }, 13 | VersionSelect { choices : Choices, repo : { name : Str, version : Str } }, 14 | UpdateSelect { choices : Choices }, 15 | Confirmation { choices : Choices }, 16 | ChooseFlags { choices : Choices }, 17 | Finished { choices : Choices }, 18 | Splash { choices : Choices }, 19 | UserExited, 20 | ] 21 | -------------------------------------------------------------------------------- /src/tui/StateTransitions.roc: -------------------------------------------------------------------------------- 1 | module [ 2 | to_user_exited_state, 3 | to_main_menu_state, 4 | to_settings_menu_state, 5 | to_settings_submenu_state, 6 | to_input_app_name_state, 7 | to_splash_state, 8 | to_platform_select_state, 9 | to_package_select_state, 10 | to_version_select_state, 11 | to_update_select_state, 12 | to_finished_state, 13 | to_confirmation_state, 14 | to_choose_flags_state, 15 | to_search_state, 16 | ] 17 | 18 | import Choices 19 | import Utils 20 | import Model exposing [Model] 21 | import repos.Manager as RM 22 | import heck.Heck 23 | import rtils.Compare 24 | 25 | ## Transition to the UserExited state 26 | to_user_exited_state : Model -> Model 27 | to_user_exited_state = |model| { model & state: UserExited, sender: model.state } 28 | 29 | ## Transition to the MainMenu state 30 | to_main_menu_state : Model -> Model 31 | to_main_menu_state = |model| 32 | menu = Model.main_menu 33 | { row, choices: new_choices } = 34 | when model.state is 35 | InputAppName({ choices, name_buffer }) -> 36 | menu_row = 37 | when choices is 38 | App(_) -> model.menu_row 39 | Upgrade(_) -> model.menu_row + 2 40 | _ -> model.menu_row 41 | filename = name_buffer |> Str.from_utf8 |> Result.with_default("main") 42 | { choices: choices |> Choices.set_filename(filename), row: menu_row } 43 | 44 | PlatformSelect({ choices }) -> 45 | platform = Model.get_highlighted_item(model) |> Utils.menu_item_to_repo 46 | { choices: choices |> Choices.set_platform(platform), row: model.menu_row } 47 | 48 | PackageSelect({ choices }) -> 49 | menu_row = 50 | when choices is 51 | Package(_) -> model.menu_row + 1 52 | Upgrade(_) -> model.menu_row + 3 53 | _ -> model.menu_row 54 | package_repos = model.selected |> List.map(Utils.menu_item_to_repo) 55 | { choices: choices |> Choices.set_packages(package_repos), row: menu_row } 56 | 57 | UpdateSelect({ choices }) -> 58 | selected = Model.get_selected_items(model) 59 | { choices: choices |> Choices.set_updates(selected), row: model.menu_row + 4 } 60 | 61 | SettingsMenu({ choices }) -> { choices, row: model.menu_row + 5 } 62 | MainMenu({ choices }) -> { choices, row: 2 } 63 | SettingsSubmenu({ choices }) -> { choices, row: 2 } 64 | Search({ choices }) -> { choices, row: 2 } 65 | VersionSelect({ choices }) -> { choices, row: 2 } 66 | Confirmation({ choices }) -> { choices, row: 2 } 67 | ChooseFlags({ choices }) -> { choices, row: 2 } 68 | Finished({ choices }) -> { choices, row: 2 } 69 | Splash({ choices }) -> { choices, row: 2 } 70 | UserExited -> { choices: NothingToDo, row: 2 } 71 | 72 | { model & 73 | cursor: { row, col: 2 }, 74 | menu, 75 | full_menu: menu, 76 | state: MainMenu({ choices: new_choices }), 77 | sender: model.state, 78 | } 79 | 80 | ## Transition to the SettingsMenu state 81 | to_settings_menu_state : Model -> Model 82 | to_settings_menu_state = |model| 83 | menu = ["Theme", "Verbosity", "Default platform", "Save changes"] 84 | when model.state is 85 | MainMenu({ choices }) -> 86 | new_choices = Choices.to_config(choices) 87 | { model & 88 | cursor: { row: 2, col: 2 }, 89 | full_menu: menu, 90 | state: SettingsMenu({ choices: new_choices }), 91 | sender: model.state, 92 | } 93 | 94 | SettingsSubmenu({ choices, submenu }) -> 95 | { row, choices: new_choices } = 96 | if model.cursor.row < model.menu_row then 97 | when submenu is 98 | Theme -> 99 | { row: model.menu_row, choices } 100 | 101 | Verbosity -> 102 | { row: model.menu_row + 1, choices } 103 | else 104 | selection = Model.get_highlighted_item(model) 105 | when submenu is 106 | Theme -> 107 | { row: model.menu_row, choices: choices |> Choices.set_config_theme(selection |> Heck.to_kebab_case) } 108 | 109 | Verbosity -> 110 | { row: model.menu_row + 1, choices: choices |> Choices.set_config_verbosity(selection |> Heck.to_kebab_case) } 111 | 112 | { model & 113 | cursor: { row, col: 2 }, 114 | full_menu: menu, 115 | state: SettingsMenu({ choices: new_choices }), 116 | sender: model.state, 117 | } 118 | 119 | PlatformSelect({ choices }) -> 120 | new_choices = 121 | if model.cursor.row < model.menu_row then 122 | choices 123 | else 124 | platform = Model.get_highlighted_item(model) 125 | choices |> Choices.set_config_platform(platform) 126 | { model & 127 | cursor: { row: model.menu_row + 2, col: 2 }, 128 | full_menu: menu, 129 | state: SettingsMenu({ choices: new_choices }), 130 | sender: model.state, 131 | } 132 | 133 | VersionSelect({ choices, repo }) -> 134 | selected_version = Model.get_highlighted_item(model) |> |v| if v == "latest" then "" else v 135 | new_repo = { repo & version: selected_version } 136 | platform_menu = add_or_update_platform_menu(model.platform_menu, new_repo) 137 | new_repo_str = if Str.is_empty(new_repo.version) then new_repo.name else "${new_repo.name}:${new_repo.version}" 138 | new_choices = choices |> Choices.set_config_platform(new_repo_str) 139 | { model & 140 | platform_menu, 141 | cursor: { row: model.menu_row + 2, col: 2 }, 142 | full_menu: menu, 143 | state: SettingsMenu({ choices: new_choices }), 144 | sender: model.state, 145 | } 146 | 147 | Confirmation({ choices }) -> 148 | { model & 149 | cursor: { row: model.menu_row + 3, col: 2 }, 150 | full_menu: menu, 151 | state: SettingsMenu({ choices }), 152 | sender: model.state, 153 | } 154 | 155 | _ -> model 156 | 157 | ## Transition to the SettingsSubmenu state 158 | to_settings_submenu_state : Model, [Theme, Verbosity] -> Model 159 | to_settings_submenu_state = |model, submenu| 160 | menu = 161 | when submenu is 162 | Theme -> model.theme_names |> List.sort_with(Compare.str) |> List.map(Heck.to_title_case) 163 | Verbosity -> ["Verbose", "Quiet", "Silent"] 164 | 165 | choices = Model.get_choices(model) 166 | { model & 167 | cursor: { row: 2, col: 2 }, 168 | full_menu: menu, 169 | state: SettingsSubmenu({ choices, submenu }), 170 | sender: model.state, 171 | } 172 | 173 | ## Transition to the InputAppName state 174 | to_input_app_name_state : Model -> Model 175 | to_input_app_name_state = |model| 176 | when model.state is 177 | MainMenu({ choices }) -> 178 | menu_choice = Model.get_highlighted_item(model) |> |s| if s == "Start app" then App else if Str.contains(s, "Upgrade") then Upgrade else Invalid 179 | new_choices = 180 | when menu_choice is 181 | App -> Choices.to_app(choices) 182 | Upgrade -> Choices.to_upgrade(choices) 183 | Invalid -> choices 184 | when menu_choice is 185 | Invalid -> model 186 | App | Upgrade -> 187 | { model & 188 | cursor: { row: 2, col: 2 }, 189 | menu: [], 190 | full_menu: [], 191 | state: InputAppName({ choices: new_choices, name_buffer: [] }), 192 | sender: model.state, 193 | } 194 | 195 | PlatformSelect({ choices }) -> 196 | filename = Choices.get_filename(choices) |> Str.drop_suffix(".roc") 197 | { model & 198 | cursor: { row: 2, col: 2 }, 199 | menu: [], 200 | full_menu: [], 201 | state: InputAppName({ choices, name_buffer: filename |> Str.to_utf8 }), 202 | sender: model.state, 203 | } 204 | 205 | Splash({ choices }) -> 206 | filename = Choices.get_filename(choices) |> Str.drop_suffix(".roc") 207 | { model & 208 | cursor: { row: 2, col: 2 }, 209 | state: InputAppName({ choices, name_buffer: filename |> Str.to_utf8 }), 210 | sender: model.state, 211 | } 212 | 213 | _ -> model 214 | 215 | ## Transition to the Splash state 216 | to_splash_state : Model -> Model 217 | to_splash_state = |model| 218 | when model.state is 219 | MainMenu({ choices }) -> 220 | { model & 221 | state: Splash({ choices }), 222 | sender: model.state, 223 | } 224 | 225 | _ -> model 226 | 227 | ## Transition to the PlatformSelect state 228 | to_platform_select_state : Model -> Model 229 | to_platform_select_state = |model| 230 | when model.state is 231 | InputAppName({ choices, name_buffer }) -> 232 | filename = name_buffer |> Str.from_utf8 |> Result.with_default("main") |> |name| if Str.is_empty(name) then "main" else name 233 | new_choices = choices |> Choices.set_filename(filename) 234 | menu = 235 | when new_choices is 236 | Upgrade(_) -> [["No change"], model.platform_menu] |> List.join 237 | _ -> model.platform_menu 238 | { model & 239 | page_first_item: 0, 240 | menu, 241 | full_menu: menu, 242 | cursor: { row: 2, col: 2 }, 243 | state: PlatformSelect({ choices: new_choices }), 244 | sender: model.state, 245 | } 246 | 247 | Search({ choices, search_buffer, prior_sender }) -> 248 | filtered_menu = 249 | model.platform_menu 250 | |> List.keep_if(|item| Str.contains(item, (search_buffer |> Str.from_utf8 |> Result.with_default("")))) 251 | { model & 252 | page_first_item: 0, 253 | menu: filtered_menu, 254 | full_menu: filtered_menu, 255 | cursor: { row: 2, col: 2 }, 256 | state: PlatformSelect({ choices }), 257 | sender: prior_sender, 258 | } 259 | 260 | PackageSelect({ choices }) -> 261 | package_repos = model.selected |> List.map(Utils.menu_item_to_repo) 262 | new_choices = choices |> Choices.set_packages(package_repos) 263 | menu = 264 | when new_choices is 265 | Upgrade(_) -> [["No change"], model.platform_menu] |> List.join 266 | _ -> model.platform_menu 267 | { model & 268 | page_first_item: 0, 269 | menu: menu, 270 | full_menu: menu, 271 | cursor: { row: 2, col: 2 }, 272 | state: PlatformSelect({ choices: new_choices }), 273 | sender: model.state, 274 | } 275 | 276 | VersionSelect({ choices }) -> 277 | { model & 278 | page_first_item: 0, 279 | menu: model.platform_menu, 280 | full_menu: model.platform_menu, 281 | cursor: { row: 2, col: 2 }, 282 | state: PlatformSelect({ choices }), 283 | sender: model.state, 284 | } 285 | 286 | SettingsMenu({ choices }) -> 287 | { model & 288 | page_first_item: 0, 289 | menu: model.platform_menu, 290 | full_menu: model.platform_menu, 291 | cursor: { row: 2, col: 2 }, 292 | state: PlatformSelect({ choices }), 293 | sender: model.state, 294 | } 295 | 296 | _ -> model 297 | 298 | ## Transition to the PackageSelect state 299 | to_package_select_state : Model -> Model 300 | to_package_select_state = |model| 301 | when model.state is 302 | MainMenu({ choices }) -> 303 | menu_choice = Model.get_highlighted_item(model) |> |s| if s == "Start package" then Package else if Str.contains(s, "Upgrade") then Upgrade else Invalid 304 | when menu_choice is 305 | Package | Upgrade -> 306 | new_choices = 307 | when menu_choice is 308 | Package -> Choices.to_package(choices) 309 | Upgrade -> Choices.to_upgrade(choices) 310 | Invalid -> choices 311 | selected = Choices.get_packages(new_choices) |> packages_to_menu_items 312 | { model & 313 | page_first_item: 0, 314 | menu: model.package_menu, 315 | full_menu: model.package_menu, 316 | cursor: { row: 2, col: 2 }, 317 | selected, 318 | state: PackageSelect({ choices: new_choices }), 319 | sender: model.state, 320 | } 321 | 322 | _ -> model 323 | 324 | PlatformSelect({ choices }) -> 325 | platform = Model.get_highlighted_item(model) |> |s| if s == "No change" then "" else s 326 | new_choices = choices |> Choices.set_platform(platform) 327 | selected = Choices.get_packages(new_choices) |> packages_to_menu_items 328 | { model & 329 | page_first_item: 0, 330 | menu: model.package_menu, 331 | full_menu: model.package_menu, 332 | cursor: { row: 2, col: 2 }, 333 | selected, 334 | state: PackageSelect({ choices: new_choices }), 335 | sender: model.state, 336 | } 337 | 338 | Search({ choices, search_buffer, prior_sender }) -> 339 | filtered_menu = 340 | model.package_menu 341 | |> List.keep_if(|item| Str.contains(item, (search_buffer |> Str.from_utf8 |> Result.with_default("")))) 342 | selected = Choices.get_packages(choices) |> packages_to_menu_items 343 | { model & 344 | page_first_item: 0, 345 | menu: filtered_menu, 346 | full_menu: filtered_menu, 347 | cursor: { row: 2, col: 2 }, 348 | selected, 349 | state: PackageSelect({ choices }), 350 | sender: prior_sender, 351 | } 352 | 353 | Confirmation({ choices }) -> 354 | selected = Choices.get_packages(choices) |> packages_to_menu_items 355 | { model & 356 | page_first_item: 0, 357 | menu: model.package_menu, 358 | full_menu: model.package_menu, 359 | selected, 360 | cursor: { row: 2, col: 2 }, 361 | state: PackageSelect({ choices }), 362 | sender: model.state, 363 | } 364 | 365 | VersionSelect({ choices, repo }) -> 366 | when model.sender is 367 | PackageSelect(_) if model.cursor.row != 0 -> 368 | selected_version = Model.get_highlighted_item(model) |> |v| if v == "latest" then "" else v 369 | new_repo = { repo & version: selected_version } 370 | selected = Choices.get_packages(choices) |> packages_to_menu_items |> add_or_update_package_menu(new_repo) 371 | package_menu = update_menu_with_version(model.package_menu, new_repo) 372 | new_choices = choices |> Choices.set_packages(selected |> List.map(Utils.menu_item_to_repo)) 373 | { model & 374 | page_first_item: 0, 375 | package_menu, 376 | menu: package_menu, 377 | full_menu: package_menu, 378 | selected, 379 | cursor: { row: 2, col: 2 }, 380 | state: PackageSelect({ choices: new_choices }), 381 | sender: model.state, 382 | } 383 | 384 | PackageSelect(_) -> 385 | selected = Choices.get_packages(choices) |> packages_to_menu_items 386 | { model & 387 | page_first_item: 0, 388 | menu: model.package_menu, 389 | full_menu: model.package_menu, 390 | selected, 391 | cursor: { row: 2, col: 2 }, 392 | state: PackageSelect({ choices }), 393 | sender: model.state, 394 | } 395 | 396 | PlatformSelect(_) -> 397 | selected_version = Model.get_highlighted_item(model) |> |v| if v == "latest" then "" else v 398 | new_repo = { repo & version: selected_version } 399 | platform_menu = add_or_update_platform_menu(model.platform_menu, new_repo) 400 | new_platform = if Str.is_empty(selected_version) then new_repo.name else "${new_repo.name}:${selected_version}" 401 | new_choices = choices |> Choices.set_platform(new_platform) 402 | { model & 403 | page_first_item: 0, 404 | platform_menu, 405 | menu: model.package_menu, 406 | full_menu: model.package_menu, 407 | cursor: { row: 2, col: 2 }, 408 | selected: Choices.get_packages(new_choices) |> packages_to_menu_items, 409 | state: PackageSelect({ choices: new_choices }), 410 | sender: model.state, 411 | } 412 | 413 | _ -> model 414 | 415 | _ -> model 416 | 417 | ## Transition to the VersionSelect state 418 | to_version_select_state : Model -> Model 419 | to_version_select_state = |model| 420 | when model.state is 421 | PackageSelect({ choices }) -> 422 | package_repos = model.selected |> List.map(Utils.menu_item_to_repo) 423 | new_choices = choices |> Choices.set_packages(package_repos) 424 | package_choice = Model.get_highlighted_item(model) |> Utils.menu_item_to_repo 425 | { package_name, package_version } = Str.split_first(package_choice, ":") |> Result.with_default({ before: package_choice, after: "" }) |> |{ before, after }| { package_name: before, package_version: after } 426 | package_repo = RM.get_full_repo_name(model.package_name_map, package_name, Package) |> Result.with_default(package_name) 427 | package_releases = Dict.get(model.packages, package_repo) |> Result.with_default([]) 428 | when package_releases is 429 | [] -> model 430 | _ -> 431 | versions = package_releases |> List.map(|{ tag }| tag) |> List.prepend("latest") 432 | { model & 433 | page_first_item: 0, 434 | menu: versions, 435 | full_menu: versions, 436 | cursor: { row: 2, col: 2 }, 437 | state: VersionSelect({ choices: new_choices, repo: { name: package_name, version: package_version } }), 438 | sender: model.state, 439 | } 440 | 441 | PlatformSelect({ choices }) -> 442 | platform = Model.get_highlighted_item(model) |> Utils.menu_item_to_repo 443 | new_choices = choices |> Choices.set_platform(platform) 444 | { platform_name, platform_version } = Str.split_first(platform, ":") |> Result.with_default({ before: platform, after: "" }) |> |{ before, after }| { platform_name: before, platform_version: after } 445 | platform_repo = RM.get_full_repo_name(model.platform_name_map, platform_name, Platform) |> Result.with_default(platform_name) 446 | platform_releases = Dict.get(model.platforms, platform_repo) |> Result.with_default([]) 447 | when platform_releases is 448 | [] -> model 449 | _ -> 450 | versions = platform_releases |> List.map(|{ tag }| tag) |> List.prepend("latest") 451 | { model & 452 | page_first_item: 0, 453 | menu: versions, 454 | full_menu: versions, 455 | cursor: { row: 2, col: 2 }, 456 | state: VersionSelect({ choices: new_choices, repo: { name: platform_name, version: platform_version } }), 457 | sender: model.state, 458 | } 459 | 460 | _ -> model 461 | 462 | ## Transition to the UpdateSelect state 463 | to_update_select_state : Model -> Model 464 | to_update_select_state = |model| 465 | menu = ["Platforms", "Packages", "Plugins", "Themes", "Installation"] 466 | when model.state is 467 | MainMenu({ choices }) -> 468 | new_choices = Choices.to_update(choices) 469 | selected = Choices.get_updates(new_choices) 470 | { model & 471 | page_first_item: 0, 472 | menu, 473 | full_menu: menu, 474 | cursor: { row: 2, col: 2 }, 475 | selected, 476 | state: UpdateSelect({ choices: new_choices }), 477 | sender: model.state, 478 | } 479 | 480 | Confirmation({ choices }) -> 481 | selected = Choices.get_updates(choices) 482 | { model & 483 | page_first_item: 0, 484 | menu, 485 | full_menu: menu, 486 | cursor: { row: 2, col: 2 }, 487 | selected, 488 | state: UpdateSelect({ choices }), 489 | sender: model.state, 490 | } 491 | 492 | _ -> model 493 | 494 | ## Transition to the Finished state 495 | to_finished_state : Model -> Model 496 | to_finished_state = |model| 497 | model_with_packages = Model.add_selected_packages_to_config(model) 498 | when model_with_packages.state is 499 | Confirmation({ choices }) -> { model & state: Finished({ choices }), sender: model.state } 500 | UpdateSelect({ choices }) -> 501 | new_choices = choices |> Choices.set_updates(Model.get_selected_items(model)) 502 | { model & state: Finished({ choices: new_choices }), sender: model.state } 503 | 504 | _ -> model 505 | 506 | ## Transition to the Confirmation state 507 | to_confirmation_state : Model -> Model 508 | to_confirmation_state = |model| 509 | model_with_packages = Model.add_selected_packages_to_config(model) 510 | when model_with_packages.state is 511 | PlatformSelect({ choices }) -> { model & state: Confirmation({ choices }), sender: model.state } 512 | PackageSelect({ choices }) -> { model & state: Confirmation({ choices }), sender: model.state } 513 | UpdateSelect({ choices }) -> 514 | new_choices = choices |> Choices.set_updates(Model.get_selected_items(model)) 515 | { model & state: Confirmation({ choices: new_choices }), sender: model.state } 516 | 517 | ChooseFlags({ choices }) -> 518 | new_choices = choices |> Choices.set_flags(Model.get_selected_items(model)) 519 | { model & state: Confirmation({ choices: new_choices }), sender: model.state } 520 | 521 | SettingsMenu({ choices }) -> { model & state: Confirmation({ choices }), sender: model.state } 522 | _ -> model 523 | 524 | # # Transition to the ChooseFlags state 525 | to_choose_flags_state : Model -> Model 526 | to_choose_flags_state = |model| 527 | when model.state is 528 | Confirmation({ choices }) -> 529 | menu = 530 | when choices is 531 | App(_) -> ["Force", "No Plugin"] 532 | Package(_) -> ["Force"] 533 | _ -> [] 534 | when choices is 535 | App(_) | Package(_) -> 536 | selected = Choices.get_flags(choices) |> List.map(Heck.to_title_case) 537 | { model & 538 | cursor: { row: 2, col: 2 }, 539 | full_menu: menu, 540 | state: ChooseFlags({ choices }), 541 | selected, 542 | sender: model.state, 543 | } 544 | 545 | _ -> model 546 | 547 | _ -> model 548 | 549 | ## Transition to the Search state 550 | to_search_state : Model -> Model 551 | to_search_state = |model| 552 | when model.state is 553 | PlatformSelect({ choices }) -> 554 | { model & 555 | cursor: { row: model.menu_row, col: 2 }, 556 | state: Search({ choices, search_buffer: [], prior_sender: model.sender }), 557 | sender: model.state, 558 | } 559 | 560 | PackageSelect({ choices }) -> 561 | package_repos = model.selected |> List.map(Utils.menu_item_to_repo) 562 | new_choices = choices |> Choices.set_packages(package_repos) 563 | { model & 564 | cursor: { row: model.menu_row, col: 2 }, 565 | state: Search({ choices: new_choices, search_buffer: [], prior_sender: model.sender }), 566 | sender: model.state, 567 | } 568 | 569 | _ -> model 570 | 571 | # ============================================================================= 572 | # Helpers functions 573 | 574 | packages_to_menu_items : List { name : Str, version : Str } -> List Str 575 | packages_to_menu_items = |packages| 576 | List.map( 577 | packages, 578 | |{ name: repo, version }| 579 | when Str.split_first(repo, "/") is 580 | Ok({ before: owner, after: name }) -> 581 | "${name} (${owner})" 582 | |> |s| if Str.is_empty(version) then s else "${s} : ${version}" 583 | 584 | _ -> if Str.is_empty(version) then repo else "${repo} : ${version}", 585 | ) 586 | 587 | update_menu_with_version : List Str, { name : Str, version : Str } -> List Str 588 | update_menu_with_version = |menu, { name, version }| 589 | match_name = name |> Utils.repo_to_menu_item 590 | insert_item = if Str.is_empty(version) then name else "${name}:${version}" |> Utils.repo_to_menu_item 591 | List.map( 592 | menu, 593 | |item| 594 | when Str.split_first(item, " : ") is 595 | Ok({ before: item_name }) -> 596 | if item_name == match_name then insert_item else item 597 | 598 | _ -> 599 | if item == match_name then insert_item else item, 600 | ) 601 | 602 | add_or_update_package_menu : List Str, { name : Str, version : Str } -> List Str 603 | add_or_update_package_menu = |menu, { name, version }| 604 | match_name = name |> Utils.repo_to_menu_item 605 | insert_item = if Str.is_empty(version) then name else "${name}:${version}" |> Utils.repo_to_menu_item 606 | List.walk( 607 | menu, 608 | (Bool.false, []), 609 | |(found, new_menu), item| 610 | when Str.split_first(item, " : ") is 611 | Ok({ before: item_name }) -> 612 | if item_name == match_name then 613 | (Bool.true, List.append(new_menu, insert_item)) 614 | else 615 | (Bool.false, List.append(new_menu, item)) 616 | 617 | _ -> 618 | if item == match_name then 619 | (Bool.true, List.append(new_menu, insert_item)) 620 | else 621 | (found, List.append(new_menu, item)), 622 | ) 623 | |> |(found, new_menu)| if found then new_menu else List.append(new_menu, insert_item) 624 | 625 | add_or_update_platform_menu : List Str, { name : Str, version : Str } -> List Str 626 | add_or_update_platform_menu = |menu, { name, version }| add_or_update_package_menu(menu, { name, version }) 627 | -------------------------------------------------------------------------------- /src/tui/UserAction.roc: -------------------------------------------------------------------------------- 1 | module [UserAction] 2 | 3 | import Keys exposing [Key] 4 | 5 | UserAction : [ 6 | Cancel, 7 | ClearFilter, 8 | CursorDown, 9 | CursorUp, 10 | Exit, 11 | Finish, 12 | GoBack, 13 | MultiConfirm, 14 | MultiSelect, 15 | VersionSelect, 16 | NextPage, 17 | PrevPage, 18 | Search, 19 | SearchGo, 20 | SingleSelect, 21 | TextInput Key, 22 | TextBackspace, 23 | TextSubmit, 24 | SetFlags, 25 | Secret, 26 | Continue, 27 | None, 28 | ] 29 | -------------------------------------------------------------------------------- /src/tui/Utils.roc: -------------------------------------------------------------------------------- 1 | module [ 2 | str_to_slug, 3 | str_to_lower, 4 | str_to_upper, 5 | repo_to_menu_item, 6 | menu_item_to_repo, 7 | ] 8 | 9 | import rtils.StrUtils 10 | 11 | str_to_slug : Str -> Str 12 | str_to_slug = |str| 13 | str 14 | |> Str.to_utf8 15 | |> List.drop_if( 16 | |c| 17 | if 18 | (c == '-') 19 | or (c == '_') 20 | or (c == ' ') 21 | or (c >= 'A' and c <= 'Z') 22 | or (c >= 'a' and c <= 'z') 23 | or (c >= '0' and c <= '9') 24 | then 25 | Bool.false 26 | else 27 | Bool.true, 28 | ) 29 | |> List.map(|c| if c == ' ' then '_' else c) 30 | |> Str.from_utf8 31 | |> Result.with_default("") 32 | 33 | expect str_to_slug("AZ") == "AZ" 34 | expect str_to_slug("az") == "az" 35 | expect str_to_slug("@") == "" 36 | expect str_to_slug("[") == "" 37 | expect str_to_slug("a z") == "a_z" 38 | expect str_to_slug("a-z") == "a-z" 39 | 40 | str_to_lower : Str -> Str 41 | str_to_lower = |str| 42 | str 43 | |> Str.to_utf8 44 | |> List.map(|c| if c >= 'A' and c <= 'Z' then c + 32 else c) 45 | |> Str.from_utf8 46 | |> Result.with_default("") 47 | 48 | expect str_to_lower("AZ") == "az" 49 | expect str_to_lower("az") == "az" 50 | expect str_to_lower("@") == "@" 51 | expect str_to_lower("[") == "[" 52 | 53 | str_to_upper : Str -> Str 54 | str_to_upper = |str| 55 | str 56 | |> Str.to_utf8 57 | |> List.map(|c| if c >= 'a' and c <= 'z' then c - 32 else c) 58 | |> Str.from_utf8 59 | |> Result.with_default("") 60 | 61 | expect str_to_upper("AZ") == "AZ" 62 | expect str_to_upper("az") == "AZ" 63 | expect str_to_upper("@") == "@" 64 | expect str_to_upper("[") == "[" 65 | 66 | repo_to_menu_item : Str -> Str 67 | repo_to_menu_item = |repo| 68 | when Str.split_first(repo, "/") is 69 | Ok({ before: owner, after: name_maybe_version }) -> 70 | when Str.split_first(name_maybe_version, ":") is 71 | Ok({ before: name, after: version }) -> "${name} (${owner}) : ${version}" 72 | _ -> "${name_maybe_version} (${owner})" 73 | 74 | _ -> 75 | when Str.split_first(repo, ":") is 76 | Ok({ before: name, after: version }) -> "${name} : ${version}" 77 | _ -> repo 78 | 79 | expect repo_to_menu_item("owner/name:version") == "name (owner) : version" 80 | expect repo_to_menu_item("owner/name") == "name (owner)" 81 | expect repo_to_menu_item("name:version") == "name : version" 82 | expect repo_to_menu_item("name") == "name" 83 | 84 | menu_item_to_repo : Str -> Str 85 | menu_item_to_repo = |item| 86 | when StrUtils.split_if(item, |c| List.contains(['(', ')'], c)) is 87 | [name_dirty, owner, version_dirty] -> 88 | name = Str.trim(name_dirty) 89 | version = Str.drop_prefix(version_dirty, " : ") |> Str.trim 90 | "${owner}/${name}:${version}" 91 | 92 | [name_dirty, owner] -> 93 | name = Str.trim(name_dirty) 94 | "${owner}/${name}" 95 | 96 | _ -> 97 | when Str.split_first(item, " : ") is 98 | Ok({ before: name, after: version }) -> "${name}:${version}" 99 | _ -> item 100 | 101 | expect menu_item_to_repo("name (owner) : version") == "owner/name:version" 102 | expect menu_item_to_repo("name (owner)") == "owner/name" 103 | expect menu_item_to_repo("name : version") == "name:version" 104 | expect menu_item_to_repo("name") == "name" 105 | 106 | expect menu_item_to_repo(repo_to_menu_item("owner/name:version")) == "owner/name:version" 107 | expect menu_item_to_repo(repo_to_menu_item("owner/name")) == "owner/name" 108 | expect menu_item_to_repo(repo_to_menu_item("name:version")) == "name:version" 109 | expect menu_item_to_repo(repo_to_menu_item("name")) == "name" 110 | 111 | expect repo_to_menu_item(menu_item_to_repo("name (owner) : version")) == "name (owner) : version" 112 | expect repo_to_menu_item(menu_item_to_repo("name (owner)")) == "name (owner)" 113 | expect repo_to_menu_item(menu_item_to_repo("name : version")) == "name : version" 114 | expect repo_to_menu_item(menu_item_to_repo("name")) == "name" 115 | -------------------------------------------------------------------------------- /src/tui/main.roc: -------------------------------------------------------------------------------- 1 | package [Controller, Model, View, InputHandlers] { 2 | rtils: "https://github.com/imclerran/rtils/releases/download/v0.1.5/qkk2T6MxEFLNKfQFq9GBk3nq6S2TMkbtHPt7KIHnIew.tar.br", 3 | ansi: "https://github.com/lukewilliamboswell/roc-ansi/releases/download/0.8.0/RQlGWlkQEfxtkSYKl0nHNQaOFT0-Jh7NNFEX2IPXlec.tar.br", 4 | heck: "https://github.com/imclerran/roc-heck/releases/download/v0.1.0/jxGXBo18syk4Ej1V5Y7lP5JnjKlCg_yIzdadvx7Tqc8.tar.br", 5 | themes: "../themes/main.roc", 6 | repos: "../repos/main.roc", 7 | } 8 | -------------------------------------------------------------------------------- /themes/.rocstartthemes: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "roc", 4 | "primary": "#8965DE", 5 | "secondary": "#39ABDB", 6 | "tertiary": "#FFFFFF", 7 | "okay": "#7ADE64", 8 | "warn": "#FAB387", 9 | "error": "#DE647C" 10 | }, 11 | { 12 | "name": "roc-mono", 13 | "primary": "#8965DE", 14 | "secondary": "#8965DE", 15 | "tertiary": "#8965DE", 16 | "okay": "#7ADE64", 17 | "warn": "#FAB387", 18 | "error": "#DE647C" 19 | }, 20 | { 21 | "name": "warn-only", 22 | "primary": "", 23 | "secondary": "", 24 | "tertiary": "", 25 | "okay": "", 26 | "warn": "#FAB387", 27 | "error": "#DE647C" 28 | }, 29 | { 30 | "name": "no-color", 31 | "primary": "", 32 | "secondary": "", 33 | "tertiary": "", 34 | "okay": "", 35 | "warn": "", 36 | "error": "" 37 | }, 38 | { 39 | "name": "catppuccin-latte", 40 | "primary": "#8839ef", 41 | "secondary": "#209fb5", 42 | "tertiary": "#4c4f69", 43 | "okay": "#40a02b", 44 | "warn": "#df8e1d", 45 | "error": "#d20f39" 46 | }, 47 | { 48 | "name": "catppuccin-frappe", 49 | "primary": "#ca9ee6", 50 | "secondary": "#85c1dc", 51 | "tertiary": "#c6d0f5", 52 | "okay": "#a6d189", 53 | "warn": "#e5c890", 54 | "error": "#e78284" 55 | }, 56 | { 57 | "name": "catppuccin-macchiato", 58 | "primary": "#c6a0f6", 59 | "secondary": "#7dc4e4", 60 | "tertiary": "#cad3f5", 61 | "okay": "#a6da95", 62 | "warn": "#eed49f", 63 | "error": "#ed8796" 64 | }, 65 | { 66 | "name": "cattpuccin-mocha", 67 | "primary": "#cba6f7", 68 | "secondary": "#74c7ec", 69 | "tertiary": "#cdd6f4", 70 | "okay": "#a6e3a1", 71 | "warn": "#f9e2af", 72 | "error": "#f38ba8" 73 | } 74 | ] 75 | --------------------------------------------------------------------------------