├── .dockerignore ├── .gitignore ├── .stylua.toml ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── config.toml ├── helpers └── print-key │ ├── Cargo.toml │ └── main.rs ├── img ├── mprocs.yaml ├── screenshot1.png ├── screenshot2.png ├── update.lua └── update.sh ├── npm ├── README.md ├── cli.js ├── index.js ├── package.json └── release.sh ├── rustfmt.toml ├── scoop └── mprocs.json ├── scripts ├── _build-linux.sh ├── build-darwin-aarch64.sh ├── build-darwin-x86_64.sh ├── build-linux-aarch64.sh ├── build-linux-x86_64.sh ├── build-unix.sh ├── build-windows-x86_64.cmd ├── release-scoop.sh ├── update-readme.sh └── version.sh ├── src ├── Cargo.toml ├── app.rs ├── client.rs ├── clipboard.rs ├── config.rs ├── config_lua.rs ├── ctl.rs ├── encode_term.rs ├── error.rs ├── event.rs ├── host │ ├── daemon.rs │ ├── mod.rs │ ├── receiver.rs │ ├── sender.rs │ └── socket.rs ├── just.rs ├── kernel │ ├── kernel_message.rs │ └── mod.rs ├── key.rs ├── keymap.rs ├── main.rs ├── modal │ ├── add_proc.rs │ ├── commands_menu.rs │ ├── mod.rs │ ├── modal.rs │ ├── quit.rs │ ├── remove_proc.rs │ └── rename_proc.rs ├── mouse.rs ├── package_json.rs ├── proc │ ├── handle.rs │ ├── inst.rs │ ├── mod.rs │ ├── msg.rs │ └── proc.rs ├── protocol.rs ├── settings.rs ├── state.rs ├── theme.rs ├── ui_keymap.rs ├── ui_procs.rs ├── ui_term.rs ├── ui_zoom_tip.rs ├── widgets │ ├── mod.rs │ └── text_input.rs └── yaml_val.rs ├── tests └── tests.lua └── vendor └── vt100 ├── Cargo.toml └── src ├── attrs.rs ├── cell.rs ├── grid.rs ├── lib.rs ├── parser.rs ├── row.rs ├── screen.rs ├── size.rs ├── term.rs └── term_reply.rs /.dockerignore: -------------------------------------------------------------------------------- 1 | /target/ 2 | /.git/ 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /mprocs.json 2 | /mprocs.log 3 | /mprocs.yaml 4 | 5 | /npm/mprocs-* 6 | 7 | /release/ 8 | 9 | # Cargo 10 | /target 11 | -------------------------------------------------------------------------------- /.stylua.toml: -------------------------------------------------------------------------------- 1 | column_width = 80 2 | indent_type = "Spaces" 3 | indent_width = 2 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Unreleased 2 | 3 | - Use pwsh.exe for shell commands (instead of cmd.exe) 4 | 5 | ## 0.7.3 - 2025-05-21 6 | 7 | - Add --just argument to load recipes from justfile 8 | - Add title configuration for the proc list pane 9 | - Add name optional argument to "add-proc" 10 | - Fix nushell (Handle CPR sequence) 11 | 12 | ## 0.7.2 - 2025-01-20 13 | 14 | - Add duplicate selected process command 15 | - Use blue color for "DOWN (0)" 16 | - Auto-restart only when exit code is not zero 17 | - Add key binding for toggling keymap 18 | 19 | ## 0.7.1 - 2024-06-29 20 | 21 | - Not using upx anymore 22 | - Disable xterm modifyOtherKeys in iTerm2 23 | 24 | ## 0.7.0 - 2024-06-24 25 | 26 | - Terminal sequences parser rewritten using termwiz vt parser 27 | - Copying fixes for Wayland (#88) 28 | - Support cursor shapes 29 | - Various fixes for keyboard handling 30 | - Accept configs with `.json` extension 31 | - Introduce commands menu (press `p`) 32 | - Add autorestart proccess config option 33 | - Add scrollback config option 34 | 35 | ## 0.6.4 - 2023-02-17 36 | 37 | - Add command for renaming the currently selected process (default: `e`) 38 | 39 | ## 0.6.3 - 2022-08-20 40 | 41 | - Reimplement copying. 42 | 43 | ## 0.6.2 - 2022-08-09 44 | 45 | - Fix global mprocs.yaml path when XDG_CONFIG_HOME env var is defined 46 | 47 | ## 0.6.1 - 2022-07-22 48 | 49 | - Add copy mode 50 | - Add `procs_list_width` to settings 51 | - Add mouse scroll config 52 | - Add quit confirmation dialog 53 | 54 | ## 0.6.0 - 2022-07-04 55 | 56 | - Add `hide_keymap_window` to settings 57 | - Add `--npm` argument 58 | - Add `add_path` to proc config 59 | - Highlight changed unselected processes 60 | - Keymap help now uses actual keys (respecting config) 61 | - Clears the terminal before the first render. 62 | 63 | ## 0.5.0 - 2022-06-20 64 | 65 | - Add command for scrolling by N lines (`C-e`/`C-y`) 66 | - Add mouse support 67 | - Add autostart field to the process config 68 | 69 | ## 0.4.1 - 2022-06-17 70 | 71 | - Zoom mode 72 | - Support batching commands 73 | - Allow passing `null` to clear key bindings 74 | 75 | ## 0.4.0 - 2022-06-08 76 | 77 | - Add _--names_ cli argument 78 | - Add stop field to the process config 79 | - Add cwd field to the process config 80 | - Add key bindings for selecting procs by index (`M-1` - `M-8`) 81 | - Add keymap settings 82 | 83 | ## 0.3.0 - 2022-05-30 84 | 85 | - Add "Remove process" 86 | - Change default config path to mprocs.yaml 87 | - Parse config file as yaml 88 | 89 | ## 0.2.3 - 2022-05-28 90 | 91 | - Add "Add process" feature 92 | - Use only indexed colors 93 | 94 | ## 0.2.2 - 2022-05-22 95 | 96 | - Add experimental remote control 97 | - Add $select operator in config 98 | - Add restart command 99 | - Add new arrow and page keybindings 100 | - Fix build on rust stable 101 | 102 | ## 0.2.1 - 2022-05-15 103 | 104 | - Fix terminal size on Windows 105 | 106 | ## 0.2.0 - 2022-05-15 107 | 108 | - Scrolling terminal with / 109 | - Environment variables per process in config 110 | - Set commands via cli args 111 | 112 | ## 0.1.0 - 2022-04-05 113 | 114 | - Full rewrite in Rust. Now compiles well on Windows 115 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [profile.release] 2 | strip = "symbols" 3 | opt-level = "z" # optimize for binary size 4 | lto = true # link time optimization 5 | panic = "abort" 6 | codegen-units = 1 7 | 8 | [workspace] 9 | resolver = "2" 10 | default-members = ["src"] 11 | members = ["helpers/print-key", "src", "vendor/vt100"] 12 | 13 | [workspace.lints.clippy] 14 | all = "allow" 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Pavel Volokitin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 5 | Warp sponsorship 6 | 7 | 8 | ### [Warp, the intelligent terminal for developers](https://www.warp.dev/mprocs) 9 | #### [Try running mprocs in Warp](https://www.warp.dev/mprocs)
10 | 11 |
12 | 13 | # mprocs 14 | 15 | _mprocs_ runs multiple commands in parallel and shows output of each command 16 | separately. 17 | 18 | When you work on a project you very often need the same list of commands to be 19 | running. For example: `webpack serve`, `jest --watch`, `node src/server.js`. 20 | With mprocs you can list these command in `mprocs.yaml` and run all of them by 21 | running `mprocs`. Then you can switch between outputs of running commands and 22 | interact with them. 23 | 24 | It is similar to 25 | [concurrently](https://github.com/open-cli-tools/concurrently) but _mprocs_ 26 | shows output of each command separately and allows to interact with processes 27 | (you can even work in _vim_ inside _mprocs_). 28 | 29 | 30 | 31 | - [Screenshots](#screenshots) 32 | - [Installation](#installation) 33 | - [Download binary (Linux, Macos, Windows)](#download-binary-linux-macos-windows) 34 | - [npm (Linux, Macos, Windows)](#npm-linux-macos-windows) 35 | - [homebrew (Macos)](#homebrew-macos) 36 | - [cargo (All platforms)](#cargo-all-platforms) 37 | - [scoop (Windows)](#scoop-windows) 38 | - [AUR (Arch Linux)](#aur-arch-linux) 39 | - [MPR (Debian/Ubuntu)](#mpr-debianubuntu) 40 | - [Usage](#usage) 41 | - [Config](#config) 42 | - [Keymap](#keymap) 43 | - [$select operator](#select-operator) 44 | - [Running scripts from package.json](#running-scripts-from-packagejson) 45 | - [Default keymap](#default-keymap) 46 | - [Remote control](#remote-control) 47 | - [FAQ](#faq) 48 | - [mprocs vs tmux/screen](#mprocs-vs-tmuxscreen) 49 | 50 | 51 | 52 | 53 | 54 | 55 | ## Screenshots 56 | 57 | 58 | 59 | 60 | ## Installation 61 | 62 | [![Packaging status](https://repology.org/badge/vertical-allrepos/mprocs.svg)](https://repology.org/project/mprocs/versions) 63 | 64 | ### Download binary (Linux, Macos, Windows) 65 | 66 | [Download](https://github.com/pvolok/mprocs/releases) executable for your 67 | platform and put it into a directory included in PATH. 68 | 69 | ### npm (Linux, Macos, Windows) 70 | 71 | ```sh 72 | npm install -g mprocs 73 | ``` 74 | 75 | ```sh 76 | yarn global add mprocs 77 | ``` 78 | 79 | ### homebrew (Macos, Linux) 80 | 81 | ```sh 82 | brew install mprocs 83 | ``` 84 | 85 | ### cargo (All platforms) 86 | 87 | ```sh 88 | cargo install mprocs 89 | ``` 90 | 91 | ### scoop (Windows) 92 | 93 | ```sh 94 | scoop install mprocs 95 | ``` 96 | 97 | ### AUR (Arch Linux) 98 | 99 | ```sh 100 | yay mprocs 101 | ``` 102 | 103 | ```sh 104 | yay mprocs-bin 105 | ``` 106 | 107 | ### MPR (Debian/Ubuntu) 108 | 109 | ```sh 110 | git clone 'https://mpr.makedeb.org/mprocs' 111 | cd mprocs/ 112 | makedeb -si 113 | ``` 114 | 115 | ## Usage 116 | 117 | 1. Run `mprocs cmd1 cmd2 …` (example: `mprocs "yarn test -w" "webpack serve"`) 118 | 119 | OR 120 | 121 | 1. Create `mprocs.yaml` file 122 | 2. Run `mprocs` command 123 | 124 | Example `mprocs.yaml`: 125 | 126 | ```yaml 127 | procs: 128 | nvim: 129 | cmd: ["nvim"] 130 | server: 131 | shell: "nodemon server.js" 132 | webpack: "webpack serve" 133 | tests: 134 | shell: "jest -w" 135 | env: 136 | NODE_ENV: test 137 | ``` 138 | 139 | ### Config 140 | 141 | [JSON/YAML Configuration Schema](https://json.schemastore.org/mprocs-0.6.4.json) 142 | 143 | There are two kinds of configs: global and local. _Global_ config is loaded 144 | from `~/.config/mprocs/mprocs.yaml` (or 145 | `~\AppData\Roaming\mprocs\mprocs.yaml` on Windows). _Local_ config 146 | is loaded from `mprocs.yaml` from current directory (or set via cli argument: 147 | `mprocs --config ./cfg/mprocs.yaml`). Settings in the _local_ config override 148 | settings the _global_. 149 | 150 | - **procs**: _object_ - Processes to run. Only allowed in local config. 151 | - **shell**: _string_ - Shell command to run (exactly one of **shell** or 152 | **cmd** must be provided). 153 | - **cmd**: _array_ - Array of command and args to run (exactly one of 154 | **shell** or **cmd** must be provided). 155 | - **cwd**: _string_ - Set working directory for the process. Prefix 156 | `` will be replaced with the path of the directory where the 157 | config is located. 158 | - **env**: _object_ - Set env variables. Object keys are 159 | variable names. Assign variable to null, to clear variables inherited from 160 | parent process. 161 | - **add_path**: _string|array_ - Add entries to the _PATH_ 162 | environment variable. 163 | - **autostart**: _bool_ - Start process when mprocs starts. Default: _true_. 164 | - **autorestart**: _bool_ - Restart process when it exits. Default: false. Note: If process exits within 1 second of starting, it will not be restarted. 165 | - **stop**: _"SIGINT"|"SIGTERM"|"SIGKILL"|{send-keys: 166 | array}|"hard-kill"_ - 167 | A way to stop a process (using `x` key or when quitting mprocs). 168 | - **hide_keymap_window**: _bool_ - Hide the pane at the bottom of the screen 169 | showing key bindings. 170 | - **mouse_scroll_speed**: _integer_ - Number of lines to scrollper one mouse 171 | scroll. 172 | - **scrollback**: _integer_ - Scrollback size. Default: _1000_. 173 | - **proc_list_width**: _integer_ - Process list window width. 174 | - **keymap_procs**: _object_ - Key bindings for process list. See 175 | [Keymap](#keymap). 176 | - **keymap_term**: _object_ - Key bindings for terminal window. See 177 | [Keymap](#keymap). 178 | - **keymap_copy**: _object_ - Key bindings for copy mode. See 179 | [Keymap](#keymap). 180 | 181 | #### Keymap 182 | 183 | Default key bindings can be overridden in config using _keymap_procs_, 184 | _keymap_term_, or _keymap_copy_ fields. Available commands are documented in 185 | the [Remote control](#remote-control) section. 186 | 187 | There are three keymap levels: 188 | 189 | - Default keymaps 190 | - `~/.config/mprocs/mprocs.yaml` (or `~\AppData\Roaming\mprocs\mprocs.yaml` on Windows) 191 | - `./mprocs.yaml` (can be overridden by the _-c/--config_ cli arg) 192 | 193 | Lower levels override bindings from previous levels. Key bindings from previous 194 | levels can be cleared by specifying `reset: true` field at the same level as 195 | keys. 196 | 197 | Key bindings are defined between `<` and `>`, e.g., `` (enter key), `` (down arrow), `` (up arrow), `` (CTRL + q). 198 | 199 | ```yaml 200 | keymap_procs: # keymap when process list is focused 201 | : { c: toggle-focus } 202 | : null # unbind key 203 | keymap_term: # keymap when terminal is focused 204 | reset: true 205 | : { c: toggle-focus } 206 | : 207 | c: batch 208 | cmds: 209 | - { c: focus-procs } 210 | - { c: next-proc } 211 | ``` 212 | 213 | #### `$select` operator 214 | 215 | You can define different values depending on the current operating system. Any 216 | value in config can be wrapped with a _$select_ operator. To provide different 217 | values based on current OS define an object with: 218 | 219 | - First field `$select: os` 220 | - Fields defining values for different OSes: `macos: value`. Possible 221 | values are listed here: 222 | https://doc.rust-lang.org/std/env/consts/constant.OS.html. 223 | - Field `$else: default value` will be matched if no value was defined for 224 | current OS. If current OS is not matched and field `$else` is missing, then 225 | mprocs will fail to load config. 226 | 227 | Example `mprocs.yaml`: 228 | 229 | ```yaml 230 | procs: 231 | my process: 232 | shell: 233 | $select: os 234 | windows: "echo %TEXT%" 235 | $else: "echo $TEXT" 236 | env: 237 | TEXT: 238 | $select: os 239 | windows: Windows 240 | linux: Linux 241 | macos: Macos 242 | freebsd: FreeBSD 243 | ``` 244 | 245 | #### Running scripts from package.json 246 | 247 | If you run _mprocs_ with an `--npm` argument, it will load scripts from 248 | `package.json`. But the scripts are not run by default, and you can launch 249 | desired scripts manually. 250 | 251 | ```sh 252 | # Run mprocs with scripts from package.json 253 | mprocs --npm 254 | ``` 255 | 256 | ### Default keymap 257 | 258 | Process list focused: 259 | 260 | - `q` - Quit (soft kill processes and wait then to exit) 261 | - `Q` - Force quit (terminate processes) 262 | - `C-a` - Focus output pane 263 | - `x` - Soft kill selected process (send SIGTERM signal, hard kill on Windows) 264 | - `X` - Hard kill selected process (send SIGKILL) 265 | - `s` - Start selected process, if it is not running 266 | - `r` - Soft kill selected process and restart it when it stops 267 | - `R` - Hard kill selected process and restart it when it stops 268 | - `a` - Add new process 269 | - `C` - Duplicate selected process 270 | - `d` - Remove selected process (process must be stopped first) 271 | - `e` - Rename selected process 272 | - `k` or `↑` - Select previous process 273 | - `j` or `↓` - Select next process 274 | - `M-1` - `M-8` - Select process 1-8 275 | - `C-d` or `page down` - Scroll output down 276 | - `C-u` or `page up` - Scroll output up 277 | - `C-e` - Scroll output down by 3 lines 278 | - `C-y` - Scroll output up by 3 lines 279 | - `z` - Zoom into terminal window 280 | - `v` - Enter copy mode 281 | 282 | Process output focused: 283 | 284 | - `C-a` - Focus processes pane 285 | 286 | Copy mode: 287 | 288 | - `v` - Start selecting end point 289 | - `c` - Copy selected text 290 | - `Esc` - Leave copy mode 291 | - `C-a` - Focus processes pane 292 | - `C-d` or `page down` - Scroll output down 293 | - `C-u` or `page up` - Scroll output up 294 | - `C-e` - Scroll output down by 3 lines 295 | - `C-y` - Scroll output up by 3 lines 296 | - `h` or `↑` - Move cursor up 297 | - `l` or `→` - Move cursor right 298 | - `j` or `↓` - Move cursor down 299 | - `h` or `←` - Move cursor left 300 | 301 | ### Remote control 302 | 303 | Optionally, _mprocs_ can listen on TCP port for remote commands. 304 | You have to define remote control server address in `mprocs.yaml` 305 | (`server: 127.0.0.1:4050`) or via cli argument (`mprocs --server 127.0.0.1:4050`). To send a command to running _mprocs_ instance 306 | use the **ctl** argument: `mprocs --ctl '{c: quit}'` or `mprocs --ctl '{c: send-key, key: }'`. 307 | 308 | Commands are encoded as yaml. Available commands: 309 | 310 | - `{c: quit-or-ask}` - Stop processes and quit. If any processes are running, 311 | show a confirmation dialog. 312 | - `{c: quit}` - Stop processes and quit. Does not show confirm dialog. 313 | - `{c: force-quit}` 314 | - `{c: toggle-focus}` - Toggle focus between process list and terminal. 315 | - `{c: focus-procs}` - Focus process list 316 | - `{c: focus-term}` - Focus process terminal window 317 | - `{c: zoom}` - Zoom into terminal window 318 | - `{c: next-proc}` 319 | - `{c: prev-proc}` 320 | - `{c: select-proc, index: }` - Select process by index, top process has index 0 321 | - `{c: start-proc}` 322 | - `{c: term-proc}` 323 | - `{c: kill-proc}` 324 | - `{c: restart-proc}` 325 | - `{c: force-restart-proc}` 326 | - `{c: show-add-proc}` 327 | - `{c: add-proc, cmd: "", name: ""}` - Add proccess. `name` field is optional. 328 | - `{c: duplicate-proc}` 329 | - `{c: show-remove-proc}` 330 | - `{c: remove-proc, id: ""}` 331 | - `{c: show-rename-proc}` 332 | - `{c: rename-proc, name: ""}` - Rename currently selected process 333 | - `{c: scroll-down}` 334 | - `{c: scroll-up}` 335 | - `{c: scroll-down-lines, n: }` 336 | - `{c: scroll-up-lines, n: }` 337 | - `{c: copy-mode-enter}` - Enter copy mode 338 | - `{c: copy-mode-leave}` - Leave copy mode 339 | - `{c: copy-mode-move, dir: }` - Move starting or ending position 340 | of the selection. Available directions: `up/right/down/left`. 341 | - `{c: copy-mode-end}` - Start selecting end point of the selection. 342 | - `{c: copy-mode-copy}` - Copy selected text to the clipboard and leave copy 343 | mode. 344 | - `{c: send-key, key: ""}` - Send key to current process. Key examples: 345 | ``, `` 346 | - `{c: batch, cmds: [{c: focus-procs}, …]}` - Send multiple commands 347 | 348 | ## FAQ 349 | 350 | ### mprocs vs tmux/screen 351 | 352 | _mprocs_ is meant to make it easier to run specific commands that you end up 353 | running repeatedly, such as compilers and test runners. This is in contrast 354 | with _tmux_, which is usually used to run much more long-lived processes - 355 | usually a shell - in each window/pane. Another difference is that _tmux_ runs a 356 | server and a client, which allows the client to detach and reattach later, 357 | keeping the processes running. _mprocs_ is meant more for finite lifetime 358 | processes that you keep re-running, but when _mprocs_ ends, so do the processes 359 | it is running within its windows. 360 | 361 | ### Copying doesn't work in tmux 362 | 363 | Tmux doesn't have escape sequences for copying enabled by default. To enable it 364 | add the following to `~/.tmux.conf`: 365 | 366 | ``` 367 | set -g set-clipboard on 368 | ``` 369 | -------------------------------------------------------------------------------- /config.toml: -------------------------------------------------------------------------------- 1 | [target.x86_64-unknown-linux-musl] 2 | linker = "x86_64-linux-musl-gcc" 3 | 4 | [target.aarch64-unknown-linux-musl] 5 | linker = "x86_64-linux-musl-gcc" 6 | 7 | [target.x86_64-pc-windows-msvc] 8 | rustflags = ["-C", "target-feature=+crt-static"] 9 | -------------------------------------------------------------------------------- /helpers/print-key/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "print-key" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [[bin]] 7 | name = "print-key" 8 | path = "main.rs" 9 | 10 | [dependencies] 11 | crossterm = { version = "0.27" } 12 | -------------------------------------------------------------------------------- /helpers/print-key/main.rs: -------------------------------------------------------------------------------- 1 | use crossterm::{ 2 | event::{Event, KeyCode}, 3 | terminal::{disable_raw_mode, enable_raw_mode}, 4 | }; 5 | 6 | fn main() { 7 | println!("Press \"z\" to exit."); 8 | 9 | enable_raw_mode().unwrap(); 10 | 11 | loop { 12 | match crossterm::event::read().unwrap() { 13 | Event::FocusGained => (), 14 | Event::FocusLost => (), 15 | Event::Key(key_event) => { 16 | print!("{:?}\r\n", key_event); 17 | 18 | if key_event.code == KeyCode::Char('z') 19 | && key_event.modifiers.is_empty() 20 | { 21 | break; 22 | } 23 | } 24 | Event::Mouse(_) => (), 25 | Event::Paste(_) => (), 26 | Event::Resize(_, _) => (), 27 | } 28 | } 29 | 30 | disable_raw_mode().unwrap(); 31 | } 32 | -------------------------------------------------------------------------------- /img/mprocs.yaml: -------------------------------------------------------------------------------- 1 | procs: 2 | nvim: nvim 3 | server: echo "Listening on port 8080" && sleep 10 4 | webpack: 5 | shell: echo webpack 6 | autostart: false 7 | tests: 8 | shell: echo tests 9 | autostart: false 10 | -------------------------------------------------------------------------------- /img/screenshot1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pvolok/mprocs/79e60f8be0d990a0fbd77ac3cd56e69cbe23e464/img/screenshot1.png -------------------------------------------------------------------------------- /img/screenshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pvolok/mprocs/79e60f8be0d990a0fbd77ac3cd56e69cbe23e464/img/screenshot2.png -------------------------------------------------------------------------------- /img/update.lua: -------------------------------------------------------------------------------- 1 | local proc = vt.start( 2 | "cargo r -- -c img/mprocs.yaml", 3 | { width = 90, height = 30 } 4 | ) 5 | 6 | proc:wait_text("[No Name]") 7 | 8 | proc:dump_png("img/screenshot1.png") 9 | 10 | proc:send_str("j") 11 | proc:send_key("") 12 | proc:wait_text("Listening") 13 | 14 | proc:dump_png("img/screenshot2.png") 15 | 16 | proc:send_key("") 17 | proc:send_str("q") 18 | proc:wait() 19 | -------------------------------------------------------------------------------- /img/update.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cargo build 6 | 7 | virterm img/update.lua 8 | 9 | convert img/screenshot1.png -density 144 -units PixelsPerInch img/screenshot1.png 10 | convert img/screenshot2.png -density 144 -units PixelsPerInch img/screenshot2.png 11 | -------------------------------------------------------------------------------- /npm/README.md: -------------------------------------------------------------------------------- 1 | # mprocs 2 | 3 | mprocs runs multiple commands in parallel and shows output of each command 4 | separately. 5 | 6 | This is an npm wrapper of a precompiled binary for linux (x64, arm64), macos 7 | (x64, arm64), and windows (x64). 8 | 9 | For documentation check out the readme file in the repo: 10 | https://github.com/pvolok/mprocs 11 | -------------------------------------------------------------------------------- /npm/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | "use strict"; 4 | var spawn = require("child_process").spawn; 5 | 6 | var input = process.argv.slice(2); 7 | var bin = require("./"); 8 | 9 | if (bin !== null) { 10 | spawn(bin, input, { stdio: "inherit" }).on("exit", process.exit); 11 | } else { 12 | throw new Error( 13 | "Platform not supported by npm distribution. " + 14 | "Check https://github.com/pvolok/mprocs for other ways to install procs on your platform." 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /npm/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var VERSION = require("./package.json").version; 4 | 5 | var path = require("path"); 6 | 7 | function getBinPath() { 8 | if (process.platform === "darwin") { 9 | if (process.arch === "arm64") { 10 | return path.join(__dirname, `mprocs-${VERSION}-darwin-aarch64/mprocs`); 11 | } else { 12 | return path.join(__dirname, `mprocs-${VERSION}-darwin-x86_64/mprocs`); 13 | } 14 | } 15 | if (process.platform === "linux") { 16 | if (process.arch === "arm64") { 17 | return path.join( 18 | __dirname, 19 | `mprocs-${VERSION}-linux-aarch64-musl/mprocs` 20 | ); 21 | } else { 22 | return path.join(__dirname, `mprocs-${VERSION}-linux-x86_64-musl/mprocs`); 23 | } 24 | } 25 | if (process.platform === "win32") { 26 | return path.join(__dirname, `mprocs-${VERSION}-windows-x86_64/mprocs.exe`); 27 | } 28 | 29 | const os = process.platform; 30 | const arch = process.arch; 31 | throw new Error( 32 | `Npm package of mprocs doesn't include binaries for ${os}-${arch}.` 33 | ); 34 | } 35 | 36 | module.exports = getBinPath(); 37 | -------------------------------------------------------------------------------- /npm/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mprocs", 3 | "version": "0.7.3", 4 | "description": "Run multiple processes in parallel", 5 | "license": "MIT", 6 | "repository": "github:pvolok/mprocs", 7 | "author": { 8 | "name": "Pavel Volokitin", 9 | "email": "pavelvolokitin@gmail.com" 10 | }, 11 | "bin": { 12 | "mprocs": "cli.js" 13 | }, 14 | "engines": { 15 | "node": ">=0.10.0" 16 | }, 17 | "files": [ 18 | "mprocs-*/mprocs", 19 | "mprocs-*/mprocs.exe", 20 | "cli.js", 21 | "index.js" 22 | ], 23 | "keywords": [ 24 | "cli", 25 | "concurrent", 26 | "parallel", 27 | "process", 28 | "runner", 29 | "task" 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /npm/release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | DIR=`dirname $0` 6 | 7 | # Set version from Cargo.toml 8 | VERSION=`$DIR/../scripts/version.sh` 9 | cat $DIR/package.json | jq ".version = \"$VERSION\"" | sponge $DIR/package.json 10 | 11 | # Remove previous artifact 12 | rm -rf $DIR/mprocs-* 13 | 14 | # linux x64 15 | NAME=mprocs-$VERSION-linux-x86_64-musl 16 | mkdir -p $DIR/$NAME/ 17 | tar zxvf $DIR/../release/$NAME.tar.gz -C $DIR/$NAME/ 18 | # linux arm64 19 | NAME=mprocs-$VERSION-linux-aarch64-musl 20 | mkdir -p $DIR/$NAME/ 21 | tar zxvf $DIR/../release/$NAME.tar.gz -C $DIR/$NAME/ 22 | # macos x64 23 | NAME=mprocs-$VERSION-darwin-x86_64 24 | mkdir -p $DIR/$NAME/ 25 | tar zxvf $DIR/../release/$NAME.tar.gz -C $DIR/$NAME/ 26 | # macos arm64 27 | NAME=mprocs-$VERSION-darwin-aarch64 28 | mkdir -p $DIR/$NAME/ 29 | tar zxvf $DIR/../release/$NAME.tar.gz -C $DIR/$NAME/ 30 | # windows x64 31 | NAME=mprocs-$VERSION-windows-x86_64 32 | mkdir -p $DIR/$NAME/ 33 | unzip -a $DIR/../release/$NAME.zip -d $DIR/$NAME/ 34 | 35 | # npm publish 36 | pushd $DIR 37 | npm publish 38 | popd 39 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | edition = "2021" 2 | 3 | max_width = 80 4 | tab_spaces = 2 5 | -------------------------------------------------------------------------------- /scoop/mprocs.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.5.0", 3 | "description": "Run multiple processes in parallel", 4 | "homepage": "https://github.com/pvolok/mprocs", 5 | "url": "https://github.com/pvolok/mprocs/releases/download/v0.5.0/mprocs-0.5.0-win64.zip", 6 | "hash": "deb155709960b71a7f7b7f2f70154e8ec37793e94261370389e36d480165d472", 7 | "bin": "mprocs.exe" 8 | } 9 | -------------------------------------------------------------------------------- /scripts/_build-linux.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | set -e 4 | 5 | apk add --no-cache musl-dev bash jq make 6 | 7 | DIR=`dirname $0` 8 | 9 | bash $DIR/build-unix.sh 10 | -------------------------------------------------------------------------------- /scripts/build-darwin-aarch64.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | DIR=`dirname $0` 6 | 7 | export ARCH="aarch64" 8 | 9 | bash $DIR/build-unix.sh 10 | -------------------------------------------------------------------------------- /scripts/build-darwin-x86_64.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | DIR=`dirname $0` 6 | 7 | export ARCH="x86_64" 8 | 9 | bash $DIR/build-unix.sh 10 | -------------------------------------------------------------------------------- /scripts/build-linux-aarch64.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | set -e 4 | 5 | mkdir -p release 6 | 7 | podman run -it --workdir /app \ 8 | --platform linux/arm64 \ 9 | --env ARCH=aarch64 \ 10 | -v $(pwd)/scripts:/app/scripts \ 11 | -v $(pwd)/Cargo.lock:/app/Cargo.lock \ 12 | -v $(pwd)/Cargo.toml:/app/Cargo.toml \ 13 | -v $(pwd)/vendor:/app/vendor \ 14 | -v $(pwd)/src:/app/src \ 15 | -v $(pwd)/helpers:/app/helpers \ 16 | -v $(pwd)/scripts:/app/scripts \ 17 | -v $(pwd)/release:/app/release \ 18 | rust:1.87.0-alpine3.21 \ 19 | scripts/_build-linux.sh 20 | -------------------------------------------------------------------------------- /scripts/build-linux-x86_64.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | set -e 4 | 5 | mkdir -p release 6 | 7 | podman run -it --workdir /app \ 8 | --platform linux/amd64 \ 9 | --env ARCH=x86_64 \ 10 | -v $(pwd)/scripts:/app/scripts \ 11 | -v $(pwd)/Cargo.lock:/app/Cargo.lock \ 12 | -v $(pwd)/Cargo.toml:/app/Cargo.toml \ 13 | -v $(pwd)/vendor:/app/vendor \ 14 | -v $(pwd)/src:/app/src \ 15 | -v $(pwd)/helpers:/app/helpers \ 16 | -v $(pwd)/scripts:/app/scripts \ 17 | -v $(pwd)/release:/app/release \ 18 | rust:1.87.0-alpine3.21 \ 19 | scripts/_build-linux.sh 20 | -------------------------------------------------------------------------------- /scripts/build-unix.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | DIR=`dirname $0` 6 | 7 | VERSION=`$DIR/version.sh` 8 | 9 | # 10 | # Define ARCH 11 | # 12 | # if [ -z "${!ARCH}" ]; then 13 | if [ -z "$(eval echo \$ARCH)" ]; then 14 | echo "Error: ARCH is not defined or is empty." 15 | exit 1 16 | fi 17 | 18 | # 19 | # Define OS_TYPE 20 | # 21 | if [[ "$(uname)" == "Darwin" ]]; then 22 | OS_TYPE="darwin" 23 | elif [[ "$(uname)" == "Linux" ]]; then 24 | OS_TYPE="linux" 25 | else 26 | echo "Error: Unsupported operating system." 27 | exit 1 28 | fi 29 | 30 | # 31 | # Define TRIPLE and OS_ARCH 32 | # 33 | case "$OS_TYPE" in 34 | linux) 35 | TRIPLE="$ARCH-unknown-linux-musl" 36 | OS_ARCH="linux-$ARCH-musl" 37 | ;; 38 | darwin) 39 | TRIPLE="$ARCH-apple-darwin" 40 | OS_ARCH="darwin-$ARCH" 41 | ;; 42 | *) 43 | echo "Error: Unsupported OS_TYPE ($OS_TYPE)." 44 | exit 1 45 | ;; 46 | esac 47 | 48 | mkdir -p release/mprocs-$VERSION-$OS_ARCH 49 | 50 | cargo build -p mprocs --release --target=$TRIPLE 51 | 52 | cp target/$TRIPLE/release/mprocs release/mprocs-$VERSION-$OS_ARCH/mprocs 53 | 54 | tar -czvf release/mprocs-$VERSION-$OS_ARCH.tar.gz \ 55 | -C release/mprocs-$VERSION-$OS_ARCH \ 56 | mprocs 57 | -------------------------------------------------------------------------------- /scripts/build-windows-x86_64.cmd: -------------------------------------------------------------------------------- 1 | SET VERSION=0.7.3 2 | 3 | MKDIR release\mprocs-%VERSION%-windows-x86_64 || exit /b 4 | 5 | :: Windows x64 6 | 7 | cargo build --release || exit /b 8 | 9 | COPY target\release\mprocs.exe release\mprocs-%VERSION%-windows-x86_64\mprocs.exe || exit /b 10 | 11 | tar.exe -a -c -f release\mprocs-%VERSION%-windows-x86_64.zip -C release\mprocs-%VERSION%-windows-x86_64 mprocs.exe 12 | -------------------------------------------------------------------------------- /scripts/release-scoop.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | DIR=`dirname $0` 6 | ROOT=`dirname $DIR` 7 | 8 | VERSION=`$DIR/../scripts/version.sh` 9 | RELEASE_URL="https://github.com/pvolok/mprocs/releases/download/v$VERSION/mprocs-$VERSION-win64.zip" 10 | 11 | cat $ROOT/scoop/mprocs.json | jq ".version = \"$VERSION\"" | sponge $ROOT/scoop/mprocs.json 12 | cat $ROOT/scoop/mprocs.json | jq ".url = \"$RELEASE_URL\"" | sponge $ROOT/scoop/mprocs.json 13 | 14 | SHA256=`curl -LJ0s $RELEASE_URL | shasum -a 256 | cut -f 1 -d " "` 15 | cat $ROOT/scoop/mprocs.json | jq ".hash = \"$SHA256\"" | sponge $ROOT/scoop/mprocs.json 16 | -------------------------------------------------------------------------------- /scripts/update-readme.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | DIR=`dirname $0` 6 | 7 | gh-md-toc --insert --no-backup --skip-header README.md 8 | prettier -w README.md 9 | -------------------------------------------------------------------------------- /scripts/version.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | DIR=`dirname $0` 6 | 7 | pushd $DIR/.. > /dev/null 8 | cargo metadata --format-version=1 \ 9 | | jq -r '.packages | map(select(.name == "mprocs").version)[0]' 10 | popd > /dev/null 11 | -------------------------------------------------------------------------------- /src/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "mprocs" 3 | version = "0.7.3" 4 | description = "TUI for running multiple processes" 5 | repository = "https://github.com/pvolok/mprocs" 6 | authors = ["Pavel Volokitin "] 7 | license = "MIT" 8 | edition = "2021" 9 | categories = [ 10 | "command-line-interface", 11 | "command-line-utilities", 12 | "development-tools", 13 | ] 14 | keywords = ["cli", "terminal", "tui", "utility"] 15 | 16 | include = ["*"] 17 | 18 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 19 | 20 | [[bin]] 21 | name = "mprocs" 22 | path = "main.rs" 23 | 24 | [dependencies] 25 | vt100 = { package = "mprocs-vt100", path = "../vendor/vt100", version = "0.7.3" } 26 | anyhow = "1.0.72" 27 | assert_matches = "1.5.0" 28 | base64 = "0.22.0" 29 | clap = { version = "4.3.19", features = ["cargo"] } 30 | clipboard-win = "5.3.1" 31 | crossterm = { version = "0.27.0", features = ["event-stream", "serde"] } 32 | dunce = "1.0.4" 33 | # Excluded "textfilter" feature that depends on regex (~0.7 MiB). 34 | flexi_logger = { version = "0.28.0", default-features = false, features = [ 35 | "colors", 36 | ] } 37 | futures = { version = "0.3.28" } 38 | indexmap = { version = "2.0.0", features = ["serde"] } 39 | lazy_static = "1.4.0" 40 | libc = "0.2.147" 41 | log = "0.4.19" 42 | mlua = { version = "0.9.0", features = ["lua52", "vendored", "serialize"] } 43 | phf = { version = "0.11.2", features = ["macros"] } 44 | tui = { package = "ratatui", version = "0.26.2", features = ["serde"] } 45 | serde = { version = "1.0.177", features = ["derive"] } 46 | serde_json = "1.0.138" 47 | serde_yaml = "0.9.25" 48 | tokio = { version = "1", features = ["full"] } 49 | triggered = "0.1.2" 50 | tui-input = "0.8.0" 51 | unicode-segmentation = "1.10.1" 52 | unicode-width = "0.1.10" 53 | which = "6.0.1" 54 | xdg = "2.5.2" 55 | termwiz = "0.23.3" 56 | portable-pty = { version = "0.9" } 57 | bitflags = { version = "2.3.3", features = ["serde"] } 58 | compact_str = { version = "0.9.0", features = ["serde"] } 59 | bincode = "1.3.3" 60 | tokio-util = { version = "0.7.8", features = ["full"] } 61 | bytes = "1.5.0" 62 | log-panics = { version = "2.1.0", features = ["with-backtrace"] } 63 | scopeguard = "1.2.0" 64 | 65 | [target."cfg(unix)".dependencies] 66 | daemonize = "0.5.0" 67 | 68 | [target."cfg(windows)".dependencies] 69 | winapi = { version = "0.3", features = ["consoleapi", "winuser"] } 70 | 71 | [lints.clippy] 72 | while_let_loop = "allow" 73 | collapsible_else_if = "allow" 74 | -------------------------------------------------------------------------------- /src/client.rs: -------------------------------------------------------------------------------- 1 | use crossterm::{ 2 | cursor::SetCursorStyle, 3 | event::{DisableMouseCapture, EnableMouseCapture, Event, EventStream}, 4 | execute, 5 | terminal::{ 6 | disable_raw_mode, enable_raw_mode, Clear, ClearType, EnterAlternateScreen, 7 | LeaveAlternateScreen, 8 | }, 9 | }; 10 | use futures::StreamExt; 11 | use scopeguard::defer; 12 | use tokio::select; 13 | use tui::backend::{Backend, CrosstermBackend}; 14 | 15 | use crate::{ 16 | error::ResultLogger, 17 | host::{receiver::MsgReceiver, sender::MsgSender}, 18 | protocol::{CltToSrv, CursorStyle, SrvToClt}, 19 | }; 20 | 21 | pub async fn client_main( 22 | sender: MsgSender, 23 | receiver: MsgReceiver, 24 | ) -> anyhow::Result<()> { 25 | enable_raw_mode()?; 26 | 27 | defer!(disable_raw_mode().log_ignore()); 28 | 29 | // If xterm modifyOtherKeys is enabled in iTerm2 then Ctrl prefixed key 30 | // presses are not captured. That is while using crossterm. 31 | // But termwiz works well, even though it seems to be using modifyOtherKeys 32 | // also. 33 | let (otherkeys_on, otherkeys_off) = 34 | if std::env::var("TERM_PROGRAM").unwrap_or_default() == "iTerm.app" { 35 | ("", "") 36 | } else { 37 | ("\x1b[>4;2m", "\x1b[>4;0m") 38 | }; 39 | 40 | execute!( 41 | std::io::stdout(), 42 | EnterAlternateScreen, 43 | Clear(ClearType::All), 44 | EnableMouseCapture, 45 | // https://wezfurlong.org/wezterm/config/key-encoding.html#xterm-modifyotherkeys 46 | crossterm::style::Print(otherkeys_on), 47 | )?; 48 | 49 | defer!(execute!( 50 | std::io::stdout(), 51 | crossterm::style::Print(otherkeys_off), 52 | DisableMouseCapture, 53 | LeaveAlternateScreen 54 | ) 55 | .log_ignore()); 56 | 57 | client_main_loop(sender, receiver).await 58 | } 59 | 60 | async fn client_main_loop( 61 | mut sender: MsgSender, 62 | mut receiver: MsgReceiver, 63 | ) -> anyhow::Result<()> { 64 | let mut backend = CrosstermBackend::new(std::io::stdout()); 65 | 66 | let init_size = backend.size()?; 67 | sender.send(CltToSrv::Init { 68 | width: init_size.width, 69 | height: init_size.height, 70 | })?; 71 | 72 | let mut term_events = EventStream::new(); 73 | loop { 74 | #[derive(Debug)] 75 | enum LocalEvent { 76 | ServerMsg(Option), 77 | TermEvent(Option>), 78 | } 79 | let event: LocalEvent = select! { 80 | msg = receiver.recv() => LocalEvent::ServerMsg(msg.transpose()?), 81 | event = term_events.next() => LocalEvent::TermEvent(event), 82 | }; 83 | match event { 84 | LocalEvent::ServerMsg(msg) => match msg { 85 | Some(msg) => match msg { 86 | SrvToClt::Draw { cells } => { 87 | let cells = cells 88 | .iter() 89 | .map(|(a, b, cell)| (*a, *b, tui::buffer::Cell::from(cell))) 90 | .collect::>(); 91 | backend.draw(cells.iter().map(|(a, b, cell)| (*a, *b, cell)))? 92 | } 93 | SrvToClt::SetCursor { x, y } => backend.set_cursor(x, y)?, 94 | SrvToClt::ShowCursor => backend.show_cursor()?, 95 | SrvToClt::CursorShape(cursor_style) => { 96 | let cursor_style = match cursor_style { 97 | CursorStyle::Default => SetCursorStyle::DefaultUserShape, 98 | CursorStyle::BlinkingBlock => SetCursorStyle::BlinkingBlock, 99 | CursorStyle::SteadyBlock => SetCursorStyle::SteadyBlock, 100 | CursorStyle::BlinkingUnderline => { 101 | SetCursorStyle::BlinkingUnderScore 102 | } 103 | CursorStyle::SteadyUnderline => SetCursorStyle::SteadyUnderScore, 104 | CursorStyle::BlinkingBar => SetCursorStyle::BlinkingBar, 105 | CursorStyle::SteadyBar => SetCursorStyle::SteadyBar, 106 | }; 107 | execute!(std::io::stdout(), cursor_style)?; 108 | } 109 | SrvToClt::HideCursor => backend.hide_cursor()?, 110 | SrvToClt::Clear => backend.clear()?, 111 | SrvToClt::Flush => backend.flush()?, 112 | SrvToClt::Quit => break, 113 | }, 114 | _ => break, 115 | }, 116 | LocalEvent::TermEvent(event) => match event { 117 | Some(Ok(event)) => sender.send(CltToSrv::Key(event))?, 118 | _ => break, 119 | }, 120 | } 121 | } 122 | 123 | Ok(()) 124 | } 125 | -------------------------------------------------------------------------------- /src/clipboard.rs: -------------------------------------------------------------------------------- 1 | use std::process::Stdio; 2 | 3 | use anyhow::Result; 4 | use base64::Engine; 5 | use which::which; 6 | 7 | #[allow(dead_code)] 8 | enum Provider { 9 | OSC52, 10 | Exec(&'static str, Vec<&'static str>), 11 | #[cfg(windows)] 12 | Win, 13 | NoOp, 14 | } 15 | 16 | #[cfg(windows)] 17 | fn detect_copy_provider() -> Provider { 18 | Provider::Win 19 | } 20 | 21 | #[cfg(target_os = "macos")] 22 | fn detect_copy_provider() -> Provider { 23 | if let Some(provider) = check_prog("pbcopy", &[]) { 24 | return provider; 25 | } 26 | Provider::OSC52 27 | } 28 | 29 | #[cfg(not(any(target_os = "macos", target_os = "windows")))] 30 | fn detect_copy_provider() -> Provider { 31 | // Wayland 32 | if std::env::var("WAYLAND_DISPLAY").is_ok() { 33 | if let Some(provider) = check_prog("wl-copy", &["--type", "text/plain"]) { 34 | return provider; 35 | } 36 | } 37 | // X11 38 | if std::env::var("DISPLAY").is_ok() { 39 | if let Some(provider) = 40 | check_prog("xclip", &["-i", "-selection", "clipboard"]) 41 | { 42 | return provider; 43 | } 44 | if let Some(provider) = check_prog("xsel", &["-i", "-b"]) { 45 | return provider; 46 | } 47 | } 48 | // Termux 49 | if let Some(provider) = check_prog("termux-clipboard-set", &[]) { 50 | return provider; 51 | } 52 | // Tmux 53 | if std::env::var("TMUX").is_ok() { 54 | if let Some(provider) = check_prog("tmux", &["load-buffer", "-"]) { 55 | return provider; 56 | } 57 | } 58 | 59 | Provider::OSC52 60 | } 61 | 62 | #[allow(dead_code)] 63 | fn check_prog(cmd: &'static str, args: &[&'static str]) -> Option { 64 | if which(cmd).is_ok() { 65 | Some(Provider::Exec(cmd, args.to_vec())) 66 | } else { 67 | None 68 | } 69 | } 70 | 71 | fn copy_impl(s: &str, provider: &Provider) -> Result<()> { 72 | match provider { 73 | Provider::OSC52 => { 74 | let mut stdout = std::io::stdout().lock(); 75 | use std::io::Write; 76 | write!( 77 | &mut stdout, 78 | "\x1b]52;;{}\x07", 79 | base64::engine::general_purpose::STANDARD.encode(s) 80 | )?; 81 | } 82 | 83 | Provider::Exec(prog, args) => { 84 | let mut child = std::process::Command::new(prog) 85 | .args(args) 86 | .stdin(Stdio::piped()) 87 | .stdout(Stdio::null()) 88 | .stderr(Stdio::null()) 89 | .spawn() 90 | .unwrap(); 91 | std::io::Write::write_all( 92 | &mut child.stdin.as_ref().unwrap(), 93 | s.as_bytes(), 94 | )?; 95 | child.wait()?; 96 | } 97 | 98 | #[cfg(windows)] 99 | Provider::Win => clipboard_win::set_clipboard_string(s) 100 | .map_err(|e| anyhow::Error::msg(e.to_string()))?, 101 | 102 | Provider::NoOp => (), 103 | }; 104 | 105 | Ok(()) 106 | } 107 | 108 | lazy_static::lazy_static! { 109 | static ref PROVIDER: Provider = detect_copy_provider(); 110 | } 111 | 112 | pub fn copy(s: &str) { 113 | match copy_impl(s, &PROVIDER) { 114 | Ok(()) => (), 115 | Err(err) => log::warn!("Copying error: {}", err.to_string()), 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use std::{ffi::OsString, path::PathBuf, str::FromStr}; 2 | 3 | use anyhow::{bail, Result}; 4 | use indexmap::IndexMap; 5 | use portable_pty::CommandBuilder; 6 | use serde::{Deserialize, Serialize}; 7 | use serde_yaml::Value; 8 | 9 | use crate::{ 10 | proc::StopSignal, 11 | settings::Settings, 12 | yaml_val::{value_to_string, Val}, 13 | }; 14 | 15 | pub struct ConfigContext { 16 | pub path: PathBuf, 17 | } 18 | 19 | pub struct Config { 20 | pub procs: Vec, 21 | pub server: Option, 22 | pub hide_keymap_window: bool, 23 | pub mouse_scroll_speed: usize, 24 | pub scrollback_len: usize, 25 | pub proc_list_width: usize, 26 | pub proc_list_title: String, 27 | } 28 | 29 | impl Config { 30 | pub fn from_value( 31 | value: &Value, 32 | ctx: &ConfigContext, 33 | settings: &Settings, 34 | ) -> Result { 35 | let config = Val::new(value)?; 36 | let config = config.as_object()?; 37 | 38 | let procs = if let Some(procs) = config.get(&Value::from("procs")) { 39 | let procs = procs 40 | .as_object()? 41 | .into_iter() 42 | .map(|(name, proc)| { 43 | Ok(ProcConfig::from_val( 44 | value_to_string(&name)?, 45 | settings.mouse_scroll_speed, 46 | settings.scrollback_len, 47 | proc, 48 | ctx, 49 | )?) 50 | }) 51 | .collect::>>()? 52 | .into_iter() 53 | .filter_map(|x| x) 54 | .collect::>(); 55 | procs 56 | } else { 57 | Vec::new() 58 | }; 59 | 60 | let server = if let Some(addr) = config.get(&Value::from("server")) { 61 | Some(ServerConfig::from_str(addr.as_str()?)?) 62 | } else { 63 | None 64 | }; 65 | 66 | let proc_list_title = 67 | if let Some(title) = config.get(&Value::from("proc_list_title")) { 68 | title.as_str()?.to_string() 69 | } else { 70 | settings.proc_list_title.clone() 71 | }; 72 | 73 | let config = Config { 74 | procs, 75 | server, 76 | hide_keymap_window: settings.hide_keymap_window, 77 | mouse_scroll_speed: settings.mouse_scroll_speed, 78 | scrollback_len: settings.scrollback_len, 79 | proc_list_width: settings.proc_list_width, 80 | proc_list_title, 81 | }; 82 | 83 | Ok(config) 84 | } 85 | 86 | pub fn make_default(settings: &Settings) -> Self { 87 | Self { 88 | procs: Vec::new(), 89 | server: None, 90 | hide_keymap_window: settings.hide_keymap_window, 91 | mouse_scroll_speed: settings.mouse_scroll_speed, 92 | scrollback_len: settings.scrollback_len, 93 | proc_list_width: settings.proc_list_width, 94 | proc_list_title: settings.proc_list_title.clone(), 95 | } 96 | } 97 | } 98 | 99 | pub struct ProcConfig { 100 | pub name: String, 101 | pub cmd: CmdConfig, 102 | pub cwd: Option, 103 | pub env: Option>>, 104 | pub autostart: bool, 105 | pub autorestart: bool, 106 | 107 | pub stop: StopSignal, 108 | 109 | pub mouse_scroll_speed: usize, 110 | pub scrollback_len: usize, 111 | } 112 | 113 | impl ProcConfig { 114 | fn from_val( 115 | name: String, 116 | mouse_scroll_speed: usize, 117 | scrollback_len: usize, 118 | val: Val, 119 | ctx: &ConfigContext, 120 | ) -> Result> { 121 | match val.raw() { 122 | Value::Null => Ok(None), 123 | Value::Bool(_) => todo!(), 124 | Value::Number(_) => todo!(), 125 | Value::String(shell) => Ok(Some(ProcConfig { 126 | name, 127 | cmd: CmdConfig::Shell { 128 | shell: shell.to_owned(), 129 | }, 130 | cwd: None, 131 | env: None, 132 | autostart: true, 133 | autorestart: false, 134 | stop: StopSignal::default(), 135 | 136 | mouse_scroll_speed, 137 | scrollback_len, 138 | })), 139 | Value::Sequence(_) => { 140 | let cmd = val.as_array()?; 141 | let cmd = cmd 142 | .into_iter() 143 | .map(|item| item.as_str().map(|s| s.to_owned())) 144 | .collect::>>()?; 145 | 146 | Ok(Some(ProcConfig { 147 | name, 148 | cmd: CmdConfig::Cmd { cmd }, 149 | cwd: None, 150 | env: None, 151 | autostart: true, 152 | autorestart: false, 153 | stop: StopSignal::default(), 154 | mouse_scroll_speed, 155 | scrollback_len, 156 | })) 157 | } 158 | Value::Mapping(_) => { 159 | let map = val.as_object()?; 160 | 161 | let cmd = { 162 | let shell = map.get(&Value::from("shell")); 163 | let cmd = map.get(&Value::from("cmd")); 164 | 165 | match (shell, cmd) { 166 | (None, Some(cmd)) => CmdConfig::Cmd { 167 | cmd: cmd 168 | .as_array()? 169 | .into_iter() 170 | .map(|v| v.as_str().map(|s| s.to_owned())) 171 | .collect::>>()?, 172 | }, 173 | (Some(shell), None) => CmdConfig::Shell { 174 | shell: shell.as_str()?.to_owned(), 175 | }, 176 | (None, None) => todo!(), 177 | (Some(_), Some(_)) => todo!(), 178 | } 179 | }; 180 | 181 | let cwd = match map.get(&Value::from("cwd")) { 182 | Some(cwd) => { 183 | let cwd = cwd.as_str()?; 184 | let mut buf = OsString::new(); 185 | if let Some(rest) = cwd.strip_prefix("") { 186 | if let Some(parent) = dunce::canonicalize(&ctx.path)?.parent() { 187 | buf.push(parent); 188 | } 189 | buf.push(rest); 190 | } else { 191 | buf.push(cwd); 192 | } 193 | Some(buf) 194 | } 195 | None => None, 196 | }; 197 | 198 | let env = match map.get(&Value::from("env")) { 199 | Some(env) => { 200 | let env = env.as_object()?; 201 | let env = env 202 | .into_iter() 203 | .map(|(k, v)| { 204 | let v = match v.raw() { 205 | Value::Null => Ok(None), 206 | Value::String(v) => Ok(Some(v.to_owned())), 207 | _ => Err(v.error_at("Expected string or null")), 208 | }; 209 | Ok((value_to_string(&k)?, v?)) 210 | }) 211 | .collect::>>()?; 212 | Some(env) 213 | } 214 | None => None, 215 | }; 216 | let env = match map.get(&Value::from("add_path")) { 217 | Some(add_path) => { 218 | let extra_paths = match add_path.raw() { 219 | Value::String(path) => vec![path.as_str()], 220 | Value::Sequence(paths) => paths 221 | .into_iter() 222 | .filter_map(|path| path.as_str()) 223 | .collect::>(), 224 | _ => { 225 | bail!(add_path.error_at("Expected string or array")); 226 | } 227 | }; 228 | let extra_paths = extra_paths 229 | .into_iter() 230 | .map(|p| PathBuf::from_str(p).map_err(anyhow::Error::from)) 231 | .collect::>>()?; 232 | let mut paths = std::env::var_os("PATH").map_or_else( 233 | || Vec::new(), 234 | |path_var| { 235 | std::env::split_paths(&path_var) 236 | .map(|p| p.into_os_string()) 237 | .collect::>() 238 | }, 239 | ); 240 | for p in extra_paths { 241 | paths.push(p.into_os_string()); 242 | } 243 | let path_var = 244 | std::env::join_paths(paths)?.to_string_lossy().to_string(); 245 | let env = if let Some(mut env) = env { 246 | env.insert("PATH".to_string(), Some(path_var)); 247 | env 248 | } else { 249 | let mut env = IndexMap::with_capacity(1); 250 | env.insert("PATH".to_string(), Some(path_var)); 251 | env 252 | }; 253 | Some(env) 254 | } 255 | None => env, 256 | }; 257 | 258 | let autostart = map 259 | .get(&Value::from("autostart")) 260 | .map_or(Ok(true), |v| v.as_bool())?; 261 | 262 | let autorestart = map 263 | .get(&Value::from("autorestart")) 264 | .map_or(Ok(false), |v| v.as_bool())?; 265 | 266 | let stop_signal = if let Some(val) = map.get(&Value::from("stop")) { 267 | StopSignal::from_val(val)? 268 | } else { 269 | StopSignal::default() 270 | }; 271 | 272 | Ok(Some(ProcConfig { 273 | name, 274 | cmd, 275 | cwd, 276 | env, 277 | autostart, 278 | autorestart, 279 | stop: stop_signal, 280 | mouse_scroll_speed, 281 | scrollback_len, 282 | })) 283 | } 284 | Value::Tagged(_) => anyhow::bail!("Yaml tags are not supported"), 285 | } 286 | } 287 | } 288 | 289 | pub enum ServerConfig { 290 | Tcp(String), 291 | } 292 | 293 | impl ServerConfig { 294 | pub fn from_str(server_addr: &str) -> Result { 295 | Ok(Self::Tcp(server_addr.to_string())) 296 | } 297 | } 298 | 299 | #[derive(Deserialize, Serialize)] 300 | #[serde(untagged)] 301 | pub enum CmdConfig { 302 | Cmd { cmd: Vec }, 303 | Shell { shell: String }, 304 | } 305 | 306 | impl From<&ProcConfig> for CommandBuilder { 307 | fn from(cfg: &ProcConfig) -> Self { 308 | let mut cmd = match &cfg.cmd { 309 | CmdConfig::Cmd { cmd } => CommandBuilder::from_argv( 310 | cmd.iter().map(|s| OsString::from(s)).collect(), 311 | ), 312 | CmdConfig::Shell { shell } => cmd_from_shell(shell), 313 | }; 314 | 315 | if let Some(env) = &cfg.env { 316 | for (k, v) in env { 317 | if let Some(v) = v { 318 | cmd.env(k, v); 319 | } else { 320 | cmd.env_remove(k); 321 | } 322 | } 323 | } 324 | 325 | if let Some(cwd) = &cfg.cwd { 326 | cmd.cwd(cwd); 327 | } else if let Ok(cwd) = std::env::current_dir() { 328 | cmd.cwd(cwd); 329 | } 330 | 331 | cmd 332 | } 333 | } 334 | 335 | #[cfg(windows)] 336 | pub fn cmd_from_shell(shell: &str) -> CommandBuilder { 337 | CommandBuilder::from_argv(vec![ 338 | "pwsh.exe".into(), 339 | "-Command".into(), 340 | shell.into(), 341 | ]) 342 | } 343 | 344 | #[cfg(not(windows))] 345 | pub fn cmd_from_shell(shell: &str) -> CommandBuilder { 346 | CommandBuilder::from_argv(vec!["/bin/sh".into(), "-c".into(), shell.into()]) 347 | } 348 | -------------------------------------------------------------------------------- /src/config_lua.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{bail, Result}; 2 | use mlua::{Lua, Value}; 3 | 4 | type V = serde_yaml::Value; 5 | 6 | pub fn load_lua_config(_path: &str, src: &str) -> Result { 7 | let lua = mlua::Lua::new(); 8 | let v: Value = lua.load(src).eval().unwrap(); 9 | conv_value(&lua, v) 10 | } 11 | 12 | fn conv_value(lua: &Lua, value: Value) -> Result { 13 | let v = match value { 14 | Value::Nil => V::Null, 15 | Value::Boolean(x) => V::Bool(x), 16 | Value::LightUserData(_) => todo!(), 17 | Value::Integer(x) => V::Number(x.into()), 18 | Value::Number(x) => V::Number(x.into()), 19 | Value::String(x) => V::String(x.to_string_lossy().to_string()), 20 | Value::Table(x) => { 21 | let mut map = serde_yaml::Mapping::new(); 22 | for entry in x.pairs::() { 23 | let (k, v) = entry.unwrap(); 24 | map.insert(conv_value(lua, k)?, conv_value(lua, v)?); 25 | } 26 | V::Mapping(map) 27 | } 28 | Value::Function(_x) => todo!(), 29 | Value::Thread(_x) => todo!(), 30 | Value::UserData(_) => todo!(), 31 | Value::Error(err) => bail!(err), 32 | }; 33 | Ok(v) 34 | } 35 | -------------------------------------------------------------------------------- /src/ctl.rs: -------------------------------------------------------------------------------- 1 | use serde_yaml::Value; 2 | 3 | use crate::{ 4 | config::{Config, ServerConfig}, 5 | event::AppEvent, 6 | }; 7 | 8 | pub async fn run_ctl(ctl: &str, config: &Config) -> anyhow::Result<()> { 9 | let event: AppEvent = match serde_yaml::from_str(ctl) { 10 | Ok(event) => event, 11 | Err(err) => { 12 | let val: Value = serde_yaml::from_str(ctl)?; 13 | println!( 14 | "Remote command parsed as:\n{}", 15 | serde_yaml::to_string(&val)? 16 | ); 17 | return Err(err.into()); 18 | } 19 | }; 20 | 21 | let socket = match &config.server { 22 | Some(ServerConfig::Tcp(addr)) => std::net::TcpStream::connect(addr)?, 23 | None => anyhow::bail!("Server address is not defined."), 24 | }; 25 | 26 | serde_yaml::to_writer(socket, &event).unwrap(); 27 | 28 | Ok(()) 29 | } 30 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | pub trait ResultLogger { 2 | fn log_ignore(&self) -> (); 3 | 4 | fn log_get(self) -> Option; 5 | } 6 | 7 | impl ResultLogger for Result { 8 | fn log_ignore(&self) -> () { 9 | match self { 10 | Ok(_) => (), 11 | Err(err) => log::error!("Error: {}", err.to_string()), 12 | } 13 | } 14 | 15 | fn log_get(self) -> Option { 16 | match &self { 17 | Ok(_) => (), 18 | Err(err) => log::error!("Error: {}", err.to_string()), 19 | } 20 | self.ok() 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/event.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | 5 | use crate::{app::ClientId, key::Key}; 6 | 7 | #[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] 8 | #[serde(tag = "c", rename_all = "kebab-case")] 9 | pub enum AppEvent { 10 | Batch { cmds: Vec }, 11 | 12 | QuitOrAsk, 13 | Quit, 14 | ForceQuit, 15 | Detach { client_id: ClientId }, 16 | 17 | ToggleFocus, 18 | FocusProcs, 19 | FocusTerm, 20 | Zoom, 21 | 22 | ShowCommandsMenu, 23 | NextProc, 24 | PrevProc, 25 | SelectProc { index: usize }, 26 | StartProc, 27 | TermProc, 28 | KillProc, 29 | RestartProc, 30 | RenameProc { name: String }, 31 | ForceRestartProc, 32 | ShowAddProc, 33 | ShowRenameProc, 34 | AddProc { cmd: String, name: Option }, 35 | DuplicateProc, 36 | ShowRemoveProc, 37 | RemoveProc { id: usize }, 38 | 39 | CloseCurrentModal, 40 | 41 | ScrollDownLines { n: usize }, 42 | ScrollUpLines { n: usize }, 43 | ScrollDown, 44 | ScrollUp, 45 | 46 | CopyModeEnter, 47 | CopyModeLeave, 48 | CopyModeMove { dir: CopyMove }, 49 | CopyModeEnd, 50 | CopyModeCopy, 51 | ToggleKeymapWindow, 52 | 53 | SendKey { key: Key }, 54 | } 55 | 56 | impl AppEvent { 57 | pub fn desc(&self) -> String { 58 | match self { 59 | AppEvent::Batch { cmds: _ } => "Send multiple events".to_string(), 60 | AppEvent::QuitOrAsk => "Quit".to_string(), 61 | AppEvent::Quit => "Quit".to_string(), 62 | AppEvent::ForceQuit => "Force quit".to_string(), 63 | AppEvent::Detach { client_id } => { 64 | format!("Detach client #{:?}", client_id) 65 | } 66 | AppEvent::ToggleFocus => "Toggle focus".to_string(), 67 | AppEvent::FocusProcs => "Focus process list".to_string(), 68 | AppEvent::FocusTerm => "Focus terminal".to_string(), 69 | AppEvent::Zoom => "Zoom into terminal".to_string(), 70 | AppEvent::ShowCommandsMenu => "Show commands menu".to_string(), 71 | AppEvent::NextProc => "Next".to_string(), 72 | AppEvent::PrevProc => "Prev".to_string(), 73 | AppEvent::SelectProc { index } => format!("Select process #{}", index), 74 | AppEvent::StartProc => "Start".to_string(), 75 | AppEvent::TermProc => "Stop".to_string(), 76 | AppEvent::KillProc => "Kill".to_string(), 77 | AppEvent::RestartProc => "Restart".to_string(), 78 | AppEvent::RenameProc { name } => format!("Rename to \"{}\"", name), 79 | AppEvent::ForceRestartProc => "Force restart".to_string(), 80 | AppEvent::ShowAddProc => "New process dialog".to_string(), 81 | AppEvent::ShowRenameProc => "Rename process dialog".to_string(), 82 | AppEvent::AddProc { cmd, name } => format!("New process `{}`", cmd), 83 | AppEvent::DuplicateProc => "Duplicate current process".to_string(), 84 | AppEvent::ShowRemoveProc => "Remove process dialog".to_string(), 85 | AppEvent::RemoveProc { id } => format!("Remove process by id {}", id), 86 | AppEvent::CloseCurrentModal => "Close current modal".to_string(), 87 | AppEvent::ScrollDownLines { n } => { 88 | format!("Scroll down {} {}", n, lines_str(*n)) 89 | } 90 | AppEvent::ScrollUpLines { n } => { 91 | format!("Scroll up {} {}", n, lines_str(*n)) 92 | } 93 | AppEvent::ScrollDown => "Scroll down".to_string(), 94 | AppEvent::ScrollUp => "Scroll up".to_string(), 95 | AppEvent::CopyModeEnter => "Enter copy mode".to_string(), 96 | AppEvent::CopyModeLeave => "Leave copy mode".to_string(), 97 | AppEvent::CopyModeMove { dir } => { 98 | format!("Move selection cursor {}", dir) 99 | } 100 | AppEvent::CopyModeEnd => "Select end position".to_string(), 101 | AppEvent::CopyModeCopy => "Copy selected text".to_string(), 102 | AppEvent::ToggleKeymapWindow => "Toggle help".to_string(), 103 | AppEvent::SendKey { key } => format!("Send {} key", key.to_string()), 104 | } 105 | } 106 | } 107 | 108 | fn lines_str(n: usize) -> &'static str { 109 | if n == 1 { 110 | "line" 111 | } else { 112 | "lines" 113 | } 114 | } 115 | 116 | #[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] 117 | pub enum CopyMove { 118 | Up, 119 | Right, 120 | Left, 121 | Down, 122 | } 123 | 124 | impl Display for CopyMove { 125 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 126 | let str = match self { 127 | CopyMove::Up => "up", 128 | CopyMove::Right => "right", 129 | CopyMove::Left => "left", 130 | CopyMove::Down => "down", 131 | }; 132 | f.write_str(str) 133 | } 134 | } 135 | 136 | #[cfg(test)] 137 | mod tests { 138 | use super::*; 139 | 140 | #[test] 141 | fn serialize() { 142 | assert_eq!( 143 | serde_yaml::to_string(&AppEvent::ForceQuit).unwrap(), 144 | "c: force-quit\n" 145 | ); 146 | 147 | assert_eq!( 148 | serde_yaml::to_string(&AppEvent::SendKey { 149 | key: Key::parse("").unwrap() 150 | }) 151 | .unwrap(), 152 | "c: send-key\nkey: \n" 153 | ); 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/host/daemon.rs: -------------------------------------------------------------------------------- 1 | pub fn spawn_server_daemon() -> anyhow::Result<()> { 2 | let exe = std::env::current_exe()?; 3 | 4 | #[cfg(unix)] 5 | return self::unix::spawn_impl(exe); 6 | #[cfg(windows)] 7 | return self::windows::spawn_impl(exe); 8 | } 9 | 10 | #[cfg(unix)] 11 | mod unix { 12 | use std::{ffi::CString, path::PathBuf}; 13 | 14 | use anyhow::bail; 15 | 16 | pub fn spawn_impl(exe: PathBuf) -> anyhow::Result<()> { 17 | let daemon = 18 | daemonize::Daemonize::new().working_directory(std::env::current_dir()?); 19 | 20 | match daemon.execute() { 21 | daemonize::Outcome::Parent(_) => (), 22 | daemonize::Outcome::Child(_) => exec(&[ 23 | exe.to_str().ok_or_else(|| { 24 | anyhow::format_err!("Failed to convert exe path: {:?}", exe) 25 | })?, 26 | "server", 27 | ])?, 28 | } 29 | 30 | Ok(()) 31 | } 32 | 33 | #[cfg(unix)] 34 | fn exec(argv: &[&str]) -> anyhow::Result<()> { 35 | // Add null terminations to our strings and our argument array, 36 | // converting them into a C-compatible format. 37 | let program_cstring = CString::new( 38 | argv 39 | .first() 40 | .ok_or_else(|| anyhow::format_err!("Empty argv"))? 41 | .as_bytes(), 42 | )?; 43 | let arg_cstrings = argv 44 | .iter() 45 | .map(|arg| CString::new(arg.as_bytes())) 46 | .collect::, _>>()?; 47 | let mut arg_charptrs: Vec<_> = 48 | arg_cstrings.iter().map(|arg| arg.as_ptr()).collect(); 49 | arg_charptrs.push(std::ptr::null()); 50 | 51 | // Use an `unsafe` block so that we can call directly into C. 52 | let res = 53 | unsafe { libc::execvp(program_cstring.as_ptr(), arg_charptrs.as_ptr()) }; 54 | 55 | // Handle our error result. 56 | if res < 0 { 57 | bail!("Error calling execvp"); 58 | } else { 59 | // Should never happen. 60 | panic!("execvp returned unexpectedly") 61 | } 62 | } 63 | } 64 | 65 | #[cfg(windows)] 66 | mod windows { 67 | use std::path::PathBuf; 68 | 69 | pub fn spawn_impl(path: PathBuf) -> anyhow::Result<()> { 70 | use std::{os::windows::process::CommandExt, process::Stdio}; 71 | 72 | use winapi::um::winbase::{CREATE_NEW_PROCESS_GROUP, DETACHED_PROCESS}; 73 | 74 | std::process::Command::new(path) 75 | .arg("server") 76 | .stdin(Stdio::null()) 77 | .stdout(Stdio::null()) 78 | .stdout(Stdio::null()) 79 | .creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS) 80 | .spawn()?; 81 | 82 | Ok(()) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/host/mod.rs: -------------------------------------------------------------------------------- 1 | mod daemon; 2 | pub mod receiver; 3 | pub mod sender; 4 | pub mod socket; 5 | -------------------------------------------------------------------------------- /src/host/receiver.rs: -------------------------------------------------------------------------------- 1 | use std::marker::PhantomData; 2 | 3 | use bytes::{Buf, BytesMut}; 4 | use futures::StreamExt; 5 | use serde::de::DeserializeOwned; 6 | use tokio::io::AsyncRead; 7 | 8 | struct MsgDecoder { 9 | state: DecoderState, 10 | t: PhantomData, 11 | } 12 | 13 | enum DecoderState { 14 | Header, 15 | Data(usize), 16 | } 17 | 18 | impl MsgDecoder { 19 | pub fn new() -> MsgDecoder { 20 | MsgDecoder { 21 | state: DecoderState::Header, 22 | t: PhantomData, 23 | } 24 | } 25 | } 26 | 27 | impl tokio_util::codec::Decoder for MsgDecoder { 28 | type Item = T; 29 | 30 | type Error = bincode::Error; 31 | 32 | fn decode( 33 | &mut self, 34 | src: &mut BytesMut, 35 | ) -> Result, Self::Error> { 36 | if let DecoderState::Header = self.state { 37 | let len = if src.len() >= 4 { 38 | src.get_u32() as usize 39 | } else { 40 | return Ok(None); 41 | }; 42 | self.state = DecoderState::Data(len); 43 | } 44 | let len = match self.state { 45 | DecoderState::Header => { 46 | let len = if src.len() >= 4 { 47 | src.get_u32() as usize 48 | } else { 49 | return Ok(None); 50 | }; 51 | self.state = DecoderState::Data(len); 52 | len 53 | } 54 | DecoderState::Data(len) => len, 55 | }; 56 | 57 | if src.len() >= len { 58 | let msg: T = bincode::deserialize(&src[..len])?; 59 | if src.len() == len { 60 | src.clear(); 61 | } else { 62 | src.advance(len); 63 | } 64 | self.state = DecoderState::Header; 65 | Ok(Some(msg)) 66 | } else { 67 | Ok(None) 68 | } 69 | } 70 | } 71 | 72 | pub struct MsgReceiver { 73 | receiver: tokio::sync::mpsc::UnboundedReceiver, 74 | } 75 | 76 | impl MsgReceiver { 77 | pub fn new(receiver: tokio::sync::mpsc::UnboundedReceiver) -> Self { 78 | MsgReceiver { receiver } 79 | } 80 | 81 | pub fn new_read(read: R) -> Self { 82 | let mut framed = 83 | tokio_util::codec::FramedRead::new(read, MsgDecoder::::new()); 84 | 85 | let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); 86 | tokio::spawn(async move { 87 | loop { 88 | let msg = framed.next().await; 89 | let msg = match msg { 90 | Some(Ok(msg)) => msg, 91 | _ => break, 92 | }; 93 | match tx.send(msg) { 94 | Ok(()) => (), 95 | Err(_) => break, 96 | }; 97 | } 98 | }); 99 | 100 | MsgReceiver { receiver: rx } 101 | } 102 | } 103 | 104 | impl MsgReceiver { 105 | pub async fn recv(&mut self) -> Option> { 106 | let msg = self.receiver.recv().await; 107 | msg.map(|x| Ok(x)) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/host/sender.rs: -------------------------------------------------------------------------------- 1 | use std::{fmt::Debug, marker::PhantomData}; 2 | 3 | use bytes::{BufMut, BytesMut}; 4 | use futures::SinkExt; 5 | use serde::{de::DeserializeOwned, Serialize}; 6 | use tokio::io::AsyncWrite; 7 | 8 | #[derive(Clone)] 9 | pub struct MsgSender { 10 | sender: tokio::sync::mpsc::UnboundedSender, 11 | } 12 | 13 | impl MsgSender { 14 | pub fn new(sender: tokio::sync::mpsc::UnboundedSender) -> Self { 15 | MsgSender { sender } 16 | } 17 | 18 | pub fn new_write(write: W) -> Self { 19 | let mut framed = 20 | tokio_util::codec::FramedWrite::new(write, MsgEncoder::::new()); 21 | 22 | let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); 23 | 24 | tokio::spawn(async move { 25 | loop { 26 | let msg = rx.recv().await; 27 | let msg = match msg { 28 | Some(msg) => msg, 29 | None => break, 30 | }; 31 | 32 | // TODO: Use `framed.feed()` 33 | match framed.send(msg).await { 34 | Ok(()) => (), 35 | Err(_) => break, 36 | } 37 | } 38 | }); 39 | 40 | MsgSender { sender: tx } 41 | } 42 | } 43 | 44 | impl MsgSender { 45 | pub fn send( 46 | &mut self, 47 | msg: T, 48 | ) -> Result<(), tokio::sync::mpsc::error::SendError> { 49 | self.sender.send(msg) 50 | } 51 | } 52 | 53 | struct MsgEncoder { 54 | t: PhantomData, 55 | buf: Vec, 56 | } 57 | 58 | impl MsgEncoder { 59 | pub fn new() -> Self { 60 | MsgEncoder { 61 | t: PhantomData, 62 | buf: Vec::new(), 63 | } 64 | } 65 | } 66 | 67 | impl tokio_util::codec::Encoder for MsgEncoder { 68 | type Error = bincode::Error; 69 | 70 | fn encode(&mut self, item: T, dst: &mut BytesMut) -> Result<(), Self::Error> { 71 | bincode::serialize_into(&mut self.buf, &item)?; 72 | dst.put_u32(self.buf.len() as u32); 73 | dst.extend_from_slice(&self.buf); 74 | self.buf.clear(); 75 | Ok(()) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/host/socket.rs: -------------------------------------------------------------------------------- 1 | #[cfg(unix)] 2 | pub use self::unix::{bind_server_socket, connect_client_socket}; 3 | #[cfg(windows)] 4 | pub use self::windows::{bind_server_socket, connect_client_socket}; 5 | 6 | #[cfg(unix)] 7 | mod unix { 8 | use std::{fmt::Debug, path::PathBuf, time::Duration}; 9 | 10 | use serde::{de::DeserializeOwned, Serialize}; 11 | use tokio::net::{UnixListener, UnixStream}; 12 | 13 | use crate::{ 14 | error::ResultLogger, 15 | host::{ 16 | daemon::spawn_server_daemon, receiver::MsgReceiver, sender::MsgSender, 17 | }, 18 | }; 19 | 20 | fn get_socket_path() -> PathBuf { 21 | let mut path = std::env::temp_dir(); 22 | path.push("dekit.sock"); 23 | path 24 | } 25 | 26 | pub async fn bind_server_socket() -> anyhow::Result { 27 | let path = get_socket_path(); 28 | 29 | let bind = || UnixListener::bind(&path); 30 | let listener = match bind() { 31 | Ok(listener) => listener, 32 | Err(err) => match err.kind() { 33 | std::io::ErrorKind::AddrInUse => { 34 | std::fs::remove_file(&path)?; 35 | bind()? 36 | } 37 | _ => return Err(err.into()), 38 | }, 39 | }; 40 | 41 | Ok(ServerSocket { path, listener }) 42 | } 43 | 44 | pub struct ServerSocket { 45 | path: PathBuf, 46 | listener: UnixListener, 47 | } 48 | 49 | impl Drop for ServerSocket { 50 | fn drop(&mut self) { 51 | std::fs::remove_file(&self.path).log_ignore(); 52 | } 53 | } 54 | 55 | impl ServerSocket { 56 | pub async fn accept< 57 | S: Serialize + Debug + Send + 'static, 58 | R: DeserializeOwned + Send + 'static, 59 | >( 60 | &mut self, 61 | ) -> anyhow::Result<(MsgSender, MsgReceiver)> { 62 | let (stream, _addr) = self.listener.accept().await?; 63 | let (read, write) = stream.into_split(); 64 | let sender = MsgSender::new_write(write); 65 | let receiver = MsgReceiver::new_read(read); 66 | Ok((sender, receiver)) 67 | } 68 | } 69 | 70 | pub async fn connect_client_socket< 71 | S: Serialize + Debug + Send + 'static, 72 | R: DeserializeOwned + Send + 'static, 73 | >( 74 | mut spawn_server: bool, 75 | ) -> anyhow::Result<(MsgSender, MsgReceiver)> { 76 | let path = get_socket_path(); 77 | loop { 78 | match UnixStream::connect(&path).await { 79 | Ok(socket) => { 80 | let (read, write) = socket.into_split(); 81 | let sender = MsgSender::new_write(write); 82 | let receiver = MsgReceiver::new_read(read); 83 | return Ok((sender, receiver)); 84 | } 85 | Err(err) => { 86 | match err.kind() { 87 | std::io::ErrorKind::NotFound 88 | | std::io::ErrorKind::ConnectionRefused => { 89 | // ConnectionRefused: Socket exists, but no process is listening. 90 | 91 | if spawn_server { 92 | spawn_server = false; 93 | spawn_server_daemon()?; 94 | } 95 | } 96 | _ => (), 97 | } 98 | tokio::time::sleep(Duration::from_millis(20)).await; 99 | } 100 | } 101 | } 102 | } 103 | } 104 | 105 | #[cfg(windows)] 106 | mod windows { 107 | use std::{ 108 | fmt::Debug, io::Write, os::windows::prelude::OpenOptionsExt, path::PathBuf, 109 | time::Duration, 110 | }; 111 | 112 | use serde::{de::DeserializeOwned, Serialize}; 113 | use tokio::net::{TcpListener, TcpStream}; 114 | use winapi::um::winbase::FILE_FLAG_DELETE_ON_CLOSE; 115 | 116 | use crate::host::{ 117 | daemon::spawn_server_daemon, receiver::MsgReceiver, sender::MsgSender, 118 | }; 119 | 120 | fn get_socket_path() -> PathBuf { 121 | let mut path = std::env::temp_dir(); 122 | path.push("dekit.addr"); 123 | path 124 | } 125 | 126 | fn get_socket_addr() -> anyhow::Result { 127 | let path = get_socket_path(); 128 | let addr = std::fs::read_to_string(path)?; 129 | Ok(addr) 130 | } 131 | 132 | pub async fn bind_server_socket() -> anyhow::Result { 133 | let path = get_socket_path(); 134 | 135 | let bind = || TcpListener::bind(("127.0.0.1", 0)); 136 | let (file, listener) = match bind().await { 137 | Ok(listener) => { 138 | let addr = listener.local_addr()?.to_string(); 139 | log::info!("Listening on {}", addr); 140 | 141 | let mut file_opts = std::fs::OpenOptions::new(); 142 | file_opts 143 | .write(true) 144 | .truncate(true) 145 | .create(true) 146 | .custom_flags(FILE_FLAG_DELETE_ON_CLOSE); 147 | let mut file = file_opts.open(&path)?; 148 | file.write_all(addr.as_bytes())?; 149 | log::info!("Wrote socket address into {}", path.to_string_lossy()); 150 | 151 | (file, listener) 152 | } 153 | Err(err) => return Err(err.into()), 154 | }; 155 | 156 | Ok(ServerSocket { file, listener }) 157 | } 158 | 159 | pub struct ServerSocket { 160 | #[allow(dead_code)] 161 | /// Handle to file with socket address. File has FILE_FLAG_DELETE_ON_CLOSE 162 | /// flag. 163 | file: std::fs::File, 164 | listener: TcpListener, 165 | } 166 | 167 | impl ServerSocket { 168 | pub async fn accept< 169 | S: Serialize + Debug + Send + 'static, 170 | R: DeserializeOwned + Send + 'static, 171 | >( 172 | &mut self, 173 | ) -> anyhow::Result<(MsgSender, MsgReceiver)> { 174 | let (stream, _addr) = self.listener.accept().await?; 175 | let (read, write) = stream.into_split(); 176 | let sender = MsgSender::new_write(write); 177 | let receiver = MsgReceiver::new_read(read); 178 | Ok((sender, receiver)) 179 | } 180 | } 181 | 182 | pub async fn connect_client_socket< 183 | S: Serialize + Debug + Send + 'static, 184 | R: DeserializeOwned + Send + 'static, 185 | >( 186 | mut spawn_server: bool, 187 | ) -> anyhow::Result<(MsgSender, MsgReceiver)> { 188 | loop { 189 | let addr = match get_socket_addr() { 190 | Ok(addr) => addr, 191 | Err(_) => { 192 | // Socket doesn't exist. 193 | if spawn_server { 194 | spawn_server = false; 195 | spawn_server_daemon()?; 196 | } 197 | tokio::time::sleep(Duration::from_millis(50)).await; 198 | continue; 199 | } 200 | }; 201 | match TcpStream::connect(&addr).await { 202 | Ok(socket) => { 203 | let (read, write) = socket.into_split(); 204 | let sender = MsgSender::new_write(write); 205 | let receiver = MsgReceiver::new_read(read); 206 | return Ok((sender, receiver)); 207 | } 208 | Err(err) => { 209 | match err.kind() { 210 | std::io::ErrorKind::NotFound 211 | | std::io::ErrorKind::ConnectionRefused => { 212 | // ConnectionRefused: Socket exists, but no process is listening. 213 | if spawn_server { 214 | spawn_server = false; 215 | spawn_server_daemon()?; 216 | } 217 | } 218 | _ => (), 219 | } 220 | tokio::time::sleep(Duration::from_millis(50)).await; 221 | } 222 | } 223 | } 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /src/just.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use indexmap::IndexMap; 3 | use serde_json::Value; 4 | 5 | use crate::{ 6 | config::{CmdConfig, ProcConfig}, 7 | proc::StopSignal, 8 | settings::Settings, 9 | }; 10 | 11 | #[derive(serde::Deserialize)] 12 | struct Justfile { 13 | recipes: IndexMap, 14 | } 15 | 16 | // Use `just` command to get recipes from a justfile 17 | pub fn load_just_procs(settings: &Settings) -> Result> { 18 | let output = std::process::Command::new("just") 19 | .arg("--dump") 20 | .arg("--dump-format=json") 21 | // Specify the justfile to avoid loading the default justfile 22 | .arg("--justfile=justfile") 23 | .output() 24 | .map_err(|e| anyhow::Error::msg(format!("Failed to run just: {}", e)))?; 25 | 26 | if !output.status.success() { 27 | return Err(anyhow::Error::msg(format!( 28 | "Failed to run just: {}", 29 | String::from_utf8_lossy(&output.stderr) 30 | ))); 31 | } 32 | 33 | let justfile: Justfile = serde_json::from_slice(&output.stdout)?; 34 | let procs = justfile 35 | .recipes 36 | .into_iter() 37 | .map(|(name, _recipes)| ProcConfig { 38 | name: name.clone(), 39 | cmd: CmdConfig::Shell { 40 | shell: format!("just {}", name.clone()), 41 | }, 42 | cwd: None, 43 | env: None, 44 | autostart: false, 45 | autorestart: false, 46 | 47 | stop: StopSignal::default(), 48 | mouse_scroll_speed: settings.mouse_scroll_speed, 49 | scrollback_len: settings.scrollback_len, 50 | }); 51 | Ok(procs.collect()) 52 | } 53 | -------------------------------------------------------------------------------- /src/kernel/kernel_message.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | app::{ClientHandle, ClientId}, 3 | protocol::CltToSrv, 4 | }; 5 | 6 | pub type KernelSender = tokio::sync::mpsc::UnboundedSender; 7 | 8 | pub enum KernelMessage { 9 | ClientMessage { client_id: ClientId, msg: CltToSrv }, 10 | ClientConnected { handle: ClientHandle }, 11 | ClientDisconnected { client_id: ClientId }, 12 | } 13 | -------------------------------------------------------------------------------- /src/kernel/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod kernel_message; 2 | -------------------------------------------------------------------------------- /src/key.rs: -------------------------------------------------------------------------------- 1 | use anyhow::bail; 2 | use crossterm::event::{KeyCode, KeyModifiers}; 3 | use serde::{Deserialize, Serialize}; 4 | 5 | static KEYS: phf::Map<&'static str, KeyCode> = phf::phf_map! { 6 | "bs" => KeyCode::Backspace, 7 | "enter" => KeyCode::Enter, 8 | "left" => KeyCode::Left, 9 | "right" => KeyCode::Right, 10 | "up" => KeyCode::Up, 11 | "down" => KeyCode::Down, 12 | "home" => KeyCode::Home, 13 | "end" => KeyCode::End, 14 | "pageup" => KeyCode::PageUp, 15 | "pagedown" => KeyCode::PageDown, 16 | "tab" => KeyCode::Tab, 17 | "del" => KeyCode::Delete, 18 | "insert" => KeyCode::Insert, 19 | "nul" => KeyCode::Null, 20 | "esc" => KeyCode::Esc, 21 | 22 | "lt" => KeyCode::Char('<'), 23 | "gt" => KeyCode::Char('>'), 24 | "minus" => KeyCode::Char('-'), 25 | 26 | "f1" => KeyCode::F(1), 27 | "f2" => KeyCode::F(2), 28 | "f3" => KeyCode::F(3), 29 | "f4" => KeyCode::F(4), 30 | "f5" => KeyCode::F(5), 31 | "f6" => KeyCode::F(6), 32 | "f7" => KeyCode::F(7), 33 | "f8" => KeyCode::F(8), 34 | "f9" => KeyCode::F(9), 35 | "f10" => KeyCode::F(10), 36 | "f11" => KeyCode::F(11), 37 | "f12" => KeyCode::F(12), 38 | }; 39 | 40 | static SPECIAL_CHARS: phf::Map = phf::phf_map! { 41 | '<' => "LT", 42 | '>' => "GT", 43 | '-' => "Minus", 44 | }; 45 | 46 | #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] 47 | pub struct Key { 48 | code: KeyCode, 49 | mods: KeyModifiers, 50 | } 51 | 52 | impl Key { 53 | pub fn new(code: KeyCode, mods: KeyModifiers) -> Key { 54 | Self { code, mods } 55 | } 56 | 57 | pub fn parse(text: &str) -> anyhow::Result { 58 | KeyParser::parse(text) 59 | } 60 | 61 | pub fn code(&self) -> KeyCode { 62 | self.code 63 | } 64 | 65 | pub fn mods(&self) -> KeyModifiers { 66 | self.mods 67 | } 68 | 69 | pub fn set_mods(mut self, mods: KeyModifiers) -> Self { 70 | self.mods = mods; 71 | self 72 | } 73 | } 74 | 75 | impl From for Key { 76 | fn from(code: KeyCode) -> Self { 77 | Key::new(code, KeyModifiers::NONE) 78 | } 79 | } 80 | 81 | impl ToString for Key { 82 | fn to_string(&self) -> String { 83 | let mut buf = String::new(); 84 | 85 | buf.push('<'); 86 | 87 | let mods = self.mods; 88 | if mods.intersects(KeyModifiers::CONTROL) { 89 | buf.push_str("C-"); 90 | } 91 | if mods.intersects(KeyModifiers::SHIFT) { 92 | buf.push_str("S-"); 93 | } 94 | if mods.intersects(KeyModifiers::ALT) { 95 | buf.push_str("M-"); 96 | } 97 | 98 | match self.code { 99 | KeyCode::Backspace => buf.push_str("BS"), 100 | KeyCode::Enter => buf.push_str("Enter"), 101 | KeyCode::Left => buf.push_str("Left"), 102 | KeyCode::Right => buf.push_str("Right"), 103 | KeyCode::Up => buf.push_str("Up"), 104 | KeyCode::Down => buf.push_str("Down"), 105 | KeyCode::Home => buf.push_str("Home"), 106 | KeyCode::End => buf.push_str("End"), 107 | KeyCode::PageUp => buf.push_str("PageUp"), 108 | KeyCode::PageDown => buf.push_str("PageDown"), 109 | KeyCode::Tab => buf.push_str("Tab"), 110 | KeyCode::BackTab => buf.push_str("S-Tab"), 111 | KeyCode::Delete => buf.push_str("Del"), 112 | KeyCode::Insert => buf.push_str("Insert"), 113 | KeyCode::F(n) => { 114 | buf.push('F'); 115 | buf.push_str(n.to_string().as_str()); 116 | } 117 | KeyCode::Char(ch) => { 118 | if let Some(s) = SPECIAL_CHARS.get(&ch) { 119 | buf.push_str(s) 120 | } else { 121 | buf.push(ch) 122 | } 123 | } 124 | KeyCode::Null => buf.push_str("Nul"), 125 | KeyCode::Esc => buf.push_str("Esc"), 126 | KeyCode::CapsLock => buf.push_str("CapsLock"), 127 | KeyCode::ScrollLock => buf.push_str("ScrollLock"), 128 | KeyCode::NumLock => buf.push_str("NumLock"), 129 | KeyCode::PrintScreen => buf.push_str("PrintScreen"), 130 | KeyCode::Pause => buf.push_str("Pause"), 131 | KeyCode::Menu => buf.push_str("Menu"), 132 | KeyCode::KeypadBegin => buf.push_str("KeypadBegin"), 133 | KeyCode::Media(_code) => { 134 | // TODO 135 | buf.push_str("Nul"); 136 | } 137 | KeyCode::Modifier(_code) => { 138 | // TODO 139 | buf.push_str("Nul"); 140 | } 141 | } 142 | 143 | buf.push('>'); 144 | 145 | buf 146 | } 147 | } 148 | 149 | impl Serialize for Key { 150 | fn serialize(&self, serializer: S) -> Result 151 | where 152 | S: serde::Serializer, 153 | { 154 | serializer.serialize_str(self.to_string().as_str()) 155 | } 156 | } 157 | 158 | impl<'de> Deserialize<'de> for Key { 159 | fn deserialize(deserializer: D) -> Result 160 | where 161 | D: serde::Deserializer<'de>, 162 | { 163 | let text = String::deserialize(deserializer)?; 164 | Key::parse(text.as_str()) 165 | .map_err(|err| serde::de::Error::custom(err.to_string())) 166 | } 167 | } 168 | 169 | struct KeyParser<'a> { 170 | text: &'a str, 171 | pos: usize, 172 | } 173 | 174 | impl KeyParser<'_> { 175 | fn parse(text: &str) -> anyhow::Result { 176 | let mut parser = KeyParser { text, pos: 0 }; 177 | 178 | parser.expect("<")?; 179 | let mods = parser.take_mods()?; 180 | let code = { 181 | let word = parser.take_word()?; 182 | if let Some(code) = KEYS.get(word.to_ascii_lowercase().as_str()) { 183 | *code 184 | } else if word.len() == 1 { 185 | KeyCode::Char(word.chars().next().unwrap()) 186 | } else { 187 | bail!("Wrong key code: \"{}\"", word); 188 | } 189 | }; 190 | parser.expect(">")?; 191 | 192 | Ok(Key::new(code, mods)) 193 | } 194 | 195 | fn expect(&mut self, s: &str) -> anyhow::Result<()> { 196 | let next_pos = self.pos + s.len(); 197 | if next_pos > self.text.len() { 198 | bail!("Expected \"{}\"", s); 199 | } 200 | let subtext = &self.text[self.pos..next_pos]; 201 | if subtext != s { 202 | bail!("Expected \"{}\"", s); 203 | } 204 | 205 | self.pos = next_pos; 206 | Ok(()) 207 | } 208 | 209 | fn take_word(&mut self) -> anyhow::Result<&str> { 210 | let mut next_pos = self.pos; 211 | let chars = self.text[self.pos..].chars(); 212 | for ch in chars { 213 | if ch.is_alphanumeric() { 214 | next_pos += ch.len_utf8(); 215 | } else { 216 | break; 217 | } 218 | } 219 | let start = self.pos; 220 | self.pos = next_pos; 221 | Ok(&self.text[start..next_pos]) 222 | } 223 | 224 | fn take_mods(&mut self) -> anyhow::Result { 225 | let mut mods = KeyModifiers::NONE; 226 | let mut pos = self.pos; 227 | while pos + 1 < self.text.len() && &self.text[pos + 1..pos + 2] == "-" { 228 | match &self.text[pos..pos + 1] { 229 | "c" | "C" => mods = mods.union(KeyModifiers::CONTROL), 230 | "s" | "S" => mods = mods.union(KeyModifiers::SHIFT), 231 | "m" | "M" => mods = mods.union(KeyModifiers::ALT), 232 | ch => bail!("Wrong key modifier: \"{}\"", ch), 233 | } 234 | pos += 2; 235 | } 236 | self.pos = pos; 237 | Ok(mods) 238 | } 239 | } 240 | 241 | #[cfg(test)] 242 | mod tests { 243 | use assert_matches::assert_matches; 244 | 245 | use super::*; 246 | 247 | #[test] 248 | fn parse() { 249 | assert_eq!( 250 | Key::parse("").unwrap(), 251 | Key::new(KeyCode::Tab, KeyModifiers::NONE) 252 | ); 253 | assert_eq!( 254 | Key::parse("").unwrap(), 255 | Key::new(KeyCode::Enter, KeyModifiers::CONTROL) 256 | ); 257 | assert_eq!( 258 | Key::parse("").unwrap(), 259 | Key::new(KeyCode::Esc, KeyModifiers::CONTROL) 260 | ); 261 | 262 | assert_eq!( 263 | Key::parse("").unwrap(), 264 | Key::new(KeyCode::F(1), KeyModifiers::NONE) 265 | ); 266 | assert_eq!( 267 | Key::parse("").unwrap(), 268 | Key::new(KeyCode::F(12), KeyModifiers::NONE) 269 | ); 270 | assert_matches!(Key::parse(""), Err(_)); 271 | 272 | assert_eq!( 273 | Key::parse("").unwrap(), 274 | Key::new(KeyCode::Char('a'), KeyModifiers::NONE) 275 | ); 276 | assert_eq!( 277 | Key::parse("").unwrap(), 278 | Key::new(KeyCode::Char('a'), KeyModifiers::CONTROL) 279 | ); 280 | assert_eq!( 281 | Key::parse("").unwrap(), 282 | Key::new( 283 | KeyCode::Char('a'), 284 | KeyModifiers::CONTROL | KeyModifiers::ALT 285 | ) 286 | ); 287 | } 288 | 289 | #[test] 290 | fn parse_and_print() { 291 | fn in_out(key: &str) { 292 | assert_eq!(Key::parse(key).unwrap().to_string(), key); 293 | } 294 | 295 | in_out(""); 296 | in_out(""); 297 | in_out(""); 298 | in_out(""); 299 | in_out(""); 300 | in_out(""); 301 | in_out(""); 302 | in_out(""); 303 | in_out(""); 304 | in_out(""); 305 | in_out(""); 306 | in_out(""); 307 | in_out(""); 308 | in_out(""); 309 | in_out(""); 310 | 311 | in_out(""); 312 | in_out(""); 313 | 314 | in_out(""); 315 | in_out(""); 316 | in_out(""); 317 | 318 | in_out(""); 319 | in_out(""); 320 | in_out(""); 321 | } 322 | } 323 | -------------------------------------------------------------------------------- /src/keymap.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use crate::{event::AppEvent, key::Key}; 4 | 5 | pub struct Keymap { 6 | pub procs: HashMap, 7 | pub rev_procs: HashMap, 8 | pub term: HashMap, 9 | pub rev_term: HashMap, 10 | pub copy: HashMap, 11 | pub rev_copy: HashMap, 12 | } 13 | 14 | #[derive(Clone, Copy, Debug)] 15 | pub enum KeymapGroup { 16 | Procs, 17 | Term, 18 | Copy, 19 | } 20 | 21 | impl Keymap { 22 | pub fn new() -> Self { 23 | Keymap { 24 | procs: HashMap::new(), 25 | rev_procs: HashMap::new(), 26 | term: HashMap::new(), 27 | rev_term: HashMap::new(), 28 | copy: HashMap::new(), 29 | rev_copy: HashMap::new(), 30 | } 31 | } 32 | 33 | pub fn bind(&mut self, group: KeymapGroup, key: Key, event: AppEvent) { 34 | let (map, rev_map) = match group { 35 | KeymapGroup::Procs => (&mut self.procs, &mut self.rev_procs), 36 | KeymapGroup::Term => (&mut self.term, &mut self.rev_term), 37 | KeymapGroup::Copy => (&mut self.copy, &mut self.rev_copy), 38 | }; 39 | map.insert(key.clone(), event.clone()); 40 | rev_map.insert(event, key); 41 | } 42 | 43 | pub fn bind_p(&mut self, key: Key, event: AppEvent) { 44 | self.bind(KeymapGroup::Procs, key, event); 45 | } 46 | 47 | pub fn bind_t(&mut self, key: Key, event: AppEvent) { 48 | self.bind(KeymapGroup::Term, key, event); 49 | } 50 | 51 | pub fn bind_c(&mut self, key: Key, event: AppEvent) { 52 | self.bind(KeymapGroup::Copy, key, event); 53 | } 54 | 55 | pub fn resolve(&self, group: KeymapGroup, key: &Key) -> Option<&AppEvent> { 56 | let map = match group { 57 | KeymapGroup::Procs => &self.procs, 58 | KeymapGroup::Term => &self.term, 59 | KeymapGroup::Copy => &self.copy, 60 | }; 61 | map.get(key) 62 | } 63 | 64 | pub fn resolve_key( 65 | &self, 66 | group: KeymapGroup, 67 | event: &AppEvent, 68 | ) -> Option<&Key> { 69 | let rev_map = match group { 70 | KeymapGroup::Procs => &self.rev_procs, 71 | KeymapGroup::Term => &self.rev_term, 72 | KeymapGroup::Copy => &self.rev_copy, 73 | }; 74 | rev_map.get(event) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | mod app; 2 | mod client; 3 | mod clipboard; 4 | mod config; 5 | mod config_lua; 6 | mod ctl; 7 | mod encode_term; 8 | mod error; 9 | mod event; 10 | mod host; 11 | mod just; 12 | mod kernel; 13 | mod key; 14 | mod keymap; 15 | mod modal; 16 | mod mouse; 17 | mod package_json; 18 | mod proc; 19 | mod protocol; 20 | mod settings; 21 | mod state; 22 | mod theme; 23 | mod ui_keymap; 24 | mod ui_procs; 25 | mod ui_term; 26 | mod ui_zoom_tip; 27 | mod widgets; 28 | mod yaml_val; 29 | 30 | use std::{io::Read, path::Path}; 31 | 32 | use anyhow::{bail, Result}; 33 | use app::start_kernel_thread; 34 | use clap::{arg, command, ArgMatches}; 35 | use client::client_main; 36 | use config::{CmdConfig, Config, ConfigContext, ProcConfig, ServerConfig}; 37 | use config_lua::load_lua_config; 38 | use ctl::run_ctl; 39 | use flexi_logger::{FileSpec, LoggerHandle}; 40 | use host::{receiver::MsgReceiver, sender::MsgSender}; 41 | use just::load_just_procs; 42 | use keymap::Keymap; 43 | use package_json::load_npm_procs; 44 | use proc::StopSignal; 45 | use serde_yaml::Value; 46 | use settings::Settings; 47 | use yaml_val::Val; 48 | 49 | enum LogTarget { 50 | File, 51 | Stderr, 52 | } 53 | 54 | fn setup_logger(target: LogTarget) -> LoggerHandle { 55 | let logger_str = if cfg!(debug_assertions) { 56 | "debug" 57 | } else { 58 | "warn" 59 | }; 60 | let logger = flexi_logger::Logger::try_with_str(logger_str).unwrap(); 61 | let logger = match target { 62 | LogTarget::File => logger 63 | .log_to_file(FileSpec::default().suppress_timestamp()) 64 | .append(), 65 | LogTarget::Stderr => logger.log_to_stderr(), 66 | }; 67 | 68 | std::panic::set_hook(Box::new(|info| { 69 | let stacktrace = std::backtrace::Backtrace::capture(); 70 | log::error!("Got panic. @info:{}\n@stackTrace:{}", info, stacktrace); 71 | })); 72 | 73 | logger.use_utc().start().unwrap() 74 | } 75 | 76 | #[tokio::main] 77 | async fn main() -> Result<(), std::io::Error> { 78 | match run_app().await { 79 | Ok(()) => Ok(()), 80 | Err(err) => { 81 | eprintln!("Error: {:?}", err); 82 | Ok(()) 83 | } 84 | } 85 | } 86 | 87 | async fn run_app() -> anyhow::Result<()> { 88 | let matches = command!() 89 | .arg(arg!(-c --config [PATH] "Config path [default: mprocs.yaml]")) 90 | .arg(arg!(-s --server [PATH] "Remote control server address. Example: 127.0.0.1:4050.")) 91 | .arg(arg!(--ctl [YAML] "Send yaml/json encoded command to running mprocs")) 92 | .arg(arg!(--"proc-list-title" [TITLE] "Title for the processes pane")) 93 | .arg(arg!(--names [NAMES] "Names for processes provided by cli arguments. Separated by comma.")) 94 | .arg(arg!(--npm "Run scripts from package.json. Scripts are not started by default.")) 95 | .arg(arg!(--just "Run recipes from justfile. Recipes are not started by default. Requires just to be installed.")) 96 | .arg(arg!([COMMANDS]... "Commands to run (if omitted, commands from config will be run)")) 97 | // .subcommand(Command::new("server")) 98 | // .subcommand(Command::new("attach")) 99 | .get_matches(); 100 | 101 | let config_value = load_config_value(&matches) 102 | .map_err(|e| anyhow::Error::msg(format!("[{}] {}", "config", e)))?; 103 | 104 | let mut settings = Settings::default(); 105 | 106 | // merge ~/.config/mprocs/mprocs.yaml 107 | settings.merge_from_xdg().map_err(|e| { 108 | anyhow::Error::msg(format!("[{}] {}", "global settings", e)) 109 | })?; 110 | // merge ./mprocs.yaml 111 | if let Some((value, _)) = &config_value { 112 | settings 113 | .merge_value(Val::new(value)?) 114 | .map_err(|e| anyhow::Error::msg(format!("[{}] {}", "local config", e)))?; 115 | } 116 | 117 | let mut keymap = Keymap::new(); 118 | settings.add_to_keymap(&mut keymap)?; 119 | 120 | let config = { 121 | let mut config = if let Some((v, ctx)) = config_value { 122 | Config::from_value(&v, &ctx, &settings)? 123 | } else { 124 | Config::make_default(&settings) 125 | }; 126 | 127 | if let Some(server_addr) = matches.get_one::("server") { 128 | config.server = Some(ServerConfig::from_str(server_addr)?); 129 | } 130 | 131 | if let Some(ctl_arg) = matches.get_one::("ctl") { 132 | return run_ctl(ctl_arg, &config).await; 133 | } 134 | 135 | if let Some(title) = matches.get_one::("proc-list-title") { 136 | config.proc_list_title = title.to_string(); 137 | } 138 | 139 | if let Some(cmds) = matches.get_many::("COMMANDS") { 140 | let names = matches 141 | .get_one::("names") 142 | .map_or(Vec::new(), |arg| arg.split(',').collect::>()); 143 | let procs = cmds 144 | .into_iter() 145 | .enumerate() 146 | .map(|(i, cmd)| ProcConfig { 147 | name: names 148 | .get(i) 149 | .map_or_else(|| cmd.to_string(), |s| s.to_string()), 150 | cmd: CmdConfig::Shell { 151 | shell: cmd.to_string(), 152 | }, 153 | env: None, 154 | cwd: None, 155 | autostart: true, 156 | autorestart: false, 157 | stop: StopSignal::default(), 158 | mouse_scroll_speed: settings.mouse_scroll_speed, 159 | scrollback_len: settings.scrollback_len, 160 | }) 161 | .collect::>(); 162 | 163 | config.procs = procs; 164 | } else if matches.get_flag("npm") { 165 | let procs = load_npm_procs(&settings)?; 166 | config.procs = procs; 167 | } else if matches.get_flag("just") { 168 | let procs = load_just_procs(&settings)?; 169 | config.procs = procs; 170 | } 171 | 172 | config 173 | }; 174 | 175 | match matches.subcommand() { 176 | // Some(("attach", _args)) => { 177 | // let logger = setup_logger(LogTarget::File); 178 | // let ret = client_main(false).await; 179 | // drop(logger); 180 | // ret 181 | // } 182 | // Some(("server", _args)) => { 183 | // let logger = setup_logger(LogTarget::Stderr); 184 | // let ret = start_kernel_process(config, keymap).await; 185 | // drop(logger); 186 | // ret 187 | // } 188 | Some((cmd, _args)) => { 189 | bail!("Unexpected command: {}", cmd); 190 | } 191 | None => { 192 | let logger = setup_logger(LogTarget::File); 193 | 194 | let (srv_to_clt_sender, srv_to_clt_receiver) = { 195 | let (sender, receiver) = tokio::sync::mpsc::unbounded_channel(); 196 | let sender = MsgSender::new(sender); 197 | let receiver = MsgReceiver::new(receiver); 198 | (sender, receiver) 199 | }; 200 | let (clt_to_srv_sender, clt_to_srv_receiver) = { 201 | let (sender, receiver) = tokio::sync::mpsc::unbounded_channel(); 202 | let sender = MsgSender::new(sender); 203 | let receiver = MsgReceiver::new(receiver); 204 | (sender, receiver) 205 | }; 206 | 207 | start_kernel_thread( 208 | config, 209 | keymap, 210 | (srv_to_clt_sender, clt_to_srv_receiver), 211 | ) 212 | .await?; 213 | 214 | let ret = client_main(clt_to_srv_sender, srv_to_clt_receiver).await; 215 | drop(logger); 216 | ret 217 | } 218 | } 219 | } 220 | 221 | fn load_config_value( 222 | matches: &ArgMatches, 223 | ) -> Result> { 224 | if let Some(path) = matches.get_one::("config") { 225 | return Ok(Some(( 226 | read_value(path)?, 227 | ConfigContext { path: path.into() }, 228 | ))); 229 | } 230 | 231 | { 232 | let path = "mprocs.lua"; 233 | if Path::new(path).is_file() { 234 | return Ok(Some(( 235 | read_value(path)?, 236 | ConfigContext { path: path.into() }, 237 | ))); 238 | } 239 | } 240 | 241 | { 242 | let path = "mprocs.yaml"; 243 | if Path::new(path).is_file() { 244 | return Ok(Some(( 245 | read_value(path)?, 246 | ConfigContext { path: path.into() }, 247 | ))); 248 | } 249 | } 250 | 251 | { 252 | let path = "mprocs.json"; 253 | if Path::new(path).is_file() { 254 | return Ok(Some(( 255 | read_value(path)?, 256 | ConfigContext { path: path.into() }, 257 | ))); 258 | } 259 | } 260 | 261 | Ok(None) 262 | } 263 | 264 | fn read_value(path: &str) -> Result { 265 | // Open the file in read-only mode with buffer. 266 | let file = match std::fs::File::open(path) { 267 | Ok(file) => file, 268 | Err(err) => match err.kind() { 269 | std::io::ErrorKind::NotFound => { 270 | bail!("Config file '{}' not found.", path); 271 | } 272 | _kind => return Err(err.into()), 273 | }, 274 | }; 275 | let mut reader = std::io::BufReader::new(file); 276 | let ext = std::path::Path::new(path) 277 | .extension() 278 | .map_or_else(|| "".to_string(), |ext| ext.to_string_lossy().to_string()); 279 | let mut value: Value = match ext.as_str() { 280 | "yaml" | "yml" | "json" => serde_yaml::from_reader(reader)?, 281 | "lua" => { 282 | let mut buf = String::new(); 283 | reader.read_to_string(&mut buf)?; 284 | load_lua_config(path, &buf)? 285 | } 286 | _ => bail!("Supported config extensions: lua, yaml, yml, json."), 287 | }; 288 | value.apply_merge().unwrap(); 289 | Ok(value) 290 | } 291 | -------------------------------------------------------------------------------- /src/modal/add_proc.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event::{Event, KeyCode, KeyEvent}; 2 | use tokio::sync::mpsc::UnboundedSender; 3 | use tui::{ 4 | prelude::{Margin, Rect}, 5 | text::Span, 6 | Frame, 7 | }; 8 | use tui_input::Input; 9 | 10 | use crate::{ 11 | app::LoopAction, error::ResultLogger, event::AppEvent, state::State, 12 | theme::Theme, widgets::text_input::TextInput, 13 | }; 14 | 15 | use super::modal::Modal; 16 | 17 | pub struct AddProcModal { 18 | input: Input, 19 | app_sender: UnboundedSender, 20 | } 21 | 22 | impl AddProcModal { 23 | pub fn new(app_sender: UnboundedSender) -> Self { 24 | AddProcModal { 25 | input: Input::default(), 26 | app_sender, 27 | } 28 | } 29 | } 30 | 31 | impl Modal for AddProcModal { 32 | fn boxed(self) -> Box { 33 | Box::new(self) 34 | } 35 | 36 | fn handle_input( 37 | &mut self, 38 | _state: &mut State, 39 | loop_action: &mut LoopAction, 40 | event: &Event, 41 | ) -> bool { 42 | match event { 43 | Event::Key(KeyEvent { 44 | code: KeyCode::Enter, 45 | modifiers, 46 | .. 47 | }) if modifiers.is_empty() => { 48 | self 49 | .app_sender 50 | .send(AppEvent::CloseCurrentModal) 51 | .log_ignore(); 52 | self 53 | .app_sender 54 | .send(AppEvent::AddProc { 55 | cmd: self.input.value().to_string(), 56 | name: None, 57 | }) 58 | .unwrap(); 59 | // Skip because AddProc event will immediately rerender. 60 | return true; 61 | } 62 | Event::Key(KeyEvent { 63 | code: KeyCode::Esc, 64 | modifiers, 65 | .. 66 | }) if modifiers.is_empty() => { 67 | self 68 | .app_sender 69 | .send(AppEvent::CloseCurrentModal) 70 | .log_ignore(); 71 | loop_action.render(); 72 | return true; 73 | } 74 | _ => (), 75 | } 76 | 77 | let req = tui_input::backend::crossterm::to_input_request(&event); 78 | if let Some(req) = req { 79 | self.input.handle(req); 80 | loop_action.render(); 81 | return true; 82 | } 83 | 84 | match event { 85 | Event::FocusGained => false, 86 | Event::FocusLost => false, 87 | // Block keys 88 | Event::Key(_) => true, 89 | // Block mouse 90 | Event::Mouse(_) => true, 91 | // Block paste 92 | Event::Paste(_) => true, 93 | Event::Resize(_, _) => false, 94 | } 95 | } 96 | 97 | fn get_size(&mut self, _: Rect) -> (u16, u16) { 98 | (42, 3) 99 | } 100 | 101 | fn render(&mut self, frame: &mut Frame) { 102 | let area = self.area(frame.size()); 103 | let theme = Theme::default(); 104 | 105 | let block = theme 106 | .pane(true) 107 | .title(Span::styled("Add process", theme.pane_title(true))); 108 | frame.render_widget(block, area); 109 | 110 | let inner = area.inner(&Margin::new(1, 1)); 111 | 112 | let mut cursor = (0u16, 0u16); 113 | let text_input = TextInput::new(&mut self.input); 114 | frame.render_stateful_widget( 115 | text_input, 116 | Rect::new(inner.x, inner.y, inner.width, 1), 117 | &mut cursor, 118 | ); 119 | 120 | frame.set_cursor(cursor.0, cursor.1); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/modal/commands_menu.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; 2 | use tokio::sync::mpsc::UnboundedSender; 3 | use tui::{ 4 | prelude::{Margin, Rect}, 5 | style::{Modifier, Style}, 6 | text::{Line, Span}, 7 | widgets::{Clear, HighlightSpacing, ListItem, ListState, Paragraph}, 8 | Frame, 9 | }; 10 | use tui_input::Input; 11 | 12 | use crate::{ 13 | app::LoopAction, error::ResultLogger, event::AppEvent, state::State, 14 | theme::Theme, widgets::text_input::TextInput, 15 | }; 16 | 17 | use super::modal::Modal; 18 | 19 | pub struct CommandsMenuModal { 20 | input: Input, 21 | list_state: ListState, 22 | items: Vec, 23 | app_sender: UnboundedSender, 24 | } 25 | 26 | impl CommandsMenuModal { 27 | pub fn new(app_sender: UnboundedSender) -> Self { 28 | CommandsMenuModal { 29 | input: Input::default(), 30 | list_state: ListState::default().with_selected(Some(0)), 31 | items: get_commands(""), 32 | app_sender, 33 | } 34 | } 35 | } 36 | 37 | impl Modal for CommandsMenuModal { 38 | fn boxed(self) -> Box { 39 | Box::new(self) 40 | } 41 | 42 | fn handle_input( 43 | &mut self, 44 | _state: &mut State, 45 | loop_action: &mut LoopAction, 46 | event: &Event, 47 | ) -> bool { 48 | match event { 49 | Event::Key(KeyEvent { 50 | code: KeyCode::Enter, 51 | modifiers, 52 | .. 53 | }) if modifiers.is_empty() => { 54 | self 55 | .app_sender 56 | .send(AppEvent::CloseCurrentModal) 57 | .log_ignore(); 58 | if let Some((_, _, event)) = 59 | self.list_state.selected().and_then(|i| self.items.get(i)) 60 | { 61 | self.app_sender.send(event.clone()).unwrap(); 62 | } 63 | // Skip because AddProc event will immediately rerender. 64 | return true; 65 | } 66 | Event::Key(KeyEvent { 67 | code: KeyCode::Esc, 68 | modifiers, 69 | .. 70 | }) if modifiers.is_empty() => { 71 | self 72 | .app_sender 73 | .send(AppEvent::CloseCurrentModal) 74 | .log_ignore(); 75 | loop_action.render(); 76 | return true; 77 | } 78 | // List bindings 79 | Event::Key(KeyEvent { 80 | code: KeyCode::Char('n'), 81 | modifiers, 82 | .. 83 | }) if modifiers == &KeyModifiers::CONTROL => { 84 | let index = self.list_state.selected().unwrap_or(0); 85 | let index = if index >= self.items.len() - 1 { 86 | 0 87 | } else { 88 | index + 1 89 | }; 90 | self.list_state.select(Some(index)); 91 | loop_action.render(); 92 | return true; 93 | } 94 | Event::Key(KeyEvent { 95 | code: KeyCode::Char('p'), 96 | modifiers, 97 | .. 98 | }) if modifiers == &KeyModifiers::CONTROL => { 99 | let index = self.list_state.selected().unwrap_or(0); 100 | let index = if index == 0 { 101 | self.items.len() - 1 102 | } else { 103 | index - 1 104 | }; 105 | self.list_state.select(Some(index)); 106 | loop_action.render(); 107 | return true; 108 | } 109 | _ => (), 110 | } 111 | 112 | let req = tui_input::backend::crossterm::to_input_request(event); 113 | if let Some(req) = req { 114 | let res = self.input.handle(req); 115 | if let Some(res) = res { 116 | if res.value { 117 | self.items = get_commands(self.input.value()); 118 | } 119 | } 120 | loop_action.render(); 121 | return true; 122 | } 123 | 124 | match event { 125 | Event::FocusGained => false, 126 | Event::FocusLost => false, 127 | // Block keys 128 | Event::Key(_) => true, 129 | // Block mouse 130 | Event::Mouse(_) => true, 131 | // Block paste 132 | Event::Paste(_) => true, 133 | Event::Resize(_, _) => false, 134 | } 135 | } 136 | 137 | fn get_size(&mut self, _: Rect) -> (u16, u16) { 138 | (60, 30) 139 | } 140 | 141 | fn render(&mut self, frame: &mut Frame) { 142 | let area = self.area(frame.size()); 143 | let theme = Theme::default(); 144 | 145 | let block = theme 146 | .pane(true) 147 | .border_type(tui::widgets::BorderType::Rounded); 148 | frame.render_widget(block, area); 149 | 150 | let inner = area.inner(&Margin::new(1, 1)); 151 | let list_area = Rect::new( 152 | inner.x, 153 | inner.y, 154 | inner.width, 155 | inner.height.saturating_sub(2), 156 | ); 157 | let above_input = Rect::new( 158 | inner.x, 159 | (inner.y + inner.height).saturating_sub(2), 160 | inner.width, 161 | 1, 162 | ); 163 | 164 | frame.render_widget(Clear, inner); 165 | 166 | let list_items = self 167 | .items 168 | .iter() 169 | .map(|(cmd, desc, _event)| { 170 | let line = Line::from(vec![ 171 | Span::styled(*cmd, Style::reset().fg(tui::style::Color::White)), 172 | " ".into(), 173 | Span::styled( 174 | desc, 175 | Style::reset() 176 | .fg(tui::style::Color::DarkGray) 177 | .add_modifier(Modifier::ITALIC), 178 | ), 179 | ]); 180 | ListItem::new(line) 181 | }) 182 | .collect::>(); 183 | let list = tui::widgets::List::new(list_items) 184 | .highlight_spacing(HighlightSpacing::Always) 185 | .highlight_symbol(">") 186 | .direction(tui::widgets::ListDirection::TopToBottom); 187 | frame.render_stateful_widget(list, list_area, &mut self.list_state); 188 | 189 | let input_label = "Run command"; 190 | frame.render_widget(Paragraph::new(input_label), above_input); 191 | 192 | frame.render_widget( 193 | Paragraph::new(tui::symbols::line::VERTICAL_RIGHT), 194 | Rect::new(area.x, above_input.y, 1, 1), 195 | ); 196 | frame.render_widget( 197 | Paragraph::new(tui::symbols::line::VERTICAL_LEFT), 198 | Rect::new(above_input.right(), above_input.y, 1, 1), 199 | ); 200 | for x in above_input.x + input_label.len() as u16 201 | ..above_input.x + above_input.width 202 | { 203 | frame.render_widget( 204 | Paragraph::new(tui::symbols::line::HORIZONTAL), 205 | Rect::new(x, above_input.y, 1, 1), 206 | ); 207 | } 208 | 209 | let mut cursor = (0u16, 0u16); 210 | let text_input = TextInput::new(&mut self.input); 211 | frame.render_stateful_widget( 212 | text_input, 213 | Rect::new(inner.x, inner.y + inner.height - 1, inner.width, 1), 214 | &mut cursor, 215 | ); 216 | 217 | frame.set_cursor(cursor.0, cursor.1); 218 | } 219 | } 220 | 221 | type CommandInfo = (&'static str, String, AppEvent); 222 | 223 | fn get_commands(search: &str) -> Vec { 224 | let events = [ 225 | // ("quit-or-ask", AppEvent::QuitOrAsk), 226 | ("quit", AppEvent::Quit), 227 | ("force-quit", AppEvent::ForceQuit), 228 | ("toggle-focus", AppEvent::ToggleFocus), 229 | ("focus-term", AppEvent::FocusTerm), 230 | ("zoom", AppEvent::Zoom), 231 | ("show-commands-menu", AppEvent::ShowCommandsMenu), 232 | ("next-proc", AppEvent::NextProc), 233 | ("prev-proc", AppEvent::PrevProc), 234 | ("start-proc", AppEvent::StartProc), 235 | ("term-proc", AppEvent::TermProc), 236 | ("kill-proc", AppEvent::KillProc), 237 | ("restart-proc", AppEvent::RestartProc), 238 | ("duplicate-proc", AppEvent::DuplicateProc), 239 | ("force-restart-proc", AppEvent::ForceRestartProc), 240 | ("show-add-proc", AppEvent::ShowAddProc), 241 | ("show-rename-proc", AppEvent::ShowRenameProc), 242 | ("show-remove-proc", AppEvent::ShowRemoveProc), 243 | ("close-current-modal", AppEvent::CloseCurrentModal), 244 | ("scroll-down", AppEvent::ScrollDown), 245 | ("scroll-up", AppEvent::ScrollUp), 246 | ("copy-mode-enter", AppEvent::CopyModeEnter), 247 | ("copy-mode-leave", AppEvent::CopyModeLeave), 248 | ("copy-mode-end", AppEvent::CopyModeEnd), 249 | ("copy-mode-copy", AppEvent::CopyModeCopy), 250 | ]; 251 | 252 | let mut result = Vec::new(); 253 | for (cmd, event) in events { 254 | let desc = event.desc(); 255 | if cmd.contains(search) || desc.contains(search) { 256 | result.push((cmd, desc, event)); 257 | } 258 | } 259 | 260 | result 261 | } 262 | -------------------------------------------------------------------------------- /src/modal/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod add_proc; 2 | pub mod commands_menu; 3 | pub mod modal; 4 | pub mod quit; 5 | pub mod remove_proc; 6 | pub mod rename_proc; 7 | -------------------------------------------------------------------------------- /src/modal/modal.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event::Event; 2 | use tui::{prelude::Rect, Frame}; 3 | 4 | use crate::{app::LoopAction, state::State}; 5 | 6 | pub trait Modal: Send { 7 | fn boxed(self) -> Box; 8 | 9 | fn handle_input( 10 | &mut self, 11 | state: &mut State, 12 | loop_action: &mut LoopAction, 13 | event: &Event, 14 | ) -> bool; 15 | 16 | fn get_size(&mut self, frame_area: Rect) -> (u16, u16); 17 | 18 | fn area(&mut self, frame_area: Rect) -> Rect { 19 | let (w, h) = self.get_size(frame_area); 20 | 21 | let y = frame_area.height.saturating_sub(h) / 2; 22 | let x = frame_area.width.saturating_sub(w) / 2; 23 | 24 | let w = w.min(frame_area.width); 25 | let h = h.min(frame_area.height); 26 | 27 | Rect { 28 | x, 29 | y, 30 | width: w, 31 | height: h, 32 | } 33 | } 34 | 35 | fn render(&mut self, frame: &mut Frame); 36 | } 37 | -------------------------------------------------------------------------------- /src/modal/quit.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event::{Event, KeyCode, KeyEvent}; 2 | use tokio::sync::mpsc::UnboundedSender; 3 | use tui::{ 4 | prelude::{Margin, Rect}, 5 | text::Line, 6 | widgets::{Clear, Paragraph}, 7 | Frame, 8 | }; 9 | 10 | use crate::{ 11 | app::LoopAction, error::ResultLogger, event::AppEvent, state::State, 12 | theme::Theme, 13 | }; 14 | 15 | use super::modal::Modal; 16 | 17 | pub struct QuitModal { 18 | app_sender: UnboundedSender, 19 | } 20 | 21 | impl QuitModal { 22 | pub fn new(app_sender: UnboundedSender) -> Self { 23 | QuitModal { app_sender } 24 | } 25 | } 26 | 27 | impl Modal for QuitModal { 28 | fn boxed(self) -> Box { 29 | Box::new(self) 30 | } 31 | 32 | fn handle_input( 33 | &mut self, 34 | state: &mut State, 35 | loop_action: &mut LoopAction, 36 | event: &Event, 37 | ) -> bool { 38 | match event { 39 | Event::Key(KeyEvent { 40 | code: KeyCode::Char('e'), 41 | modifiers, 42 | .. 43 | }) if modifiers.is_empty() => { 44 | self 45 | .app_sender 46 | .send(AppEvent::CloseCurrentModal) 47 | .log_ignore(); 48 | self.app_sender.send(AppEvent::Quit).unwrap(); 49 | return true; 50 | } 51 | Event::Key(KeyEvent { 52 | code: KeyCode::Char('d'), 53 | modifiers, 54 | .. 55 | }) if modifiers.is_empty() => { 56 | if let Some(client_id) = state.current_client_id { 57 | self 58 | .app_sender 59 | .send(AppEvent::CloseCurrentModal) 60 | .log_ignore(); 61 | self 62 | .app_sender 63 | .send(AppEvent::Detach { client_id }) 64 | .unwrap(); 65 | } 66 | return true; 67 | } 68 | Event::Key(KeyEvent { 69 | code: KeyCode::Esc, 70 | modifiers, 71 | .. 72 | }) 73 | | Event::Key(KeyEvent { 74 | code: KeyCode::Char('n'), 75 | modifiers, 76 | .. 77 | }) if modifiers.is_empty() => { 78 | self 79 | .app_sender 80 | .send(AppEvent::CloseCurrentModal) 81 | .log_ignore(); 82 | loop_action.render(); 83 | return true; 84 | } 85 | _ => (), 86 | } 87 | 88 | match event { 89 | Event::FocusGained => false, 90 | Event::FocusLost => false, 91 | // Block keys 92 | Event::Key(_) => true, 93 | // Block mouse 94 | Event::Mouse(_) => true, 95 | // Block paste 96 | Event::Paste(_) => true, 97 | Event::Resize(_, _) => false, 98 | } 99 | } 100 | 101 | fn get_size(&mut self, _: Rect) -> (u16, u16) { 102 | (36, 5) 103 | } 104 | 105 | fn render(&mut self, frame: &mut Frame) { 106 | let area = self.area(frame.size()); 107 | let theme = Theme::default(); 108 | 109 | let block = theme.pane(true); 110 | frame.render_widget(block, area); 111 | 112 | let inner = area.inner(&Margin::new(1, 1)); 113 | 114 | let txt = Paragraph::new(vec![ 115 | Line::from(" - exit client and server"), 116 | Line::from(" - detach client"), 117 | Line::from(" - cancel"), 118 | ]); 119 | let txt_area = Rect::new(inner.x, inner.y, inner.width, 3); 120 | frame.render_widget(Clear, txt_area); 121 | frame.render_widget(txt, txt_area); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/modal/remove_proc.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event::{Event, KeyCode, KeyEvent}; 2 | use tokio::sync::mpsc::UnboundedSender; 3 | use tui::{ 4 | prelude::{Margin, Rect}, 5 | widgets::{Clear, Paragraph}, 6 | Frame, 7 | }; 8 | 9 | use crate::{ 10 | app::LoopAction, error::ResultLogger, event::AppEvent, state::State, 11 | theme::Theme, 12 | }; 13 | 14 | use super::modal::Modal; 15 | 16 | pub struct RemoveProcModal { 17 | id: usize, 18 | app_sender: UnboundedSender, 19 | } 20 | 21 | impl RemoveProcModal { 22 | pub fn new(id: usize, app_sender: UnboundedSender) -> Self { 23 | RemoveProcModal { id, app_sender } 24 | } 25 | } 26 | 27 | impl Modal for RemoveProcModal { 28 | fn boxed(self) -> Box { 29 | Box::new(self) 30 | } 31 | 32 | fn handle_input( 33 | &mut self, 34 | _state: &mut State, 35 | loop_action: &mut LoopAction, 36 | event: &Event, 37 | ) -> bool { 38 | match event { 39 | Event::Key(KeyEvent { 40 | code: KeyCode::Char('y'), 41 | modifiers, 42 | .. 43 | }) if modifiers.is_empty() => { 44 | self 45 | .app_sender 46 | .send(AppEvent::CloseCurrentModal) 47 | .log_ignore(); 48 | self 49 | .app_sender 50 | .send(AppEvent::RemoveProc { id: self.id }) 51 | .log_ignore(); 52 | // Skip because RemoveProc event will immediately rerender. 53 | return true; 54 | } 55 | Event::Key(KeyEvent { 56 | code: KeyCode::Esc, 57 | modifiers, 58 | .. 59 | }) 60 | | Event::Key(KeyEvent { 61 | code: KeyCode::Char('n'), 62 | modifiers, 63 | .. 64 | }) if modifiers.is_empty() => { 65 | self 66 | .app_sender 67 | .send(AppEvent::CloseCurrentModal) 68 | .log_ignore(); 69 | loop_action.render(); 70 | return true; 71 | } 72 | _ => (), 73 | } 74 | 75 | match event { 76 | Event::FocusGained => false, 77 | Event::FocusLost => false, 78 | // Block keys 79 | Event::Key(_) => true, 80 | // Block mouse 81 | Event::Mouse(_) => true, 82 | // Block paste 83 | Event::Paste(_) => true, 84 | Event::Resize(_, _) => false, 85 | } 86 | } 87 | 88 | fn get_size(&mut self, _: Rect) -> (u16, u16) { 89 | (36, 3) 90 | } 91 | 92 | fn render(&mut self, frame: &mut Frame) { 93 | let area = self.area(frame.size()); 94 | let theme = Theme::default(); 95 | 96 | let block = theme.pane(true); 97 | frame.render_widget(block, area); 98 | 99 | let inner = area.inner(&Margin::new(1, 1)); 100 | 101 | let txt = Paragraph::new("Remove process? (y/n)"); 102 | let txt_area = Rect::new(inner.x, inner.y, inner.width, 1); 103 | frame.render_widget(Clear, txt_area); 104 | frame.render_widget(txt, txt_area); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/modal/rename_proc.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event::{Event, KeyCode, KeyEvent}; 2 | use tokio::sync::mpsc::UnboundedSender; 3 | use tui::{ 4 | prelude::{Margin, Rect}, 5 | text::Span, 6 | Frame, 7 | }; 8 | use tui_input::Input; 9 | 10 | use crate::{ 11 | app::LoopAction, error::ResultLogger, event::AppEvent, state::State, 12 | theme::Theme, widgets::text_input::TextInput, 13 | }; 14 | 15 | use super::modal::Modal; 16 | 17 | pub struct RenameProcModal { 18 | input: Input, 19 | app_sender: UnboundedSender, 20 | } 21 | 22 | impl RenameProcModal { 23 | pub fn new(app_sender: UnboundedSender) -> Self { 24 | RenameProcModal { 25 | input: Input::default(), 26 | app_sender, 27 | } 28 | } 29 | } 30 | 31 | impl Modal for RenameProcModal { 32 | fn boxed(self) -> Box { 33 | Box::new(self) 34 | } 35 | 36 | fn handle_input( 37 | &mut self, 38 | _state: &mut State, 39 | loop_action: &mut LoopAction, 40 | event: &Event, 41 | ) -> bool { 42 | match event { 43 | Event::Key(KeyEvent { 44 | code: KeyCode::Enter, 45 | modifiers, 46 | .. 47 | }) if modifiers.is_empty() => { 48 | self 49 | .app_sender 50 | .send(AppEvent::CloseCurrentModal) 51 | .log_ignore(); 52 | self 53 | .app_sender 54 | .send(AppEvent::RenameProc { 55 | name: self.input.value().to_string(), 56 | }) 57 | .log_ignore(); 58 | // Skip because RenameProc event will immediately rerender. 59 | return true; 60 | } 61 | Event::Key(KeyEvent { 62 | code: KeyCode::Esc, 63 | modifiers, 64 | .. 65 | }) if modifiers.is_empty() => { 66 | self 67 | .app_sender 68 | .send(AppEvent::CloseCurrentModal) 69 | .log_ignore(); 70 | loop_action.render(); 71 | return true; 72 | } 73 | _ => (), 74 | } 75 | 76 | let req = tui_input::backend::crossterm::to_input_request(&event); 77 | if let Some(req) = req { 78 | self.input.handle(req); 79 | loop_action.render(); 80 | return true; 81 | } 82 | 83 | match event { 84 | Event::FocusGained => false, 85 | Event::FocusLost => false, 86 | // Block keys 87 | Event::Key(_) => true, 88 | // Block mouse 89 | Event::Mouse(_) => true, 90 | // Block paste 91 | Event::Paste(_) => true, 92 | Event::Resize(_, _) => false, 93 | } 94 | } 95 | 96 | fn get_size(&mut self, _: Rect) -> (u16, u16) { 97 | (42, 3) 98 | } 99 | 100 | fn render(&mut self, frame: &mut Frame) { 101 | let area = self.area(frame.size()); 102 | let theme = Theme::default(); 103 | 104 | let block = theme 105 | .pane(true) 106 | .title(Span::styled("Rename process", theme.pane_title(true))); 107 | frame.render_widget(block, area); 108 | 109 | let inner = area.inner(&Margin::new(1, 1)); 110 | 111 | let mut cursor = (0u16, 0u16); 112 | let text_input = TextInput::new(&mut self.input); 113 | frame.render_stateful_widget( 114 | text_input, 115 | Rect::new(inner.x, inner.y, inner.width, 1), 116 | &mut cursor, 117 | ); 118 | 119 | frame.set_cursor(cursor.0, cursor.1); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/mouse.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event::{KeyModifiers, MouseEventKind}; 2 | use tui::prelude::Rect; 3 | 4 | #[derive(Debug)] 5 | pub struct MouseEvent { 6 | pub kind: MouseEventKind, 7 | pub x: i32, 8 | pub y: i32, 9 | pub mods: KeyModifiers, 10 | } 11 | 12 | impl MouseEvent { 13 | pub fn from_crossterm(event: crossterm::event::MouseEvent) -> Self { 14 | Self { 15 | kind: event.kind, 16 | x: event.column.into(), 17 | y: event.row.into(), 18 | mods: event.modifiers, 19 | } 20 | } 21 | 22 | pub fn translate(self, area: Rect) -> Self { 23 | let mut ret = self; 24 | ret.x -= area.x as i32; 25 | ret.y -= area.y as i32; 26 | ret 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/package_json.rs: -------------------------------------------------------------------------------- 1 | use std::{fs::File, io::BufReader}; 2 | 3 | use anyhow::Result; 4 | use indexmap::IndexMap; 5 | use serde::Deserialize; 6 | 7 | use crate::{ 8 | config::{CmdConfig, ProcConfig}, 9 | proc::StopSignal, 10 | settings::Settings, 11 | }; 12 | 13 | #[derive(Deserialize)] 14 | struct Package { 15 | scripts: IndexMap, 16 | } 17 | 18 | pub fn load_npm_procs(settings: &Settings) -> Result> { 19 | let file = File::open("package.json")?; 20 | let reader = BufReader::new(file); 21 | let package: Package = serde_yaml::from_reader(reader)?; 22 | 23 | let mut paths = if let Ok(path_var) = std::env::var("PATH") { 24 | let paths = std::env::split_paths(&path_var) 25 | .map(|p| p.to_string_lossy().to_string()) 26 | .collect::>(); 27 | paths 28 | } else { 29 | Vec::with_capacity(1) 30 | }; 31 | paths.push("./node_modules/.bin".to_string()); 32 | let mut env = IndexMap::with_capacity(1); 33 | env.insert( 34 | "PATH".to_string(), 35 | Some(std::env::join_paths(paths)?.into_string().map_err(|_| { 36 | anyhow::Error::msg( 37 | "Failed to set PATH variable while loading package.json.", 38 | ) 39 | })?), 40 | ); 41 | 42 | let procs = package.scripts.into_iter().map(|(name, cmd)| ProcConfig { 43 | name, 44 | cmd: CmdConfig::Shell { shell: cmd }, 45 | cwd: None, 46 | env: Some(env.clone()), 47 | autostart: false, 48 | autorestart: false, 49 | 50 | stop: StopSignal::default(), 51 | mouse_scroll_speed: settings.mouse_scroll_speed, 52 | scrollback_len: settings.scrollback_len, 53 | }); 54 | Ok(procs.collect()) 55 | } 56 | -------------------------------------------------------------------------------- /src/proc/handle.rs: -------------------------------------------------------------------------------- 1 | use super::{ 2 | msg::{ProcCmd, ProcEvent}, 3 | proc::{Proc, ProcState}, 4 | CopyMode, ReplySender, 5 | }; 6 | 7 | use std::time::Instant; 8 | 9 | /// Amount of time a process has to stay up for autorestart to trigger 10 | const RESTART_THRESHOLD_SECONDS: f64 = 1.0; 11 | 12 | pub struct ProcHandle { 13 | id: usize, 14 | name: String, 15 | is_up: bool, 16 | exit_code: Option, 17 | 18 | pub to_restart: bool, 19 | pub autorestart: bool, 20 | last_start: Option, 21 | changed: bool, 22 | 23 | proc: Proc, 24 | } 25 | 26 | impl ProcHandle { 27 | pub fn from_proc(name: String, proc: Proc, autorestart: bool) -> Self { 28 | Self { 29 | id: proc.id, 30 | name, 31 | is_up: false, 32 | exit_code: None, 33 | to_restart: false, 34 | autorestart, 35 | last_start: None, 36 | changed: false, 37 | proc, 38 | } 39 | } 40 | 41 | pub fn send(&mut self, cmd: ProcCmd) { 42 | self.proc.handle_cmd(cmd) 43 | } 44 | 45 | pub fn rename(&mut self, name: &str) { 46 | self.name.replace_range(.., name); 47 | } 48 | 49 | pub fn id(&self) -> usize { 50 | self.id 51 | } 52 | 53 | pub fn exit_code(&self) -> Option { 54 | self.exit_code 55 | } 56 | 57 | pub fn lock_view(&self) -> ProcViewFrame { 58 | match &self.proc.inst { 59 | ProcState::None => ProcViewFrame::Empty, 60 | ProcState::Some(inst) => inst 61 | .vt 62 | .read() 63 | .map_or(ProcViewFrame::Empty, |vt| ProcViewFrame::Vt(vt)), 64 | ProcState::Error(err) => ProcViewFrame::Err(err), 65 | } 66 | } 67 | 68 | pub fn name(&self) -> &str { 69 | &self.name 70 | } 71 | 72 | pub fn is_up(&self) -> bool { 73 | self.is_up 74 | } 75 | 76 | pub fn changed(&self) -> bool { 77 | self.changed 78 | } 79 | 80 | pub fn copy_mode(&self) -> &CopyMode { 81 | &self.proc.copy_mode 82 | } 83 | 84 | pub fn focus(&mut self) { 85 | self.changed = false; 86 | } 87 | 88 | pub fn duplicate(&self) -> Self { 89 | let proc = self.proc.duplicate(); 90 | Self { 91 | id: proc.id, 92 | name: self.name.clone(), 93 | is_up: false, 94 | exit_code: None, 95 | to_restart: false, 96 | autorestart: self.autorestart, 97 | last_start: None, 98 | changed: false, 99 | proc, 100 | } 101 | } 102 | } 103 | 104 | impl ProcHandle { 105 | fn maybe_stopped(&mut self) { 106 | if self.is_up { 107 | return; 108 | } 109 | 110 | let exit_code = self.exit_code().unwrap(); 111 | if self.autorestart && !self.to_restart && exit_code != 0 { 112 | match self.last_start { 113 | Some(last_start) => { 114 | let elapsed_time = Instant::now().duration_since(last_start); 115 | if elapsed_time.as_secs_f64() > RESTART_THRESHOLD_SECONDS { 116 | self.to_restart = true; 117 | } 118 | } 119 | None => self.to_restart = true, 120 | } 121 | } 122 | if self.to_restart { 123 | self.to_restart = false; 124 | self.send(ProcCmd::Start); 125 | } 126 | } 127 | 128 | pub fn handle_event(&mut self, event: ProcEvent, selected: bool) { 129 | match event { 130 | ProcEvent::Render => { 131 | if !selected { 132 | self.changed = true; 133 | } 134 | } 135 | ProcEvent::Exited(exit_code) => { 136 | self.proc.handle_exited(exit_code); 137 | self.exit_code = Some(exit_code); 138 | self.is_up = self.proc.is_up(); 139 | 140 | self.maybe_stopped(); 141 | } 142 | ProcEvent::StdoutEOF => { 143 | self.proc.handle_stdout_eof(); 144 | self.is_up = self.proc.is_up(); 145 | 146 | self.maybe_stopped(); 147 | } 148 | ProcEvent::Started => { 149 | self.last_start = Some(Instant::now()); 150 | self.is_up = true; 151 | } 152 | ProcEvent::TermReply(s) => { 153 | self.send(ProcCmd::SendRaw(s)); 154 | } 155 | } 156 | } 157 | } 158 | 159 | pub enum ProcViewFrame<'a> { 160 | Empty, 161 | Vt(std::sync::RwLockReadGuard<'a, vt100::Parser>), 162 | Err(&'a str), 163 | } 164 | -------------------------------------------------------------------------------- /src/proc/inst.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Debug; 2 | use std::io::Write; 3 | use std::sync::{Arc, RwLock}; 4 | use std::thread::spawn; 5 | 6 | use portable_pty::MasterPty; 7 | use portable_pty::{native_pty_system, ChildKiller, CommandBuilder, PtySize}; 8 | use tokio::sync::mpsc::UnboundedSender; 9 | use tokio::task::spawn_blocking; 10 | 11 | use crate::error::ResultLogger; 12 | 13 | use super::msg::ProcEvent; 14 | use super::{ReplySender, Size}; 15 | 16 | pub struct Inst { 17 | pub vt: VtWrap, 18 | 19 | pub pid: u32, 20 | pub master: Option>, 21 | pub writer: Box, 22 | pub killer: Box, 23 | 24 | pub exit_code: Option, 25 | pub stdout_eof: bool, 26 | } 27 | 28 | impl Debug for Inst { 29 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 30 | f.debug_struct("Inst") 31 | .field("pid", &self.pid) 32 | .field("exited", &self.exit_code) 33 | .field("stdout_eof", &self.stdout_eof) 34 | .finish() 35 | } 36 | } 37 | 38 | pub type VtWrap = Arc>>; 39 | 40 | impl Inst { 41 | pub fn spawn( 42 | id: usize, 43 | cmd: CommandBuilder, 44 | tx: UnboundedSender<(usize, ProcEvent)>, 45 | size: &Size, 46 | scrollback_len: usize, 47 | ) -> anyhow::Result { 48 | let vt = vt100::Parser::new( 49 | size.height, 50 | size.width, 51 | scrollback_len, 52 | ReplySender { 53 | proc_id: id, 54 | sender: tx.clone(), 55 | }, 56 | ); 57 | let vt = Arc::new(RwLock::new(vt)); 58 | 59 | let pty_system = native_pty_system(); 60 | let pair = pty_system.openpty(PtySize { 61 | rows: size.height, 62 | cols: size.width, 63 | pixel_width: 0, 64 | pixel_height: 0, 65 | })?; 66 | 67 | let mut child = pair.slave.spawn_command(cmd)?; 68 | let pid = child.process_id().unwrap_or(0); 69 | let killer = child.clone_killer(); 70 | 71 | let _r = tx.send((id, ProcEvent::Started)); 72 | 73 | let mut reader = pair.master.try_clone_reader().unwrap(); 74 | let writer = pair.master.take_writer().unwrap(); 75 | 76 | { 77 | let tx = tx.clone(); 78 | let vt = vt.clone(); 79 | spawn_blocking(move || { 80 | let mut buf = vec![0; 32 * 1024]; 81 | loop { 82 | match reader.read(&mut buf[..]) { 83 | Ok(count) => { 84 | if count == 0 { 85 | break; 86 | } 87 | if let Ok(mut vt) = vt.write() { 88 | vt.process(&buf[..count]); 89 | match tx.send((id, ProcEvent::Render)) { 90 | Ok(_) => (), 91 | Err(err) => { 92 | log::debug!("Proc read error: ({:?})", err); 93 | break; 94 | } 95 | } 96 | } 97 | } 98 | _ => break, 99 | } 100 | } 101 | let _ = tx.send((id, ProcEvent::StdoutEOF)); 102 | }); 103 | } 104 | 105 | { 106 | let tx = tx.clone(); 107 | spawn(move || { 108 | // Block until program exits 109 | let exit_code = match child.wait() { 110 | Ok(status) => status.exit_code(), 111 | Err(_e) => 211, 112 | }; 113 | let _result = tx.send((id, ProcEvent::Exited(exit_code))); 114 | }); 115 | } 116 | 117 | let inst = Inst { 118 | vt, 119 | 120 | pid, 121 | master: Some(pair.master), 122 | writer, 123 | killer, 124 | 125 | exit_code: None, 126 | stdout_eof: false, 127 | }; 128 | Ok(inst) 129 | } 130 | 131 | pub fn resize(&self, size: &Size) { 132 | let rows = size.height; 133 | let cols = size.width; 134 | 135 | if let Some(master) = &self.master { 136 | master 137 | .resize(PtySize { 138 | rows, 139 | cols, 140 | pixel_width: 0, 141 | pixel_height: 0, 142 | }) 143 | .log_ignore(); 144 | } 145 | 146 | if let Ok(mut vt) = self.vt.write() { 147 | vt.set_size(rows, cols); 148 | } 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/proc/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod handle; 2 | mod inst; 3 | pub mod msg; 4 | mod proc; 5 | 6 | use std::fmt::Debug; 7 | 8 | use anyhow::bail; 9 | use compact_str::CompactString; 10 | use handle::ProcHandle; 11 | use proc::Proc; 12 | use serde::{Deserialize, Serialize}; 13 | use tokio::sync::mpsc::UnboundedSender; 14 | use tui::layout::Rect; 15 | use vt100::TermReplySender; 16 | 17 | use crate::config::ProcConfig; 18 | use crate::key::Key; 19 | use crate::mouse::MouseEvent; 20 | use crate::yaml_val::Val; 21 | 22 | use self::msg::ProcEvent; 23 | 24 | pub fn create_proc( 25 | name: String, 26 | cfg: &ProcConfig, 27 | tx: UnboundedSender<(usize, ProcEvent)>, 28 | size: Rect, 29 | ) -> ProcHandle { 30 | let proc = Proc::new(cfg, tx, size); 31 | ProcHandle::from_proc(name, proc, cfg.autorestart) 32 | } 33 | 34 | #[derive(Clone, Debug, Default)] 35 | pub enum StopSignal { 36 | SIGINT, 37 | #[default] 38 | SIGTERM, 39 | SIGKILL, 40 | SendKeys(Vec), 41 | HardKill, 42 | } 43 | 44 | impl StopSignal { 45 | pub fn from_val(val: &Val) -> anyhow::Result { 46 | match val.raw() { 47 | serde_yaml::Value::String(str) => match str.as_str() { 48 | "SIGINT" => return Ok(Self::SIGINT), 49 | "SIGTERM" => return Ok(Self::SIGTERM), 50 | "SIGKILL" => return Ok(Self::SIGKILL), 51 | "hard-kill" => return Ok(Self::HardKill), 52 | _ => (), 53 | }, 54 | serde_yaml::Value::Mapping(map) => { 55 | if map.len() == 1 { 56 | if let Some(keys) = map.get("send-keys") { 57 | let keys: Vec = serde_yaml::from_value(keys.clone())?; 58 | return Ok(Self::SendKeys(keys)); 59 | } 60 | } 61 | } 62 | _ => (), 63 | } 64 | bail!("Unexpected 'stop' value: {:?}.", val.raw()); 65 | } 66 | } 67 | 68 | fn translate_mouse_pos(event: &MouseEvent, scrollback: usize) -> Pos { 69 | Pos { 70 | y: event.y - scrollback as i32, 71 | x: event.x, 72 | } 73 | } 74 | 75 | #[derive(Clone)] 76 | struct Size { 77 | width: u16, 78 | height: u16, 79 | } 80 | 81 | impl Size { 82 | fn new(rect: Rect) -> Size { 83 | Size { 84 | width: rect.width.max(3), 85 | height: rect.height.max(3), 86 | } 87 | } 88 | } 89 | 90 | pub enum CopyMode { 91 | None(Option), 92 | Start(vt100::Screen, Pos), 93 | Range(vt100::Screen, Pos, Pos), 94 | } 95 | 96 | impl Default for CopyMode { 97 | fn default() -> Self { 98 | CopyMode::None(None) 99 | } 100 | } 101 | 102 | #[derive( 103 | Clone, Debug, Default, Deserialize, Eq, Hash, PartialEq, Serialize, 104 | )] 105 | pub struct Pos { 106 | pub y: i32, 107 | pub x: i32, 108 | } 109 | 110 | impl Pos { 111 | pub fn to_low_high<'a>(a: &'a Self, b: &'a Self) -> (&'a Self, &'a Self) { 112 | if a.y > b.y { 113 | return (b, a); 114 | } else if a.y == b.y && a.x > b.x { 115 | return (b, a); 116 | } 117 | (a, b) 118 | } 119 | 120 | pub fn within(start: &Self, end: &Self, target: &Self) -> bool { 121 | let y = target.y; 122 | let x = target.x; 123 | let (low, high) = Pos::to_low_high(start, end); 124 | 125 | if y > low.y { 126 | if y < high.y { 127 | true 128 | } else if y == high.y && x <= high.x { 129 | true 130 | } else { 131 | false 132 | } 133 | } else if y == low.y { 134 | if y < high.y { 135 | x >= low.x 136 | } else if y == high.y { 137 | x >= low.x && x <= high.x 138 | } else { 139 | false 140 | } 141 | } else { 142 | false 143 | } 144 | } 145 | } 146 | 147 | #[derive(Clone)] 148 | pub struct ReplySender { 149 | proc_id: usize, 150 | sender: UnboundedSender<(usize, ProcEvent)>, 151 | } 152 | 153 | impl TermReplySender for ReplySender { 154 | fn reply(&self, s: CompactString) { 155 | let _ = self.sender.send((self.proc_id, ProcEvent::TermReply(s))); 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/proc/msg.rs: -------------------------------------------------------------------------------- 1 | use compact_str::CompactString; 2 | 3 | use crate::{event::CopyMove, key::Key, mouse::MouseEvent}; 4 | 5 | #[derive(Debug)] 6 | pub enum ProcCmd { 7 | Start, 8 | Stop, 9 | Kill, 10 | 11 | SendKey(Key), 12 | SendMouse(MouseEvent), 13 | SendRaw(CompactString), 14 | 15 | ScrollUp, 16 | ScrollDown, 17 | ScrollUpLines { n: usize }, 18 | ScrollDownLines { n: usize }, 19 | 20 | CopyModeEnter, 21 | CopyModeLeave, 22 | CopyModeMove { dir: CopyMove }, 23 | CopyModeEnd, 24 | CopyModeCopy, 25 | 26 | Resize { x: u16, y: u16, w: u16, h: u16 }, 27 | } 28 | 29 | #[derive(Debug)] 30 | pub enum ProcEvent { 31 | Render, 32 | Exited(u32), 33 | StdoutEOF, 34 | Started, 35 | TermReply(CompactString), 36 | } 37 | -------------------------------------------------------------------------------- /src/protocol.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Debug; 2 | 3 | use crossterm::event::Event; 4 | use serde::{Deserialize, Serialize}; 5 | use tui::{backend::Backend, style::Modifier}; 6 | 7 | use crate::{error::ResultLogger, host::sender::MsgSender}; 8 | 9 | #[derive(Clone, Debug, Deserialize, Serialize)] 10 | pub enum SrvToClt { 11 | Draw { cells: Vec<(u16, u16, Cell)> }, 12 | SetCursor { x: u16, y: u16 }, 13 | ShowCursor, 14 | HideCursor, 15 | CursorShape(CursorStyle), 16 | Clear, 17 | Flush, 18 | Quit, 19 | } 20 | 21 | #[derive( 22 | Debug, Default, Deserialize, Clone, Copy, PartialEq, Eq, Serialize, 23 | )] 24 | pub enum CursorStyle { 25 | #[default] 26 | Default = 0, 27 | BlinkingBlock = 1, 28 | SteadyBlock = 2, 29 | BlinkingUnderline = 3, 30 | SteadyUnderline = 4, 31 | BlinkingBar = 5, 32 | SteadyBar = 6, 33 | } 34 | 35 | impl From for CursorStyle { 36 | fn from(value: termwiz::escape::csi::CursorStyle) -> Self { 37 | use termwiz::escape::csi::CursorStyle as CS; 38 | 39 | match value { 40 | CS::Default => Self::Default, 41 | CS::BlinkingBlock => Self::BlinkingBlock, 42 | CS::SteadyBlock => Self::SteadyBlock, 43 | CS::BlinkingUnderline => Self::BlinkingUnderline, 44 | CS::SteadyUnderline => Self::SteadyUnderline, 45 | CS::BlinkingBar => Self::BlinkingBar, 46 | CS::SteadyBar => Self::SteadyBar, 47 | } 48 | } 49 | } 50 | 51 | #[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] 52 | pub enum CltToSrv { 53 | Init { width: u16, height: u16 }, 54 | Key(Event), 55 | } 56 | 57 | #[derive(Clone, Debug, Deserialize, Serialize)] 58 | pub struct Cell { 59 | str: String, 60 | fg: Color, 61 | bg: Color, 62 | underline_color: Color, 63 | mods: Modifier, 64 | skip: bool, 65 | } 66 | 67 | impl From<&Cell> for tui::buffer::Cell { 68 | fn from(value: &Cell) -> Self { 69 | let mut cell = tui::buffer::Cell::default(); 70 | cell.set_symbol(&value.str); 71 | cell.set_style( 72 | tui::style::Style::new() 73 | .fg(value.fg.into()) 74 | .bg(value.bg.into()) 75 | .underline_color(value.underline_color.into()) 76 | .add_modifier(value.mods), 77 | ); 78 | cell.set_skip(value.skip); 79 | cell 80 | } 81 | } 82 | 83 | impl From<&tui::buffer::Cell> for Cell { 84 | fn from(value: &tui::buffer::Cell) -> Self { 85 | Cell { 86 | str: value.symbol().to_string(), 87 | fg: value.fg.into(), 88 | bg: value.bg.into(), 89 | underline_color: value.underline_color.into(), 90 | mods: value.modifier, 91 | skip: value.skip, 92 | } 93 | } 94 | } 95 | 96 | #[derive(Clone, Copy, Debug, Deserialize, Serialize)] 97 | pub enum Color { 98 | Reset, 99 | Black, 100 | Red, 101 | Green, 102 | Yellow, 103 | Blue, 104 | Magenta, 105 | Cyan, 106 | Gray, 107 | DarkGray, 108 | LightRed, 109 | LightGreen, 110 | LightYellow, 111 | LightBlue, 112 | LightMagenta, 113 | LightCyan, 114 | White, 115 | Rgb(u8, u8, u8), 116 | Indexed(u8), 117 | } 118 | 119 | impl From for tui::style::Color { 120 | fn from(value: Color) -> Self { 121 | match value { 122 | Color::Reset => tui::style::Color::Reset, 123 | Color::Black => tui::style::Color::Black, 124 | Color::Red => tui::style::Color::Red, 125 | Color::Green => tui::style::Color::Green, 126 | Color::Yellow => tui::style::Color::Yellow, 127 | Color::Blue => tui::style::Color::Blue, 128 | Color::Magenta => tui::style::Color::Magenta, 129 | Color::Cyan => tui::style::Color::Cyan, 130 | Color::Gray => tui::style::Color::Gray, 131 | Color::DarkGray => tui::style::Color::DarkGray, 132 | Color::LightRed => tui::style::Color::LightRed, 133 | Color::LightGreen => tui::style::Color::LightGreen, 134 | Color::LightYellow => tui::style::Color::LightYellow, 135 | Color::LightBlue => tui::style::Color::LightBlue, 136 | Color::LightMagenta => tui::style::Color::LightMagenta, 137 | Color::LightCyan => tui::style::Color::LightCyan, 138 | Color::White => tui::style::Color::White, 139 | Color::Rgb(r, g, b) => tui::style::Color::Rgb(r, g, b), 140 | Color::Indexed(idx) => tui::style::Color::Indexed(idx), 141 | } 142 | } 143 | } 144 | 145 | impl From for Color { 146 | fn from(value: tui::style::Color) -> Self { 147 | match value { 148 | tui::style::Color::Reset => Color::Reset, 149 | tui::style::Color::Black => Color::Black, 150 | tui::style::Color::Red => Color::Red, 151 | tui::style::Color::Green => Color::Green, 152 | tui::style::Color::Yellow => Color::Yellow, 153 | tui::style::Color::Blue => Color::Blue, 154 | tui::style::Color::Magenta => Color::Magenta, 155 | tui::style::Color::Cyan => Color::Cyan, 156 | tui::style::Color::Gray => Color::Gray, 157 | tui::style::Color::DarkGray => Color::DarkGray, 158 | tui::style::Color::LightRed => Color::LightRed, 159 | tui::style::Color::LightGreen => Color::LightGreen, 160 | tui::style::Color::LightYellow => Color::LightYellow, 161 | tui::style::Color::LightBlue => Color::LightBlue, 162 | tui::style::Color::LightMagenta => Color::LightMagenta, 163 | tui::style::Color::LightCyan => Color::LightCyan, 164 | tui::style::Color::White => Color::White, 165 | tui::style::Color::Rgb(r, g, b) => Color::Rgb(r, g, b), 166 | tui::style::Color::Indexed(idx) => Color::Indexed(idx), 167 | } 168 | } 169 | } 170 | 171 | pub struct ProxyBackend { 172 | pub tx: MsgSender, 173 | pub height: u16, 174 | pub width: u16, 175 | pub x: u16, 176 | pub y: u16, 177 | } 178 | 179 | impl ProxyBackend { 180 | fn send(&mut self, msg: SrvToClt) { 181 | self.tx.send(msg).log_ignore() 182 | } 183 | 184 | pub fn set_size(&mut self, width: u16, height: u16) { 185 | self.width = width; 186 | self.height = height; 187 | } 188 | } 189 | 190 | impl Backend for ProxyBackend { 191 | fn draw<'a, I>(&mut self, content: I) -> Result<(), std::io::Error> 192 | where 193 | I: Iterator, 194 | { 195 | let msg = SrvToClt::Draw { 196 | cells: content 197 | .map(|(a, b, cell)| (a, b, Cell::from(cell))) 198 | .collect(), 199 | }; 200 | self.send(msg); 201 | Ok(()) 202 | } 203 | 204 | fn hide_cursor(&mut self) -> Result<(), std::io::Error> { 205 | self.send(SrvToClt::HideCursor); 206 | Ok(()) 207 | } 208 | 209 | fn show_cursor(&mut self) -> Result<(), std::io::Error> { 210 | self.send(SrvToClt::ShowCursor); 211 | Ok(()) 212 | } 213 | 214 | fn get_cursor(&mut self) -> Result<(u16, u16), std::io::Error> { 215 | Ok((self.x, self.y)) 216 | } 217 | 218 | fn set_cursor(&mut self, x: u16, y: u16) -> Result<(), std::io::Error> { 219 | self.x = x; 220 | self.y = y; 221 | self.send(SrvToClt::SetCursor { x, y }); 222 | Ok(()) 223 | } 224 | 225 | fn clear(&mut self) -> Result<(), std::io::Error> { 226 | self.send(SrvToClt::Clear); 227 | Ok(()) 228 | } 229 | 230 | fn window_size(&mut self) -> std::io::Result { 231 | let win_size = tui::backend::WindowSize { 232 | columns_rows: tui::layout::Size { 233 | width: self.width, 234 | height: self.height, 235 | }, 236 | pixels: tui::layout::Size { 237 | width: 0, 238 | height: 0, 239 | }, 240 | }; 241 | Ok(win_size) 242 | } 243 | 244 | fn size(&self) -> Result { 245 | let rect = tui::layout::Rect::new(0, 0, self.width, self.height); 246 | Ok(rect) 247 | } 248 | 249 | fn flush(&mut self) -> Result<(), std::io::Error> { 250 | self.send(SrvToClt::Flush); 251 | Ok(()) 252 | } 253 | } 254 | -------------------------------------------------------------------------------- /src/settings.rs: -------------------------------------------------------------------------------- 1 | use std::{fs::File, io::BufReader, path::PathBuf}; 2 | 3 | use anyhow::Result; 4 | use crossterm::event::{KeyCode, KeyModifiers}; 5 | use indexmap::IndexMap; 6 | use serde_yaml::Value; 7 | 8 | use crate::{ 9 | event::{AppEvent, CopyMove}, 10 | key::Key, 11 | keymap::Keymap, 12 | yaml_val::{value_to_string, Val}, 13 | }; 14 | 15 | #[derive(Debug)] 16 | pub struct Settings { 17 | keymap_procs: IndexMap, 18 | keymap_term: IndexMap, 19 | keymap_copy: IndexMap, 20 | pub hide_keymap_window: bool, 21 | pub mouse_scroll_speed: usize, 22 | pub scrollback_len: usize, 23 | pub proc_list_width: usize, 24 | pub proc_list_title: String, 25 | } 26 | 27 | impl Default for Settings { 28 | fn default() -> Self { 29 | let mut settings = Self { 30 | keymap_procs: Default::default(), 31 | keymap_term: Default::default(), 32 | keymap_copy: Default::default(), 33 | hide_keymap_window: false, 34 | mouse_scroll_speed: 5, 35 | scrollback_len: 1000, 36 | proc_list_width: 30, 37 | proc_list_title: "Processes".to_string(), 38 | }; 39 | settings.add_defaults(); 40 | settings 41 | } 42 | } 43 | 44 | impl Settings { 45 | pub fn merge_from_xdg(&mut self) -> Result<()> { 46 | if let Some(path) = self.get_xdg_config_path() { 47 | match File::open(path) { 48 | Ok(file) => { 49 | let reader = BufReader::new(file); 50 | let settings_value: Value = serde_yaml::from_reader(reader)?; 51 | let settings_val = Val::new(&settings_value)?; 52 | self.merge_value(settings_val)?; 53 | } 54 | Err(err) => match err.kind() { 55 | std::io::ErrorKind::NotFound => (), 56 | _ => return Err(err.into()), 57 | }, 58 | } 59 | } 60 | 61 | Ok(()) 62 | } 63 | 64 | fn get_xdg_config_path(&self) -> Option { 65 | let mut buf = if let Ok(path) = std::env::var("XDG_CONFIG_HOME") { 66 | PathBuf::from(path) 67 | } else { 68 | self.get_xdg_config_dir()? 69 | }; 70 | buf.push("mprocs/mprocs.yaml"); 71 | 72 | Some(buf) 73 | } 74 | 75 | #[cfg(windows)] 76 | fn get_xdg_config_dir(&self) -> Option { 77 | let path = PathBuf::from(std::env::var_os("APPDATA")?); 78 | Some(path) 79 | } 80 | 81 | #[cfg(not(windows))] 82 | fn get_xdg_config_dir(&self) -> Option { 83 | use std::ffi::OsString; 84 | 85 | let mut path = PathBuf::from( 86 | std::env::var_os("HOME").unwrap_or_else(|| OsString::from("/")), 87 | ); 88 | path.push(".config"); 89 | Some(path) 90 | } 91 | 92 | pub fn merge_value(&mut self, val: Val) -> Result<()> { 93 | let obj = val.as_object()?; 94 | 95 | fn add_keys<'a>( 96 | into: &mut IndexMap, 97 | val: Option<&'a Val>, 98 | ) -> Result<()> { 99 | if let Some(keymap) = val { 100 | let mut keymap = keymap.as_object()?; 101 | 102 | if let Some(reset) = keymap.shift_remove(&Value::from("reset")) { 103 | if reset.as_bool()? { 104 | into.clear(); 105 | } 106 | } 107 | 108 | for (key, event) in keymap { 109 | let key = Key::parse(value_to_string(&key)?.as_str())?; 110 | if event.raw().is_null() { 111 | into.shift_remove(&key); 112 | } else { 113 | let event: AppEvent = serde_yaml::from_value(event.raw().clone())?; 114 | into.insert(key, event); 115 | } 116 | } 117 | } 118 | Ok(()) 119 | } 120 | add_keys( 121 | &mut self.keymap_procs, 122 | obj.get(&Value::from("keymap_procs")), 123 | )?; 124 | add_keys(&mut self.keymap_term, obj.get(&Value::from("keymap_term")))?; 125 | add_keys(&mut self.keymap_copy, obj.get(&Value::from("keymap_copy")))?; 126 | 127 | if let Some(hide_keymap_window) = 128 | obj.get(&Value::from("hide_keymap_window")) 129 | { 130 | self.hide_keymap_window = hide_keymap_window.as_bool()?; 131 | } 132 | 133 | if let Some(mouse_scroll_speed) = 134 | obj.get(&Value::from("mouse_scroll_speed")) 135 | { 136 | self.mouse_scroll_speed = mouse_scroll_speed.as_usize()?; 137 | } 138 | 139 | if let Some(scrollback) = obj.get(&Value::from("scrollback")) { 140 | self.scrollback_len = scrollback.as_usize()?; 141 | } 142 | 143 | if let Some(proc_list_title) = obj.get(&Value::from("proc_list_title")) { 144 | self.proc_list_title = proc_list_title.as_str()?.to_string().into(); 145 | } 146 | 147 | if let Some(proc_list_width) = obj.get(&Value::from("proc_list_width")) { 148 | self.proc_list_width = proc_list_width.as_usize()?; 149 | } 150 | 151 | Ok(()) 152 | } 153 | 154 | pub fn add_defaults(&mut self) { 155 | let s = self; 156 | 157 | s.keymap_add_p( 158 | Key::new(KeyCode::Char('a'), KeyModifiers::CONTROL), 159 | AppEvent::ToggleFocus, 160 | ); 161 | s.keymap_add_t( 162 | Key::new(KeyCode::Char('a'), KeyModifiers::CONTROL), 163 | AppEvent::ToggleFocus, 164 | ); 165 | s.keymap_add_c( 166 | Key::new(KeyCode::Char('a'), KeyModifiers::CONTROL), 167 | AppEvent::ToggleFocus, 168 | ); 169 | 170 | s.keymap_add_p(KeyCode::Char('q').into(), AppEvent::Quit); 171 | s.keymap_add_p(KeyCode::Char('Q').into(), AppEvent::ForceQuit); 172 | s.keymap_add_p(KeyCode::Char('p').into(), AppEvent::ShowCommandsMenu); 173 | s.keymap_add_p( 174 | Key::new(KeyCode::Down, KeyModifiers::NONE), 175 | AppEvent::NextProc, 176 | ); 177 | s.keymap_add_p( 178 | Key::new(KeyCode::Char('j'), KeyModifiers::NONE), 179 | AppEvent::NextProc, 180 | ); 181 | s.keymap_add_p( 182 | Key::new(KeyCode::Up, KeyModifiers::NONE), 183 | AppEvent::PrevProc, 184 | ); 185 | s.keymap_add_p( 186 | Key::new(KeyCode::Char('k'), KeyModifiers::NONE), 187 | AppEvent::PrevProc, 188 | ); 189 | s.keymap_add_p( 190 | Key::new(KeyCode::Char('s'), KeyModifiers::NONE), 191 | AppEvent::StartProc, 192 | ); 193 | s.keymap_add_p( 194 | Key::new(KeyCode::Char('x'), KeyModifiers::NONE), 195 | AppEvent::TermProc, 196 | ); 197 | s.keymap_add_p( 198 | Key::new(KeyCode::Char('X'), KeyModifiers::SHIFT), 199 | AppEvent::KillProc, 200 | ); 201 | s.keymap_add_p( 202 | Key::new(KeyCode::Char('r'), KeyModifiers::NONE), 203 | AppEvent::RestartProc, 204 | ); 205 | s.keymap_add_p( 206 | Key::new(KeyCode::Char('R'), KeyModifiers::SHIFT), 207 | AppEvent::ForceRestartProc, 208 | ); 209 | s.keymap_add_p( 210 | Key::new(KeyCode::Char('e'), KeyModifiers::NONE), 211 | AppEvent::ShowRenameProc, 212 | ); 213 | let ctrlc = Key::new(KeyCode::Char('c'), KeyModifiers::CONTROL); 214 | s.keymap_add_p(ctrlc, AppEvent::SendKey { key: ctrlc }); 215 | s.keymap_add_p( 216 | Key::new(KeyCode::Char('a'), KeyModifiers::NONE), 217 | AppEvent::ShowAddProc, 218 | ); 219 | s.keymap_add_p( 220 | Key::new(KeyCode::Char('C'), KeyModifiers::SHIFT), 221 | AppEvent::DuplicateProc, 222 | ); 223 | s.keymap_add_p( 224 | Key::new(KeyCode::Char('d'), KeyModifiers::NONE), 225 | AppEvent::ShowRemoveProc, 226 | ); 227 | 228 | // Scrolling in TERM and COPY modes 229 | for map in [&mut s.keymap_procs, &mut s.keymap_copy] { 230 | map.insert( 231 | Key::new(KeyCode::Char('y'), KeyModifiers::CONTROL), 232 | AppEvent::ScrollUpLines { n: 3 }, 233 | ); 234 | map.insert( 235 | Key::new(KeyCode::Char('e'), KeyModifiers::CONTROL), 236 | AppEvent::ScrollDownLines { n: 3 }, 237 | ); 238 | let ctrlu = Key::new(KeyCode::Char('u'), KeyModifiers::CONTROL); 239 | map.insert(ctrlu, AppEvent::ScrollUp); 240 | map.insert( 241 | Key::new(KeyCode::PageUp, KeyModifiers::NONE), 242 | AppEvent::ScrollUp, 243 | ); 244 | let ctrld = Key::new(KeyCode::Char('d'), KeyModifiers::CONTROL); 245 | map.insert(ctrld, AppEvent::ScrollDown); 246 | map.insert( 247 | Key::new(KeyCode::PageDown, KeyModifiers::NONE), 248 | AppEvent::ScrollDown, 249 | ); 250 | } 251 | 252 | s.keymap_add_p( 253 | Key::new(KeyCode::Char('z'), KeyModifiers::NONE), 254 | AppEvent::Zoom, 255 | ); 256 | 257 | s.keymap_add_p( 258 | Key::new(KeyCode::Char('?'), KeyModifiers::NONE), 259 | AppEvent::ToggleKeymapWindow, 260 | ); 261 | 262 | s.keymap_add_p( 263 | Key::new(KeyCode::Char('v'), KeyModifiers::NONE), 264 | AppEvent::CopyModeEnter, 265 | ); 266 | 267 | for i in 0..8 { 268 | let char = char::from_digit(i + 1, 10).unwrap(); 269 | s.keymap_add_p( 270 | Key::new(KeyCode::Char(char), KeyModifiers::ALT), 271 | AppEvent::SelectProc { index: i as usize }, 272 | ); 273 | } 274 | 275 | s.keymap_add_c(KeyCode::Esc.into(), AppEvent::CopyModeLeave); 276 | s.keymap_add_c(KeyCode::Char('v').into(), AppEvent::CopyModeEnd); 277 | s.keymap_add_c(KeyCode::Char('c').into(), AppEvent::CopyModeCopy); 278 | for code in [KeyCode::Up, KeyCode::Char('k')] { 279 | s.keymap_add_c(code.into(), AppEvent::CopyModeMove { dir: CopyMove::Up }); 280 | } 281 | for code in [KeyCode::Right, KeyCode::Char('l')] { 282 | s.keymap_add_c( 283 | code.into(), 284 | AppEvent::CopyModeMove { 285 | dir: CopyMove::Right, 286 | }, 287 | ); 288 | } 289 | for code in [KeyCode::Down, KeyCode::Char('j')] { 290 | s.keymap_add_c( 291 | code.into(), 292 | AppEvent::CopyModeMove { 293 | dir: CopyMove::Down, 294 | }, 295 | ); 296 | } 297 | for code in [KeyCode::Left, KeyCode::Char('h')] { 298 | s.keymap_add_c( 299 | code.into(), 300 | AppEvent::CopyModeMove { 301 | dir: CopyMove::Left, 302 | }, 303 | ); 304 | } 305 | } 306 | 307 | fn keymap_add_p(&mut self, key: Key, event: AppEvent) { 308 | self.keymap_procs.insert(key, event); 309 | } 310 | 311 | fn keymap_add_t(&mut self, key: Key, event: AppEvent) { 312 | self.keymap_term.insert(key, event); 313 | } 314 | 315 | fn keymap_add_c(&mut self, key: Key, event: AppEvent) { 316 | self.keymap_copy.insert(key, event); 317 | } 318 | 319 | pub fn add_to_keymap(&self, keymap: &mut Keymap) -> Result<()> { 320 | for (key, event) in &self.keymap_procs { 321 | keymap.bind_p(key.clone(), event.clone()); 322 | } 323 | for (key, event) in &self.keymap_term { 324 | keymap.bind_t(key.clone(), event.clone()); 325 | } 326 | for (key, event) in &self.keymap_copy { 327 | keymap.bind_c(key.clone(), event.clone()); 328 | } 329 | 330 | Ok(()) 331 | } 332 | } 333 | -------------------------------------------------------------------------------- /src/state.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | app::ClientId, 3 | keymap::KeymapGroup, 4 | proc::{handle::ProcHandle, CopyMode}, 5 | }; 6 | 7 | #[derive(Clone, Copy, Debug, Eq, PartialEq)] 8 | pub enum Scope { 9 | Procs, 10 | Term, 11 | TermZoom, 12 | } 13 | 14 | impl Scope { 15 | pub fn toggle(&self) -> Self { 16 | match self { 17 | Scope::Procs => Scope::Term, 18 | Scope::Term => Scope::Procs, 19 | Scope::TermZoom => Scope::Procs, 20 | } 21 | } 22 | 23 | pub fn is_zoomed(&self) -> bool { 24 | match self { 25 | Scope::Procs => false, 26 | Scope::Term => false, 27 | Scope::TermZoom => true, 28 | } 29 | } 30 | } 31 | 32 | pub struct State { 33 | pub current_client_id: Option, 34 | 35 | pub scope: Scope, 36 | pub procs: Vec, 37 | pub selected: usize, 38 | pub hide_keymap_window: bool, 39 | 40 | pub quitting: bool, 41 | } 42 | 43 | impl State { 44 | pub fn get_current_proc(&self) -> Option<&ProcHandle> { 45 | self.procs.get(self.selected) 46 | } 47 | 48 | pub fn get_current_proc_mut(&mut self) -> Option<&mut ProcHandle> { 49 | self.procs.get_mut(self.selected) 50 | } 51 | 52 | pub fn select_proc(&mut self, index: usize) { 53 | self.selected = index; 54 | if let Some(proc_handle) = self.procs.get_mut(index) { 55 | proc_handle.focus(); 56 | } 57 | } 58 | 59 | pub fn get_proc_mut(&mut self, id: usize) -> Option<&mut ProcHandle> { 60 | self.procs.iter_mut().find(|p| p.id() == id) 61 | } 62 | 63 | pub fn get_keymap_group(&self) -> KeymapGroup { 64 | match self.scope { 65 | Scope::Procs => KeymapGroup::Procs, 66 | Scope::Term | Scope::TermZoom => match self.get_current_proc() { 67 | Some(proc) => match proc.copy_mode() { 68 | CopyMode::None(_) => KeymapGroup::Term, 69 | CopyMode::Start(_, _) | CopyMode::Range(_, _, _) => KeymapGroup::Copy, 70 | }, 71 | None => KeymapGroup::Term, 72 | }, 73 | } 74 | } 75 | 76 | pub fn all_procs_down(&self) -> bool { 77 | self.procs.iter().all(|p| !p.is_up()) 78 | } 79 | 80 | pub fn toggle_keymap_window(&mut self) { 81 | self.hide_keymap_window = !self.hide_keymap_window; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/theme.rs: -------------------------------------------------------------------------------- 1 | use tui::{ 2 | style::{Color, Modifier, Style}, 3 | widgets::{Block, BorderType, Borders}, 4 | }; 5 | 6 | pub struct Theme { 7 | pub procs_item: Style, 8 | pub procs_item_active: Style, 9 | } 10 | 11 | impl Theme { 12 | pub fn pane_title(&self, active: bool) -> Style { 13 | let style = Style::default(); 14 | if active { 15 | style.fg(Color::Reset).add_modifier(Modifier::BOLD) 16 | } else { 17 | style.fg(Color::Reset) 18 | } 19 | } 20 | 21 | pub fn pane(&self, active: bool) -> Block { 22 | let type_ = match active { 23 | true => BorderType::Thick, 24 | false => BorderType::Plain, 25 | }; 26 | 27 | Block::default() 28 | .borders(Borders::ALL) 29 | .border_type(type_) 30 | .border_style(Style::default().fg(Color::Reset).bg(Color::Reset)) 31 | } 32 | 33 | pub fn copy_mode_label(&self) -> Style { 34 | Style::default() 35 | .fg(Color::Black) 36 | .bg(Color::Yellow) 37 | .add_modifier(Modifier::BOLD) 38 | } 39 | 40 | pub fn get_procs_item(&self, active: bool) -> Style { 41 | if active { 42 | self.procs_item_active 43 | } else { 44 | self.procs_item 45 | } 46 | } 47 | 48 | pub fn zoom_tip(&self) -> Style { 49 | Style::default().fg(Color::Black).bg(Color::Yellow) 50 | } 51 | } 52 | 53 | impl Default for Theme { 54 | fn default() -> Self { 55 | Self { 56 | procs_item: Style::default().fg(Color::Reset), 57 | procs_item_active: Style::default().bg(Color::Indexed(240)), 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/ui_keymap.rs: -------------------------------------------------------------------------------- 1 | use tui::{ 2 | layout::{Margin, Rect}, 3 | style::{Color, Style}, 4 | text::{Line, Span, Text}, 5 | widgets::{Clear, Paragraph}, 6 | Frame, 7 | }; 8 | 9 | use crate::{ 10 | encode_term::print_key, 11 | event::AppEvent, 12 | keymap::{Keymap, KeymapGroup}, 13 | state::State, 14 | theme::Theme, 15 | }; 16 | 17 | pub fn render_keymap( 18 | area: Rect, 19 | frame: &mut Frame, 20 | state: &mut State, 21 | keymap: &Keymap, 22 | ) { 23 | let theme = Theme::default(); 24 | 25 | let block = theme 26 | .pane(false) 27 | .title(Span::styled("Help", theme.pane_title(false))); 28 | frame.render_widget(Clear, area); 29 | frame.render_widget(block, area); 30 | 31 | let group = state.get_keymap_group(); 32 | let items = match group { 33 | KeymapGroup::Procs => vec![ 34 | AppEvent::ToggleFocus, 35 | AppEvent::Quit, 36 | AppEvent::NextProc, 37 | AppEvent::PrevProc, 38 | AppEvent::StartProc, 39 | AppEvent::TermProc, 40 | AppEvent::RestartProc, 41 | AppEvent::ToggleKeymapWindow, 42 | ], 43 | KeymapGroup::Term => vec![AppEvent::ToggleFocus], 44 | KeymapGroup::Copy => vec![ 45 | AppEvent::CopyModeEnd, 46 | AppEvent::CopyModeCopy, 47 | AppEvent::CopyModeLeave, 48 | ], 49 | }; 50 | let line = items 51 | .into_iter() 52 | .filter_map(|event| Some((keymap.resolve_key(group, &event)?, event))) 53 | .flat_map(|(key, event)| { 54 | vec![ 55 | Span::raw(" <"), 56 | Span::styled(print_key(key), Style::default().fg(Color::Yellow)), 57 | Span::raw(": "), 58 | Span::raw(event.desc()), 59 | Span::raw("> "), 60 | ] 61 | }) 62 | .collect::>(); 63 | 64 | let line = Line::from(line); 65 | let line = Text::from(vec![line]); 66 | 67 | let p = Paragraph::new(line); 68 | frame.render_widget( 69 | p, 70 | area.inner(&Margin { 71 | vertical: 1, 72 | horizontal: 1, 73 | }), 74 | ); 75 | } 76 | -------------------------------------------------------------------------------- /src/ui_procs.rs: -------------------------------------------------------------------------------- 1 | use tui::{ 2 | layout::{Margin, Rect}, 3 | style::{Color, Modifier, Style}, 4 | text::{Line, Span}, 5 | widgets::{List, ListItem, ListState}, 6 | Frame, 7 | }; 8 | 9 | use crate::{ 10 | config::Config, 11 | proc::handle::ProcHandle, 12 | state::{Scope, State}, 13 | theme::Theme, 14 | }; 15 | 16 | pub fn render_procs( 17 | area: Rect, 18 | frame: &mut Frame, 19 | state: &mut State, 20 | config: &Config, 21 | ) { 22 | if area.width <= 2 { 23 | return; 24 | } 25 | 26 | let theme = Theme::default(); 27 | let theme = &theme; 28 | 29 | let active = state.scope == Scope::Procs; 30 | 31 | let mut list_state = ListState::default(); 32 | list_state.select(Some(state.selected)); 33 | let items = state 34 | .procs 35 | .iter_mut() 36 | .enumerate() 37 | .map(|(i, proc)| { 38 | create_proc_item(proc, i == state.selected, area.width - 2, theme) 39 | }) 40 | .collect::>(); 41 | 42 | let title = { 43 | let mut spans = vec![Span::styled( 44 | config.proc_list_title.as_str(), 45 | theme.pane_title(active), 46 | )]; 47 | if state.quitting { 48 | spans.push(Span::from(" ")); 49 | spans.push(Span::styled( 50 | "QUITTING", 51 | Style::default() 52 | .fg(Color::Black) 53 | .bg(Color::Red) 54 | .add_modifier(Modifier::BOLD), 55 | )); 56 | } 57 | spans 58 | }; 59 | 60 | let items = List::new(items) 61 | .block(theme.pane(active).title(title)) 62 | .style(Style::default().fg(Color::White)); 63 | frame.render_stateful_widget(items, area, &mut list_state); 64 | } 65 | 66 | fn create_proc_item<'a>( 67 | proc_handle: &mut ProcHandle, 68 | is_cur: bool, 69 | width: u16, 70 | theme: &Theme, 71 | ) -> ListItem<'a> { 72 | let status = if proc_handle.is_up() { 73 | Span::styled( 74 | " UP ", 75 | Style::default() 76 | .fg(Color::LightGreen) 77 | .add_modifier(Modifier::BOLD), 78 | ) 79 | } else { 80 | match proc_handle.exit_code() { 81 | Some(0) => { 82 | Span::styled(" DOWN (0)", Style::default().fg(Color::LightBlue)) 83 | } 84 | Some(exit_code) => Span::styled( 85 | format!(" DOWN ({})", exit_code), 86 | Style::default().fg(Color::LightRed), 87 | ), 88 | None => Span::styled(" DOWN ", Style::default().fg(Color::LightRed)), 89 | } 90 | }; 91 | 92 | let mark = if is_cur { 93 | Span::raw("•") 94 | } else { 95 | Span::raw(" ") 96 | }; 97 | 98 | let mut name = proc_handle.name().to_string(); 99 | let name_max = (width as usize) 100 | .saturating_sub(mark.width()) 101 | .saturating_sub(status.width()); 102 | let name_len = name.chars().count(); 103 | if name_len > name_max { 104 | name.truncate( 105 | name 106 | .char_indices() 107 | .nth(name_max) 108 | .map_or(name.len(), |(n, _)| n), 109 | ) 110 | } 111 | if name_len < name_max { 112 | for _ in name_len..name_max { 113 | name.push(' '); 114 | } 115 | } 116 | 117 | let name_style = Style::default(); 118 | let name_style = if proc_handle.changed() { 119 | name_style.add_modifier(Modifier::BOLD) 120 | } else { 121 | name_style 122 | }; 123 | let name = Span::styled(name, name_style); 124 | 125 | ListItem::new(Line::from(vec![mark, name, status])) 126 | .style(theme.get_procs_item(is_cur)) 127 | } 128 | 129 | pub fn procs_get_clicked_index( 130 | area: Rect, 131 | x: u16, 132 | y: u16, 133 | state: &State, 134 | ) -> Option { 135 | let inner = area.inner(&Margin { 136 | vertical: 1, 137 | horizontal: 1, 138 | }); 139 | if procs_check_hit(area, x, y) { 140 | let index = y - inner.y; 141 | let scroll = (state.selected + 1).saturating_sub(inner.height as usize); 142 | let index = index as usize + scroll; 143 | if index < state.procs.len() { 144 | return Some(index as usize); 145 | } 146 | } 147 | None 148 | } 149 | 150 | pub fn procs_check_hit(area: Rect, x: u16, y: u16) -> bool { 151 | area.x < x 152 | && area.x + area.width > x + 1 153 | && area.y < y 154 | && area.y + area.height > y + 1 155 | } 156 | -------------------------------------------------------------------------------- /src/ui_term.rs: -------------------------------------------------------------------------------- 1 | use termwiz::escape::csi::CursorStyle; 2 | use tui::{ 3 | layout::{Margin, Rect}, 4 | style::{Color, Style}, 5 | text::{Line, Span, Text}, 6 | widgets::{Clear, Paragraph, Widget, Wrap}, 7 | Frame, 8 | }; 9 | 10 | use crate::{ 11 | proc::{handle::ProcViewFrame, CopyMode, Pos, ReplySender}, 12 | state::{Scope, State}, 13 | theme::Theme, 14 | }; 15 | 16 | pub fn render_term( 17 | area: Rect, 18 | frame: &mut Frame, 19 | state: &mut State, 20 | cursor_style: &mut CursorStyle, 21 | ) { 22 | if area.width < 3 || area.height < 3 { 23 | return; 24 | } 25 | 26 | let theme = Theme::default(); 27 | 28 | let active = match state.scope { 29 | Scope::Procs => false, 30 | Scope::Term | Scope::TermZoom => true, 31 | }; 32 | 33 | if let Some(proc) = state.get_current_proc() { 34 | let mut title = Vec::with_capacity(4); 35 | title.push(Span::styled("Terminal", theme.pane_title(active))); 36 | match proc.copy_mode() { 37 | CopyMode::None(_) => (), 38 | CopyMode::Start(_, _) | CopyMode::Range(_, _, _) => { 39 | title.push(Span::raw(" ")); 40 | title.push(Span::styled("COPY MODE", theme.copy_mode_label())); 41 | } 42 | }; 43 | 44 | let block = theme.pane(active).title(Line::from(title)); 45 | frame.render_widget(Clear, area); 46 | frame.render_widget(block, area); 47 | 48 | match &proc.lock_view() { 49 | ProcViewFrame::Empty => (), 50 | ProcViewFrame::Vt(vt) => { 51 | let (screen, cursor) = match proc.copy_mode() { 52 | CopyMode::None(_) => { 53 | let screen = vt.screen(); 54 | let cursor = if screen.hide_cursor() { 55 | None 56 | } else { 57 | let cursor = screen.cursor_position(); 58 | Some((area.x + 1 + cursor.1, area.y + 1 + cursor.0)) 59 | }; 60 | (screen, cursor) 61 | } 62 | CopyMode::Start(screen, pos) | CopyMode::Range(screen, _, pos) => { 63 | let y = area.y as i32 + 1 + (pos.y + screen.scrollback() as i32); 64 | let cursor = if y >= 0 { 65 | Some((area.x + 1 + pos.x as u16, y as u16)) 66 | } else { 67 | None 68 | }; 69 | (screen, cursor) 70 | } 71 | }; 72 | 73 | let term = UiTerm::new(screen, proc.copy_mode()); 74 | frame.render_widget( 75 | term, 76 | area.inner(&Margin { 77 | vertical: 1, 78 | horizontal: 1, 79 | }), 80 | ); 81 | 82 | if active { 83 | if let Some(cursor) = cursor { 84 | frame.set_cursor(cursor.0, cursor.1); 85 | *cursor_style = vt.screen().cursor_style(); 86 | } 87 | } 88 | } 89 | ProcViewFrame::Err(err) => { 90 | let text = 91 | Text::styled(*err, Style::default().fg(tui::style::Color::Red)); 92 | frame.render_widget( 93 | Paragraph::new(text).wrap(Wrap { trim: false }), 94 | area.inner(&Margin { 95 | vertical: 1, 96 | horizontal: 1, 97 | }), 98 | ); 99 | } 100 | } 101 | } 102 | } 103 | 104 | pub struct UiTerm<'a> { 105 | screen: &'a vt100::Screen, 106 | copy_mode: &'a CopyMode, 107 | } 108 | 109 | impl<'a> UiTerm<'a> { 110 | pub fn new( 111 | screen: &'a vt100::Screen, 112 | copy_mode: &'a CopyMode, 113 | ) -> Self { 114 | UiTerm { screen, copy_mode } 115 | } 116 | } 117 | 118 | impl Widget for UiTerm<'_> { 119 | fn render(self, area: Rect, buf: &mut tui::buffer::Buffer) { 120 | let screen = self.screen; 121 | 122 | for row in 0..area.height { 123 | for col in 0..area.width { 124 | let to_cell = buf.get_mut(area.x + col, area.y + row); 125 | if let Some(cell) = screen.cell(row, col) { 126 | *to_cell = cell.to_tui(); 127 | if !cell.has_contents() { 128 | to_cell.set_char(' '); 129 | } 130 | 131 | let copy_mode = match self.copy_mode { 132 | CopyMode::None(_) => None, 133 | CopyMode::Start(_, start) => Some((start, start)), 134 | CopyMode::Range(_, start, end) => Some((start, end)), 135 | }; 136 | if let Some((start, end)) = copy_mode { 137 | if Pos::within( 138 | start, 139 | end, 140 | &Pos { 141 | y: (row as i32) - screen.scrollback() as i32, 142 | x: col as i32, 143 | }, 144 | ) { 145 | to_cell.fg = Color::Black; // Black 146 | to_cell.bg = Color::Cyan; // Cyan 147 | } 148 | } 149 | } else { 150 | // Out of bounds. 151 | to_cell.set_char('?'); 152 | } 153 | } 154 | } 155 | 156 | let scrollback = screen.scrollback(); 157 | if scrollback > 0 { 158 | let str = format!(" -{} ", scrollback); 159 | let width = str.len() as u16; 160 | let span = Span::styled( 161 | str, 162 | Style::reset() 163 | .bg(tui::style::Color::LightYellow) 164 | .fg(tui::style::Color::Black), 165 | ); 166 | let x = area.x + area.width - width; 167 | let y = area.y; 168 | buf.set_span(x, y, &span, width); 169 | } 170 | } 171 | } 172 | 173 | pub fn term_check_hit(area: Rect, x: u16, y: u16) -> bool { 174 | area.x <= x 175 | && area.x + area.width >= x + 1 176 | && area.y <= y 177 | && area.y + area.height >= y + 1 178 | } 179 | -------------------------------------------------------------------------------- /src/ui_zoom_tip.rs: -------------------------------------------------------------------------------- 1 | use tui::{layout::Rect, text::Text, widgets::Paragraph, Frame}; 2 | 3 | use crate::{ 4 | event::AppEvent, 5 | keymap::{Keymap, KeymapGroup}, 6 | theme::Theme, 7 | }; 8 | 9 | pub fn render_zoom_tip(area: Rect, frame: &mut Frame, keymap: &Keymap) { 10 | let theme = Theme::default(); 11 | 12 | let events = vec![ 13 | AppEvent::FocusTerm, 14 | AppEvent::ToggleFocus, 15 | AppEvent::FocusProcs, 16 | ]; 17 | let key = events 18 | .into_iter() 19 | .find_map(|event| keymap.resolve_key(KeymapGroup::Term, &event)); 20 | 21 | let line = if let Some(key) = key { 22 | Text::from(format!(" To exit zoom mode press {}", key.to_string())) 23 | } else { 24 | Text::from(" No key bound to exit the zoom mode") 25 | }; 26 | let p = Paragraph::new(line).style(theme.zoom_tip()); 27 | frame.render_widget(p, area); 28 | } 29 | -------------------------------------------------------------------------------- /src/widgets/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod text_input; 2 | -------------------------------------------------------------------------------- /src/widgets/text_input.rs: -------------------------------------------------------------------------------- 1 | use tui::widgets::{Clear, Paragraph, StatefulWidget, Widget}; 2 | use tui_input::Input; 3 | 4 | pub struct TextInput<'a> { 5 | input: &'a mut Input, 6 | } 7 | 8 | impl<'a> TextInput<'a> { 9 | pub fn new(input: &'a mut Input) -> Self { 10 | TextInput { input } 11 | } 12 | } 13 | 14 | impl<'a> StatefulWidget for TextInput<'a> { 15 | type State = (u16, u16); 16 | 17 | fn render( 18 | self, 19 | area: tui::prelude::Rect, 20 | buf: &mut tui::prelude::Buffer, 21 | cursor_pos: &mut Self::State, 22 | ) { 23 | let input = self.input; 24 | let value = input.value(); 25 | 26 | let left_trim = input.cursor().saturating_sub(area.width as usize); 27 | let (value, cursor) = if left_trim > 0 { 28 | let start = unicode_segmentation::UnicodeSegmentation::grapheme_indices( 29 | value, true, 30 | ) 31 | .skip(left_trim) 32 | .next() 33 | .map_or_else(|| value.len(), |(len, _)| len); 34 | (&value[start..], input.cursor() - left_trim) 35 | } else { 36 | (value, input.cursor()) 37 | }; 38 | 39 | // TODO: render directly 40 | let txt = Paragraph::new(value); 41 | Clear.render(area, buf); 42 | txt.render(area, buf); 43 | 44 | *cursor_pos = (area.x + cursor as u16, area.y); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/yaml_val.rs: -------------------------------------------------------------------------------- 1 | use std::{env::consts::OS, rc::Rc}; 2 | 3 | use anyhow::bail; 4 | use indexmap::IndexMap; 5 | use serde_yaml::Value; 6 | 7 | #[derive(Clone)] 8 | struct Trace(Option>>); 9 | 10 | impl Trace { 11 | pub fn empty() -> Self { 12 | Trace(None) 13 | } 14 | 15 | pub fn add(&self, seg: T) -> Self { 16 | Trace(Some(Rc::new(Box::new((seg.to_string(), self.clone()))))) 17 | } 18 | 19 | pub fn to_string(&self) -> String { 20 | let mut str = String::new(); 21 | fn add(buf: &mut String, trace: &Trace) { 22 | match &trace.0 { 23 | Some(part) => { 24 | add(buf, &part.1); 25 | buf.push('.'); 26 | buf.push_str(&part.0); 27 | } 28 | None => buf.push_str(""), 29 | } 30 | } 31 | add(&mut str, self); 32 | 33 | str 34 | } 35 | } 36 | 37 | pub struct Val<'a>(&'a Value, Trace); 38 | 39 | impl<'a> Val<'a> { 40 | pub fn new(value: &'a Value) -> anyhow::Result { 41 | Self::create(value, Trace::empty()) 42 | } 43 | 44 | fn create(value: &'a Value, trace: Trace) -> anyhow::Result { 45 | match value { 46 | Value::Mapping(map) => { 47 | if map 48 | .into_iter() 49 | .next() 50 | .map_or(false, |(k, _)| k.eq("$select")) 51 | { 52 | let (v, t) = Self::select(map, trace.clone())?; 53 | return Self::create(v, t); 54 | } 55 | } 56 | _ => (), 57 | } 58 | Ok(Val(value, trace)) 59 | } 60 | 61 | pub fn raw(&self) -> &Value { 62 | self.0 63 | } 64 | 65 | fn select( 66 | map: &'a serde_yaml::Mapping, 67 | trace: Trace, 68 | ) -> anyhow::Result<(&'a Value, Trace)> { 69 | if map.get(&Value::from("$select")).unwrap() == "os" { 70 | if let Some(v) = map.get(&Value::from(OS)) { 71 | return Ok((v, trace.add(OS))); 72 | } 73 | 74 | if let Some(v) = map.get(&Value::from("$else")) { 75 | return Ok((v, trace.add("$else"))); 76 | } 77 | 78 | anyhow::bail!( 79 | "No matching condition found at {}. Use \"$else\" for default value.", 80 | trace.to_string(), 81 | ) 82 | } else { 83 | anyhow::bail!("Expected \"os\" at {}", trace.add("$select").to_string()) 84 | } 85 | } 86 | 87 | pub fn error_at>(&self, msg: T) -> anyhow::Error { 88 | anyhow::format_err!("{} at {}", msg.as_ref(), self.1.to_string()) 89 | } 90 | 91 | pub fn as_bool(&self) -> anyhow::Result { 92 | self.0.as_bool().ok_or_else(|| { 93 | anyhow::format_err!("Expected bool at {}", self.1.to_string()) 94 | }) 95 | } 96 | 97 | pub fn as_usize(&self) -> anyhow::Result { 98 | self 99 | .0 100 | .as_u64() 101 | .ok_or_else(|| { 102 | anyhow::format_err!("Expected int at {}", self.1.to_string()) 103 | }) 104 | .map(|x| x as usize) 105 | } 106 | 107 | pub fn as_str(&self) -> anyhow::Result<&str> { 108 | self.0.as_str().ok_or_else(|| { 109 | anyhow::format_err!("Expected string at {}", self.1.to_string()) 110 | }) 111 | } 112 | 113 | pub fn as_array(&self) -> anyhow::Result> { 114 | self 115 | .0 116 | .as_sequence() 117 | .ok_or_else(|| { 118 | anyhow::format_err!("Expected array at {}", self.1.to_string()) 119 | })? 120 | .iter() 121 | .enumerate() 122 | .map(|(i, item)| Val::create(item, self.1.add(i))) 123 | .collect::>>() 124 | } 125 | 126 | pub fn as_object(&self) -> anyhow::Result> { 127 | self 128 | .0 129 | .as_mapping() 130 | .ok_or_else(|| { 131 | anyhow::format_err!("Expected object at {}", self.1.to_string()) 132 | })? 133 | .iter() 134 | .map(|(k, item)| { 135 | #[inline] 136 | fn mk_val<'a>( 137 | k: &'a Value, 138 | item: &'a Value, 139 | trace: &'a Trace, 140 | ) -> anyhow::Result> { 141 | Ok(Val::create(item, trace.add(value_to_string(k)?))?) 142 | } 143 | Ok((k.to_owned(), mk_val(k, item, &self.1)?)) 144 | }) 145 | .collect::>>() 146 | } 147 | } 148 | 149 | pub fn value_to_string(value: &Value) -> anyhow::Result { 150 | match value { 151 | Value::Null => Ok("null".to_string()), 152 | Value::Bool(v) => Ok(v.to_string()), 153 | Value::Number(v) => Ok(v.to_string()), 154 | Value::String(v) => Ok(v.to_string()), 155 | Value::Sequence(_v) => { 156 | bail!("`primitive_to_string` is not implemented for arrays.") 157 | } 158 | Value::Mapping(_v) => { 159 | bail!("`primitive_to_string` is not implemented for objects.") 160 | } 161 | Value::Tagged(_) => anyhow::bail!("Yaml tags are not supported"), 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /tests/tests.lua: -------------------------------------------------------------------------------- 1 | print("Building mprocs.") 2 | vt.start("cargo build"):wait() 3 | print("Built.") 4 | 5 | local BIN = "cargo r --" 6 | 7 | function test(name, f) 8 | print("TEST: " .. name) 9 | f() 10 | end 11 | 12 | test("next proc", function() 13 | local proc = vt.start(BIN .. ' "echo one" "echo two" "echo three"') 14 | 15 | local mark = "•" 16 | 17 | proc:wait_text(mark .. "echo one") 18 | 19 | proc:send_str("j") 20 | 21 | proc:wait_text(mark .. "echo two") 22 | 23 | proc:send_str("q") 24 | proc:wait() 25 | end) 26 | 27 | test("next proc 2", function() 28 | local proc = vt.start(BIN .. ' "nvim --clean"') 29 | 30 | proc:wait_text("[No Name]") 31 | 32 | proc:send_key("") 33 | proc:send_str("ihello!") 34 | proc:send_key("") 35 | proc:send_str("i ") 36 | 37 | proc:wait_text("hello !") 38 | 39 | proc:send_key("") 40 | proc:send_str("qy") 41 | proc:wait() 42 | end) 43 | 44 | test("select by mouse", function() 45 | local proc = vt.start(BIN .. ' "echo one" "echo two" "echo three"') 46 | 47 | local mark = "•" 48 | 49 | proc:wait_text(" echo three") 50 | 51 | proc:click({ x = 1, y = 3 }) 52 | 53 | proc:wait_text(mark .. "echo three") 54 | 55 | proc:send_str("q") 56 | proc:wait() 57 | end) 58 | -------------------------------------------------------------------------------- /vendor/vt100/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "mprocs-vt100" 3 | version = "0.7.3" 4 | edition = "2021" 5 | 6 | description = "Fork of vt100 for mprocs" 7 | homepage = "https://github.com/pvolok/mprocs" 8 | repository = "https://github.com/pvolok/mprocs" 9 | keywords = ["terminal", "vt100"] 10 | categories = ["command-line-interface", "encoding"] 11 | license = "MIT" 12 | include = ["src/**/*"] 13 | 14 | [lib] 15 | name = "vt100" 16 | 17 | [dependencies] 18 | itoa = "1.0.15" 19 | log = "0.4.27" 20 | termwiz = { version = "0.23.3", features = ["use_serde"] } 21 | unicode-width = "0.2.0" 22 | compact_str = { version = "0.9.0", features = ["serde"] } 23 | tui = { package = "ratatui", version = "0.26.2", features = ["serde"] } 24 | -------------------------------------------------------------------------------- /vendor/vt100/src/attrs.rs: -------------------------------------------------------------------------------- 1 | use tui::style::Modifier; 2 | 3 | use crate::term::BufWrite as _; 4 | 5 | /// Represents a foreground or background color for cells. 6 | #[derive(Eq, PartialEq, Debug, Copy, Clone)] 7 | pub enum Color { 8 | /// The default terminal color. 9 | Default, 10 | 11 | /// An indexed terminal color. 12 | Idx(u8), 13 | 14 | /// An RGB terminal color. The parameters are (red, green, blue). 15 | Rgb(u8, u8, u8), 16 | } 17 | 18 | impl Default for Color { 19 | fn default() -> Self { 20 | Self::Default 21 | } 22 | } 23 | 24 | impl Color { 25 | pub fn to_tui(self) -> tui::style::Color { 26 | match self { 27 | Color::Default => tui::style::Color::Reset, 28 | Color::Idx(index) => tui::style::Color::Indexed(index), 29 | Color::Rgb(r, g, b) => tui::style::Color::Rgb(r, g, b), 30 | } 31 | } 32 | } 33 | 34 | impl From for Color { 35 | fn from(value: termwiz::color::ColorSpec) -> Self { 36 | match value { 37 | termwiz::color::ColorSpec::Default => Self::Default, 38 | termwiz::color::ColorSpec::PaletteIndex(idx) => Self::Idx(idx), 39 | termwiz::color::ColorSpec::TrueColor(srgba) => { 40 | let (r, g, b, _) = srgba.to_srgb_u8(); 41 | Self::Rgb(r, g, b) 42 | } 43 | } 44 | } 45 | } 46 | 47 | const TEXT_MODE_BOLD: u8 = 0b0000_0001; 48 | const TEXT_MODE_ITALIC: u8 = 0b0000_0010; 49 | const TEXT_MODE_UNDERLINE: u8 = 0b0000_0100; 50 | const TEXT_MODE_INVERSE: u8 = 0b0000_1000; 51 | 52 | #[derive(Default, Clone, Copy, PartialEq, Eq, Debug)] 53 | pub struct Attrs { 54 | pub fgcolor: Color, 55 | pub bgcolor: Color, 56 | pub mode: u8, 57 | } 58 | 59 | impl Attrs { 60 | pub fn bold(&self) -> bool { 61 | self.mode & TEXT_MODE_BOLD != 0 62 | } 63 | 64 | pub fn set_bold(&mut self, bold: bool) { 65 | if bold { 66 | self.mode |= TEXT_MODE_BOLD; 67 | } else { 68 | self.mode &= !TEXT_MODE_BOLD; 69 | } 70 | } 71 | 72 | pub fn italic(&self) -> bool { 73 | self.mode & TEXT_MODE_ITALIC != 0 74 | } 75 | 76 | pub fn set_italic(&mut self, italic: bool) { 77 | if italic { 78 | self.mode |= TEXT_MODE_ITALIC; 79 | } else { 80 | self.mode &= !TEXT_MODE_ITALIC; 81 | } 82 | } 83 | 84 | pub fn underline(&self) -> bool { 85 | self.mode & TEXT_MODE_UNDERLINE != 0 86 | } 87 | 88 | pub fn set_underline(&mut self, underline: bool) { 89 | if underline { 90 | self.mode |= TEXT_MODE_UNDERLINE; 91 | } else { 92 | self.mode &= !TEXT_MODE_UNDERLINE; 93 | } 94 | } 95 | 96 | pub fn inverse(&self) -> bool { 97 | self.mode & TEXT_MODE_INVERSE != 0 98 | } 99 | 100 | pub fn set_inverse(&mut self, inverse: bool) { 101 | if inverse { 102 | self.mode |= TEXT_MODE_INVERSE; 103 | } else { 104 | self.mode &= !TEXT_MODE_INVERSE; 105 | } 106 | } 107 | 108 | pub fn write_escape_code_diff(&self, contents: &mut Vec, other: &Self) { 109 | if self != other && self == &Self::default() { 110 | crate::term::ClearAttrs::default().write_buf(contents); 111 | return; 112 | } 113 | 114 | let attrs = crate::term::Attrs::default(); 115 | 116 | let attrs = if self.fgcolor == other.fgcolor { 117 | attrs 118 | } else { 119 | attrs.fgcolor(self.fgcolor) 120 | }; 121 | let attrs = if self.bgcolor == other.bgcolor { 122 | attrs 123 | } else { 124 | attrs.bgcolor(self.bgcolor) 125 | }; 126 | let attrs = if self.bold() == other.bold() { 127 | attrs 128 | } else { 129 | attrs.bold(self.bold()) 130 | }; 131 | let attrs = if self.italic() == other.italic() { 132 | attrs 133 | } else { 134 | attrs.italic(self.italic()) 135 | }; 136 | let attrs = if self.underline() == other.underline() { 137 | attrs 138 | } else { 139 | attrs.underline(self.underline()) 140 | }; 141 | let attrs = if self.inverse() == other.inverse() { 142 | attrs 143 | } else { 144 | attrs.inverse(self.inverse()) 145 | }; 146 | 147 | attrs.write_buf(contents); 148 | } 149 | } 150 | 151 | impl Attrs { 152 | pub fn mods_to_tui(&self) -> tui::style::Modifier { 153 | let mut mods = Modifier::empty(); 154 | mods.set(Modifier::BOLD, self.bold()); 155 | mods.set(Modifier::ITALIC, self.italic()); 156 | mods.set(Modifier::UNDERLINED, self.underline()); 157 | mods.set(Modifier::REVERSED, self.inverse()); 158 | mods 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /vendor/vt100/src/cell.rs: -------------------------------------------------------------------------------- 1 | use compact_str::CompactString; 2 | use unicode_width::UnicodeWidthStr; 3 | 4 | /// Represents a single terminal cell. 5 | #[derive(Clone, Debug, Default, Eq)] 6 | pub struct Cell { 7 | text: CompactString, 8 | attrs: crate::attrs::Attrs, 9 | } 10 | 11 | impl PartialEq for Cell { 12 | fn eq(&self, other: &Self) -> bool { 13 | if self.text != other.text { 14 | return false; 15 | } 16 | if self.attrs != other.attrs { 17 | return false; 18 | } 19 | true 20 | } 21 | } 22 | 23 | impl Cell { 24 | pub(crate) fn set(&mut self, c: char, a: crate::attrs::Attrs) { 25 | self.text.clear(); 26 | self.text.push(c); 27 | self.attrs = a; 28 | } 29 | 30 | pub(crate) fn append(&mut self, c: char) { 31 | if self.text.is_empty() { 32 | self.text.push(' '); 33 | } 34 | self.text.push(c); 35 | } 36 | 37 | pub(crate) fn clear(&mut self, attrs: crate::attrs::Attrs) { 38 | self.text.clear(); 39 | self.attrs = attrs; 40 | } 41 | 42 | /// Returns the text contents of the cell. 43 | /// 44 | /// Can include multiple unicode characters if combining characters are 45 | /// used, but will contain at most one character with a non-zero character 46 | /// width. 47 | #[must_use] 48 | pub fn contents(&self) -> &str { 49 | self.text.as_str() 50 | } 51 | 52 | /// Returns whether the cell contains any text data. 53 | #[must_use] 54 | pub fn has_contents(&self) -> bool { 55 | !self.text.is_empty() 56 | } 57 | 58 | /// Returns whether the text data in the cell represents a wide character. 59 | #[must_use] 60 | pub fn is_wide(&self) -> bool { 61 | self.text.width() >= 2 62 | } 63 | 64 | pub(crate) fn attrs(&self) -> &crate::attrs::Attrs { 65 | &self.attrs 66 | } 67 | 68 | /// Returns the foreground color of the cell. 69 | #[must_use] 70 | pub fn fgcolor(&self) -> crate::attrs::Color { 71 | self.attrs.fgcolor 72 | } 73 | 74 | /// Returns the background color of the cell. 75 | #[must_use] 76 | pub fn bgcolor(&self) -> crate::attrs::Color { 77 | self.attrs.bgcolor 78 | } 79 | 80 | /// Returns whether the cell should be rendered with the bold text 81 | /// attribute. 82 | #[must_use] 83 | pub fn bold(&self) -> bool { 84 | self.attrs.bold() 85 | } 86 | 87 | /// Returns whether the cell should be rendered with the italic text 88 | /// attribute. 89 | #[must_use] 90 | pub fn italic(&self) -> bool { 91 | self.attrs.italic() 92 | } 93 | 94 | /// Returns whether the cell should be rendered with the underlined text 95 | /// attribute. 96 | #[must_use] 97 | pub fn underline(&self) -> bool { 98 | self.attrs.underline() 99 | } 100 | 101 | /// Returns whether the cell should be rendered with the inverse text 102 | /// attribute. 103 | #[must_use] 104 | pub fn inverse(&self) -> bool { 105 | self.attrs.inverse() 106 | } 107 | } 108 | 109 | impl Cell { 110 | pub fn to_tui(&self) -> tui::buffer::Cell { 111 | let attrs = self.attrs(); 112 | 113 | let mut cell = tui::buffer::Cell::default(); 114 | cell.set_symbol(&self.text); 115 | cell.set_style( 116 | tui::style::Style::new() 117 | .fg(attrs.fgcolor.to_tui()) 118 | .bg(attrs.bgcolor.to_tui()) 119 | .add_modifier(attrs.mods_to_tui()), 120 | ); 121 | 122 | cell 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /vendor/vt100/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! This crate parses a terminal byte stream and provides an in-memory 2 | //! representation of the rendered contents. 3 | //! 4 | //! # Overview 5 | //! 6 | //! This is essentially the terminal parser component of a graphical terminal 7 | //! emulator pulled out into a separate crate. Although you can use this crate 8 | //! to build a graphical terminal emulator, it also contains functionality 9 | //! necessary for implementing terminal applications that want to run other 10 | //! terminal applications - programs like `screen` or `tmux` for example. 11 | //! 12 | //! # Synopsis 13 | //! 14 | //! ``` 15 | //! let mut parser = vt100::Parser::new(24, 80, 0); 16 | //! 17 | //! let screen = parser.screen().clone(); 18 | //! parser.process(b"this text is \x1b[31mRED\x1b[m"); 19 | //! assert_eq!( 20 | //! parser.screen().cell(0, 13).unwrap().fgcolor(), 21 | //! vt100::Color::Idx(1), 22 | //! ); 23 | //! 24 | //! let screen = parser.screen().clone(); 25 | //! parser.process(b"\x1b[3D\x1b[32mGREEN"); 26 | //! assert_eq!( 27 | //! parser.screen().contents_formatted(), 28 | //! &b"\x1b[?25h\x1b[m\x1b[H\x1b[Jthis text is \x1b[32mGREEN"[..], 29 | //! ); 30 | //! assert_eq!( 31 | //! parser.screen().contents_diff(&screen), 32 | //! &b"\x1b[1;14H\x1b[32mGREEN"[..], 33 | //! ); 34 | //! ``` 35 | 36 | #![warn(clippy::cargo)] 37 | #![warn(clippy::pedantic)] 38 | #![warn(clippy::nursery)] 39 | #![warn(clippy::as_conversions)] 40 | #![warn(clippy::get_unwrap)] 41 | #![allow(clippy::cognitive_complexity)] 42 | #![allow(clippy::missing_const_for_fn)] 43 | #![allow(clippy::similar_names)] 44 | #![allow(clippy::struct_excessive_bools)] 45 | #![allow(clippy::too_many_arguments)] 46 | #![allow(clippy::too_many_lines)] 47 | #![allow(clippy::type_complexity)] 48 | 49 | mod attrs; 50 | mod cell; 51 | mod grid; 52 | mod parser; 53 | mod row; 54 | mod screen; 55 | mod size; 56 | mod term; 57 | mod term_reply; 58 | 59 | pub use attrs::Color; 60 | pub use cell::Cell; 61 | pub use parser::Parser; 62 | pub use screen::{MouseProtocolEncoding, MouseProtocolMode, Screen}; 63 | pub use size::Size; 64 | pub use term_reply::TermReplySender; 65 | -------------------------------------------------------------------------------- /vendor/vt100/src/parser.rs: -------------------------------------------------------------------------------- 1 | use std::sync::{Arc, Mutex}; 2 | 3 | use crate::TermReplySender; 4 | 5 | /// A parser for terminal output which produces an in-memory representation of 6 | /// the terminal contents. 7 | pub struct Parser { 8 | parser: Arc>, 9 | screen: crate::screen::Screen, 10 | } 11 | 12 | impl Parser { 13 | /// Creates a new terminal parser of the given size and with the given 14 | /// amount of scrollback. 15 | #[must_use] 16 | pub fn new( 17 | rows: u16, 18 | cols: u16, 19 | scrollback_len: usize, 20 | reply_sender: Reply, 21 | ) -> Self { 22 | let parser = Arc::new(Mutex::new(termwiz::escape::parser::Parser::new())); 23 | Self { 24 | parser, 25 | screen: crate::screen::Screen::new( 26 | crate::grid::Size { rows, cols }, 27 | scrollback_len, 28 | reply_sender, 29 | ), 30 | } 31 | } 32 | 33 | /// Processes the contents of the given byte string, and updates the 34 | /// in-memory terminal state. 35 | pub fn process(&mut self, bytes: &[u8]) { 36 | self.parser.lock().unwrap().parse(bytes, |action| { 37 | self.screen.handle_action(action); 38 | }); 39 | } 40 | 41 | /// Resizes the terminal. 42 | pub fn set_size(&mut self, rows: u16, cols: u16) { 43 | self.screen.set_size(rows, cols); 44 | } 45 | 46 | /// Scrolls to the given position in the scrollback. 47 | /// 48 | /// This position indicates the offset from the top of the screen, and 49 | /// should be `0` to put the normal screen in view. 50 | /// 51 | /// This affects the return values of methods called on `parser.screen()`: 52 | /// for instance, `parser.screen().cell(0, 0)` will return the top left 53 | /// corner of the screen after taking the scrollback offset into account. 54 | /// It does not affect `parser.process()` at all. 55 | /// 56 | /// The value given will be clamped to the actual size of the scrollback. 57 | pub fn set_scrollback(&mut self, rows: usize) { 58 | self.screen.set_scrollback(rows); 59 | } 60 | 61 | /// Returns a reference to a `Screen` object containing the terminal 62 | /// state. 63 | #[must_use] 64 | pub fn screen(&self) -> &crate::screen::Screen { 65 | &self.screen 66 | } 67 | } 68 | 69 | impl std::io::Write for Parser { 70 | fn write(&mut self, buf: &[u8]) -> std::io::Result { 71 | self.process(buf); 72 | Ok(buf.len()) 73 | } 74 | 75 | fn flush(&mut self) -> std::io::Result<()> { 76 | Ok(()) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /vendor/vt100/src/row.rs: -------------------------------------------------------------------------------- 1 | use crate::term::BufWrite as _; 2 | 3 | #[derive(Clone, Debug)] 4 | pub struct Row { 5 | cells: Vec, 6 | wrapped: bool, 7 | } 8 | 9 | impl Row { 10 | pub fn new(cols: u16) -> Self { 11 | Self { 12 | cells: vec![crate::cell::Cell::default(); usize::from(cols)], 13 | wrapped: false, 14 | } 15 | } 16 | 17 | pub fn cols(&self) -> u16 { 18 | self 19 | .cells 20 | .len() 21 | .try_into() 22 | // we limit the number of cols to a u16 (see Size) 23 | .unwrap() 24 | } 25 | 26 | pub fn clear(&mut self, attrs: crate::attrs::Attrs) { 27 | for cell in &mut self.cells { 28 | cell.clear(attrs); 29 | } 30 | self.wrapped = false; 31 | } 32 | 33 | fn cells(&self) -> impl Iterator { 34 | self.cells.iter() 35 | } 36 | 37 | pub fn get(&self, col: u16) -> Option<&crate::cell::Cell> { 38 | self.cells.get(usize::from(col)) 39 | } 40 | 41 | pub fn get_mut(&mut self, col: u16) -> Option<&mut crate::cell::Cell> { 42 | self.cells.get_mut(usize::from(col)) 43 | } 44 | 45 | pub fn insert(&mut self, i: u16, cell: crate::cell::Cell) { 46 | self.cells.insert(usize::from(i), cell); 47 | self.wrapped = false; 48 | } 49 | 50 | pub fn remove(&mut self, i: u16) { 51 | self.clear_wide(i); 52 | self.cells.remove(usize::from(i)); 53 | self.wrapped = false; 54 | } 55 | 56 | pub fn erase(&mut self, i: u16, attrs: crate::attrs::Attrs) { 57 | let wide = self.cells[usize::from(i)].is_wide(); 58 | self.clear_wide(i); 59 | self.cells[usize::from(i)].clear(attrs); 60 | if i == self.cols() - if wide { 2 } else { 1 } { 61 | self.wrapped = false; 62 | } 63 | } 64 | 65 | pub fn truncate(&mut self, len: u16) { 66 | self.cells.truncate(usize::from(len)); 67 | self.wrapped = false; 68 | let last_cell = &mut self.cells[usize::from(len) - 1]; 69 | if last_cell.is_wide() { 70 | last_cell.clear(*last_cell.attrs()); 71 | } 72 | } 73 | 74 | pub fn resize(&mut self, len: u16, cell: crate::cell::Cell) { 75 | self.cells.resize(usize::from(len), cell); 76 | self.wrapped = false; 77 | } 78 | 79 | pub fn wrap(&mut self, wrap: bool) { 80 | self.wrapped = wrap; 81 | } 82 | 83 | pub fn wrapped(&self) -> bool { 84 | self.wrapped 85 | } 86 | 87 | pub fn clear_wide(&mut self, col: u16) { 88 | let cell = &self.cells[usize::from(col)]; 89 | let other = if cell.is_wide() { 90 | self.cells.get_mut(usize::from(col + 1)) 91 | } else if self.is_wide_continuation(col) { 92 | self.cells.get_mut(usize::from(col - 1)) 93 | } else { 94 | return; 95 | }; 96 | if let Some(other) = other { 97 | other.clear(*other.attrs()); 98 | } 99 | } 100 | 101 | pub fn write_contents( 102 | &self, 103 | contents: &mut String, 104 | start: u16, 105 | width: u16, 106 | wrapping: bool, 107 | ) { 108 | let mut prev_was_wide = false; 109 | 110 | let mut prev_col = start; 111 | for (col, cell) in self 112 | .cells() 113 | .enumerate() 114 | .skip(usize::from(start)) 115 | .take(usize::from(width)) 116 | { 117 | if prev_was_wide { 118 | prev_was_wide = false; 119 | continue; 120 | } 121 | prev_was_wide = cell.is_wide(); 122 | 123 | // we limit the number of cols to a u16 (see Size) 124 | let col: u16 = col.try_into().unwrap(); 125 | if cell.has_contents() { 126 | for _ in 0..(col - prev_col) { 127 | contents.push(' '); 128 | } 129 | prev_col += col - prev_col; 130 | 131 | contents.push_str(&cell.contents()); 132 | prev_col += if cell.is_wide() { 2 } else { 1 }; 133 | } 134 | } 135 | if prev_col == start && wrapping { 136 | contents.push('\n'); 137 | } 138 | } 139 | 140 | pub fn write_contents_formatted( 141 | &self, 142 | contents: &mut Vec, 143 | start: u16, 144 | width: u16, 145 | row: u16, 146 | wrapping: bool, 147 | prev_pos: Option, 148 | prev_attrs: Option, 149 | ) -> (crate::grid::Pos, crate::attrs::Attrs) { 150 | let mut prev_was_wide = false; 151 | let default_cell = crate::cell::Cell::default(); 152 | 153 | let mut prev_pos = if let Some(prev_pos) = prev_pos { 154 | prev_pos 155 | } else if wrapping { 156 | crate::grid::Pos { 157 | row: row - 1, 158 | col: self.cols(), 159 | } 160 | } else { 161 | crate::grid::Pos { row, col: start } 162 | }; 163 | let mut prev_attrs = prev_attrs.unwrap_or_default(); 164 | 165 | let first_cell = &self.cells[usize::from(start)]; 166 | if wrapping && first_cell == &default_cell { 167 | let default_attrs = default_cell.attrs(); 168 | if &prev_attrs != default_attrs { 169 | default_attrs.write_escape_code_diff(contents, &prev_attrs); 170 | prev_attrs = *default_attrs; 171 | } 172 | contents.push(b' '); 173 | crate::term::Backspace::default().write_buf(contents); 174 | crate::term::EraseChar::new(1).write_buf(contents); 175 | prev_pos = crate::grid::Pos { row, col: 0 }; 176 | } 177 | 178 | let mut erase: Option<(u16, &crate::attrs::Attrs)> = None; 179 | for (col, cell) in self 180 | .cells() 181 | .enumerate() 182 | .skip(usize::from(start)) 183 | .take(usize::from(width)) 184 | { 185 | if prev_was_wide { 186 | prev_was_wide = false; 187 | continue; 188 | } 189 | prev_was_wide = cell.is_wide(); 190 | 191 | // we limit the number of cols to a u16 (see Size) 192 | let col: u16 = col.try_into().unwrap(); 193 | let pos = crate::grid::Pos { row, col }; 194 | 195 | if let Some((prev_col, attrs)) = erase { 196 | if cell.has_contents() || cell.attrs() != attrs { 197 | let new_pos = crate::grid::Pos { row, col: prev_col }; 198 | if wrapping 199 | && prev_pos.row + 1 == new_pos.row 200 | && prev_pos.col >= self.cols() 201 | { 202 | if new_pos.col > 0 { 203 | contents.extend(" ".repeat(usize::from(new_pos.col)).as_bytes()); 204 | } else { 205 | contents.extend(b" "); 206 | crate::term::Backspace::default().write_buf(contents); 207 | } 208 | } else { 209 | crate::term::MoveFromTo::new(prev_pos, new_pos).write_buf(contents); 210 | } 211 | prev_pos = new_pos; 212 | if &prev_attrs != attrs { 213 | attrs.write_escape_code_diff(contents, &prev_attrs); 214 | prev_attrs = *attrs; 215 | } 216 | crate::term::EraseChar::new(pos.col - prev_col).write_buf(contents); 217 | erase = None; 218 | } 219 | } 220 | 221 | if cell != &default_cell { 222 | let attrs = cell.attrs(); 223 | if cell.has_contents() { 224 | if pos != prev_pos { 225 | if !wrapping 226 | || prev_pos.row + 1 != pos.row 227 | || prev_pos.col < self.cols() - if cell.is_wide() { 1 } else { 0 } 228 | || pos.col != 0 229 | { 230 | crate::term::MoveFromTo::new(prev_pos, pos).write_buf(contents); 231 | } 232 | prev_pos = pos; 233 | } 234 | 235 | if &prev_attrs != attrs { 236 | attrs.write_escape_code_diff(contents, &prev_attrs); 237 | prev_attrs = *attrs; 238 | } 239 | 240 | prev_pos.col += if cell.is_wide() { 2 } else { 1 }; 241 | let cell_contents = cell.contents(); 242 | contents.extend(cell_contents.as_bytes()); 243 | } else if erase.is_none() { 244 | erase = Some((pos.col, attrs)); 245 | } 246 | } 247 | } 248 | if let Some((prev_col, attrs)) = erase { 249 | let new_pos = crate::grid::Pos { row, col: prev_col }; 250 | if wrapping 251 | && prev_pos.row + 1 == new_pos.row 252 | && prev_pos.col >= self.cols() 253 | { 254 | if new_pos.col > 0 { 255 | contents.extend(" ".repeat(usize::from(new_pos.col)).as_bytes()); 256 | } else { 257 | contents.extend(b" "); 258 | crate::term::Backspace::default().write_buf(contents); 259 | } 260 | } else { 261 | crate::term::MoveFromTo::new(prev_pos, new_pos).write_buf(contents); 262 | } 263 | prev_pos = new_pos; 264 | if &prev_attrs != attrs { 265 | attrs.write_escape_code_diff(contents, &prev_attrs); 266 | prev_attrs = *attrs; 267 | } 268 | crate::term::ClearRowForward::default().write_buf(contents); 269 | } 270 | 271 | (prev_pos, prev_attrs) 272 | } 273 | 274 | // while it's true that most of the logic in this is identical to 275 | // write_contents_formatted, i can't figure out how to break out the 276 | // common parts without making things noticeably slower. 277 | pub fn write_contents_diff( 278 | &self, 279 | contents: &mut Vec, 280 | prev: &Self, 281 | start: u16, 282 | width: u16, 283 | row: u16, 284 | wrapping: bool, 285 | prev_wrapping: bool, 286 | mut prev_pos: crate::grid::Pos, 287 | mut prev_attrs: crate::attrs::Attrs, 288 | ) -> (crate::grid::Pos, crate::attrs::Attrs) { 289 | let mut prev_was_wide = false; 290 | 291 | let first_cell = &self.cells[usize::from(start)]; 292 | let prev_first_cell = &prev.cells[usize::from(start)]; 293 | if wrapping 294 | && !prev_wrapping 295 | && first_cell == prev_first_cell 296 | && prev_pos.row + 1 == row 297 | && prev_pos.col 298 | >= self.cols() - if prev_first_cell.is_wide() { 1 } else { 0 } 299 | { 300 | let first_cell_attrs = first_cell.attrs(); 301 | if &prev_attrs != first_cell_attrs { 302 | first_cell_attrs.write_escape_code_diff(contents, &prev_attrs); 303 | prev_attrs = *first_cell_attrs; 304 | } 305 | let mut cell_contents = prev_first_cell.contents(); 306 | let need_erase = if cell_contents.is_empty() { 307 | cell_contents = " "; 308 | true 309 | } else { 310 | false 311 | }; 312 | contents.extend(cell_contents.as_bytes()); 313 | crate::term::Backspace::default().write_buf(contents); 314 | if prev_first_cell.is_wide() { 315 | crate::term::Backspace::default().write_buf(contents); 316 | } 317 | if need_erase { 318 | crate::term::EraseChar::new(1).write_buf(contents); 319 | } 320 | prev_pos = crate::grid::Pos { row, col: 0 }; 321 | } 322 | 323 | let mut erase: Option<(u16, &crate::attrs::Attrs)> = None; 324 | for (col, (cell, prev_cell)) in self 325 | .cells() 326 | .zip(prev.cells()) 327 | .enumerate() 328 | .skip(usize::from(start)) 329 | .take(usize::from(width)) 330 | { 331 | if prev_was_wide { 332 | prev_was_wide = false; 333 | continue; 334 | } 335 | prev_was_wide = cell.is_wide(); 336 | 337 | // we limit the number of cols to a u16 (see Size) 338 | let col: u16 = col.try_into().unwrap(); 339 | let pos = crate::grid::Pos { row, col }; 340 | 341 | if let Some((prev_col, attrs)) = erase { 342 | if cell.has_contents() || cell.attrs() != attrs { 343 | let new_pos = crate::grid::Pos { row, col: prev_col }; 344 | if wrapping 345 | && prev_pos.row + 1 == new_pos.row 346 | && prev_pos.col >= self.cols() 347 | { 348 | if new_pos.col > 0 { 349 | contents.extend(" ".repeat(usize::from(new_pos.col)).as_bytes()); 350 | } else { 351 | contents.extend(b" "); 352 | crate::term::Backspace::default().write_buf(contents); 353 | } 354 | } else { 355 | crate::term::MoveFromTo::new(prev_pos, new_pos).write_buf(contents); 356 | } 357 | prev_pos = new_pos; 358 | if &prev_attrs != attrs { 359 | attrs.write_escape_code_diff(contents, &prev_attrs); 360 | prev_attrs = *attrs; 361 | } 362 | crate::term::EraseChar::new(pos.col - prev_col).write_buf(contents); 363 | erase = None; 364 | } 365 | } 366 | 367 | if cell != prev_cell { 368 | let attrs = cell.attrs(); 369 | if cell.has_contents() { 370 | if pos != prev_pos { 371 | if !wrapping 372 | || prev_pos.row + 1 != pos.row 373 | || prev_pos.col < self.cols() - if cell.is_wide() { 1 } else { 0 } 374 | || pos.col != 0 375 | { 376 | crate::term::MoveFromTo::new(prev_pos, pos).write_buf(contents); 377 | } 378 | prev_pos = pos; 379 | } 380 | 381 | if &prev_attrs != attrs { 382 | attrs.write_escape_code_diff(contents, &prev_attrs); 383 | prev_attrs = *attrs; 384 | } 385 | 386 | prev_pos.col += if cell.is_wide() { 2 } else { 1 }; 387 | contents.extend(cell.contents().as_bytes()); 388 | } else if erase.is_none() { 389 | erase = Some((pos.col, attrs)); 390 | } 391 | } 392 | } 393 | if let Some((prev_col, attrs)) = erase { 394 | let new_pos = crate::grid::Pos { row, col: prev_col }; 395 | if wrapping 396 | && prev_pos.row + 1 == new_pos.row 397 | && prev_pos.col >= self.cols() 398 | { 399 | if new_pos.col > 0 { 400 | contents.extend(" ".repeat(usize::from(new_pos.col)).as_bytes()); 401 | } else { 402 | contents.extend(b" "); 403 | crate::term::Backspace::default().write_buf(contents); 404 | } 405 | } else { 406 | crate::term::MoveFromTo::new(prev_pos, new_pos).write_buf(contents); 407 | } 408 | prev_pos = new_pos; 409 | if &prev_attrs != attrs { 410 | attrs.write_escape_code_diff(contents, &prev_attrs); 411 | prev_attrs = *attrs; 412 | } 413 | crate::term::ClearRowForward::default().write_buf(contents); 414 | } 415 | 416 | // if this row is going from wrapped to not wrapped, we need to erase 417 | // and redraw the last character to break wrapping. if this row is 418 | // wrapped, we need to redraw the last character without erasing it to 419 | // position the cursor after the end of the line correctly so that 420 | // drawing the next line can just start writing and be wrapped. 421 | if (!self.wrapped && prev.wrapped) || (!prev.wrapped && self.wrapped) { 422 | let end_pos = 423 | if self.is_wide_continuation(self.cols() - 1) { 424 | crate::grid::Pos { 425 | row, 426 | col: self.cols() - 2, 427 | } 428 | } else { 429 | crate::grid::Pos { 430 | row, 431 | col: self.cols() - 1, 432 | } 433 | }; 434 | crate::term::MoveFromTo::new(prev_pos, end_pos).write_buf(contents); 435 | prev_pos = end_pos; 436 | if !self.wrapped { 437 | crate::term::EraseChar::new(1).write_buf(contents); 438 | } 439 | let end_cell = &self.cells[usize::from(end_pos.col)]; 440 | if end_cell.has_contents() { 441 | let attrs = end_cell.attrs(); 442 | if &prev_attrs != attrs { 443 | attrs.write_escape_code_diff(contents, &prev_attrs); 444 | prev_attrs = *attrs; 445 | } 446 | contents.extend(end_cell.contents().as_bytes()); 447 | prev_pos.col += if end_cell.is_wide() { 2 } else { 1 }; 448 | } 449 | } 450 | 451 | (prev_pos, prev_attrs) 452 | } 453 | 454 | pub(crate) fn is_wide_continuation(&self, col: u16) -> bool { 455 | if col == 0 { 456 | return false; 457 | } 458 | 459 | self 460 | .cells 461 | .get(col as usize - 1) 462 | .map_or(false, |c| c.is_wide()) 463 | } 464 | } 465 | -------------------------------------------------------------------------------- /vendor/vt100/src/size.rs: -------------------------------------------------------------------------------- 1 | #[derive(Clone, Copy, Eq, PartialEq)] 2 | pub struct Size { 3 | pub width: u16, 4 | pub height: u16, 5 | } 6 | -------------------------------------------------------------------------------- /vendor/vt100/src/term.rs: -------------------------------------------------------------------------------- 1 | // TODO: read all of this from terminfo 2 | 3 | pub trait BufWrite { 4 | fn write_buf(&self, buf: &mut Vec); 5 | } 6 | 7 | #[derive(Default, Debug)] 8 | #[must_use = "this struct does nothing unless you call write_buf"] 9 | pub struct ClearScreen; 10 | 11 | impl BufWrite for ClearScreen { 12 | fn write_buf(&self, buf: &mut Vec) { 13 | buf.extend_from_slice(b"\x1b[H\x1b[J"); 14 | } 15 | } 16 | 17 | #[derive(Default, Debug)] 18 | #[must_use = "this struct does nothing unless you call write_buf"] 19 | pub struct ClearRowForward; 20 | 21 | impl BufWrite for ClearRowForward { 22 | fn write_buf(&self, buf: &mut Vec) { 23 | buf.extend_from_slice(b"\x1b[K"); 24 | } 25 | } 26 | 27 | #[derive(Default, Debug)] 28 | #[must_use = "this struct does nothing unless you call write_buf"] 29 | pub struct Crlf; 30 | 31 | impl BufWrite for Crlf { 32 | fn write_buf(&self, buf: &mut Vec) { 33 | buf.extend_from_slice(b"\r\n"); 34 | } 35 | } 36 | 37 | #[derive(Default, Debug)] 38 | #[must_use = "this struct does nothing unless you call write_buf"] 39 | pub struct Backspace; 40 | 41 | impl BufWrite for Backspace { 42 | fn write_buf(&self, buf: &mut Vec) { 43 | buf.extend_from_slice(b"\x08"); 44 | } 45 | } 46 | 47 | #[derive(Default, Debug)] 48 | #[must_use = "this struct does nothing unless you call write_buf"] 49 | pub struct SaveCursor; 50 | 51 | impl BufWrite for SaveCursor { 52 | fn write_buf(&self, buf: &mut Vec) { 53 | buf.extend_from_slice(b"\x1b7"); 54 | } 55 | } 56 | 57 | #[derive(Default, Debug)] 58 | #[must_use = "this struct does nothing unless you call write_buf"] 59 | pub struct RestoreCursor; 60 | 61 | impl BufWrite for RestoreCursor { 62 | fn write_buf(&self, buf: &mut Vec) { 63 | buf.extend_from_slice(b"\x1b8"); 64 | } 65 | } 66 | 67 | #[derive(Default, Debug)] 68 | #[must_use = "this struct does nothing unless you call write_buf"] 69 | pub struct MoveTo { 70 | row: u16, 71 | col: u16, 72 | } 73 | 74 | impl MoveTo { 75 | pub fn new(pos: crate::grid::Pos) -> Self { 76 | Self { 77 | row: pos.row, 78 | col: pos.col, 79 | } 80 | } 81 | } 82 | 83 | impl BufWrite for MoveTo { 84 | fn write_buf(&self, buf: &mut Vec) { 85 | if self.row == 0 && self.col == 0 { 86 | buf.extend_from_slice(b"\x1b[H"); 87 | } else { 88 | buf.extend_from_slice(b"\x1b["); 89 | extend_itoa(buf, self.row + 1); 90 | buf.push(b';'); 91 | extend_itoa(buf, self.col + 1); 92 | buf.push(b'H'); 93 | } 94 | } 95 | } 96 | 97 | #[derive(Default, Debug)] 98 | #[must_use = "this struct does nothing unless you call write_buf"] 99 | pub struct ClearAttrs; 100 | 101 | impl BufWrite for ClearAttrs { 102 | fn write_buf(&self, buf: &mut Vec) { 103 | buf.extend_from_slice(b"\x1b[m"); 104 | } 105 | } 106 | 107 | #[derive(Default, Debug)] 108 | #[must_use = "this struct does nothing unless you call write_buf"] 109 | pub struct Attrs { 110 | fgcolor: Option, 111 | bgcolor: Option, 112 | bold: Option, 113 | italic: Option, 114 | underline: Option, 115 | inverse: Option, 116 | } 117 | 118 | impl Attrs { 119 | pub fn fgcolor(mut self, fgcolor: crate::attrs::Color) -> Self { 120 | self.fgcolor = Some(fgcolor); 121 | self 122 | } 123 | 124 | pub fn bgcolor(mut self, bgcolor: crate::attrs::Color) -> Self { 125 | self.bgcolor = Some(bgcolor); 126 | self 127 | } 128 | 129 | pub fn bold(mut self, bold: bool) -> Self { 130 | self.bold = Some(bold); 131 | self 132 | } 133 | 134 | pub fn italic(mut self, italic: bool) -> Self { 135 | self.italic = Some(italic); 136 | self 137 | } 138 | 139 | pub fn underline(mut self, underline: bool) -> Self { 140 | self.underline = Some(underline); 141 | self 142 | } 143 | 144 | pub fn inverse(mut self, inverse: bool) -> Self { 145 | self.inverse = Some(inverse); 146 | self 147 | } 148 | } 149 | 150 | impl BufWrite for Attrs { 151 | #[allow(unused_assignments)] 152 | #[allow(clippy::branches_sharing_code)] 153 | fn write_buf(&self, buf: &mut Vec) { 154 | if self.fgcolor.is_none() 155 | && self.bgcolor.is_none() 156 | && self.bold.is_none() 157 | && self.italic.is_none() 158 | && self.underline.is_none() 159 | && self.inverse.is_none() 160 | { 161 | return; 162 | } 163 | 164 | buf.extend_from_slice(b"\x1b["); 165 | let mut first = true; 166 | 167 | macro_rules! write_param { 168 | ($i:expr) => { 169 | if first { 170 | first = false; 171 | } else { 172 | buf.push(b';'); 173 | } 174 | extend_itoa(buf, $i); 175 | }; 176 | } 177 | 178 | if let Some(fgcolor) = self.fgcolor { 179 | match fgcolor { 180 | crate::attrs::Color::Default => { 181 | write_param!(39); 182 | } 183 | crate::attrs::Color::Idx(i) => { 184 | if i < 8 { 185 | write_param!(i + 30); 186 | } else if i < 16 { 187 | write_param!(i + 82); 188 | } else { 189 | write_param!(38); 190 | write_param!(5); 191 | write_param!(i); 192 | } 193 | } 194 | crate::attrs::Color::Rgb(r, g, b) => { 195 | write_param!(38); 196 | write_param!(2); 197 | write_param!(r); 198 | write_param!(g); 199 | write_param!(b); 200 | } 201 | } 202 | } 203 | 204 | if let Some(bgcolor) = self.bgcolor { 205 | match bgcolor { 206 | crate::attrs::Color::Default => { 207 | write_param!(49); 208 | } 209 | crate::attrs::Color::Idx(i) => { 210 | if i < 8 { 211 | write_param!(i + 40); 212 | } else if i < 16 { 213 | write_param!(i + 92); 214 | } else { 215 | write_param!(48); 216 | write_param!(5); 217 | write_param!(i); 218 | } 219 | } 220 | crate::attrs::Color::Rgb(r, g, b) => { 221 | write_param!(48); 222 | write_param!(2); 223 | write_param!(r); 224 | write_param!(g); 225 | write_param!(b); 226 | } 227 | } 228 | } 229 | 230 | if let Some(bold) = self.bold { 231 | if bold { 232 | write_param!(1); 233 | } else { 234 | write_param!(22); 235 | } 236 | } 237 | 238 | if let Some(italic) = self.italic { 239 | if italic { 240 | write_param!(3); 241 | } else { 242 | write_param!(23); 243 | } 244 | } 245 | 246 | if let Some(underline) = self.underline { 247 | if underline { 248 | write_param!(4); 249 | } else { 250 | write_param!(24); 251 | } 252 | } 253 | 254 | if let Some(inverse) = self.inverse { 255 | if inverse { 256 | write_param!(7); 257 | } else { 258 | write_param!(27); 259 | } 260 | } 261 | 262 | buf.push(b'm'); 263 | } 264 | } 265 | 266 | #[derive(Debug)] 267 | #[must_use = "this struct does nothing unless you call write_buf"] 268 | pub struct MoveRight { 269 | count: u16, 270 | } 271 | 272 | impl MoveRight { 273 | pub fn new(count: u16) -> Self { 274 | Self { count } 275 | } 276 | } 277 | 278 | impl Default for MoveRight { 279 | fn default() -> Self { 280 | Self { count: 1 } 281 | } 282 | } 283 | 284 | impl BufWrite for MoveRight { 285 | fn write_buf(&self, buf: &mut Vec) { 286 | match self.count { 287 | 0 => {} 288 | 1 => buf.extend_from_slice(b"\x1b[C"), 289 | n => { 290 | buf.extend_from_slice(b"\x1b["); 291 | extend_itoa(buf, n); 292 | buf.push(b'C'); 293 | } 294 | } 295 | } 296 | } 297 | 298 | #[derive(Debug)] 299 | #[must_use = "this struct does nothing unless you call write_buf"] 300 | pub struct EraseChar { 301 | count: u16, 302 | } 303 | 304 | impl EraseChar { 305 | pub fn new(count: u16) -> Self { 306 | Self { count } 307 | } 308 | } 309 | 310 | impl Default for EraseChar { 311 | fn default() -> Self { 312 | Self { count: 1 } 313 | } 314 | } 315 | 316 | impl BufWrite for EraseChar { 317 | fn write_buf(&self, buf: &mut Vec) { 318 | match self.count { 319 | 0 => {} 320 | 1 => buf.extend_from_slice(b"\x1b[X"), 321 | n => { 322 | buf.extend_from_slice(b"\x1b["); 323 | extend_itoa(buf, n); 324 | buf.push(b'X'); 325 | } 326 | } 327 | } 328 | } 329 | 330 | #[derive(Default, Debug)] 331 | #[must_use = "this struct does nothing unless you call write_buf"] 332 | pub struct HideCursor { 333 | state: bool, 334 | } 335 | 336 | impl HideCursor { 337 | pub fn new(state: bool) -> Self { 338 | Self { state } 339 | } 340 | } 341 | 342 | impl BufWrite for HideCursor { 343 | fn write_buf(&self, buf: &mut Vec) { 344 | if self.state { 345 | buf.extend_from_slice(b"\x1b[?25l"); 346 | } else { 347 | buf.extend_from_slice(b"\x1b[?25h"); 348 | } 349 | } 350 | } 351 | 352 | #[derive(Debug)] 353 | #[must_use = "this struct does nothing unless you call write_buf"] 354 | pub struct MoveFromTo { 355 | from: crate::grid::Pos, 356 | to: crate::grid::Pos, 357 | } 358 | 359 | impl MoveFromTo { 360 | pub fn new(from: crate::grid::Pos, to: crate::grid::Pos) -> Self { 361 | Self { from, to } 362 | } 363 | } 364 | 365 | impl BufWrite for MoveFromTo { 366 | fn write_buf(&self, buf: &mut Vec) { 367 | if self.to.row == self.from.row + 1 && self.to.col == 0 { 368 | crate::term::Crlf::default().write_buf(buf); 369 | } else if self.from.row == self.to.row && self.from.col < self.to.col { 370 | crate::term::MoveRight::new(self.to.col - self.from.col).write_buf(buf); 371 | } else if self.to != self.from { 372 | crate::term::MoveTo::new(self.to).write_buf(buf); 373 | } 374 | } 375 | } 376 | 377 | #[derive(Default, Debug)] 378 | #[must_use = "this struct does nothing unless you call write_buf"] 379 | pub struct AudibleBell; 380 | 381 | impl BufWrite for AudibleBell { 382 | fn write_buf(&self, buf: &mut Vec) { 383 | buf.push(b'\x07'); 384 | } 385 | } 386 | 387 | #[derive(Default, Debug)] 388 | #[must_use = "this struct does nothing unless you call write_buf"] 389 | pub struct VisualBell; 390 | 391 | impl BufWrite for VisualBell { 392 | fn write_buf(&self, buf: &mut Vec) { 393 | buf.extend_from_slice(b"\x1bg"); 394 | } 395 | } 396 | 397 | #[must_use = "this struct does nothing unless you call write_buf"] 398 | pub struct ChangeTitle<'a> { 399 | icon_name: &'a str, 400 | title: &'a str, 401 | prev_icon_name: &'a str, 402 | prev_title: &'a str, 403 | } 404 | 405 | impl<'a> ChangeTitle<'a> { 406 | pub fn new( 407 | icon_name: &'a str, 408 | title: &'a str, 409 | prev_icon_name: &'a str, 410 | prev_title: &'a str, 411 | ) -> Self { 412 | Self { 413 | icon_name, 414 | title, 415 | prev_icon_name, 416 | prev_title, 417 | } 418 | } 419 | } 420 | 421 | impl<'a> BufWrite for ChangeTitle<'a> { 422 | fn write_buf(&self, buf: &mut Vec) { 423 | if self.icon_name == self.title 424 | && (self.icon_name != self.prev_icon_name 425 | || self.title != self.prev_title) 426 | { 427 | buf.extend_from_slice(b"\x1b]0;"); 428 | buf.extend_from_slice(self.icon_name.as_bytes()); 429 | buf.push(b'\x07'); 430 | } else { 431 | if self.icon_name != self.prev_icon_name { 432 | buf.extend_from_slice(b"\x1b]1;"); 433 | buf.extend_from_slice(self.icon_name.as_bytes()); 434 | buf.push(b'\x07'); 435 | } 436 | if self.title != self.prev_title { 437 | buf.extend_from_slice(b"\x1b]2;"); 438 | buf.extend_from_slice(self.title.as_bytes()); 439 | buf.push(b'\x07'); 440 | } 441 | } 442 | } 443 | } 444 | 445 | #[derive(Default, Debug)] 446 | #[must_use = "this struct does nothing unless you call write_buf"] 447 | pub struct ApplicationKeypad { 448 | state: bool, 449 | } 450 | 451 | impl ApplicationKeypad { 452 | pub fn new(state: bool) -> Self { 453 | Self { state } 454 | } 455 | } 456 | 457 | impl BufWrite for ApplicationKeypad { 458 | fn write_buf(&self, buf: &mut Vec) { 459 | if self.state { 460 | buf.extend_from_slice(b"\x1b="); 461 | } else { 462 | buf.extend_from_slice(b"\x1b>"); 463 | } 464 | } 465 | } 466 | 467 | #[derive(Default, Debug)] 468 | #[must_use = "this struct does nothing unless you call write_buf"] 469 | pub struct ApplicationCursor { 470 | state: bool, 471 | } 472 | 473 | impl ApplicationCursor { 474 | pub fn new(state: bool) -> Self { 475 | Self { state } 476 | } 477 | } 478 | 479 | impl BufWrite for ApplicationCursor { 480 | fn write_buf(&self, buf: &mut Vec) { 481 | if self.state { 482 | buf.extend_from_slice(b"\x1b[?1h"); 483 | } else { 484 | buf.extend_from_slice(b"\x1b[?1l"); 485 | } 486 | } 487 | } 488 | 489 | #[derive(Default, Debug)] 490 | #[must_use = "this struct does nothing unless you call write_buf"] 491 | pub struct BracketedPaste { 492 | state: bool, 493 | } 494 | 495 | impl BracketedPaste { 496 | pub fn new(state: bool) -> Self { 497 | Self { state } 498 | } 499 | } 500 | 501 | impl BufWrite for BracketedPaste { 502 | fn write_buf(&self, buf: &mut Vec) { 503 | if self.state { 504 | buf.extend_from_slice(b"\x1b[?2004h"); 505 | } else { 506 | buf.extend_from_slice(b"\x1b[?2004l"); 507 | } 508 | } 509 | } 510 | 511 | #[derive(Default, Debug)] 512 | #[must_use = "this struct does nothing unless you call write_buf"] 513 | pub struct MouseProtocolMode { 514 | mode: crate::screen::MouseProtocolMode, 515 | prev: crate::screen::MouseProtocolMode, 516 | } 517 | 518 | impl MouseProtocolMode { 519 | pub fn new( 520 | mode: crate::screen::MouseProtocolMode, 521 | prev: crate::screen::MouseProtocolMode, 522 | ) -> Self { 523 | Self { mode, prev } 524 | } 525 | } 526 | 527 | impl BufWrite for MouseProtocolMode { 528 | fn write_buf(&self, buf: &mut Vec) { 529 | if self.mode == self.prev { 530 | return; 531 | } 532 | 533 | match self.mode { 534 | crate::screen::MouseProtocolMode::None => match self.prev { 535 | crate::screen::MouseProtocolMode::None => {} 536 | crate::screen::MouseProtocolMode::Press => { 537 | buf.extend_from_slice(b"\x1b[?9l"); 538 | } 539 | crate::screen::MouseProtocolMode::PressRelease => { 540 | buf.extend_from_slice(b"\x1b[?1000l"); 541 | } 542 | crate::screen::MouseProtocolMode::ButtonMotion => { 543 | buf.extend_from_slice(b"\x1b[?1002l"); 544 | } 545 | crate::screen::MouseProtocolMode::AnyMotion => { 546 | buf.extend_from_slice(b"\x1b[?1003l"); 547 | } 548 | }, 549 | crate::screen::MouseProtocolMode::Press => { 550 | buf.extend_from_slice(b"\x1b[?9h"); 551 | } 552 | crate::screen::MouseProtocolMode::PressRelease => { 553 | buf.extend_from_slice(b"\x1b[?1000h"); 554 | } 555 | crate::screen::MouseProtocolMode::ButtonMotion => { 556 | buf.extend_from_slice(b"\x1b[?1002h"); 557 | } 558 | crate::screen::MouseProtocolMode::AnyMotion => { 559 | buf.extend_from_slice(b"\x1b[?1003h"); 560 | } 561 | } 562 | } 563 | } 564 | 565 | #[derive(Default, Debug)] 566 | #[must_use = "this struct does nothing unless you call write_buf"] 567 | pub struct MouseProtocolEncoding { 568 | encoding: crate::screen::MouseProtocolEncoding, 569 | prev: crate::screen::MouseProtocolEncoding, 570 | } 571 | 572 | impl MouseProtocolEncoding { 573 | pub fn new( 574 | encoding: crate::screen::MouseProtocolEncoding, 575 | prev: crate::screen::MouseProtocolEncoding, 576 | ) -> Self { 577 | Self { encoding, prev } 578 | } 579 | } 580 | 581 | impl BufWrite for MouseProtocolEncoding { 582 | fn write_buf(&self, buf: &mut Vec) { 583 | if self.encoding == self.prev { 584 | return; 585 | } 586 | 587 | match self.encoding { 588 | crate::screen::MouseProtocolEncoding::Default => match self.prev { 589 | crate::screen::MouseProtocolEncoding::Default => {} 590 | crate::screen::MouseProtocolEncoding::Utf8 => { 591 | buf.extend_from_slice(b"\x1b[?1005l"); 592 | } 593 | crate::screen::MouseProtocolEncoding::Sgr => { 594 | buf.extend_from_slice(b"\x1b[?1006l"); 595 | } 596 | }, 597 | crate::screen::MouseProtocolEncoding::Utf8 => { 598 | buf.extend_from_slice(b"\x1b[?1005h"); 599 | } 600 | crate::screen::MouseProtocolEncoding::Sgr => { 601 | buf.extend_from_slice(b"\x1b[?1006h"); 602 | } 603 | } 604 | } 605 | } 606 | 607 | fn extend_itoa(buf: &mut Vec, i: I) { 608 | let mut itoa_buf = itoa::Buffer::new(); 609 | buf.extend_from_slice(itoa_buf.format(i).as_bytes()); 610 | } 611 | -------------------------------------------------------------------------------- /vendor/vt100/src/term_reply.rs: -------------------------------------------------------------------------------- 1 | use compact_str::CompactString; 2 | 3 | pub trait TermReplySender { 4 | fn reply(&self, s: CompactString); 5 | } 6 | --------------------------------------------------------------------------------