├── .github └── workflows │ └── rust.yml ├── .gitignore ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── chronicler-cli ├── .gitignore ├── Cargo.toml ├── README.md ├── build.rs ├── resources │ ├── chronicler-cmd-history.png │ ├── chronicler-path-history.png │ └── chronicler-path-navigation.png ├── scripts │ └── bash-integration.sh └── src │ ├── history.rs │ ├── icons.json │ ├── main.rs │ ├── navigator.rs │ ├── utils.rs │ └── walk.rs ├── resources ├── demo.cast ├── demo.gif ├── demo.png ├── sway.png └── sweep.png ├── sweep-cli ├── Cargo.toml ├── build.rs └── src │ └── main.rs ├── sweep-lib ├── Cargo.toml ├── benches │ └── scorer.rs ├── examples │ ├── basic.rs │ └── simple.rs └── src │ ├── candidate.rs │ ├── common.rs │ ├── haystack.rs │ ├── icons.json │ ├── lib.rs │ ├── rank.rs │ ├── rpc.rs │ ├── scorer.rs │ ├── sweep.rs │ └── widgets.rs └── sweep-py ├── debug.py ├── pyproject.toml └── sweep ├── __init__.py ├── __main__.py ├── apps ├── __init__.py ├── bash_history.py ├── demo.py ├── kitty.py ├── launcher.py ├── mpd.py └── path_history.py ├── py.typed ├── sweep.py └── test.py /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Rust 2 | 3 | on: [push, pull_request] 4 | 5 | env: 6 | CARGO_TERM_COLOR: always 7 | 8 | jobs: 9 | test: 10 | name: Test Suite 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | rust: 15 | - stable 16 | - nightly 17 | 18 | steps: 19 | - name: Checkout sources 20 | uses: actions/checkout@v2 21 | 22 | - name: Install toolchain 23 | uses: actions-rs/toolchain@v1 24 | with: 25 | toolchain: ${{ matrix.rust }} 26 | override: true 27 | 28 | - name: Run cargo test 29 | uses: actions-rs/cargo@v1 30 | with: 31 | command: test 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | .mypy_cache 3 | __pycache__ 4 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "sweep-py", 9 | "type": "debugpy", 10 | "request": "launch", 11 | "cwd": "${workspaceFolder}/sweep-py", 12 | "program": "debug.py", 13 | "args": [ 14 | "kitty", 15 | "--sweep=cargo run --bin sweep --", 16 | "--input=${workspaceFolder}/Cargo.toml" 17 | ], 18 | "console": "integratedTerminal" 19 | }, 20 | { 21 | "name": "mpd", 22 | "type": "debugpy", 23 | "request": "launch", 24 | "cwd": "${workspaceFolder}/sweep-py", 25 | "program": "debug.py", 26 | "args": ["mpd", "--sweep", "cargo run --bin sweep --"], 27 | "console": "integratedTerminal" 28 | }, 29 | { 30 | // NOTE: 31 | // There is ptrace protection to disable: 32 | // echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope 33 | "name": "rust-attach", 34 | "type": "lldb", 35 | "request": "attach", 36 | "sourceLanguages": ["rust"], 37 | "pid": "${command:pickMyProcess}" 38 | }, 39 | { 40 | "name": "chronicler-cli", 41 | "type": "lldb", 42 | "request": "launch", 43 | "sourceLanguages": ["rust"], 44 | "cargo": { 45 | "args": ["build", "--bin=chronicler"], 46 | "filter": { 47 | "kind": "bin" 48 | } 49 | }, 50 | "program": "${workspaceFolder}/target/debug/chronicler", 51 | "args": [ 52 | // check target terminal with `tty` cmd, and run `sleep 3600` 53 | "--tty", 54 | "/dev/pts/1", 55 | "cmd" 56 | ], 57 | "cwd": "${workspaceFolder}", 58 | "console": "integratedTerminal" 59 | }, 60 | { 61 | "name": "sweep-cli", 62 | "type": "lldb", 63 | "request": "launch", 64 | "sourceLanguages": ["rust"], 65 | "cargo": { 66 | "args": ["build", "--bin=sweep"], 67 | "filter": { 68 | "kind": "bin" 69 | } 70 | }, 71 | "program": "${workspaceFolder}/target/debug/sweep", 72 | "args": [ 73 | "--input", 74 | "${workspaceFolder}/Cargo.toml", 75 | // check target terminal with `tty` cmd, and run `sleep 3600` 76 | "--tty", 77 | "/dev/pts/1", 78 | "--log", 79 | "/tmp/sweep.log" 80 | ], 81 | "cwd": "${workspaceFolder}", 82 | "env": { 83 | "RUST_LOG": "info" 84 | }, 85 | "console": "integratedTerminal" 86 | } 87 | ] 88 | } 89 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.analysis.typeCheckingMode": "strict", 3 | "python.testing.unittestArgs": ["-v", "-s", "./sweep", "-p", "*test*.py"], 4 | "python.testing.pytestEnabled": false, 5 | "python.testing.unittestEnabled": true, 6 | "python.analysis.extraPaths": ["${workspaceFolder}/sweep/sweep"], 7 | "python.analysis.diagnosticMode": "workspace", 8 | "cSpell.words": ["gruvbox", "isatty", "socketpair", "tabler"] 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "cargo build", 8 | "type": "shell", 9 | "command": "cargo build", 10 | "problemMatcher": [ 11 | "$rustc" 12 | ] 13 | } 14 | ] 15 | } -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["sweep-cli", "sweep-lib", "chronicler-cli"] 3 | resolver = "3" 4 | 5 | [workspace.package] 6 | authors = ["Pavel Aslanov "] 7 | edition = "2024" 8 | version = "0.26.0" 9 | repository = "https://github.com/aslpavel/sweep-rs" 10 | 11 | [workspace.dependencies] 12 | anyhow = { version = "^1.0" } 13 | argh = { version = "^0.1" } 14 | futures = { version = "^0.3" } 15 | mimalloc = { version = "^0.1", default-features = false } 16 | serde = { version = "^1.0", features = ["derive"] } 17 | serde_json = { version = "^1.0" } 18 | tokio = { version = "1", features = ["full"] } 19 | tracing = { version = "^0.1" } 20 | tracing-subscriber = { version = "^0.3", features = ["env-filter"] } 21 | 22 | surf_n_term = { version = "^0.18.0"} 23 | # surf_n_term = { path = "../surf-n-term" } 24 | 25 | sweep = { path = "sweep-lib" } 26 | 27 | [profile.release] 28 | lto = "fat" 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Pavel Aslanov 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 | # Sweep 2 | 3 | Sweep is a tool used to interactively search through a list of entries. It is inspired by [fzf](https://github.com/junegunn/fzf). 4 | ![screenshot](resources/sweep.png) 5 | 6 | ## Features 7 | 8 | - Fast 9 | - Beautiful 10 | - Easily customizable color palette by specifying only three main colors from which all other colors are derived. 11 | - JSON-RPC protocol can be used to communicate with sweep process. 12 | - Includes asyncio [python binding](sweep-py/sweep/sweep.py) 13 | - Configurable key bindings 14 | - Support for rendering custom icons/glyphs from SVG path (requires kitty-image or sixel protocol support) 15 | 16 | ## Usage 17 | 18 | ### Basic usage 19 | 20 |
21 | $ sweep --help 22 | 23 | ``` 24 | Usage: sweep [-p ] [--prompt-icon ] [--query ] [--theme ] [--nth ] [-d ] [--keep-order] [--scorer ] [--rpc] [--tty ] [--no-match ] [--title ] [--json] [--io-socket <io-socket>] [--input <input>] [--log <log>] [--preview <preview>] [--layout <layout>] [--version] 25 | 26 | Sweep is a command line fuzzy finder 27 | 28 | Options: 29 | -p, --prompt prompt string 30 | --prompt-icon prompt icon 31 | --query initial query string 32 | --theme theme `(light|dark),accent=<color>,fg=<color>,bg=<color>` 33 | --nth filed selectors (i.e `1,3..-1`) 34 | -d, --delimiter filed delimiter character 35 | --keep-order do not reorder candidates 36 | --scorer default scorer to rank items 37 | --rpc switch to remote-procedure-call mode 38 | --tty path to the TTY (default: /dev/tty) 39 | --no-match action when there is no match and enter is pressed 40 | --title set terminal title 41 | --json candidates in JSON pre line format (same encoding as RPC) 42 | --io-socket use unix socket (path or descriptor) instead of stdin/stdout 43 | --input read input from the file (ignored if --io-socket) 44 | --log log file (configure via RUST_LOG environment variable) 45 | --preview create preview subprocess, requires full layout 46 | --layout layout mode specified as `name(,attr=value)*` 47 | --version show sweep version and quit 48 | --help display usage information 49 | ``` 50 | 51 | </details> 52 | 53 | ### Key bindings 54 | 55 | Current key bindings can be viewed by pressing `ctrl+h`. 56 | 57 | <details> 58 | <summary>Default key bindings</summary> 59 | 60 | | Name | Key Bindings | Description | 61 | | --------------------- | ------------------------- | ----------------------------------------------- | 62 | | sweep.scorer.next | `ctrl+s` | Switch to next available scorer | 63 | | sweep.select | `ctrl+j` `ctrl+m` `enter` | Return item pointed by cursor | 64 | | sweep.quit | `ctrl+c` `esc` | Close sweep | 65 | | sweep.help | `ctrl+h` | Show help | 66 | | sweep.preview.toggle | `alt+p` | Toggle preview of an item | 67 | | input.move.forward | `right` | Move cursor forward in the input field | 68 | | input.move.backward | `left` | Move cursor backward in the input field | 69 | | input.move.end | `ctrl+e` | Move cursor to the end of the input field | 70 | | input.move.start | `ctrl+a` | Move cursor to the beginning of the input field | 71 | | input.move.next_word | `alt+f` | Move cursor to the end of the current word | 72 | | input.move.prev_word | `alt+b` | Move cursor to the start of the current word | 73 | | input.delete.backward | `backspace` | Delete character to the left | 74 | | input.delete.forward | `delete` | Delete character to the right | 75 | | input.delete.end | `ctrl+k` | Delete everything to the right | 76 | | list.item.next | `ctrl+n` `down` | Move to the next item in the list | 77 | | list.item.prev | `ctrl+p` `up` | Move to the previous item in the list | 78 | | list.page.next | `pagedown` | Move one page up | 79 | | list.page.prev | `pageup` | Move one page down | 80 | | list.home | `home` | Move to the beginning of the list | 81 | | list.end | `end` | Move to the end of the list | 82 | 83 | </details> 84 | 85 | ## Installation 86 | 87 | - Clone this repository 88 | - Install rust toolchain either with the package manager of your choice or with [rustup](https://rustup.rs/) 89 | - Build and install it with cargo (default installation path is $HOME/.cargo/bin/sweep make sure it is in your $PATH) 90 | 91 | ``` 92 | $ cargo install --path sweep 93 | ``` 94 | 95 | - Or build it and copy the binary 96 | 97 | ``` 98 | $ cargo build --release --bin=sweep 99 | $ cp target/release/sweep ~/.bin 100 | ``` 101 | 102 | - Test it 103 | 104 | ``` 105 | $ printf "one\ntwo\nthree" | sweep 106 | ``` 107 | 108 | - Enjoy 109 | 110 | ## Demo 111 | 112 | ![demo](resources/demo.gif) 113 | 114 | You can also run [`python -msweep demo`](sweep/sweep/apps/demo.py) to see different formatting and icon usage. Note that to render icons terminal needs to support support kitty-image or sixel. 115 | ![demo icons](resources/demo.png) 116 | 117 | ## JSON-RPC 118 | 119 | Sweep support [JSON-RPC](https://www.jsonrpc.org/specification) communication protocol for easy integration python and other languages. It is enabled by `--rpc` flag, by default standard input and output is used for communication but if `--io-socket` is passed it can be either file descriptor or path to a socket on the filesystem. 120 | 121 | ### Wire protocol 122 | 123 | Message is encoded as JSON per line 124 | 125 | ### Methods and Types 126 | 127 | #### Types 128 | 129 | ##### Item 130 | 131 | ``` 132 | Item = String 133 | | { 134 | target: [Field], 135 | right?: [Field], 136 | right_offset?: int = 0, 137 | right_face?: Face, 138 | preview?: [Field], 139 | preview_flex?: float = 0.0 140 | ... 141 | } 142 | ``` 143 | 144 | `Item` is a searchable item, it can either be a string or a dictionary, if it is a string then it is equivalent to `{"target": string }`. This format of item is also used when `--json` flag is passed to sweep even without `--rpc`. 145 | 146 | - `target` - Is a list of fields that is searchable shown on the left side 147 | - `right` - Is a list of fields that is show on the right side 148 | - `right_offset` - Is a number of columns that will be allocated for the right field. If it is not specified right field will take as much space as needed. 149 | - `right_face` - Face used to fill right view 150 | - `preview` - Additional information that will be shown when item is selected 151 | - `preview_flex` - Determines how much space is allocated for preview, if it equal to 1.0 then it will take half of the available space. If i t is 0.0, then it will take as much space as needed. 152 | - `...` - any additional fields are not parsed but are returned as a part of the result 153 | 154 | ##### Field 155 | 156 | ``` 157 | Field = String 158 | | [String, bool] 159 | | { 160 | text: String, 161 | active?: bool = True, 162 | glyph?: Icon, 163 | face?: Face, 164 | ref?: int 165 | } 166 | ``` 167 | 168 | `Field` is used to construct `Item`, it can either be a string, list or a dictionary. String is equivalent to having just `text` field in the dictionary. List is equivalent to having `text` and `active` field in the dictionary. 169 | 170 | - `text` - String content of the field 171 | - `active` - Whether string is searchable or not 172 | - `glyph` - Icon will be rendered in place of this field 173 | - `face` - Face used to render this field 174 | - `ref` - Reference to a field registered with `field_register`. If specified this field will inherit unspecified keys from registered field. It is useful when you want to avoid sending something like `Icon` multiple times. 175 | 176 | ##### Face 177 | 178 | ``` 179 | Face = "fg=#rrggbbaa,bg=#rrggbbaa,bold,italic,underline,blink,reverse,strike" 180 | ``` 181 | 182 | `Face` is text and glyph styling options, it is a comma separated string of text attributes. 183 | 184 | - `fg` - foreground color (text color) 185 | - `bg` - background color 186 | - `bold` - bold text 187 | - `italic` - italic text 188 | - `underline` - show underline below text 189 | - `blink` - blink text 190 | - `reverse` - swap foreground and background 191 | 192 | ##### Icon 193 | 194 | ``` 195 | Icon = { 196 | path?: String, 197 | view_box?: [float; 4], 198 | fill_rule?: "nonzero" | "evenodd", 199 | size?: [int; 2], 200 | } 201 | ``` 202 | 203 | `Icon` is an SVG path rendered as an icon, it is a dictionary with following attributes: 204 | 205 | - `path` - Path specified in the same format as `d` attribute of [SVG `path`](https://www.w3.org/TR/SVG11/paths.html). 206 | - `view_box` - [`minx`, `miny`, `width`, `height`] of the view port. If not specified bounding box with some padding is used. 207 | - `fill_rule` - same as SVG fill-rule. "nonzero" is a default. 208 | - `size` - [`rows`, `columns` ] how much space will be allocated for the icon. 209 | 210 | #### Methods 211 | 212 | | Method | Description | 213 | | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 214 | | `field_register(field: Field) -> int` | Register field that can be used as the base for the other field | 215 | | `items_extend(items: [Item])` | Extend list of searchable items | 216 | | `items_clear()` | Clear list of searchable items | 217 | | `items_current() -> Item?` | Get currently selected item if any | 218 | | `query_set(query: String)` | Set query string | 219 | | `query_get() -> String` | Get query string | 220 | | `terminate()` | Gracefully terminate sweep process | 221 | | `prompt_set(prompt: String, icon?: Icon)` | Set prompt string (label string before search input) | 222 | | `bind(key: String, tag: String)` | Assign new key binding. `key` is a space separated list of chords, `tag` can either be sweep a action, a user action (bind notification is send) or empty string which means to unbind | 223 | | `preview_set(value?: bool)` | Whether to show preview associated with the current item | 224 | 225 | #### Events 226 | 227 | Events are encoded as method calls coming from the sweep process without id 228 | | Event | Description | 229 | | - | -- | 230 | | `select(item: Item)` | Entry was selected by pressing `Enter` ("sweep.select" action) | 231 | | `bind(tag: String)` | Key binding was pressed, with previously registered key binding | 232 | | `ready(version: [String])` | Sent on initialization of sweep peer | 233 | -------------------------------------------------------------------------------- /chronicler-cli/.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /chronicler-cli/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "chronicler-cli" 3 | build = "build.rs" 4 | authors.workspace = true 5 | edition.workspace = true 6 | version.workspace = true 7 | repository.workspace = true 8 | 9 | [[bin]] 10 | name = "chronicler" 11 | path = "src/main.rs" 12 | 13 | [dependencies] 14 | anyhow = { workspace = true } 15 | argh = { workspace = true } 16 | futures = { workspace = true } 17 | mimalloc = { workspace = true } 18 | serde = { workspace = true } 19 | serde_json = { workspace = true } 20 | sweep = { workspace = true } 21 | tokio = { workspace = true } 22 | tracing = { workspace = true } 23 | tracing-subscriber = { workspace = true } 24 | 25 | async-trait = "^0.1" 26 | dirs = "^6.0" 27 | globset = "^0.4" 28 | sqlx = { version = "^0.8", features = ["runtime-tokio", "sqlite"] } 29 | time = { version = "^0.3", features = ["macros"] } 30 | tokio-stream = { version = "0.1.16", features = ["fs"] } 31 | tracing-appender = "^0.2" 32 | unix_mode = "^0.1" 33 | uzers = "^0.12" 34 | -------------------------------------------------------------------------------- /chronicler-cli/README.md: -------------------------------------------------------------------------------- 1 | # Chronicler 2 | 3 | Chronicler is a tool that is used to track history of commands executed in a shell, visited directories 4 | and quick navigation and execution of commands from the history. 5 | 6 | ## Installation 7 | 8 | - Build and install with `cargo install` 9 | - Add this line to your `.bashrc`: `source <(chronicler setup bash)` 10 | 11 | ## Usage 12 | 13 | Pressing `ctrl+r` in a bash shell opens commands history. 14 | <img src="resources/chronicler-cmd-history.png" width="600" /> 15 | 16 | Pressing `ctrl+f` in a bash shell opens path history 17 | <img src="resources/chronicler-path-history.png" width="600" /> 18 | 19 | While in path history pressing `tab` opens will start navigation in the selected directory 20 | <img src="resources/chronicler-path-navigation.png" width="600" /> 21 | 22 | Theme can be set by exporting `SWEEP_THEME` environment variable to `accent=<color>,bg=<color>,fg=<color>` where color is `#RRGGBB`. 23 | -------------------------------------------------------------------------------- /chronicler-cli/build.rs: -------------------------------------------------------------------------------- 1 | fn main() -> Result<(), Box<dyn std::error::Error>> { 2 | let commit_info = std::process::Command::new("git") 3 | .args(["show", "-s", "--format=%h %ci"]) 4 | .output() 5 | .map(|output| output.stdout) 6 | .unwrap_or_default(); 7 | println!( 8 | "cargo:rustc-env=COMMIT_INFO={}", 9 | std::str::from_utf8(&commit_info)? 10 | ); 11 | Ok(()) 12 | } 13 | -------------------------------------------------------------------------------- /chronicler-cli/resources/chronicler-cmd-history.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aslpavel/sweep-rs/df51db07262edf4d983c23a7b34c101192667a65/chronicler-cli/resources/chronicler-cmd-history.png -------------------------------------------------------------------------------- /chronicler-cli/resources/chronicler-path-history.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aslpavel/sweep-rs/df51db07262edf4d983c23a7b34c101192667a65/chronicler-cli/resources/chronicler-path-history.png -------------------------------------------------------------------------------- /chronicler-cli/resources/chronicler-path-navigation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aslpavel/sweep-rs/df51db07262edf4d983c23a7b34c101192667a65/chronicler-cli/resources/chronicler-path-navigation.png -------------------------------------------------------------------------------- /chronicler-cli/scripts/bash-integration.sh: -------------------------------------------------------------------------------- 1 | _chronicler_bin="##CHRONICLER_BIN##" 2 | _chronicler_db=$("$_chronicler_bin" update --show-db-path) 3 | 4 | # chronicler session 5 | if [ -f /proc/sys/kernel/random/uuid ]; then 6 | _chronicler_session=$(< /proc/sys/kernel/random/uuid) 7 | else 8 | _chronicler_session=$(dd if=/dev/urandom bs=33 count=1 2>/dev/null | base64) 9 | fi 10 | 11 | # create pipe bound to fd=42 to send id from `_chronicler_hist_start` to `_chronicler_hist_end` 12 | _chronicler_pipe="/tmp/chronicler-$_chronicler_session.pipe" 13 | mkfifo "$_chronicler_pipe" 14 | exec 42<>"$_chronicler_pipe" 15 | rm -f "$_chronicler_pipe" 16 | unset _chronicler_pipe 17 | 18 | # get currently executing command 19 | function _chronicler_curr_cmd() { 20 | local last_cmd 21 | last_cmd=$(HISTTIMEFORMAT="" builtin history 1) 22 | last_cmd="${last_cmd##*([[:space:]])+([[:digit:]])+([[:space:]])}" # remove leading history number and spaces 23 | builtin printf "%s" "${last_cmd//[[:cntrl:]]}" # remove any control characters 24 | } 25 | 26 | # create entry in the chronicler database 27 | function _chronicler_hist_start { 28 | local curr_cmd hist_id 29 | curr_cmd=$(_chronicler_curr_cmd) 30 | if [[ "$_chronicler_prev_cmd" == "$curr_cmd" ]]; then 31 | return 32 | fi 33 | _chronicler_prev_cmd="$curr_cmd" 34 | local now="${EPOCHREALTIME:-$(date +%s.01)}" 35 | local __sep__=$'\x0c' 36 | hist_id=$("$_chronicler_bin" update <<-EOF 37 | cmd 38 | $curr_cmd 39 | $__sep__ 40 | cwd 41 | $PWD 42 | $__sep__ 43 | hostname 44 | $HOSTNAME 45 | $__sep__ 46 | user 47 | ${USER:-$(id -un)} 48 | $__sep__ 49 | start_ts 50 | $now 51 | $__sep__ 52 | end_ts 53 | $now 54 | $__sep__ 55 | session 56 | $_chronicler_session 57 | EOF 58 | ) 59 | printf "%s\n" "$hist_id" >&42 60 | } 61 | if [[ ! $PS0 =~ "_chronicler_hist_start" ]]; then 62 | PS0="${PS0}\$(_chronicler_hist_start)" 63 | fi 64 | 65 | # update entry in the chronicler database 66 | function _chronicler_hist_end { 67 | local return_value="$?" # keep this the first command 68 | local hist_id 69 | read -r -t 0.01 -u 42 hist_id 70 | if [[ -z "$hist_id" ]]; then 71 | return 72 | fi 73 | local now=${EPOCHREALTIME:-$(date +%s.01)} 74 | local __sep__=$'\x0c' 75 | "$_chronicler_bin" update <<-EOF > /dev/null 76 | id 77 | $hist_id 78 | $__sep__ 79 | end_ts 80 | $now 81 | $__sep__ 82 | return 83 | $return_value 84 | EOF 85 | } 86 | if [[ ! " ${PROMPT_COMMAND[*]} " =~ ' _chronicler_hist_end ' ]]; then 87 | PROMPT_COMMAND+=(_chronicler_hist_end) 88 | fi 89 | 90 | function _chronicler_readline_extend { 91 | if [[ -z "$READLINE_LINE" ]]; then 92 | READLINE_LINE=$1 93 | else 94 | READLINE_LINE="${READLINE_LINE}; $1" 95 | fi 96 | } 97 | 98 | function _chronicler_complete { 99 | READLINE_LINE="" 100 | IFS=$'\x0c' read -ra items <<< "$1" 101 | local tag item item_escape mimetype 102 | for item in "${items[@]}"; do 103 | tag="${item:0:2}" 104 | item="${item:2}" 105 | item_escape=$(printf "%q" "${item}") 106 | case "$tag" in 107 | D=) _chronicler_readline_extend "cd $item_escape";; 108 | F=) 109 | if [[ ! -e $item ]]; then 110 | continue 111 | fi 112 | mimetype=$(file --mime-type --dereference --brief "$item") 113 | if [[ $mimetype == text/* || $mimetype == "application/json" ]]; then 114 | _chronicler_readline_extend "${EDITOR:-emacs} $item_escape" 115 | else 116 | case "$OSTYPE" in 117 | darwin*) 118 | # MacOS 119 | _chronicler_readline_extend "open $item_escape" 120 | ;; 121 | linux*|bsd*) 122 | # Linux | BSD 123 | _chronicler_readline_extend "xdg-open $item_escape" 124 | ;; 125 | msys*|cygwin*) 126 | # Windows 127 | ;; 128 | esac 129 | fi 130 | ;; 131 | R=) 132 | _chronicler_readline_extend "$item" 133 | ;; 134 | esac 135 | done 136 | READLINE_MARK=0 137 | READLINE_POINT=${#READLINE_LINE} 138 | } 139 | 140 | # bind cmd history 141 | function _chronicler_hist_show { 142 | _chronicler_complete "$("$_chronicler_bin" --query "$READLINE_LINE" cmd)" 143 | } 144 | bind -x '"\C-r": _chronicler_hist_show' 145 | 146 | # bind path history 147 | function _chronicler_path_show { 148 | _chronicler_complete "$("$_chronicler_bin" --query "$READLINE_LINE" path)" 149 | } 150 | bind -x '"\C-f": _chronicler_path_show' 151 | -------------------------------------------------------------------------------- /chronicler-cli/src/history.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | navigator::{FAILED_ICON, FOLDER_ICON, NavigatorContext}, 3 | utils::Table, 4 | }; 5 | 6 | use super::DATE_FORMAT; 7 | use anyhow::{Context, Error}; 8 | use futures::{Stream, TryStreamExt}; 9 | use serde::{Deserialize, Serialize}; 10 | use sqlx::{ 11 | FromRow, SqlitePool, 12 | sqlite::{SqliteConnectOptions, SqliteJournalMode}, 13 | }; 14 | use std::path::Path; 15 | use std::{fmt::Write, str::FromStr}; 16 | use sweep::{ 17 | Haystack, HaystackBasicPreview, HaystackDefaultView, Theme, 18 | surf_n_term::{ 19 | CellWrite, Face, FaceAttrs, 20 | view::{Align, Container, Flex, Justify, Text}, 21 | }, 22 | }; 23 | 24 | #[derive(Clone, Debug, FromRow)] 25 | pub struct HistoryEntry { 26 | #[allow(unused)] 27 | pub id: i64, 28 | pub cmd: String, 29 | #[sqlx(rename = "return")] 30 | pub status: i64, 31 | pub cwd: String, 32 | pub hostname: String, 33 | pub user: String, 34 | pub start_ts: f64, 35 | pub end_ts: f64, 36 | pub session: String, 37 | } 38 | 39 | impl HistoryEntry { 40 | fn start_dt(&self) -> Result<time::OffsetDateTime, Error> { 41 | let timestamp = (self.start_ts * 1e9) as i128; 42 | Ok(time::OffsetDateTime::from_unix_timestamp_nanos(timestamp)?) 43 | } 44 | } 45 | 46 | impl Haystack for HistoryEntry { 47 | type Context = NavigatorContext; 48 | type View = Flex<'static>; 49 | type Preview = HaystackBasicPreview<Container<Flex<'static>>>; 50 | type PreviewLarge = (); 51 | 52 | fn haystack_scope<S>(&self, _ctx: &Self::Context, scope: S) 53 | where 54 | S: FnMut(char), 55 | { 56 | self.cmd.chars().for_each(scope) 57 | } 58 | 59 | fn view( 60 | &self, 61 | ctx: &Self::Context, 62 | positions: sweep::Positions<&[u8]>, 63 | theme: &Theme, 64 | ) -> Self::View { 65 | let cmd = HaystackDefaultView::new(ctx, self, positions, theme); 66 | 67 | let mut right = Text::new(); 68 | if self.cwd == *ctx.cwd { 69 | right.scope(|text| { 70 | text.set_face(Face::new(Some(theme.accent), None, FaceAttrs::EMPTY)); 71 | text.put_glyph(FOLDER_ICON.clone()); 72 | }); 73 | } 74 | if self.status != 0 { 75 | right.scope(|text| { 76 | text.set_face(Face::new(Some(theme.accent), None, FaceAttrs::EMPTY)); 77 | text.put_glyph(FAILED_ICON.clone()); 78 | }); 79 | } 80 | if !theme.show_preview { 81 | if let Ok(date) = self 82 | .start_dt() 83 | .and_then(|date| Ok(date.format(DATE_FORMAT)?)) 84 | { 85 | right 86 | .put_fmt(&date, Some(theme.list_inactive)) 87 | .put_char(' '); 88 | } 89 | } 90 | 91 | Flex::row() 92 | .justify(Justify::SpaceBetween) 93 | .add_flex_child(1.0, cmd) 94 | .add_child(right) 95 | } 96 | 97 | fn preview( 98 | &self, 99 | _ctx: &Self::Context, 100 | _positions: sweep::Positions<&[u8]>, 101 | theme: &Theme, 102 | ) -> Option<Self::Preview> { 103 | let mut text = Text::new(); 104 | text.set_face(theme.list_selected); 105 | (|| { 106 | writeln!(&mut text, "Status : {}", self.status)?; 107 | if let Ok(date) = self.start_dt() { 108 | writeln!(&mut text, "Date : {}", date.format(&DATE_FORMAT)?)?; 109 | } 110 | writeln!(&mut text, "Duration : {:.3}s", self.end_ts - self.start_ts)?; 111 | writeln!(&mut text, "Directory: {}", self.cwd)?; 112 | writeln!(&mut text, "User : {}", self.user)?; 113 | writeln!(&mut text, "Hostname : {}", self.hostname)?; 114 | Ok::<_, anyhow::Error>(()) 115 | })() 116 | .expect("in memory write failed"); 117 | 118 | let left_face = Face::default() 119 | // .with_fg(Some(theme.accent)) 120 | // .with_bg(Some(theme.accent.with_alpha(0.05))) 121 | .with_attrs(FaceAttrs::BOLD); 122 | let mut table = Table::new(10, Some(left_face), None); 123 | table.push( 124 | Text::new().with_fmt("Status", None), 125 | Text::new().with_fmt(&format_args!("{}", self.status), None), 126 | ); 127 | if let Some(date) = self 128 | .start_dt() 129 | .ok() 130 | .and_then(|date| date.format(&DATE_FORMAT).ok()) 131 | { 132 | table.push( 133 | Text::new().with_fmt("Date", None), 134 | Text::new().with_fmt(date.as_str(), None), 135 | ) 136 | } 137 | table.push( 138 | Text::new().with_fmt("Duration", None), 139 | Text::new().with_fmt(&format_args!("{:.3}s", self.end_ts - self.start_ts), None), 140 | ); 141 | table.push( 142 | Text::new().with_fmt("User", None), 143 | Text::new().with_fmt(&self.user, None), 144 | ); 145 | table.push( 146 | Text::new().with_fmt("Hostname", None), 147 | Text::new().with_fmt(&self.hostname, None), 148 | ); 149 | table.push( 150 | Text::new().with_fmt("Directory", None), 151 | Text::new().with_fmt(&self.cwd, None), 152 | ); 153 | 154 | let view = Container::new(table) 155 | .with_horizontal(Align::Expand) 156 | .with_vertical(Align::Expand) 157 | .with_color(theme.list_selected.bg.unwrap_or(theme.bg)); 158 | Some(HaystackBasicPreview::new(view, Some(0.7))) 159 | } 160 | } 161 | 162 | #[derive(Clone, Debug, Serialize, Deserialize, Default, PartialEq)] 163 | #[serde(default)] 164 | pub struct HistoryUpdate { 165 | pub id: Option<i64>, 166 | pub cmd: Option<String>, 167 | pub status: Option<i64>, 168 | pub cwd: Option<String>, 169 | pub hostname: Option<String>, 170 | pub user: Option<String>, 171 | pub start_ts: Option<f64>, 172 | pub end_ts: Option<f64>, 173 | pub session: Option<String>, 174 | } 175 | 176 | impl FromStr for HistoryUpdate { 177 | type Err = Error; 178 | 179 | fn from_str(s: &str) -> Result<Self, Self::Err> { 180 | let mut result = HistoryUpdate::default(); 181 | for kv in s.split('\x0c') { 182 | let mut kv = kv.trim().splitn(2, '\n'); 183 | let key = kv.next(); 184 | let value = kv.next().map(|val| val.trim()); 185 | match (key, value) { 186 | (Some("id"), Some(val)) => result.id = Some(val.parse()?), 187 | (Some("cmd"), Some(val)) => result.cmd = Some(val.to_owned()), 188 | (Some("status" | "return"), Some(val)) => result.status = Some(val.parse()?), 189 | (Some("cwd"), Some(val)) => result.cwd = Some(val.to_owned()), 190 | (Some("hostname"), Some(val)) => result.hostname = Some(val.to_owned()), 191 | (Some("user"), Some(val)) => result.user = Some(val.to_owned()), 192 | (Some("start_ts"), Some(val)) => result.start_ts = Some(val.parse()?), 193 | (Some("end_ts"), Some(val)) => result.end_ts = Some(val.parse()?), 194 | (Some("session"), Some(val)) => result.session = Some(val.to_owned()), 195 | (Some(key), _) => { 196 | return Err(anyhow::anyhow!("history update invalid key \"{key}\"")); 197 | } 198 | _ => continue, 199 | } 200 | } 201 | Ok(result) 202 | } 203 | } 204 | 205 | #[derive(Debug, Clone, FromRow)] 206 | pub struct PathEntry { 207 | pub path: String, 208 | pub count: i64, 209 | } 210 | 211 | #[derive(Clone)] 212 | pub struct History { 213 | pool: SqlitePool, 214 | } 215 | 216 | impl History { 217 | pub async fn new(path: impl AsRef<Path>) -> Result<Self, Error> { 218 | let options = SqliteConnectOptions::new() 219 | .journal_mode(SqliteJournalMode::Wal) 220 | .create_if_missing(true) 221 | .filename(path) 222 | .thread_name(|index| format!("hist-sqlite-{index}")) 223 | .optimize_on_close(true, None); 224 | let pool = SqlitePool::connect_lazy_with(options); 225 | sqlx::query(CREATE_TABLE_QUERY) 226 | .execute(&mut *pool.acquire().await?) 227 | .await?; 228 | Ok(Self { pool }) 229 | } 230 | 231 | /// Close database, on drop it will be closed anyway but there is not way to 232 | /// return an error. 233 | pub async fn close(self) -> Result<(), Error> { 234 | self.pool.close().await; 235 | Ok(()) 236 | } 237 | 238 | /// All history entries in the database 239 | #[allow(dead_code)] 240 | pub fn entries(&self) -> impl Stream<Item = Result<HistoryEntry, Error>> + '_ { 241 | sqlx::query_as(LIST_QUERY) 242 | .fetch(&self.pool) 243 | .map_err(Error::from) 244 | } 245 | 246 | pub fn entries_session( 247 | &self, 248 | session: String, 249 | ) -> impl Stream<Item = Result<HistoryEntry, Error>> + '_ { 250 | sqlx::query_as(LIST_SESSION_QUERY) 251 | .bind(session) 252 | .fetch(&self.pool) 253 | .map_err(Error::from) 254 | } 255 | 256 | pub fn entries_unique_cmd(&self) -> impl Stream<Item = Result<HistoryEntry, Error>> + '_ { 257 | sqlx::query_as(LIST_UNIQUE_CMD_QUERY) 258 | .fetch(&self.pool) 259 | .map_err(Error::from) 260 | } 261 | 262 | pub fn path_entries(&self) -> impl Stream<Item = Result<PathEntry, Error>> + '_ { 263 | sqlx::query_as(PATH_QUERY) 264 | .fetch(&self.pool) 265 | .map_err(Error::from) 266 | } 267 | 268 | /// Update/Create new entry 269 | /// 270 | /// New entry is added if id is not specified, otherwise it is updated 271 | pub async fn update(&self, entry: HistoryUpdate) -> Result<i64, Error> { 272 | let mut conn = self.pool.acquire().await?; 273 | match entry.id { 274 | None => { 275 | let result = sqlx::query(INSERT_QUERY) 276 | .bind(entry.cmd) 277 | .bind(entry.status) 278 | .bind(entry.cwd) 279 | .bind(entry.hostname) 280 | .bind(entry.user) 281 | .bind(entry.start_ts) 282 | .bind(entry.end_ts) 283 | .bind(entry.session) 284 | .execute(&mut *conn) 285 | .await 286 | .context("insert query")?; 287 | Ok(result.last_insert_rowid()) 288 | } 289 | Some(id) => { 290 | sqlx::query(UPDATE_QUERY) 291 | .bind(id) 292 | .bind(entry.cmd) 293 | .bind(entry.status) 294 | .bind(entry.cwd) 295 | .bind(entry.hostname) 296 | .bind(entry.user) 297 | .bind(entry.start_ts) 298 | .bind(entry.end_ts) 299 | .bind(entry.session) 300 | .execute(&mut *conn) 301 | .await 302 | .context("update query")?; 303 | Ok(id) 304 | } 305 | } 306 | } 307 | } 308 | 309 | const CREATE_TABLE_QUERY: &str = r#" 310 | -- main history table 311 | CREATE TABLE IF NOT EXISTS history ( 312 | id INTEGER PRIMARY KEY, 313 | cmd TEXT, 314 | return INTEGER, 315 | cwd TEXT, 316 | hostname TEXT, 317 | user TEXT, 318 | start_ts REAL, 319 | end_ts REAL, 320 | session TEXT, 321 | duration REAL AS (end_ts - start_ts) VIRTUAL 322 | ) STRICT; 323 | 324 | -- index to speed up retrieval of most frequent paths 325 | CREATE INDEX IF NOT EXISTS history_cwd ON history(cwd, end_ts); 326 | CREATE INDEX IF NOT EXISTS history_end_ts ON history(end_ts); 327 | "#; 328 | 329 | const LIST_QUERY: &str = r#" 330 | SELECT * FROM history ORDER BY end_ts DESC; 331 | "#; 332 | 333 | const LIST_SESSION_QUERY: &str = r#" 334 | SELECT * FROM history WHERE session = $1 ORDER BY end_ts DESC; 335 | "#; 336 | 337 | const LIST_UNIQUE_CMD_QUERY: &str = r#" 338 | SELECT * 339 | FROM history h1 340 | JOIN ( 341 | SELECT cmd, MAX(end_ts) as max_ts 342 | FROM history 343 | GROUP BY cmd 344 | ) h2 345 | ON h1.cmd = h2.cmd AND h1.end_ts = h2.max_ts 346 | ORDER BY abs(return), end_ts DESC; 347 | "#; 348 | 349 | const PATH_QUERY: &str = r#" 350 | SELECT cwd as path, COUNT(cwd) as count FROM history GROUP BY cwd ORDER BY COUNT(cwd) DESC; 351 | "#; 352 | 353 | const INSERT_QUERY: &str = r#" 354 | INSERT INTO history (cmd, return, cwd, hostname, user, start_ts, end_ts, session) 355 | VALUES ( 356 | $1, -- cmd 357 | COALESCE($2, -1), -- return 358 | $3, -- cwd 359 | $4, -- hostname 360 | $5, -- user 361 | $6, -- start_ts 362 | COALESCE($7, $6), -- end_ts 363 | $8 -- session 364 | ); 365 | "#; 366 | 367 | const UPDATE_QUERY: &str = r#" 368 | UPDATE history SET 369 | cmd = COALESCE($2, cmd), 370 | return = COALESCE($3, return), 371 | cwd = COALESCE($4, cwd), 372 | hostname = COALESCE($5, hostname), 373 | user = COALESCE($6, user), 374 | start_ts = COALESCE($7, start_ts), 375 | end_ts = COALESCE($8, end_ts), 376 | session = COALESCE($9, session) 377 | WHERE 378 | id=$1; 379 | "#; 380 | -------------------------------------------------------------------------------- /chronicler-cli/src/icons.json: -------------------------------------------------------------------------------- 1 | { 2 | "path-history": { 3 | "name": "tabler-folder-heart", 4 | "view_box": [0, 0, 100, 100], 5 | "size": [1, 3], 6 | "fallback": " ", 7 | "path": "M17.75,8.9Q17.75,8.9 17.95,8.9Q22.75,8.8 27.25,8.8L36.65,8.9Q37.45,9.1 38.35,9.9Q39.25,10.7 43.75,15.3L49.75,21.1L64.15,21.1Q74.65,21.1 77.25,21.2Q79.25,21.3 80.35,21.7L80.85,21.8Q83.55,22.7 85.6,24.75Q87.65,26.8 88.55,29.5L88.65,29.7Q89.05,31 89.15,31.9Q89.25,33.1 89.25,36.9Q89.25,42.1 89.15,42.5Q89.05,43.3 88.45,44.2Q87.25,45.8 85.05,45.8Q83.95,45.8 83.25,45.5Q81.95,44.9 81.25,43.2Q81.05,42.8 80.95,42Q80.95,40.9 80.95,37.6L80.85,32.4Q80.25,31 79.45,30.4Q78.95,30 77.65,29.5L46.75,29.3Q46.05,29 45.2,28.2Q44.35,27.4 39.75,22.8L33.75,17L26.45,17Q18.95,17 18.55,17.1Q17.05,17.3 16.15,18.4Q15.55,19 15.05,20.2L14.95,43.6Q14.95,66.7 15.2,67.7Q15.45,68.7 16.3,69.5Q17.15,70.3 18.25,70.5Q18.65,70.6 30.65,70.6L40.95,70.7Q42.55,70.7 42.95,70.8Q44.45,71.4 45.1,72.25Q45.75,73.1 45.75,74.85Q45.75,76.6 44.75,77.6Q44.15,78.2 42.75,78.8L30.45,78.9Q17.85,78.9 17.25,78.8Q14.55,78.4 12.15,76.8Q8.15,74.1 6.85,68.8Q6.75,68.4 6.75,66.9L6.75,20.7Q6.75,19.3 6.85,18.9Q7.55,15.5 9.75,13Q11.55,11 14.05,9.8Q16.15,8.9 17.75,8.9ZM72.35,56.1Q72.35,56.1 72.55,56.2Q72.75,56.3 72.95,56.2Q77.35,53.6 82.15,54.35Q86.95,55.1 90.05,58.6Q93.25,62.2 93.25,67.2Q93.25,70.7 91.75,73.5Q90.85,75.1 89.05,76.9Q86.75,79.1 81,84.7Q75.25,90.3 74.95,90.5Q74.05,91.2 72.7,91.2Q71.35,91.2 70.45,90.5Q70.05,90.3 62.85,83.3Q55.65,76.3 55.15,75.6Q51.85,71.7 52.05,66.9Q52.15,62.5 55.2,58.9Q58.25,55.3 62.65,54.4Q67.55,53.4 72.35,56.1ZM63.95,62.6Q61.65,63.3 60.75,65.35Q59.85,67.4 60.85,69.4Q61.05,69.8 61.65,70.4L72.65,81.3L83.95,70.3Q84.45,69.8 84.55,69.4L84.55,69.4Q85.35,67.9 84.95,66.3Q84.55,64.7 83.25,63.6Q81.95,62.5 80.15,62.5Q78.75,62.5 77.65,63.1Q76.95,63.5 75.95,64.4Q74.95,65.3 74.35,65.6Q73.35,66.1 72.15,65.9Q71.15,65.7 70.45,65.2Q70.05,65 69.4,64.3Q68.75,63.6 68.25,63.3Q67.55,62.8 66.55,62.6Q66.05,62.5 65.3,62.5Q64.55,62.5 63.95,62.6Z" 8 | }, 9 | "path-navigation": { 10 | "name": "tabler-folder-search", 11 | "view_box": [0, 0, 100, 100], 12 | "size": [1, 3], 13 | "fallback": " ", 14 | "path": "M17.75,8.9Q17.75,8.9 17.95,8.9Q22.75,8.8 27.25,8.8L36.65,8.9Q37.45,9.1 38.35,9.9Q39.25,10.7 43.75,15.3L49.75,21.1L76.55,21.2Q78.85,21.2 79.35,21.3Q81.95,22 84.05,23.5Q88.05,26.3 89.05,31.5Q89.15,32 89.15,38.4L89.15,42.4Q89.15,44.8 89.05,45.1Q88.45,47 86.75,47.65Q85.05,48.3 83.4,47.6Q81.75,46.9 81.15,45.1Q80.95,44.7 80.95,43.8L80.95,38.6L80.85,32.4Q80.25,31 79.45,30.4Q78.95,30 77.65,29.5L46.75,29.3Q46.05,29 45.2,28.2Q44.35,27.4 39.75,22.8L33.75,17L26.45,17Q18.95,17 18.55,17.1Q17.05,17.3 16.15,18.4Q15.55,19 15.05,20.2L14.95,43.6Q14.95,66.7 15.15,67.7Q15.85,69.7 17.85,70.4Q18.25,70.6 31.55,70.6L44.85,70.6L45.55,71Q46.95,71.7 47.55,73.05Q48.15,74.4 47.8,75.85Q47.45,77.3 46.15,78.2L45.95,78.3Q45.55,78.6 44.85,78.7Q43.95,78.8 41.05,78.8L21.15,78.8Q18.15,78.8 17.05,78.7Q16.25,78.6 15.35,78.3L15.15,78.3Q11.95,77.2 9.75,74.65Q7.55,72.1 6.85,68.8Q6.75,68.4 6.75,66.9L6.75,20.7Q6.75,19.3 6.85,18.9Q7.55,15.5 9.75,13Q11.55,11 14.05,9.8Q16.15,8.9 17.75,8.9ZM93.25,87.1Q93.25,88.9 92.1,90.05Q90.95,91.2 89.15,91.2Q88.25,91.2 87.75,91.05Q87.25,90.9 86.35,90.1Q85.75,89.6 83.85,87.7L81.05,84.9L80.05,85.4Q75.95,87.4 71.5,87.05Q67.05,86.7 63.35,84.25Q59.65,81.8 57.85,77.8Q55.45,72.8 56.45,67.6Q57.45,62.8 60.95,59.15Q64.45,55.5 69.35,54.5Q74.55,53.4 79.85,55.8Q83.05,57.3 85.45,60.2Q89.15,64.6 89.15,70.6Q89.15,74.8 87.55,78L86.95,79L89.75,81.8Q91.65,83.7 92.15,84.3Q92.95,85.2 93.1,85.7Q93.25,86.2 93.25,87.1ZM70.95,62.6Q68.45,63.2 66.75,64.95Q65.05,66.7 64.6,69.1Q64.15,71.5 65.05,73.8Q66.15,76.4 68.55,77.75Q70.95,79.1 73.7,78.85Q76.45,78.6 78.45,76.6Q80.45,74.6 80.8,71.95Q81.15,69.3 80,66.9Q78.85,64.5 76.55,63.3Q74.05,62 70.95,62.6Z" 15 | }, 16 | "cmd-history": { 17 | "name": "tabler-terminal-2", 18 | "view_box": [0, 0, 100, 100], 19 | "size": [1, 3], 20 | "fallback": " ", 21 | "path": "M19.1,13.1Q20.5,12.9 49.8,12.9Q79.1,12.9 80.9,13.1Q84.7,13.7 87.5,16.4Q90.3,19.1 91.1,23Q91.2,23.4 91.2,24.9L91.2,75.1Q91.2,76.6 91.1,77Q90.6,79.7 88.9,82Q86.1,85.9 80.9,86.9Q79.1,87.1 50,87.1Q20.9,87.1 19.1,86.9Q15.2,86.3 12.45,83.6Q9.7,80.9 8.9,77Q8.8,76.6 8.8,75.1L8.8,24.9Q8.8,23.4 8.9,23Q9.7,19.1 12.45,16.4Q15.2,13.7 19.1,13.1ZM20.3,21.2Q17.8,22 17.1,24.3L17.1,75.7Q17.9,78 20.1,78.7L79.9,78.7Q82.1,78 82.9,75.7L82.9,24.3Q82.3,22.8 81.8,22.35Q81.3,21.9 79.9,21.3L50.1,21.2L20.3,21.2ZM29.4,38.2Q29.1,36.6 30.1,35.3Q31.1,34 32.7,33.6Q34.3,33.2 35.6,34.1Q36.2,34.5 42.8,41L43.8,42Q47.2,45.5 48.1,46.4Q49.4,47.8 49.65,48.35Q49.9,48.9 49.9,49.7L49.9,51Q49.8,51.3 49.6,51.75Q49.4,52.2 48.8,52.8L37.6,64.1Q36,65.5 35.5,65.9Q35,66.3 34.3,66.4L34.2,66.4Q32,66.8 30.55,65.35Q29.1,63.9 29.4,61.8L29.4,61.6Q29.5,61 29.8,60.55Q30.1,60.1 31.1,58.95Q32.1,57.8 34.9,55L40,50L34.9,44.9Q32.1,42.1 31.1,41Q30.1,39.9 29.8,39.45Q29.5,39 29.4,38.4L29.4,38.2ZM53.1,58.4Q51.9,58.7 51,59.7Q50,60.8 50,62.35Q50,63.9 51,65.1Q51.9,66 53.1,66.3Q53.7,66.5 60.25,66.5Q66.8,66.5 67.4,66.3Q69.4,65.8 70.2,64.15Q71,62.5 70.25,60.75Q69.5,59 67.4,58.4Q66.8,58.3 60.25,58.3Q53.7,58.3 53.1,58.4Z" 22 | }, 23 | "failed": { 24 | "name": "tabler-square-rounded-x", 25 | "view_box": [0, 0, 100, 100], 26 | "size": [1, 2], 27 | "fallback": "[X]", 28 | "path": "M44.8,8.85Q61.2,8.35 70.55,10.9Q79.9,13.45 84.6,19.75Q89,25.65 90.5,36.55Q91.1,41.15 91.3,46.95Q91.6,57.55 90.1,65.85Q88.1,77.25 82.8,82.55Q79.5,85.85 74.85,87.8Q70.2,89.75 63.5,90.55Q59.4,91.05 53.8,91.25Q43,91.65 34.6,90.25Q22.9,88.15 17.5,82.85Q15.3,80.65 13.9,78.25Q11.4,73.85 10.1,67.15Q8.4,58.55 8.8,46.15Q9,36.55 10.5,30.55Q12.5,22.35 17.4,17.45Q21.6,13.35 28.1,11.3Q34.6,9.25 44.8,8.85ZM50,83.05Q60.7,83.05 66.6,81.75Q73.2,80.35 76.7,76.85Q80.2,73.35 81.6,66.75Q83,60.75 83,50.05Q83,40.25 81.8,34.25Q80.6,28.15 77.95,24.7Q75.3,21.25 70.6,19.45Q66.4,17.95 59.2,17.35Q52.2,16.85 45,17.15Q31.8,17.65 26,21.15Q22.7,23.15 20.75,26.65Q18.8,30.15 17.9,35.75Q17,41.35 17,50.05Q17,60.95 18.5,67.25Q20,73.55 23.3,76.85Q26.3,79.75 31.7,81.25Q38.2,83.05 50,83.05ZM40.8,37.75Q39.8,37.95 38.85,38.85Q37.9,39.75 37.65,41.1Q37.4,42.45 38.2,43.95Q38.4,44.35 41.3,47.15L44.1,50.05L41.3,52.95Q38.4,55.75 38.2,56.15Q37.2,57.95 37.9,59.65Q38.6,61.35 40.35,62.1Q42.1,62.85 43.9,61.85Q44.3,61.65 47.1,58.75L50,55.95L52.9,58.75Q55.7,61.65 56.1,61.85Q57.9,62.85 59.65,62.1Q61.4,61.35 62.1,59.65Q62.8,57.95 61.8,56.15Q61.6,55.75 58.7,52.95L55.9,50.05L58.7,47.15Q61.6,44.35 61.8,43.95Q62.8,42.15 62.05,40.4Q61.3,38.65 59.6,37.95Q57.9,37.25 56.1,38.25Q55.7,38.45 52.9,41.35L50,44.15L47.1,41.35Q44.6,38.85 43.7,38.25Q42.4,37.45 40.8,37.75Z" 29 | }, 30 | "folder": { 31 | "name": "tabler-folder", 32 | "view_box": [0, 0, 100, 100], 33 | "size": [1, 2], 34 | "fallback": "[F]", 35 | "path": "M14.2,17.05Q15.8,15.95 17.25,15.55Q18.7,15.15 21.4,14.95Q23.2,14.95 29.2,14.95L38.7,15.05Q39.5,15.25 40.4,16.05Q41.3,16.85 45.8,21.45L51.8,27.25L65.8,27.25Q79.3,27.25 80.9,27.45Q84.7,28.05 87.5,30.8Q90.3,33.55 91.1,37.35Q91.2,37.75 91.2,38.95L91.2,74.25L91,75.25Q90.3,78.15 88.5,80.45Q85.8,83.95 81,84.95Q80.3,85.05 75.8,85.05L24.2,85.05Q19.7,85.05 19,84.95Q16.5,84.45 14.2,82.85Q10,80.05 8.9,75.05Q8.8,74.65 8.8,73.25L8.8,26.65Q8.8,25.35 8.9,24.95Q10.1,19.75 14.2,17.05ZM19.7,23.45Q18.4,23.95 17.8,24.85Q17.4,25.35 17.1,26.35L17.1,73.65Q17.6,74.75 18.1,75.35Q18.9,76.25 20.2,76.55Q21.2,76.75 50,76.75Q78.8,76.75 79.8,76.55Q81.1,76.25 81.9,75.35Q82.4,74.75 82.9,73.65L82.9,38.55Q82.2,37.25 81.85,36.9Q81.5,36.55 79.9,35.65L58.7,35.55L51.7,35.55Q49.6,35.45 49,35.4Q48.4,35.35 48.1,35.15L47.9,35.05Q47.7,34.95 41.8,28.95L36,23.25L20.3,23.25L19.7,23.45Z" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /chronicler-cli/src/main.rs: -------------------------------------------------------------------------------- 1 | #![deny(warnings)] 2 | 3 | mod history; 4 | mod navigator; 5 | mod utils; 6 | mod walk; 7 | 8 | use anyhow::Error; 9 | use history::History; 10 | use navigator::{CmdHistoryMode, Navigator, NavigatorItem, PathHistoryMode, PathMode}; 11 | use sweep::{SweepOptions, Theme}; 12 | use time::{format_description::FormatItem, macros::format_description}; 13 | 14 | use std::{io::Read, path::PathBuf}; 15 | use tracing_subscriber::{EnvFilter, fmt::format::FmtSpan}; 16 | 17 | const HISTORY_DB: &str = "chronicler/history.db"; 18 | 19 | #[global_allocator] 20 | static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; 21 | 22 | const DATE_FORMAT: &[FormatItem<'_>] = 23 | format_description!("[year]-[month]-[day] [hour]:[minute]:[second]"); 24 | 25 | #[tokio::main] 26 | async fn main() -> Result<(), Error> { 27 | let args: Args = argh::from_env(); 28 | 29 | if args.version { 30 | println!( 31 | "chronicler {} ({})", 32 | env!("CARGO_PKG_VERSION"), 33 | env!("COMMIT_INFO") 34 | ); 35 | return Ok(()); 36 | } 37 | 38 | // setup log 39 | if let Some(mut cache_dir) = dirs::cache_dir() { 40 | cache_dir.push("chronicler"); 41 | let appnder = tracing_appender::rolling::never(cache_dir, "chronicler.log"); 42 | tracing_subscriber::fmt() 43 | .with_span_events(FmtSpan::CLOSE) 44 | .with_env_filter(EnvFilter::from_default_env()) 45 | .with_writer(appnder) 46 | .init(); 47 | } 48 | 49 | let db_path = args 50 | .db 51 | .or_else(|| Some(dirs::data_dir()?.join(HISTORY_DB))) 52 | .ok_or_else(|| anyhow::anyhow!("faield to determine home directory"))?; 53 | let db_dir = db_path 54 | .parent() 55 | .ok_or_else(|| anyhow::anyhow!("failed determine db directory"))?; 56 | if !db_dir.exists() { 57 | std::fs::create_dir_all(db_dir)?; 58 | } 59 | 60 | let options = SweepOptions { 61 | theme: args.theme, 62 | tty_path: args.tty_path, 63 | ..Default::default() 64 | }; 65 | let query = (!args.query.is_empty()).then_some(args.query.as_ref()); 66 | 67 | match args.subcommand { 68 | ArgsSubcommand::Cmd(_args) => { 69 | let mut navigator = Navigator::new(options, db_path).await?; 70 | let items = navigator 71 | .run(query, CmdHistoryMode::new(None, None)) 72 | .await?; 73 | std::mem::drop(navigator); 74 | print_items(&items); 75 | } 76 | ArgsSubcommand::Path(args) => { 77 | let mut navigator = Navigator::new(options, db_path).await?; 78 | let mode = match args.path { 79 | None => PathHistoryMode::new(), 80 | Some(path) => PathMode::new(path.canonicalize()?, String::new()), 81 | }; 82 | let items = navigator.run(query, mode).await?; 83 | std::mem::drop(navigator); 84 | print_items(&items); 85 | } 86 | ArgsSubcommand::Update(args) if args.show_db_path => { 87 | print!("{}", db_path.to_string_lossy()) 88 | } 89 | ArgsSubcommand::Update(args) => { 90 | let history = History::new(db_path).await?; 91 | let mut update_str = String::new(); 92 | std::io::stdin().read_to_string(&mut update_str)?; 93 | let update = if args.json { 94 | serde_json::from_str(&update_str)? 95 | } else { 96 | update_str.parse()? 97 | }; 98 | tracing::info!(?update, "[main.update]"); 99 | let id = history.update(update).await?; 100 | tracing::info!(?id, "[main.update]"); 101 | history.close().await?; 102 | print!("{id}") 103 | } 104 | ArgsSubcommand::Setup(args) => { 105 | const CHRONICLER_PATTERN: &str = "##CHRONICLER_BIN##"; 106 | let chronicler_path = std::env::current_exe()?; 107 | let chronicler_bin = chronicler_path.to_str().unwrap_or("chronicler"); 108 | let setup = match args.shell { 109 | Shell::Bash => include_str!("../scripts/bash-integration.sh") 110 | .replace(CHRONICLER_PATTERN, chronicler_bin), 111 | }; 112 | print!("{setup}") 113 | } 114 | } 115 | Ok(()) 116 | } 117 | 118 | fn print_items(items: &[NavigatorItem]) { 119 | let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")); 120 | for (index, item) in items.iter().enumerate() { 121 | print!("{}=", item.tag()); 122 | match item { 123 | NavigatorItem::History(history) => print!("{}", history.cmd), 124 | NavigatorItem::Path(path) => match path.path.strip_prefix(&cwd) { 125 | Ok(path) => { 126 | let path_str = path.to_string_lossy(); 127 | if path_str.is_empty() { 128 | print!("."); 129 | } else { 130 | print!("{}", path_str); 131 | } 132 | } 133 | Err(_) => print!("{}", path.path.to_string_lossy()), 134 | }, 135 | } 136 | if index + 1 != items.len() { 137 | print!("\x0c") 138 | } 139 | } 140 | } 141 | 142 | /// Select entry from the cmd history database 143 | #[derive(Debug, argh::FromArgs)] 144 | #[argh(subcommand, name = "cmd")] 145 | struct ArgsCmd {} 146 | 147 | /// Update entry in the history database 148 | #[derive(Debug, argh::FromArgs)] 149 | #[argh(subcommand, name = "update")] 150 | struct ArgsUpdate { 151 | /// return path to the database 152 | #[argh(switch)] 153 | show_db_path: bool, 154 | 155 | /// json input format 156 | #[argh(switch)] 157 | json: bool, 158 | } 159 | 160 | /// List path 161 | #[derive(Debug, argh::FromArgs)] 162 | #[argh(subcommand, name = "path")] 163 | struct ArgsPath { 164 | /// path that will be listed 165 | #[argh(positional)] 166 | path: Option<PathBuf>, 167 | } 168 | 169 | #[derive(Debug, Clone, Copy)] 170 | enum Shell { 171 | Bash, 172 | } 173 | 174 | impl std::str::FromStr for Shell { 175 | type Err = String; 176 | 177 | fn from_str(s: &str) -> Result<Self, Self::Err> { 178 | match s { 179 | "bash" | "sh" => Ok(Shell::Bash), 180 | _ => Err(format!("failed to parse shell type: {s}")), 181 | } 182 | } 183 | } 184 | 185 | /// Output script that will setup chronicler 186 | #[derive(Debug, argh::FromArgs)] 187 | #[argh(subcommand, name = "setup")] 188 | struct ArgsSetup { 189 | #[argh(positional)] 190 | shell: Shell, 191 | } 192 | 193 | #[derive(Debug, argh::FromArgs)] 194 | #[argh(subcommand)] 195 | enum ArgsSubcommand { 196 | Cmd(ArgsCmd), 197 | Update(ArgsUpdate), 198 | Path(ArgsPath), 199 | Setup(ArgsSetup), 200 | } 201 | 202 | /// History manager 203 | #[derive(Debug, argh::FromArgs)] 204 | struct Args { 205 | /// show sweep version and quit 206 | #[argh(switch)] 207 | pub version: bool, 208 | 209 | /// history database path 210 | #[argh(option)] 211 | db: Option<PathBuf>, 212 | 213 | /// theme as a list of comma-separated attributes 214 | #[argh(option, default = "Theme::from_env()")] 215 | pub theme: Theme, 216 | 217 | /// path to the TTY 218 | #[argh(option, long = "tty", default = "\"/dev/tty\".to_string()")] 219 | pub tty_path: String, 220 | 221 | /// initial query 222 | #[argh(option, long = "query", default = "String::new()")] 223 | pub query: String, 224 | 225 | /// action 226 | #[argh(subcommand)] 227 | subcommand: ArgsSubcommand, 228 | } 229 | -------------------------------------------------------------------------------- /chronicler-cli/src/utils.rs: -------------------------------------------------------------------------------- 1 | use sweep::surf_n_term::{ 2 | Face, 3 | view::{Align, Container, Flex, IntoView}, 4 | }; 5 | 6 | pub(crate) struct Table<'a> { 7 | left_width: usize, 8 | left_face: Option<Face>, 9 | right_face: Option<Face>, 10 | view: Flex<'a>, 11 | } 12 | 13 | impl<'a> Table<'a> { 14 | pub(crate) fn new( 15 | left_width: usize, 16 | left_face: Option<Face>, 17 | right_face: Option<Face>, 18 | ) -> Self { 19 | Self { 20 | left_width, 21 | left_face, 22 | right_face, 23 | view: Flex::column(), 24 | } 25 | } 26 | 27 | pub(crate) fn push(&mut self, left: impl IntoView + 'a, right: impl IntoView + 'a) { 28 | let row = Flex::row() 29 | .add_child_ext( 30 | Container::new(left) 31 | .with_width(self.left_width) 32 | .with_horizontal(Align::Expand), 33 | None, 34 | self.left_face, 35 | Align::Start, 36 | ) 37 | .add_child_ext(right, None, self.right_face, Align::Start); 38 | self.view.push_child(row) 39 | } 40 | } 41 | 42 | impl<'a> IntoView for Table<'a> { 43 | type View = Flex<'a>; 44 | 45 | fn into_view(self) -> Self::View { 46 | self.view 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /chronicler-cli/src/walk.rs: -------------------------------------------------------------------------------- 1 | use super::DATE_FORMAT; 2 | use crate::navigator::NavigatorContext; 3 | use anyhow::Error; 4 | use futures::{ 5 | FutureExt, Stream, StreamExt, TryStreamExt, ready, 6 | stream::{self, FuturesOrdered}, 7 | }; 8 | use globset::{GlobBuilder, GlobMatcher}; 9 | use std::{ 10 | collections::{HashSet, VecDeque}, 11 | fmt::{self, Write}, 12 | fs::Metadata, 13 | future::Future, 14 | ops::Deref, 15 | os::unix::fs::MetadataExt, 16 | path::{Path, PathBuf}, 17 | sync::Arc, 18 | task::Poll, 19 | }; 20 | use sweep::{ 21 | Haystack, HaystackBasicPreview, HaystackDefaultView, 22 | surf_n_term::{ 23 | CellWrite, Face, FaceAttrs, 24 | view::{Flex, Justify, Text}, 25 | }, 26 | }; 27 | use time::OffsetDateTime; 28 | use tokio::fs; 29 | use tokio_stream::wrappers::ReadDirStream; 30 | 31 | #[derive(Clone)] 32 | pub struct PathItem { 33 | /// Number of bytes in the path that are part of the root of the walk 34 | pub root_length: usize, 35 | /// Full path of an item 36 | pub path: PathBuf, 37 | /// Metadata associated with path 38 | pub metadata: Option<Metadata>, 39 | /// Ignore matcher 40 | pub ignore: Option<PathIgnoreArc>, 41 | /// Number of visits (only set if generated by history) 42 | pub visits: Option<i64>, 43 | } 44 | 45 | impl PathItem { 46 | pub fn is_dir(&self) -> bool { 47 | self.metadata.as_ref().map_or_else(|| false, |m| m.is_dir()) 48 | } 49 | 50 | pub async fn unfold(&self) -> Result<Vec<PathItem>, Error> { 51 | if !self.is_dir() { 52 | return Ok(Vec::new()); 53 | } 54 | 55 | let ignore: Option<PathIgnoreArc> = 56 | match PathIgnoreGit::new(self.path.join(".gitignore")).await { 57 | Err(_) => self.ignore.clone(), 58 | Ok(git_ignore) => { 59 | if let Some(ignore) = self.ignore.clone() { 60 | Some(Arc::new(git_ignore.chain(ignore.clone()))) 61 | } else { 62 | Some(Arc::new(git_ignore)) 63 | } 64 | } 65 | }; 66 | 67 | let read_dir = fs::read_dir(&self.path).await?; 68 | let mut entries: Vec<_> = ReadDirStream::new(read_dir) 69 | .map_ok(|entry| entry.path()) 70 | .try_filter_map(|path| async { 71 | let metadata = fs::symlink_metadata(&path).await.ok(); 72 | let path_item = PathItem { 73 | path, 74 | metadata, 75 | root_length: self.root_length, 76 | ignore: ignore.clone(), 77 | visits: None, 78 | }; 79 | if ignore 80 | .as_ref() 81 | .map_or_else(|| false, |ignore| ignore.matches(&path_item)) 82 | { 83 | return Ok(None); 84 | } 85 | Ok(Some(path_item)) 86 | }) 87 | .try_collect() 88 | .await?; 89 | 90 | entries.sort_unstable_by(|a, b| path_sort_key(b).cmp(&path_sort_key(a))); 91 | Ok(entries) 92 | } 93 | } 94 | 95 | impl fmt::Debug for PathItem { 96 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 97 | f.debug_struct("PathItem") 98 | .field("root_length", &self.root_length) 99 | .field("path", &self.path) 100 | .field("metadata", &self.metadata) 101 | .finish() 102 | } 103 | } 104 | 105 | impl Haystack for PathItem { 106 | type Context = NavigatorContext; 107 | type View = Flex<'static>; 108 | type Preview = HaystackBasicPreview<Text>; 109 | type PreviewLarge = (); 110 | 111 | fn haystack_scope<S>(&self, ctx: &Self::Context, mut scope: S) 112 | where 113 | S: FnMut(char), 114 | { 115 | let path = self.path.to_string_lossy(); 116 | let home_dir_len = ctx.home_dir.chars().count(); 117 | let skip = if !ctx.home_dir.is_empty() 118 | && self.root_length < home_dir_len 119 | && path.starts_with(ctx.home_dir.deref()) 120 | { 121 | scope('~'); 122 | home_dir_len - self.root_length 123 | } else { 124 | 0 125 | }; 126 | match path.get(self.root_length..) { 127 | Some(path) => path.chars().skip(skip).for_each(&mut scope), 128 | None => path.chars().skip(skip).for_each(&mut scope), 129 | } 130 | if self.is_dir() { 131 | scope('/') 132 | } 133 | } 134 | 135 | fn view( 136 | &self, 137 | ctx: &Self::Context, 138 | positions: sweep::Positions<&[u8]>, 139 | theme: &sweep::Theme, 140 | ) -> Self::View { 141 | let path = HaystackDefaultView::new(ctx, self, positions, theme); 142 | let mut right = Text::new(); 143 | right.set_face(theme.list_inactive); 144 | if let Some(visits) = self.visits { 145 | write!(&mut right, "{visits} ").expect("in memory write failed"); 146 | } 147 | Flex::row() 148 | .justify(Justify::SpaceBetween) 149 | .add_child(path) 150 | .add_child(right) 151 | } 152 | 153 | fn preview( 154 | &self, 155 | ctx: &Self::Context, 156 | _positions: sweep::Positions<&[u8]>, 157 | _theme: &sweep::Theme, 158 | ) -> Option<Self::Preview> { 159 | let metadata = self.metadata.as_ref()?; 160 | let left_face = Some(Face::default().with_attrs(FaceAttrs::BOLD)); 161 | let mut text = Text::new() 162 | .with_fmt("Mode ", left_face) 163 | .with_fmt( 164 | &format_args!("{}\n", unix_mode::to_string(metadata.mode())), 165 | None, 166 | ) 167 | .with_fmt("Size ", left_face) 168 | .with_fmt( 169 | &format_args!("{:.2}\n", SizeDisplay::new(metadata.len())), 170 | None, 171 | ); 172 | text.put_fmt("Owner ", left_face); 173 | match ctx.get_user_by_uid(metadata.uid()) { 174 | None => text.put_fmt(&format_args!("{}", metadata.uid()), None), 175 | Some(user) => text.put_fmt(&format_args!("{}", user.name().to_string_lossy()), None), 176 | }; 177 | text.put_char(':'); 178 | match ctx.get_group_by_gid(metadata.gid()) { 179 | None => text.put_fmt(&format_args!("{}", metadata.uid()), None), 180 | Some(group) => text.put_fmt(&format_args!("{}", group.name().to_string_lossy()), None), 181 | }; 182 | text.put_char('\n'); 183 | if let Ok(created) = metadata.created() { 184 | let date = OffsetDateTime::from(created).format(&DATE_FORMAT).ok()?; 185 | text.put_fmt("Created ", left_face); 186 | text.put_fmt(&format_args!("{}\n", date), None); 187 | } 188 | if let Ok(modified) = metadata.modified() { 189 | let date = OffsetDateTime::from(modified).format(&DATE_FORMAT).ok()?; 190 | text.put_fmt("Modified ", left_face); 191 | text.put_fmt(&format_args!("{}\n", date), None); 192 | } 193 | if let Ok(accessed) = metadata.accessed() { 194 | let date = OffsetDateTime::from(accessed).format(&DATE_FORMAT).ok()?; 195 | text.put_fmt("Accessed ", left_face); 196 | text.put_fmt(&format_args!("{}\n", date), None); 197 | } 198 | Some(HaystackBasicPreview::new(text, None)) 199 | } 200 | } 201 | 202 | /// Walk directory returning a stream of [PathItem] in the breadth first order 203 | pub fn walk<'caller>( 204 | root: impl AsRef<Path> + 'caller, 205 | ignore: Option<PathIgnoreArc>, 206 | ) -> impl Stream<Item = Result<PathItem, Error>> + 'caller { 207 | let root = root 208 | .as_ref() 209 | .canonicalize() 210 | .unwrap_or_else(|_| root.as_ref().to_owned()); 211 | fs::symlink_metadata(root.to_owned()) 212 | .then(move |metadata| async move { 213 | let root_length = root.as_os_str().len() + 1; 214 | let init = PathItem { 215 | root_length, 216 | path: root, 217 | metadata: metadata.ok(), 218 | ignore, 219 | visits: None, 220 | }; 221 | bounded_unfold(64, Some(init), |item| async move { 222 | let children = match item.unfold().await { 223 | Ok(children) => children, 224 | Err(error) => { 225 | tracing::warn!(?item.path, ?error, "[walk]"); 226 | Vec::new() 227 | } 228 | }; 229 | Ok((item, children)) 230 | }) 231 | }) 232 | .into_stream() 233 | .flatten() 234 | } 235 | 236 | fn path_sort_key(item: &PathItem) -> (bool, bool, &Path) { 237 | let hidden = item 238 | .path 239 | .file_name() 240 | .and_then(|s| s.to_str()) 241 | .map_or_else(|| false, |name| name.starts_with('.')); 242 | let is_dir = item 243 | .metadata 244 | .as_ref() 245 | .map_or_else(|| false, |meta| meta.is_dir()); 246 | (hidden, !is_dir, &item.path) 247 | } 248 | 249 | /// Format size in a human readable form 250 | pub struct SizeDisplay { 251 | size: u64, 252 | } 253 | 254 | impl SizeDisplay { 255 | pub fn new(size: u64) -> Self { 256 | Self { size } 257 | } 258 | } 259 | 260 | impl std::fmt::Display for SizeDisplay { 261 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 262 | if self.size < 1024 { 263 | return write!(f, "{}B", self.size); 264 | } 265 | let mut size = self.size as f64; 266 | let precision = f.precision().unwrap_or(1); 267 | for mark in "KMGTP".chars() { 268 | size /= 1024.0; 269 | if size < 1024.0 { 270 | return write!(f, "{0:.1$}{2}", size, precision, mark); 271 | } 272 | } 273 | write!(f, "{0:.1$}P", size, precision) 274 | } 275 | } 276 | 277 | pub trait PathIgnore { 278 | fn matches(&self, item: &PathItem) -> bool; 279 | 280 | fn chain<O>(self, other: O) -> PathIgnoreChain<Self, O> 281 | where 282 | O: PathIgnore + Sized, 283 | Self: Sized, 284 | { 285 | PathIgnoreChain { 286 | first: self, 287 | second: other, 288 | } 289 | } 290 | } 291 | 292 | pub struct PathIgnoreChain<F, S> { 293 | first: F, 294 | second: S, 295 | } 296 | 297 | impl<F, S> PathIgnore for PathIgnoreChain<F, S> 298 | where 299 | F: PathIgnore, 300 | S: PathIgnore, 301 | { 302 | fn matches(&self, item: &PathItem) -> bool { 303 | self.first.matches(item) || self.second.matches(item) 304 | } 305 | } 306 | 307 | pub type PathIgnoreArc = Arc<dyn PathIgnore + Send + Sync + 'static>; 308 | 309 | impl PathIgnore for PathIgnoreArc { 310 | fn matches(&self, item: &PathItem) -> bool { 311 | (**self).matches(item) 312 | } 313 | } 314 | 315 | struct GlobGit { 316 | matcher: GlobMatcher, 317 | /// match on filename only 318 | is_filename: bool, 319 | /// match on directory only 320 | is_dir: bool, 321 | /// match is a whitelist 322 | is_whitelist: bool, 323 | } 324 | 325 | impl GlobGit { 326 | fn new(string: &str) -> impl Iterator<Item = GlobGit> + '_ { 327 | string.lines().filter_map(|line| { 328 | let line = line.trim(); 329 | // comments 330 | if line.starts_with('#') || line.is_empty() { 331 | return None; 332 | } 333 | 334 | // directory only match 335 | let (is_dir, line) = if line.ends_with('/') { 336 | (true, line.trim_end_matches('/')) 337 | } else { 338 | (false, line) 339 | }; 340 | 341 | // whitelist 342 | let (is_whitelist, line) = if line.starts_with('!') { 343 | (true, line.trim_start_matches('!')) 344 | } else { 345 | (false, line) 346 | }; 347 | 348 | // filename match 349 | let is_filename = !line.contains('/'); 350 | let line = line.trim_start_matches('/'); 351 | 352 | if let Ok(glob) = GlobBuilder::new(line).literal_separator(true).build() { 353 | Some(GlobGit { 354 | matcher: glob.compile_matcher(), 355 | is_dir, 356 | is_filename, 357 | is_whitelist, 358 | }) 359 | } else { 360 | None 361 | } 362 | }) 363 | } 364 | } 365 | 366 | struct PathIgnoreGit { 367 | positive: Vec<GlobGit>, 368 | negative: Vec<GlobGit>, 369 | root: PathBuf, 370 | } 371 | 372 | impl PathIgnoreGit { 373 | async fn new(path: impl AsRef<Path>) -> Result<Self, Error> { 374 | let data = fs::read(path.as_ref()).await?; 375 | let (positive, negative) = 376 | GlobGit::new(std::str::from_utf8(&data)?).partition(|glob| !glob.is_whitelist); 377 | Ok(Self { 378 | positive, 379 | negative, 380 | root: path 381 | .as_ref() 382 | .parent() 383 | .unwrap_or(Path::new("")) 384 | .canonicalize()?, 385 | }) 386 | } 387 | } 388 | 389 | impl PathIgnore for PathIgnoreGit { 390 | fn matches(&self, item: &PathItem) -> bool { 391 | let Ok(path) = item.path.strip_prefix(&self.root) else { 392 | return false; 393 | }; 394 | let filename = Path::new( 395 | path.file_name() 396 | .and_then(|path| path.to_str()) 397 | .unwrap_or(""), 398 | ); 399 | let is_dir = item.is_dir(); 400 | 401 | for glob in &self.negative { 402 | if glob.is_dir && !is_dir { 403 | continue; 404 | } 405 | let matched = if glob.is_filename { 406 | glob.matcher.is_match(filename) 407 | } else { 408 | glob.matcher.is_match(path) 409 | }; 410 | if matched { 411 | return false; 412 | } 413 | } 414 | 415 | for glob in &self.positive { 416 | if glob.is_dir && !is_dir { 417 | continue; 418 | } 419 | let matched = if glob.is_filename { 420 | glob.matcher.is_match(filename) 421 | } else { 422 | glob.matcher.is_match(path) 423 | }; 424 | if matched { 425 | return true; 426 | } 427 | } 428 | false 429 | } 430 | } 431 | 432 | pub struct PathIgnoreSet { 433 | pub filenames: HashSet<String>, 434 | } 435 | 436 | impl Default for PathIgnoreSet { 437 | fn default() -> Self { 438 | Self { 439 | filenames: [".hg", ".git"].iter().map(|f| f.to_string()).collect(), 440 | } 441 | } 442 | } 443 | 444 | impl PathIgnore for PathIgnoreSet { 445 | fn matches(&self, item: &PathItem) -> bool { 446 | let filename = item 447 | .path 448 | .file_name() 449 | .and_then(|path| path.to_str()) 450 | .unwrap_or(""); 451 | self.filenames.contains(filename) 452 | } 453 | } 454 | 455 | pub async fn path_ignore_for_path(path: impl AsRef<Path>) -> PathIgnoreArc { 456 | let mut ignore: PathIgnoreArc = Arc::new(PathIgnoreSet::default()); 457 | for path in path.as_ref().ancestors() { 458 | if let Ok(git_ignore) = PathIgnoreGit::new(path.join(".gitignore")).await { 459 | ignore = Arc::new(git_ignore.chain(ignore)); 460 | } 461 | } 462 | ignore 463 | } 464 | 465 | /// Similar to unfold but runs unfold function in parallel with the specified 466 | /// level of parallelism 467 | pub fn bounded_unfold<'caller, In, Init, Ins, Out, Unfold, UFut, UErr>( 468 | scheduled_max: usize, 469 | init: Init, 470 | unfold: Unfold, 471 | ) -> impl Stream<Item = Result<Out, UErr>> + 'caller 472 | where 473 | In: 'caller, 474 | Out: 'caller, 475 | Unfold: Fn(In) -> UFut + 'caller, 476 | UErr: 'caller, 477 | UFut: Future<Output = Result<(Out, Ins), UErr>> + 'caller, 478 | Init: IntoIterator<Item = In> + 'caller, 479 | Ins: IntoIterator<Item = In> + 'caller, 480 | { 481 | let mut unscheduled = VecDeque::from_iter(init); 482 | let mut scheduled = FuturesOrdered::new(); 483 | stream::poll_fn(move |cx| { 484 | loop { 485 | if scheduled.is_empty() && unscheduled.is_empty() { 486 | return Poll::Ready(None); 487 | } 488 | 489 | for item in unscheduled 490 | .drain(..std::cmp::min(unscheduled.len(), scheduled_max - scheduled.len())) 491 | { 492 | scheduled.push_back(unfold(item)) 493 | } 494 | 495 | if let Some((out, children)) = ready!(scheduled.poll_next_unpin(cx)).transpose()? { 496 | for child in children { 497 | unscheduled.push_front(child); 498 | } 499 | return Poll::Ready(Some(Ok(out))); 500 | } 501 | } 502 | }) 503 | } 504 | -------------------------------------------------------------------------------- /resources/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aslpavel/sweep-rs/df51db07262edf4d983c23a7b34c101192667a65/resources/demo.gif -------------------------------------------------------------------------------- /resources/demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aslpavel/sweep-rs/df51db07262edf4d983c23a7b34c101192667a65/resources/demo.png -------------------------------------------------------------------------------- /resources/sway.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aslpavel/sweep-rs/df51db07262edf4d983c23a7b34c101192667a65/resources/sway.png -------------------------------------------------------------------------------- /resources/sweep.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aslpavel/sweep-rs/df51db07262edf4d983c23a7b34c101192667a65/resources/sweep.png -------------------------------------------------------------------------------- /sweep-cli/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "sweep-cli" 3 | build = "build.rs" 4 | authors.workspace = true 5 | edition.workspace = true 6 | version.workspace = true 7 | repository.workspace = true 8 | 9 | [[bin]] 10 | name = "sweep" 11 | path = "src/main.rs" 12 | 13 | [dependencies] 14 | anyhow = { workspace = true } 15 | argh = { workspace = true } 16 | futures = { workspace = true } 17 | mimalloc = { workspace = true } 18 | serde_json = { workspace = true } 19 | surf_n_term = { workspace = true } 20 | sweep = { workspace = true } 21 | tokio = { workspace = true } 22 | tracing-subscriber = { workspace = true } 23 | -------------------------------------------------------------------------------- /sweep-cli/build.rs: -------------------------------------------------------------------------------- 1 | fn main() -> Result<(), Box<dyn std::error::Error>> { 2 | let commit_info = std::process::Command::new("git") 3 | .args(["show", "-s", "--format=%h %ci"]) 4 | .output() 5 | .map(|output| output.stdout) 6 | .unwrap_or_default(); 7 | println!( 8 | "cargo:rustc-env=COMMIT_INFO={}", 9 | std::str::from_utf8(&commit_info)? 10 | ); 11 | Ok(()) 12 | } 13 | -------------------------------------------------------------------------------- /sweep-cli/src/main.rs: -------------------------------------------------------------------------------- 1 | #![deny(warnings)] 2 | #![allow(clippy::type_complexity)] 3 | 4 | use anyhow::{Context, Error}; 5 | use argh::FromArgs; 6 | use futures::TryStreamExt; 7 | use std::{ 8 | fs::File, 9 | io::Write, 10 | os::unix::{io::FromRawFd, net::UnixStream as StdUnixStream}, 11 | pin::Pin, 12 | sync::{Arc, Mutex}, 13 | }; 14 | use surf_n_term::Glyph; 15 | use sweep::{ 16 | ALL_SCORER_BUILDERS, Candidate, CandidateContext, FieldSelector, ProcessCommandBuilder, Sweep, 17 | SweepEvent, SweepOptions, Theme, WindowId, WindowLayout, WindowLayoutSize, 18 | common::{VecDeserializeSeed, json_from_slice_seed}, 19 | scorer_by_name, 20 | }; 21 | use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt}; 22 | use tracing_subscriber::fmt::format::FmtSpan; 23 | 24 | #[cfg(not(target_os = "macos"))] 25 | use std::{io::IsTerminal, os::unix::io::AsFd}; 26 | 27 | #[global_allocator] 28 | static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; 29 | 30 | #[tokio::main(flavor = "current_thread")] 31 | async fn main() -> Result<(), Error> { 32 | let args: Args = argh::from_env(); 33 | 34 | if args.version { 35 | println!( 36 | "sweep {} ({})", 37 | env!("CARGO_PKG_VERSION"), 38 | env!("COMMIT_INFO") 39 | ); 40 | return Ok(()); 41 | } 42 | 43 | if let Some(log_path) = args.log { 44 | let log = Log::new(log_path)?; 45 | tracing_subscriber::fmt() 46 | .with_ansi(false) 47 | .with_span_events(FmtSpan::CLOSE) 48 | .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) 49 | .with_writer(move || log.clone()) 50 | .init(); 51 | } 52 | 53 | let (mut input, mut output): ( 54 | Pin<Box<dyn AsyncRead + Send>>, 55 | Pin<Box<dyn AsyncWrite + Send>>, 56 | ) = match args.io_socket { 57 | None => { 58 | let input: Pin<Box<dyn AsyncRead + Send>> = match args.input.as_deref() { 59 | Some("-") | None => { 60 | let stdin = tokio::io::stdin(); 61 | #[cfg(not(target_os = "macos"))] 62 | { 63 | if stdin.as_fd().is_terminal() { 64 | return Err(anyhow::anyhow!( 65 | "stdin can not be a tty, pipe in data instead" 66 | )); 67 | } 68 | } 69 | Box::pin(stdin) 70 | } 71 | Some(path) => Box::pin(tokio::fs::File::open(path).await?), 72 | }; 73 | 74 | let output: Pin<Box<dyn AsyncWrite + Send>> = { 75 | let stdout = tokio::io::stdout(); 76 | // Disabling `isatty` check on {stdin|stdout} on MacOS. When used 77 | // from asyncio python interface, sweep subprocess is created with 78 | // `socketpair` as its {stdin|stdout}, but `isatty` when used on socket 79 | // under MacOS causes "Operation not supported on socket" error. 80 | #[cfg(not(target_os = "macos"))] 81 | { 82 | if args.rpc && stdout.as_fd().is_terminal() { 83 | return Err(anyhow::anyhow!("stdout can not be a tty if rpc is enabled")); 84 | } 85 | } 86 | Box::pin(stdout) 87 | }; 88 | (input, output) 89 | } 90 | Some(ref address) => { 91 | let stream = match address.parse() { 92 | Ok(fd) => unsafe { StdUnixStream::from_raw_fd(fd) }, 93 | Err(_) => { 94 | StdUnixStream::connect(address).context("failed to connnect to io-socket")? 95 | } 96 | }; 97 | stream.set_nonblocking(true)?; 98 | let stream = tokio::net::UnixStream::from_std(stream)?; 99 | let (input, output) = tokio::io::split(stream); 100 | (Box::pin(input), Box::pin(output)) 101 | } 102 | }; 103 | 104 | let theme = args.theme; 105 | let candidate_context = CandidateContext::new(); 106 | candidate_context.update_named_colors(&theme); 107 | let mut scorers = ALL_SCORER_BUILDERS.clone(); 108 | scorer_by_name(&mut scorers, Some(args.scorer.as_str())); 109 | let sweep: Sweep<Candidate> = Sweep::new( 110 | candidate_context.clone(), 111 | SweepOptions { 112 | prompt: args.prompt.clone(), 113 | prompt_icon: Some(args.prompt_icon), 114 | theme, 115 | keep_order: args.keep_order, 116 | tty_path: args.tty_path, 117 | title: args.title, 118 | window_uid: args.window_uid.clone(), 119 | scorers, 120 | layout: args.layout.unwrap_or_else(|| { 121 | if args.preview_builder.is_some() { 122 | WindowLayout::Full { 123 | height: WindowLayoutSize::Fraction(-0.3), 124 | } 125 | } else { 126 | WindowLayout::default() 127 | } 128 | }), 129 | }, 130 | )?; 131 | if let Some(preview_builder) = args.preview_builder { 132 | candidate_context.preview_set(preview_builder, sweep.waker()); 133 | } 134 | 135 | if args.rpc { 136 | sweep 137 | .serve_seed( 138 | candidate_context.clone(), 139 | Some(Arc::new(candidate_context.clone())), 140 | input, 141 | output, 142 | |peer| Candidate::setup(peer, sweep.waker(), candidate_context), 143 | ) 144 | .await?; 145 | } else { 146 | let uid = args.window_uid.unwrap_or_else(|| "default".into()); 147 | sweep.window_switch(uid, false).await?; 148 | sweep.query_set(None, args.query.clone()); 149 | 150 | if args.json { 151 | let mut data: Vec<u8> = Vec::new(); 152 | tokio::io::copy(&mut input, &mut data).await?; 153 | let seed = VecDeserializeSeed(&candidate_context); 154 | let candidates = 155 | json_from_slice_seed(seed, data.as_ref()).context("failed to parse input JSON")?; 156 | sweep.items_extend(None, candidates); 157 | } else { 158 | let sweep = sweep.clone(); 159 | let field_dilimiter = args.field_delimiter; 160 | let field_selector = args.field_selector.clone(); 161 | tokio::spawn(async move { 162 | let candidates = Candidate::from_lines(input, field_dilimiter, field_selector); 163 | tokio::pin!(candidates); 164 | while let Some(candidates) = candidates.try_next().await? { 165 | sweep.items_extend(None, candidates); 166 | } 167 | Ok::<_, Error>(()) 168 | }); 169 | }; 170 | while let Some(event) = sweep.next_event().await { 171 | if let SweepEvent::Select { items, .. } = event { 172 | if items.is_empty() && !args.no_match_use_input { 173 | continue; 174 | } 175 | let input = sweep.query_get(None).await?; 176 | std::mem::drop(sweep); // cleanup terminal 177 | let result = if args.json { 178 | let mut result = serde_json::to_string(&items)?; 179 | result.push('\n'); 180 | result 181 | } else { 182 | use std::fmt::Write as _; 183 | let mut result = String::new(); 184 | for item in &items { 185 | writeln!(&mut result, "{}", item)?; 186 | } 187 | if result.is_empty() { 188 | writeln!(&mut result, "{}", input)?; 189 | } 190 | result 191 | }; 192 | output.write_all(result.as_bytes()).await?; 193 | break; 194 | } 195 | } 196 | } 197 | 198 | Ok(()) 199 | } 200 | 201 | #[derive(Clone)] 202 | struct Log { 203 | file: Arc<Mutex<File>>, 204 | } 205 | 206 | impl Log { 207 | fn new(file: String) -> Result<Self, Error> { 208 | let file = Arc::new(Mutex::new(File::create(file)?)); 209 | Ok(Self { file }) 210 | } 211 | } 212 | 213 | impl Write for Log { 214 | fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> { 215 | let mut file = self.file.lock().expect("lock poisoned"); 216 | file.write(buf) 217 | } 218 | 219 | fn flush(&mut self) -> std::io::Result<()> { 220 | let mut file = self.file.lock().expect("lock poisoned"); 221 | file.flush() 222 | } 223 | } 224 | 225 | /// Sweep is a command line fuzzy finder 226 | #[derive(FromArgs)] 227 | pub struct Args { 228 | /// prompt string 229 | #[argh(option, short = 'p', default = "\"INPUT\".to_string()")] 230 | pub prompt: String, 231 | 232 | /// prompt icon 233 | #[argh(option, default = "sweep::PROMPT_DEFAULT_ICON.clone()")] 234 | pub prompt_icon: Glyph, 235 | 236 | /// initial query string 237 | #[argh(option, default = "String::new()")] 238 | pub query: String, 239 | 240 | /// theme `(light|dark),accent=<color>,fg=<color>,bg=<color>` 241 | #[argh(option, default = "Theme::from_env()")] 242 | pub theme: Theme, 243 | 244 | /// filed selectors (i.e `1,3..-1`) 245 | #[argh(option, long = "nth")] 246 | pub field_selector: Option<FieldSelector>, 247 | 248 | /// filed delimiter character 249 | #[argh(option, long = "delimiter", short = 'd', default = "' '")] 250 | pub field_delimiter: char, 251 | 252 | /// do not reorder candidates 253 | #[argh(switch, long = "keep-order")] 254 | pub keep_order: bool, 255 | 256 | /// default scorer to rank items 257 | #[argh(option, from_str_fn(scorer_arg), default = "\"fuzzy\".to_string()")] 258 | pub scorer: String, 259 | 260 | /// switch to remote-procedure-call mode 261 | #[argh(switch)] 262 | pub rpc: bool, 263 | 264 | /// path to the TTY (default: /dev/tty) 265 | #[argh(option, long = "tty", default = "\"/dev/tty\".to_string()")] 266 | pub tty_path: String, 267 | 268 | /// action when there is no match and enter is pressed 269 | #[argh( 270 | option, 271 | long = "no-match", 272 | default = "false", 273 | from_str_fn(parse_no_input) 274 | )] 275 | pub no_match_use_input: bool, 276 | 277 | /// set terminal title 278 | #[argh(option, default = "\"sweep\".to_string()")] 279 | pub title: String, 280 | 281 | /// internal windows stack window identifier 282 | #[argh( 283 | option, 284 | from_str_fn(parse_window_id), 285 | default = "Some(\"default\".into())" 286 | )] 287 | pub window_uid: Option<WindowId>, 288 | 289 | /// candidates in JSON pre line format (same encoding as RPC) 290 | #[argh(switch)] 291 | pub json: bool, 292 | 293 | /// use unix socket (path or descriptor) instead of stdin/stdout 294 | #[argh(option)] 295 | pub io_socket: Option<String>, 296 | 297 | /// read input from the file (ignored if --io-socket) 298 | #[argh(option)] 299 | pub input: Option<String>, 300 | 301 | /// log file (configure via RUST_LOG environment variable) 302 | #[argh(option)] 303 | pub log: Option<String>, 304 | 305 | /// create preview subprocess, requires full layout 306 | #[argh(option, long = "preview")] 307 | pub preview_builder: Option<ProcessCommandBuilder>, 308 | 309 | /// layout mode specified as `name(,attr=value)*` 310 | #[argh(option)] 311 | pub layout: Option<WindowLayout>, 312 | 313 | /// show sweep version and quit 314 | #[argh(switch)] 315 | pub version: bool, 316 | } 317 | 318 | fn parse_window_id(value: &str) -> Result<Option<WindowId>, String> { 319 | if value.is_empty() { 320 | return Ok(None); 321 | } 322 | let uid = match value.parse() { 323 | Ok(num) => WindowId::Number(num), 324 | Err(_) => WindowId::String(value.into()), 325 | }; 326 | Ok(Some(uid)) 327 | } 328 | 329 | fn parse_no_input(value: &str) -> Result<bool, String> { 330 | match value { 331 | "nothing" => Ok(false), 332 | "input" => Ok(true), 333 | _ => Err("invalid no-match achtion, possible values {nothing|input}".to_string()), 334 | } 335 | } 336 | 337 | fn scorer_arg(name: &str) -> Result<String, String> { 338 | match name { 339 | "substr" => Ok(name.to_string()), 340 | "fuzzy" => Ok(name.to_string()), 341 | _ => Err(format!("unknown scorer type: {}", name)), 342 | } 343 | } 344 | -------------------------------------------------------------------------------- /sweep-lib/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "sweep" 3 | authors.workspace = true 4 | edition.workspace = true 5 | version.workspace = true 6 | repository.workspace = true 7 | 8 | [dependencies] 9 | anyhow.workspace = true 10 | futures.workspace = true 11 | serde.workspace = true 12 | serde_json.workspace = true 13 | surf_n_term.workspace = true 14 | tokio.workspace = true 15 | tracing.workspace = true 16 | 17 | arrow-array = "^54.0" 18 | arrow-data = "^54.0" 19 | crossbeam-channel = "^0.5" 20 | either = "^1.13" 21 | rayon = { version = "^1.10" } 22 | shlex = "^1.3.0" 23 | smallvec = "^1.9.0" 24 | tracing-futures = "^0.2" 25 | 26 | [dev-dependencies] 27 | mimalloc.workspace = true 28 | criterion = "^0.5" 29 | 30 | 31 | [[bench]] 32 | harness = false 33 | name = "scorer" 34 | -------------------------------------------------------------------------------- /sweep-lib/benches/scorer.rs: -------------------------------------------------------------------------------- 1 | use criterion::{Criterion, Throughput, criterion_group, criterion_main}; 2 | use mimalloc::MiMalloc; 3 | use sweep::{FuzzyScorer, KMPPattern, Positions, Score, Scorer, SubstrScorer}; 4 | 5 | #[global_allocator] 6 | static GLOBAL: MiMalloc = MiMalloc; 7 | 8 | const CANDIDATE: &str = "./benchmark/target/release/.fingerprint/semver-parser-a5e84da67081840e/test/lib-semver_parser-a5e84da67081840e.json"; 9 | 10 | pub fn scorer_benchmark(c: &mut Criterion) { 11 | let haystack: Vec<_> = CANDIDATE.chars().collect(); 12 | let needle: Vec<_> = "test".chars().collect(); 13 | let fuzzy = FuzzyScorer::new(needle.clone()); 14 | let substr = SubstrScorer::new(needle.clone()); 15 | let kmp = KMPPattern::new(needle); 16 | 17 | let mut group = c.benchmark_group("scorer"); 18 | group.throughput(Throughput::Elements(1_u64)); 19 | 20 | let mut score = Score::MIN; 21 | let mut positions = Positions::new_owned(CANDIDATE.len()); 22 | group.bench_function("fuzzy", |b| { 23 | b.iter(|| fuzzy.score_ref(haystack.as_slice(), &mut score, positions.as_mut())) 24 | }); 25 | 26 | let mut score = Score::MIN; 27 | let mut positions = Positions::new_owned(CANDIDATE.len()); 28 | group.bench_function("substr", |b| { 29 | b.iter(|| substr.score_ref(haystack.as_slice(), &mut score, positions.as_mut())) 30 | }); 31 | 32 | group.bench_function("knuth-morris-pratt", |b| { 33 | b.iter(|| kmp.search(haystack.as_slice())) 34 | }); 35 | 36 | group.finish(); 37 | } 38 | 39 | criterion_group!(benches, scorer_benchmark); 40 | criterion_main!(benches); 41 | -------------------------------------------------------------------------------- /sweep-lib/examples/basic.rs: -------------------------------------------------------------------------------- 1 | use std::convert::Infallible; 2 | 3 | use anyhow::Error; 4 | use futures::stream; 5 | use sweep::sweep; 6 | 7 | #[tokio::main(flavor = "current_thread")] 8 | async fn main() -> Result<(), Error> { 9 | let items = ["One", "Two", "Three", "Four", "Five"] 10 | .into_iter() 11 | .map(|e| Ok::<_, Infallible>(e.to_owned())); 12 | let result = sweep(Default::default(), (), stream::iter(items)).await?; 13 | println!("{:?}", result); 14 | Ok(()) 15 | } 16 | -------------------------------------------------------------------------------- /sweep-lib/examples/simple.rs: -------------------------------------------------------------------------------- 1 | use std::sync::LazyLock; 2 | 3 | use anyhow::Error; 4 | use surf_n_term::Glyph; 5 | use sweep::{ 6 | Haystack, HaystackTagged, Sweep, SweepOptions, 7 | surf_n_term::{CellWrite, view::Text}, 8 | }; 9 | 10 | static YES: LazyLock<Glyph> = LazyLock::new(|| { 11 | let glyph_str = r#"{ 12 | "path": "M44.8,8.85Q61.2,8.35 70.55,10.9Q79.9,13.45 84.6,19.75Q89,25.65 90.5,36.55Q91.1,41.15 91.3,46.95Q91.6,57.55 90.1,65.85Q88.1,77.25 82.8,82.55Q79.5,85.85 74.85,87.8Q70.2,89.75 63.5,90.55Q59.4,91.05 53.8,91.25Q43,91.65 34.6,90.25Q22.9,88.15 17.5,82.85Q15.3,80.65 13.9,78.25Q11.4,73.85 10.1,67.15Q8.4,58.55 8.8,46.15Q9,36.55 10.5,30.55Q12.5,22.35 17.4,17.45Q21.6,13.35 28.1,11.3Q34.6,9.25 44.8,8.85ZM50,83.05Q60.7,83.05 66.6,81.75Q73.2,80.35 76.7,76.85Q80.2,73.35 81.6,66.75Q83,60.75 83,50.05Q83,40.25 81.8,34.25Q80.6,28.15 77.95,24.7Q75.3,21.25 70.6,19.45Q66.4,17.95 59.2,17.35Q52.2,16.85 45,17.15Q31.8,17.65 26,21.15Q22.7,23.15 20.75,26.65Q18.8,30.15 17.9,35.75Q17,41.35 17,50.05Q17,60.95 18.5,67.25Q20,73.55 23.3,76.85Q26.3,79.75 31.7,81.25Q38.2,83.05 50,83.05ZM40.4,29.65Q39.1,30.05 38.3,31.2Q37.5,32.35 37.6,33.85Q37.6,34.25 38,35.25L45.8,54.85L45.8,60.95Q45.8,65.85 45.9,66.85Q46,67.85 46.4,68.55Q46.8,69.25 47.4,69.75Q48.5,70.65 50,70.65Q51.5,70.65 52.6,69.75Q53.2,69.25 53.6,68.55Q54,67.85 54.1,66.85Q54.2,65.85 54.2,60.95L54.2,54.85L62,35.25Q62.4,34.25 62.4,33.85Q62.5,32.25 61.5,30.95Q60.3,29.45 58,29.45Q56.9,29.45 55.8,30.25Q54.9,30.95 54.5,31.75L50,42.95L47.8,37.55Q46.3,33.75 45.8,32.65Q45.2,31.15 44.75,30.7Q44.3,30.25 43.5,29.85Q43,29.55 42.7,29.5Q42.4,29.45 41.8,29.45Q41.2,29.45 40.4,29.65Z", 13 | "view_box": [0, 0, 100, 100], 14 | "size": [1, 2] 15 | }"#; 16 | serde_json::from_str(glyph_str).unwrap() 17 | }); 18 | static NO: LazyLock<Glyph> = LazyLock::new(|| { 19 | let glyph_str = r#"{ 20 | "path": "M44.8,8.85Q61.2,8.35 70.55,10.9Q79.9,13.45 84.6,19.75Q89,25.65 90.5,36.55Q91.1,41.15 91.3,46.95Q91.6,57.55 90.1,65.85Q88.1,77.25 82.8,82.55Q79.5,85.85 74.85,87.8Q70.2,89.75 63.5,90.55Q59.4,91.05 53.8,91.25Q43,91.65 34.6,90.25Q22.9,88.15 17.5,82.85Q15.3,80.65 13.9,78.25Q11.4,73.85 10.1,67.15Q8.4,58.55 8.8,46.15Q9,36.55 10.5,30.55Q12.5,22.35 17.4,17.45Q21.6,13.35 28.1,11.3Q34.6,9.25 44.8,8.85ZM50,83.05Q60.7,83.05 66.6,81.75Q73.2,80.35 76.7,76.85Q80.2,73.35 81.6,66.75Q83,60.75 83,50.05Q83,40.25 81.8,34.25Q80.6,28.15 77.95,24.7Q75.3,21.25 70.6,19.45Q66.4,17.95 59.2,17.35Q52.2,16.85 45,17.15Q31.8,17.65 26,21.15Q22.7,23.15 20.75,26.65Q18.8,30.15 17.9,35.75Q17,41.35 17,50.05Q17,60.95 18.5,67.25Q20,73.55 23.3,76.85Q26.3,79.75 31.7,81.25Q38.2,83.05 50,83.05ZM40.4,29.65Q39.5,29.95 38.8,30.7Q38.1,31.45 37.8,32.35Q37.7,32.75 37.6,35.05L37.6,64.95Q37.7,67.35 37.8,67.75L37.8,67.75Q38.9,70.65 42,70.65Q43.3,70.65 44.1,69.95Q44.7,69.45 45.8,67.65L45.8,51.05L53.8,66.85Q54.8,68.85 55.3,69.45Q55.7,69.85 56.4,70.15L56.5,70.25Q57.3,70.65 58,70.65Q61.1,70.65 62.2,67.75Q62.3,67.35 62.3,64.95L62.3,35.05Q62.3,32.75 62.2,32.35L62.2,32.35Q61.1,29.45 58,29.45Q56.7,29.45 55.9,30.15Q55.3,30.65 54.2,32.45L54.2,49.05L46.2,33.25Q45.1,31.25 44.6,30.65Q44.3,30.15 43.6,29.95L43.5,29.85Q43,29.55 42.7,29.5Q42.4,29.45 41.8,29.45Q41.2,29.45 40.4,29.65Z", 21 | "view_box": [0, 0, 100, 100], 22 | "size": [1, 2] 23 | }"#; 24 | serde_json::from_str(glyph_str).unwrap() 25 | }); 26 | 27 | #[tokio::main(flavor = "current_thread")] 28 | async fn main() -> Result<(), Error> { 29 | let options = SweepOptions { 30 | // tty_path: "/dev/pts/8".to_owned(), 31 | ..Default::default() 32 | }; 33 | let sweep = Sweep::<HaystackTagged<Text, &'static str>>::new((), options)?; 34 | sweep.items_extend( 35 | None, 36 | [Text::new() 37 | .put_fmt("Confirm Y/N", None) 38 | .take() 39 | .tagged("confirm", Some("1".parse()?))], 40 | ); 41 | while let Some(event) = sweep.next_event().await { 42 | if let sweep::SweepEvent::Select { items, .. } = event { 43 | let Some(item) = items.first() else { 44 | continue; 45 | }; 46 | if item.tag == "confirm" && yes_or_no(&sweep).await? { 47 | break; 48 | } 49 | } 50 | } 51 | Ok(()) 52 | } 53 | 54 | async fn yes_or_no<H: Haystack>(sweep: &Sweep<H>) -> Result<bool, Error> { 55 | let result = sweep 56 | .quick_select( 57 | Some(SweepOptions { 58 | prompt: "Y/N".to_owned(), 59 | theme: "accent=gruv-red-2".parse()?, 60 | ..Default::default() 61 | }), 62 | "yes/no".into(), 63 | (), 64 | [ 65 | Text::new() 66 | .by_ref() 67 | .with_face("fg=gruv-red-2,bold".parse()?) 68 | .with_glyph(YES.clone()) 69 | .put_fmt("es", None) 70 | .take() 71 | .tagged(true, Some("y".parse()?)), 72 | Text::new() 73 | .by_ref() 74 | .with_face("fg=gruv-green-2,bold".parse()?) 75 | .with_glyph(NO.clone()) 76 | .put_fmt("o", None) 77 | .take() 78 | .tagged(false, Some("n".parse()?)), 79 | ], 80 | ) 81 | .await?; 82 | Ok(result 83 | .into_iter() 84 | .next() 85 | .map_or_else(|| false, |item| item.tag)) 86 | } 87 | -------------------------------------------------------------------------------- /sweep-lib/src/common.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | future::Future, 3 | ops::Deref, 4 | pin::Pin, 5 | task::{Context, Poll}, 6 | }; 7 | 8 | use arrow_array::{ 9 | Array, ArrowPrimitiveType, GenericByteViewArray, PrimitiveArray, 10 | builder::{GenericByteViewBuilder, PrimitiveBuilder}, 11 | types::ByteViewType, 12 | }; 13 | use arrow_data::ByteView; 14 | use serde::{ 15 | Deserializer, 16 | de::{self, DeserializeSeed}, 17 | }; 18 | use tokio::task::JoinHandle; 19 | 20 | pub trait LockExt { 21 | type Value; 22 | 23 | fn with<Scope, Out>(&self, scope: Scope) -> Out 24 | where 25 | Scope: FnOnce(&Self::Value) -> Out; 26 | 27 | fn with_mut<Scope, Out>(&self, scope: Scope) -> Out 28 | where 29 | Scope: FnOnce(&mut Self::Value) -> Out; 30 | } 31 | 32 | impl<V> LockExt for std::sync::Mutex<V> { 33 | type Value = V; 34 | 35 | fn with<Scope, Out>(&self, scope: Scope) -> Out 36 | where 37 | Scope: FnOnce(&Self::Value) -> Out, 38 | { 39 | let value = self.lock().expect("lock poisoned"); 40 | scope(&*value) 41 | } 42 | 43 | fn with_mut<Scope, Out>(&self, scope: Scope) -> Out 44 | where 45 | Scope: FnOnce(&mut Self::Value) -> Out, 46 | { 47 | let mut value = self.lock().expect("lock poisoned"); 48 | scope(&mut *value) 49 | } 50 | } 51 | 52 | impl<V> LockExt for std::sync::RwLock<V> { 53 | type Value = V; 54 | 55 | fn with<Scope, Out>(&self, scope: Scope) -> Out 56 | where 57 | Scope: FnOnce(&Self::Value) -> Out, 58 | { 59 | let value = self.read().expect("lock poisoned"); 60 | scope(&*value) 61 | } 62 | 63 | fn with_mut<Scope, Out>(&self, scope: Scope) -> Out 64 | where 65 | Scope: FnOnce(&mut Self::Value) -> Out, 66 | { 67 | let mut value = self.write().expect("lock poisoned"); 68 | scope(&mut *value) 69 | } 70 | } 71 | 72 | /// Aborts task associated with [JoinHandle] on drop 73 | #[derive(Debug)] 74 | pub struct AbortJoinHandle<T> { 75 | handle: JoinHandle<T>, 76 | } 77 | 78 | impl<T> Drop for AbortJoinHandle<T> { 79 | fn drop(&mut self) { 80 | self.handle.abort() 81 | } 82 | } 83 | 84 | impl<T> Future for AbortJoinHandle<T> { 85 | type Output = <JoinHandle<T> as Future>::Output; 86 | fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> { 87 | Pin::new(&mut self.handle).poll(cx) 88 | } 89 | } 90 | 91 | impl<T> From<JoinHandle<T>> for AbortJoinHandle<T> { 92 | fn from(handle: JoinHandle<T>) -> Self { 93 | Self { handle } 94 | } 95 | } 96 | 97 | impl<T> Deref for AbortJoinHandle<T> { 98 | type Target = JoinHandle<T>; 99 | fn deref(&self) -> &Self::Target { 100 | &self.handle 101 | } 102 | } 103 | 104 | #[derive(Clone)] 105 | pub struct VecDeserializeSeed<S>(pub S); 106 | 107 | impl<'de, S> DeserializeSeed<'de> for VecDeserializeSeed<S> 108 | where 109 | S: DeserializeSeed<'de> + Clone, 110 | { 111 | type Value = Vec<S::Value>; 112 | 113 | fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error> 114 | where 115 | D: Deserializer<'de>, 116 | { 117 | struct VecVisitor<S> { 118 | seed: S, 119 | } 120 | 121 | impl<'de, S> de::Visitor<'de> for VecVisitor<S> 122 | where 123 | S: DeserializeSeed<'de> + Clone, 124 | { 125 | type Value = Vec<S::Value>; 126 | 127 | fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { 128 | formatter.write_str("sequence or null") 129 | } 130 | 131 | fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error> 132 | where 133 | A: de::SeqAccess<'de>, 134 | { 135 | let mut items = Vec::new(); 136 | while let Some(item) = seq.next_element_seed(self.seed.clone())? { 137 | items.push(item); 138 | } 139 | Ok(items) 140 | } 141 | 142 | fn visit_unit<E>(self) -> Result<Self::Value, E> 143 | where 144 | E: de::Error, 145 | { 146 | Ok(Vec::new()) 147 | } 148 | } 149 | 150 | deserializer.deserialize_any(VecVisitor { seed: self.0 }) 151 | } 152 | } 153 | 154 | pub fn json_from_slice_seed<'de, 'a: 'de, S: DeserializeSeed<'de>>( 155 | seed: S, 156 | slice: &'a [u8], 157 | ) -> serde_json::Result<S::Value> { 158 | use serde_json::{Deserializer, de::SliceRead}; 159 | 160 | let mut deserializer = Deserializer::new(SliceRead::new(slice)); 161 | seed.deserialize(&mut deserializer) 162 | } 163 | 164 | /// Efficient [GenericByteViewArray] filter that reuses buffers from input array 165 | pub(crate) fn byte_view_filter<T, P>( 166 | array: &GenericByteViewArray<T>, 167 | builder: &mut GenericByteViewBuilder<T>, 168 | mut predicate: P, 169 | ) where 170 | T: ByteViewType, 171 | P: FnMut(usize, &T::Native) -> bool, 172 | { 173 | let buffers = array.data_buffers(); 174 | let buffer_offset = if buffers.is_empty() { 175 | 0 176 | } else { 177 | let buffer_offset = builder.append_block(buffers[0].clone()); 178 | for buffer in &buffers[1..] { 179 | builder.append_block(buffer.clone()); 180 | } 181 | buffer_offset 182 | }; 183 | 184 | let nulls = array.nulls(); 185 | array.views().iter().enumerate().for_each(|(index, view)| { 186 | let item = unsafe { 187 | // Safety: index comes from iterating views 188 | array.value_unchecked(index) 189 | }; 190 | if !predicate(index, item) { 191 | return; 192 | } 193 | if nulls.map(|nulls| nulls.is_null(index)).unwrap_or(false) { 194 | return; 195 | } 196 | let view = ByteView::from(*view); 197 | if view.length <= 12 { 198 | builder.append_value(item); 199 | } else { 200 | unsafe { 201 | // Safety: view/blocks are taken for source string view array 202 | builder.append_view_unchecked( 203 | buffer_offset + view.buffer_index, 204 | view.offset, 205 | view.length, 206 | ); 207 | } 208 | } 209 | }); 210 | } 211 | 212 | pub(crate) fn byte_view_concat<'a, T>( 213 | arrays: impl IntoIterator<Item = &'a GenericByteViewArray<T>>, 214 | ) -> GenericByteViewArray<T> 215 | where 216 | T: ByteViewType, 217 | { 218 | let mut builder = GenericByteViewBuilder::new(); 219 | arrays 220 | .into_iter() 221 | .for_each(|array| byte_view_filter(array, &mut builder, |_, _| true)); 222 | builder.finish() 223 | } 224 | 225 | pub(crate) fn primitive_concat<'a, T>( 226 | arrays: impl IntoIterator<Item = &'a PrimitiveArray<T>>, 227 | ) -> PrimitiveArray<T> 228 | where 229 | T: ArrowPrimitiveType, 230 | { 231 | let mut builder = PrimitiveBuilder::new(); 232 | arrays.into_iter().for_each(|array| { 233 | builder.extend(array.iter()); 234 | }); 235 | builder.finish() 236 | } 237 | 238 | #[cfg(test)] 239 | mod tests { 240 | use super::*; 241 | use serde_json::de::StrRead; 242 | use std::marker::PhantomData; 243 | 244 | #[test] 245 | fn test_vec_deseed() -> Result<(), anyhow::Error> { 246 | let mut deserializer = serde_json::Deserializer::new(StrRead::new("[1, 2, 3]")); 247 | let result = VecDeserializeSeed(PhantomData::<i32>).deserialize(&mut deserializer)?; 248 | assert_eq!(result, vec![1, 2, 3]); 249 | 250 | let mut deserializer = serde_json::Deserializer::new(StrRead::new("null")); 251 | let result = VecDeserializeSeed(PhantomData::<i32>).deserialize(&mut deserializer)?; 252 | assert_eq!(result, Vec::<i32>::new()); 253 | 254 | Ok(()) 255 | } 256 | } 257 | -------------------------------------------------------------------------------- /sweep-lib/src/haystack.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use either::Either; 4 | use surf_n_term::{ 5 | CellWrite, KeyChord, Position, TerminalSurface, 6 | render::CellKind, 7 | view::{BoxConstraint, Layout, Text, View, ViewContext, ViewLayout, ViewMutLayout}, 8 | }; 9 | 10 | use crate::{Positions, Theme}; 11 | 12 | /// Haystack 13 | /// 14 | /// Item that can be scored/ranked/shown by sweep 15 | pub trait Haystack: std::fmt::Debug + Clone + Send + Sync + 'static { 16 | /// Haystack context passed when generating view and preview (for example 17 | /// [Candidate](crate::Candidate) reference resolution) 18 | type Context: Clone + Send + Sync; 19 | type View: View; 20 | type Preview: HaystackPreview; 21 | type PreviewLarge: HaystackPreview + Clone; 22 | 23 | /// Scope function is called with all characters one after another that will 24 | /// be searchable by [Scorer] 25 | fn haystack_scope<S>(&self, ctx: &Self::Context, scope: S) 26 | where 27 | S: FnMut(char); 28 | 29 | /// Key that can be used to select that item in hotkey mode 30 | fn hotkey(&self) -> Option<KeyChord> { 31 | None 32 | } 33 | 34 | /// Return a view that renders haystack item in a list 35 | fn view(&self, ctx: &Self::Context, positions: Positions<&[u8]>, theme: &Theme) -> Self::View; 36 | 37 | /// Side preview of the current item 38 | fn preview( 39 | &self, 40 | _ctx: &Self::Context, 41 | _positions: Positions<&[u8]>, 42 | _theme: &Theme, 43 | ) -> Option<Self::Preview> { 44 | None 45 | } 46 | 47 | /// Large preview of the current item 48 | fn preview_large( 49 | &self, 50 | _ctx: &Self::Context, 51 | _positions: Positions<&[u8]>, 52 | _theme: &Theme, 53 | ) -> Option<Self::PreviewLarge> { 54 | None 55 | } 56 | 57 | // Tag haystack with a value, useful for `quick_select` 58 | fn tagged<T>(self, tag: T, hotkey: Option<KeyChord>) -> HaystackTagged<Self, T> 59 | where 60 | Self: Sized, 61 | { 62 | HaystackTagged { 63 | haystack: self, 64 | hotkey, 65 | tag, 66 | } 67 | } 68 | } 69 | 70 | /// View that is used for preview, and include addition methods to make it more functional 71 | pub trait HaystackPreview: View { 72 | /// Flex value when use as a child 73 | fn flex(&self) -> Option<f64> { 74 | Some(1.0) 75 | } 76 | 77 | /// Current preview layout 78 | /// 79 | /// Size represents full size of the preview 80 | /// Offset represents vertical and horizontal scroll position 81 | fn preview_layout(&self) -> Layout { 82 | Layout::new() 83 | } 84 | 85 | /// When rendering offset by specified position (used for scrolling) 86 | /// 87 | /// Returns updated offset, default implementation is not scrollable hence 88 | /// it is always returns `Position::origin()` 89 | fn set_offset(&self, offset: Position) -> Position { 90 | _ = offset; 91 | Position::origin() 92 | } 93 | } 94 | 95 | impl HaystackPreview for () {} 96 | 97 | impl<L, R> HaystackPreview for Either<L, R> 98 | where 99 | L: HaystackPreview, 100 | R: HaystackPreview, 101 | { 102 | fn flex(&self) -> Option<f64> { 103 | match self { 104 | Either::Left(left) => left.flex(), 105 | Either::Right(right) => right.flex(), 106 | } 107 | } 108 | 109 | fn preview_layout(&self) -> Layout { 110 | match self { 111 | Either::Left(left) => left.preview_layout(), 112 | Either::Right(right) => right.preview_layout(), 113 | } 114 | } 115 | 116 | fn set_offset(&self, offset: Position) -> Position { 117 | match self { 118 | Either::Left(left) => left.set_offset(offset), 119 | Either::Right(right) => right.set_offset(offset), 120 | } 121 | } 122 | } 123 | 124 | impl<T: HaystackPreview + ?Sized> HaystackPreview for Arc<T> { 125 | fn flex(&self) -> Option<f64> { 126 | (**self).flex() 127 | } 128 | 129 | fn preview_layout(&self) -> Layout { 130 | (**self).preview_layout() 131 | } 132 | 133 | fn set_offset(&self, offset: Position) -> Position { 134 | (**self).set_offset(offset) 135 | } 136 | } 137 | 138 | #[derive(Clone)] 139 | pub struct HaystackTagged<H, T> { 140 | pub haystack: H, 141 | pub hotkey: Option<KeyChord>, 142 | pub tag: T, 143 | } 144 | 145 | impl<H: Haystack, T> std::fmt::Debug for HaystackTagged<H, T> { 146 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 147 | f.debug_struct("HaystackTagged") 148 | .field("haystack", &self.haystack) 149 | .field("hotkey", &self.hotkey) 150 | .finish() 151 | } 152 | } 153 | 154 | impl<H, T> Haystack for HaystackTagged<H, T> 155 | where 156 | H: Haystack, 157 | T: Clone + Send + Sync + 'static, 158 | { 159 | type Context = H::Context; 160 | type View = H::View; 161 | type Preview = H::Preview; 162 | type PreviewLarge = H::PreviewLarge; 163 | 164 | fn haystack_scope<S>(&self, ctx: &Self::Context, scope: S) 165 | where 166 | S: FnMut(char), 167 | { 168 | self.haystack.haystack_scope(ctx, scope); 169 | } 170 | 171 | fn view(&self, ctx: &Self::Context, positions: Positions<&[u8]>, theme: &Theme) -> Self::View { 172 | self.haystack.view(ctx, positions, theme) 173 | } 174 | 175 | fn hotkey(&self) -> Option<KeyChord> { 176 | self.hotkey.clone().or(self.haystack.hotkey()) 177 | } 178 | 179 | fn preview( 180 | &self, 181 | ctx: &Self::Context, 182 | positions: Positions<&[u8]>, 183 | theme: &Theme, 184 | ) -> Option<Self::Preview> { 185 | self.haystack.preview(ctx, positions, theme) 186 | } 187 | 188 | fn preview_large( 189 | &self, 190 | ctx: &Self::Context, 191 | positions: Positions<&[u8]>, 192 | theme: &Theme, 193 | ) -> Option<Self::PreviewLarge> { 194 | self.haystack.preview_large(ctx, positions, theme) 195 | } 196 | } 197 | 198 | impl<L, R> Haystack for Either<L, R> 199 | where 200 | L: Haystack, 201 | R: Haystack, 202 | { 203 | type Context = (L::Context, R::Context); 204 | type View = Either<L::View, R::View>; 205 | type Preview = Either<L::Preview, R::Preview>; 206 | type PreviewLarge = Either<L::PreviewLarge, R::PreviewLarge>; 207 | 208 | fn haystack_scope<S>(&self, ctx: &Self::Context, scope: S) 209 | where 210 | S: FnMut(char), 211 | { 212 | match self { 213 | Either::Left(left) => left.haystack_scope(&ctx.0, scope), 214 | Either::Right(right) => right.haystack_scope(&ctx.1, scope), 215 | } 216 | } 217 | 218 | fn view(&self, ctx: &Self::Context, positions: Positions<&[u8]>, theme: &Theme) -> Self::View { 219 | match self { 220 | Either::Left(left) => left.view(&ctx.0, positions, theme).left_view(), 221 | Either::Right(right) => right.view(&ctx.1, positions, theme).right_view(), 222 | } 223 | } 224 | 225 | fn hotkey(&self) -> Option<KeyChord> { 226 | None 227 | } 228 | 229 | fn preview( 230 | &self, 231 | ctx: &Self::Context, 232 | positions: Positions<&[u8]>, 233 | theme: &Theme, 234 | ) -> Option<Self::Preview> { 235 | let preview = match self { 236 | Either::Left(left) => left.preview(&ctx.0, positions, theme)?.left_view(), 237 | Either::Right(right) => right.preview(&ctx.1, positions, theme)?.right_view(), 238 | }; 239 | Some(preview) 240 | } 241 | 242 | fn preview_large( 243 | &self, 244 | ctx: &Self::Context, 245 | positions: Positions<&[u8]>, 246 | theme: &Theme, 247 | ) -> Option<Self::PreviewLarge> { 248 | let preview = match self { 249 | Either::Left(left) => left.preview_large(&ctx.0, positions, theme)?.left_view(), 250 | Either::Right(right) => right.preview_large(&ctx.1, positions, theme)?.right_view(), 251 | }; 252 | Some(preview) 253 | } 254 | } 255 | 256 | pub struct HaystackDefaultView { 257 | text: Text, 258 | } 259 | 260 | impl HaystackDefaultView { 261 | pub fn new<H: Haystack>( 262 | ctx: &H::Context, 263 | haystack: &H, 264 | positions: Positions<&[u8]>, 265 | theme: &Theme, 266 | ) -> Self { 267 | let mut text = Text::new(); 268 | let mut index = 0; 269 | haystack.haystack_scope(ctx, |char| { 270 | text.set_face(if positions.get(index) { 271 | theme.list_highlight 272 | } else { 273 | theme.list_text 274 | }); 275 | text.put_char(char); 276 | index += 1; 277 | }); 278 | Self { text } 279 | } 280 | } 281 | 282 | impl View for HaystackDefaultView { 283 | fn render( 284 | &self, 285 | ctx: &ViewContext, 286 | surf: TerminalSurface<'_>, 287 | layout: ViewLayout<'_>, 288 | ) -> Result<(), surf_n_term::Error> { 289 | self.text.render(ctx, surf, layout) 290 | } 291 | 292 | fn layout( 293 | &self, 294 | ctx: &ViewContext, 295 | ct: BoxConstraint, 296 | layout: ViewMutLayout<'_>, 297 | ) -> Result<(), surf_n_term::Error> { 298 | self.text.layout(ctx, ct, layout) 299 | } 300 | } 301 | 302 | pub struct HaystackBasicPreview<V> { 303 | view: V, 304 | flex: Option<f64>, 305 | } 306 | 307 | impl<V> HaystackBasicPreview<V> { 308 | pub fn new(view: V, flex: Option<f64>) -> Self { 309 | Self { view, flex } 310 | } 311 | } 312 | 313 | impl<V> View for HaystackBasicPreview<V> 314 | where 315 | V: View, 316 | { 317 | fn render( 318 | &self, 319 | ctx: &ViewContext, 320 | surf: TerminalSurface<'_>, 321 | layout: ViewLayout<'_>, 322 | ) -> Result<(), surf_n_term::Error> { 323 | self.view.render(ctx, surf, layout) 324 | } 325 | 326 | fn layout( 327 | &self, 328 | ctx: &ViewContext, 329 | ct: BoxConstraint, 330 | layout: ViewMutLayout<'_>, 331 | ) -> Result<(), surf_n_term::Error> { 332 | self.view.layout(ctx, ct, layout) 333 | } 334 | } 335 | 336 | impl<V> HaystackPreview for HaystackBasicPreview<V> 337 | where 338 | V: View, 339 | { 340 | fn flex(&self) -> Option<f64> { 341 | self.flex 342 | } 343 | } 344 | 345 | impl Haystack for String { 346 | type Context = (); 347 | type View = HaystackDefaultView; 348 | type Preview = (); 349 | type PreviewLarge = (); 350 | 351 | fn view(&self, ctx: &Self::Context, positions: Positions<&[u8]>, theme: &Theme) -> Self::View { 352 | HaystackDefaultView::new(ctx, self, positions, theme) 353 | } 354 | 355 | fn haystack_scope<S>(&self, _ctx: &Self::Context, scope: S) 356 | where 357 | S: FnMut(char), 358 | { 359 | self.chars().for_each(scope) 360 | } 361 | } 362 | 363 | impl Haystack for &'static str { 364 | type Context = (); 365 | type View = HaystackDefaultView; 366 | type Preview = (); 367 | type PreviewLarge = (); 368 | 369 | fn view(&self, ctx: &Self::Context, positions: Positions<&[u8]>, theme: &Theme) -> Self::View { 370 | HaystackDefaultView::new(ctx, self, positions, theme) 371 | } 372 | 373 | fn haystack_scope<S>(&self, _ctx: &Self::Context, scope: S) 374 | where 375 | S: FnMut(char), 376 | { 377 | self.chars().for_each(scope) 378 | } 379 | } 380 | 381 | impl Haystack for Text { 382 | type Context = (); 383 | type View = Text; 384 | type Preview = (); 385 | type PreviewLarge = (); 386 | 387 | fn haystack_scope<S>(&self, _ctx: &Self::Context, mut scope: S) 388 | where 389 | S: FnMut(char), 390 | { 391 | self.cells().iter().for_each(|cell| { 392 | if let CellKind::Char(ch) = cell.kind() { 393 | scope(*ch); 394 | } 395 | }); 396 | } 397 | 398 | fn view(&self, _ctx: &Self::Context, positions: Positions<&[u8]>, theme: &Theme) -> Self::View { 399 | let mut index = 0; 400 | self.cells() 401 | .iter() 402 | .map(|cell| { 403 | let cell = cell.clone(); 404 | if matches!(cell.kind(), CellKind::Char(..)) { 405 | let highligted = positions.get(index); 406 | index += 1; 407 | if highligted { 408 | let face = cell.face().overlay(&theme.list_highlight); 409 | cell.with_face(face) 410 | } else { 411 | cell 412 | } 413 | } else { 414 | cell 415 | } 416 | }) 417 | .collect() 418 | } 419 | } 420 | -------------------------------------------------------------------------------- /sweep-lib/src/icons.json: -------------------------------------------------------------------------------- 1 | { 2 | "fuzzy-word": { 3 | "view_box": [0, 0, 512, 256], 4 | "size": [1, 6], 5 | "fallback": " [fuzzy] ", 6 | "path": "M61.9062 12.0312C35.681 12.0312 14.5625 34.2413 14.5625 61.4688L14.5625 193.281C14.5625 220.509 35.681 242.719 61.9062 242.719L455.719 242.719C481.944 242.719 503.062 220.509 503.062 193.281L503.062 61.4688C503.062 34.2413 481.944 12.0313 455.719 12.0312L61.9062 12.0312ZM61.9062 25.2812L455.719 25.2812C474.465 25.2812 489.781 41.4242 489.781 61.4688L489.781 193.281C489.781 213.326 474.465 229.437 455.719 229.438L61.9062 229.438C43.16 229.438 27.8125 213.326 27.8125 193.281L27.8125 61.4688C27.8125 41.4242 43.16 25.2812 61.9062 25.2812ZM61.2161 186.254L61.2161 59.4505L122.116 59.4505L122.116 76.0126L80.5385 76.0126L80.5385 112.07L112.283 112.07L112.283 128.632L80.5385 128.632L80.5385 186.254L61.2161 186.254ZM167.49 187.634C164.729 187.634 162.026 187.174 159.381 186.254C156.736 185.334 154.407 183.954 152.394 182.114C150.381 180.273 148.742 178.117 147.477 175.644C146.212 173.171 145.206 170.612 144.458 167.967C143.71 165.321 143.193 162.647 142.905 159.945C142.618 157.242 142.474 154.51 142.474 151.75L142.474 94.8175L161.624 94.8175L161.624 151.75C161.624 153.245 161.71 154.74 161.883 156.235C162.055 157.731 162.343 159.197 162.745 160.635C163.148 162.072 163.723 163.424 164.47 164.689C165.218 165.954 166.138 167.104 167.231 168.139C168.323 169.174 169.589 169.922 171.026 170.382C172.464 170.842 173.93 171.072 175.426 171.072C176.921 171.072 178.387 170.842 179.825 170.382C181.263 169.922 182.528 169.174 183.62 168.139C184.713 167.104 185.633 165.954 186.381 164.689C187.128 163.424 187.703 162.072 188.106 160.635C188.509 159.197 188.796 157.731 188.969 156.235C189.141 154.74 189.227 153.245 189.227 151.75L189.227 94.8175L208.377 94.8175L208.377 186.254L189.227 186.254L189.227 172.107C188.307 174.408 187.157 176.535 185.777 178.491C184.397 180.446 182.758 182.142 180.86 183.58C178.962 185.018 176.863 186.053 174.563 186.685C172.263 187.318 169.905 187.634 167.49 187.634ZM228.735 186.254L228.735 169.692L271.52 111.38L228.735 111.38L228.735 94.8175L294.638 94.8175L294.638 111.38L251.853 169.692L294.638 169.692L294.638 186.254L228.735 186.254ZM314.996 186.254L314.996 169.692L357.781 111.38L314.996 111.38L314.996 94.8175L380.899 94.8175L380.899 111.38L338.114 169.692L380.899 169.692L380.899 186.254L314.996 186.254ZM414.368 221.621C415.634 217.481 416.841 213.311 417.991 209.113C419.142 204.915 420.292 200.746 421.442 196.605L424.547 185.047L399.187 94.8175L419.027 94.8175L434.554 160.203L449.39 94.8175L469.23 94.8175L434.209 221.621L414.368 221.621Z" 7 | }, 8 | "fuzzy": { 9 | "name": "tabler-square-rounded-letter-f", 10 | "view_box": [0, 0, 100, 100], 11 | "size": [1, 3], 12 | "fallback": " [F]", 13 | "path": "M44.85,9.05Q59.45,8.55 68.55,10.55Q77.45,12.65 82.45,17.35Q87.05,21.85 89.15,29.35Q90.85,35.65 91.25,46.35Q91.65,59.45 89.75,68.05Q88.05,76.35 83.95,81.35Q80.25,85.95 74.25,88.25Q69.05,90.25 60.95,91.05Q55.75,91.45 49.15,91.45Q42.55,91.45 37.75,90.95Q26.25,89.75 19.75,84.85Q13.75,80.35 11.25,72.15Q9.15,65.35 8.85,54.15Q8.35,40.75 10.3,32.15Q12.25,23.55 16.65,18.45Q20.95,13.75 28.15,11.45Q34.65,9.45 44.85,9.05ZM50.05,83.25Q60.75,83.25 66.65,81.95Q73.25,80.55 76.75,77.05Q80.25,73.55 81.65,66.95Q83.05,60.95 83.05,50.25Q83.05,40.15 81.85,34.15Q80.55,27.85 77.55,24.35Q74.55,20.85 69.15,19.25Q64.35,17.75 55.75,17.35Q46.25,16.95 39.25,17.75Q30.45,18.65 26.05,21.35Q22.75,23.35 20.8,26.85Q18.85,30.35 17.95,35.95Q17.05,41.55 17.05,50.25Q17.05,61.15 18.55,67.45Q20.05,73.75 23.35,77.05Q26.35,79.95 31.75,81.45Q38.25,83.25 50.05,83.25ZM40.45,29.85Q39.55,30.15 38.85,30.9Q38.15,31.65 37.85,32.55Q37.75,32.95 37.65,35.25L37.65,65.15Q37.75,67.55 37.85,67.95L37.85,67.95Q38.95,70.85 42.05,70.85Q43.35,70.85 44.15,70.15Q44.75,69.65 45.85,67.85L45.85,54.45L50.25,54.45Q53.45,54.45 54.45,54.35Q55.85,54.15 56.5,53.7Q57.15,53.25 57.85,52.05Q58.05,51.55 58.15,51.35L58.15,50.25Q58.15,48.55 57.25,47.45Q56.75,46.85 56.1,46.55Q55.45,46.25 54.65,46.15Q53.85,46.05 50.25,46.05L45.85,46.05L45.85,37.85L52.65,37.85L59.35,37.75Q60.55,37.15 60.95,36.85Q61.65,36.35 62.05,35.45Q62.65,34.05 62.3,32.6Q61.95,31.15 60.65,30.35L60.45,30.25Q59.95,29.95 59.4,29.85Q58.85,29.75 56.75,29.65L42.25,29.65Q40.85,29.75 40.45,29.85Z" 14 | }, 15 | "substr-word": { 16 | "view_box": [0, 0, 512, 256], 17 | "size": [1, 6], 18 | "fallback": " [substr] ", 19 | "path": "M60.375 12.375C34.1498 12.375 13.0312 34.585 13.0312 61.8125L13.0312 193.625C13.0312 220.852 34.1498 243.063 60.375 243.062L454.188 243.062C480.413 243.062 501.531 220.852 501.531 193.625L501.531 61.8125C501.531 34.585 480.413 12.375 454.188 12.375L60.375 12.375ZM60.375 25.6562L454.188 25.6562C472.934 25.6562 488.281 41.7679 488.281 61.8125L488.281 193.625C488.281 213.67 472.934 229.781 454.188 229.781L60.375 229.781C41.6287 229.781 26.3125 213.67 26.3125 193.625L26.3125 61.8125C26.3125 41.7679 41.6287 25.6562 60.375 25.6562ZM79.4697 192.223C76.4794 192.223 73.489 191.964 70.4986 191.447C67.5082 190.929 64.6616 190.038 61.9588 188.773C59.2559 187.507 56.8119 185.811 54.6266 183.683C52.4413 181.555 50.6298 179.198 49.1921 176.61C47.7545 174.022 46.6906 171.262 46.0005 168.329C45.3104 165.396 44.9654 162.434 44.9654 159.444C44.9654 159.329 44.9654 159.271 44.9654 159.271C44.9654 159.271 44.9654 159.214 44.9654 159.099L64.1153 159.099C64.1153 159.214 64.1153 159.271 64.1153 159.271L64.1153 159.271C64.1153 161.342 64.4316 163.412 65.0642 165.482C65.6967 167.552 66.7031 169.393 68.0833 171.003C69.4635 172.613 71.1599 173.792 73.1727 174.54C75.1854 175.287 77.2845 175.661 79.4697 175.661C81.655 175.661 83.7828 175.23 85.8531 174.367C87.9233 173.504 89.6198 172.182 90.9425 170.399C92.2651 168.616 93.2427 166.632 93.8753 164.447C94.5079 162.262 94.8242 160.076 94.8242 157.891C94.8242 155.131 94.2491 152.486 93.099 149.955C91.9488 147.425 90.3674 145.211 88.3546 143.313C86.3419 141.415 84.0991 139.834 81.6263 138.569C79.1535 137.304 76.6806 136.038 74.2078 134.773C71.735 133.508 69.3485 132.128 67.0482 130.633C64.7479 129.138 62.5626 127.499 60.4923 125.716C58.4221 123.933 56.4956 121.978 54.7128 119.85C52.9301 117.722 51.4349 115.422 50.2273 112.949C49.0196 110.476 48.1283 107.889 47.5532 105.186C46.9781 102.483 46.6906 99.7513 46.6906 96.991C46.6906 94.0006 47.0069 91.0102 47.6394 88.0198C48.272 85.0294 49.2496 82.1828 50.5723 79.48C51.895 76.7771 53.5627 74.3043 55.5755 72.0615C57.5882 69.8187 59.9173 67.9785 62.5626 66.5408C65.2079 65.1031 67.997 64.0968 70.9299 63.5217C73.8628 62.9466 76.8244 62.6591 79.8148 62.6591C82.8052 62.6591 85.7668 62.9179 88.6997 63.4354C91.6325 63.953 94.3929 64.9019 96.9807 66.282C99.5685 67.6622 101.898 69.4162 103.968 71.544C106.038 73.6717 107.735 76.0295 109.057 78.6174C110.38 81.2052 111.358 83.9655 111.99 86.8984C112.623 89.8313 112.939 92.7354 112.939 95.6108C112.939 95.6108 112.939 95.6395 112.939 95.697C112.939 95.7545 112.939 95.7833 112.939 95.7833L93.7891 95.7833L93.7891 95.7833L93.7891 95.7833C93.7891 93.713 93.5303 91.6715 93.0127 89.6588C92.4951 87.646 91.6325 85.8345 90.4249 84.2243C89.2172 82.6141 87.6645 81.3777 85.7668 80.5151C83.8691 79.6525 81.885 79.2212 79.8148 79.2212C77.7445 79.2212 75.7318 79.71 73.7765 80.6876C71.8213 81.6653 70.2686 83.0167 69.1184 84.7419C67.9683 86.4671 67.1344 88.3936 66.6169 90.5214C66.0993 92.6492 65.8405 94.7482 65.8405 96.8184C65.8405 99.5788 66.4156 102.253 67.5657 104.841C68.7159 107.429 70.2973 109.671 72.3101 111.569C74.3228 113.467 76.5369 115.048 78.9522 116.313C81.3675 117.579 83.8115 118.844 86.2844 120.109C88.7572 121.374 91.1725 122.725 93.5303 124.163C95.8881 125.601 98.1021 127.24 100.172 129.08C102.243 130.92 104.14 132.904 105.866 135.032C107.591 137.16 109.057 139.46 110.265 141.933C111.473 144.406 112.393 146.994 113.025 149.696C113.658 152.399 113.974 155.131 113.974 157.891C113.974 160.997 113.629 164.044 112.939 167.035C112.249 170.025 111.214 172.872 109.834 175.575C108.453 178.278 106.671 180.75 104.485 182.993C102.3 185.236 99.8561 187.047 97.1532 188.428C94.4504 189.808 91.575 190.785 88.5271 191.36C85.4793 191.936 82.4601 192.223 79.4697 192.223ZM147.318 193.476C144.557 193.476 141.854 193.016 139.209 192.096C136.564 191.176 134.235 189.795 132.222 187.955C130.209 186.115 128.57 183.958 127.305 181.486C126.04 179.013 125.034 176.454 124.286 173.808C123.538 171.163 123.021 168.489 122.733 165.786C122.446 163.083 122.302 160.352 122.302 157.591L122.302 100.659L141.452 100.659L141.452 157.591C141.452 159.087 141.538 160.582 141.711 162.077C141.883 163.572 142.171 165.039 142.573 166.476C142.976 167.914 143.551 169.265 144.299 170.531C145.046 171.796 145.966 172.946 147.059 173.981C148.152 175.016 149.417 175.764 150.854 176.224C152.292 176.684 153.758 176.914 155.254 176.914C156.749 176.914 158.215 176.684 159.653 176.224C161.091 175.764 162.356 175.016 163.448 173.981C164.541 172.946 165.461 171.796 166.209 170.531C166.956 169.265 167.531 167.914 167.934 166.476C168.337 165.039 168.624 163.572 168.797 162.077C168.969 160.582 169.055 159.087 169.055 157.591L169.055 100.659L188.205 100.659L188.205 192.096L169.055 192.096L169.055 177.949C168.135 180.249 166.985 182.377 165.605 184.332C164.225 186.288 162.586 187.984 160.688 189.422C158.79 190.859 156.691 191.895 154.391 192.527C152.091 193.16 149.733 193.476 147.318 193.476ZM238.751 194.323C236.336 194.323 233.978 194.007 231.677 193.374C229.377 192.741 227.249 191.706 225.294 190.269C223.339 188.831 221.671 187.134 220.291 185.179C218.911 183.224 217.761 181.096 216.841 178.796L216.841 192.943L197.691 192.943L197.691 66.139L216.841 66.139L216.841 115.653C217.761 113.353 218.911 111.225 220.291 109.269C221.671 107.314 223.339 105.618 225.294 104.18C227.249 102.742 229.377 101.707 231.677 101.075C233.978 100.442 236.336 100.126 238.751 100.126C241.511 100.126 244.243 100.557 246.946 101.42C249.648 102.282 252.064 103.605 254.192 105.388C256.319 107.17 258.102 109.298 259.54 111.771C260.977 114.244 262.099 116.803 262.904 119.448C263.709 122.094 264.255 124.825 264.543 127.643C264.83 130.461 264.974 133.25 264.974 136.01L264.974 158.438C264.974 161.199 264.83 163.988 264.543 166.806C264.255 169.623 263.709 172.355 262.904 175C262.099 177.646 260.977 180.205 259.54 182.678C258.102 185.15 256.319 187.278 254.192 189.061C252.064 190.844 249.648 192.166 246.946 193.029C244.243 193.892 241.511 194.323 238.751 194.323ZM230.815 177.761C233.115 177.761 235.329 177.214 237.457 176.122C239.585 175.029 241.281 173.534 242.546 171.636C243.811 169.738 244.674 167.639 245.134 165.339C245.594 163.039 245.824 160.739 245.824 158.438L245.824 136.01C245.824 133.71 245.594 131.41 245.134 129.11C244.674 126.809 243.811 124.71 242.546 122.812C241.281 120.915 239.585 119.42 237.457 118.327C235.329 117.234 233.115 116.688 230.815 116.688C229.32 116.688 227.853 116.918 226.416 117.378C224.978 117.838 223.684 118.557 222.534 119.535C221.384 120.512 220.435 121.662 219.687 122.985C218.94 124.308 218.364 125.688 217.962 127.126C217.559 128.563 217.272 130.03 217.099 131.525C216.927 133.02 216.841 134.515 216.841 136.01L216.841 158.438C216.841 159.933 216.927 161.429 217.099 162.924C217.272 164.419 217.559 165.885 217.962 167.323C218.364 168.761 218.94 170.141 219.687 171.464C220.435 172.786 221.384 173.936 222.534 174.914C223.684 175.892 224.978 176.611 226.416 177.071C227.853 177.531 229.32 177.761 230.815 177.761ZM302.168 194.867C299.523 194.867 296.877 194.695 294.232 194.35C291.587 194.005 288.999 193.343 286.469 192.366C283.938 191.388 281.581 190.094 279.395 188.484C277.21 186.874 275.37 184.976 273.875 182.791C272.379 180.605 271.229 178.19 270.424 175.545C269.619 172.899 269.216 170.254 269.216 167.609L288.366 167.609C288.481 169.219 288.913 170.772 289.66 172.267C290.408 173.762 291.472 174.97 292.852 175.89C294.232 176.81 295.727 177.443 297.338 177.788C298.948 178.133 300.558 178.305 302.168 178.305C303.893 178.305 305.561 178.133 307.171 177.788C308.781 177.443 310.277 176.839 311.657 175.976C313.037 175.113 314.158 173.935 315.021 172.439C315.884 170.944 316.315 169.391 316.315 167.781C316.315 165.941 315.711 164.273 314.503 162.778C313.296 161.283 311.858 160.19 310.19 159.5C308.523 158.81 306.797 158.235 305.015 157.775C303.232 157.315 301.449 156.826 299.667 156.309C297.884 155.791 296.13 155.245 294.405 154.67C292.679 154.095 290.983 153.462 289.315 152.772C287.648 152.082 286.037 151.248 284.485 150.27C282.932 149.293 281.437 148.229 279.999 147.079C278.561 145.929 277.296 144.635 276.204 143.197C275.111 141.759 274.162 140.207 273.357 138.539C272.552 136.871 271.948 135.146 271.545 133.363C271.143 131.58 270.942 129.769 270.942 127.929C270.942 125.283 271.315 122.696 272.063 120.165C272.811 117.635 273.903 115.277 275.341 113.092C276.779 110.907 278.533 108.98 280.603 107.312C282.673 105.645 284.916 104.293 287.331 103.258C289.747 102.223 292.277 101.533 294.922 101.188C297.568 100.843 300.213 100.67 302.858 100.67C305.389 100.67 307.948 100.843 310.535 101.188C313.123 101.533 315.625 102.194 318.04 103.172C320.455 104.149 322.698 105.443 324.769 107.054C326.839 108.664 328.593 110.562 330.03 112.747C331.468 114.932 332.561 117.29 333.308 119.82C334.056 122.351 334.43 124.938 334.43 127.584L315.28 127.584C315.165 126.088 314.791 124.622 314.158 123.184C313.526 121.747 312.606 120.568 311.398 119.648C310.19 118.728 308.839 118.095 307.344 117.75C305.849 117.405 304.353 117.232 302.858 117.232C301.248 117.232 299.695 117.405 298.2 117.75C296.705 118.095 295.325 118.728 294.06 119.648C292.794 120.568 291.817 121.747 291.127 123.184C290.437 124.622 290.092 126.146 290.092 127.756C290.092 129.596 290.695 131.264 291.903 132.759C293.111 134.255 294.548 135.347 296.216 136.037C297.884 136.727 299.609 137.302 301.392 137.762C303.175 138.223 304.928 138.711 306.654 139.229C308.379 139.746 310.104 140.264 311.829 140.782C313.555 141.299 315.28 141.932 317.005 142.679C318.73 143.427 320.369 144.261 321.922 145.181C323.475 146.101 324.941 147.165 326.321 148.373C327.701 149.58 328.967 150.903 330.117 152.341C331.267 153.778 332.244 155.302 333.05 156.912C333.855 158.523 334.458 160.248 334.861 162.088C335.264 163.928 335.465 165.711 335.465 167.436C335.465 170.197 335.062 172.871 334.257 175.459C333.452 178.046 332.273 180.462 330.721 182.704C329.168 184.947 327.299 186.874 325.114 188.484C322.928 190.094 320.57 191.388 318.04 192.366C315.51 193.343 312.922 194.005 310.277 194.35C307.631 194.695 304.928 194.867 302.168 194.867ZM382.268 195.479C379.393 195.479 376.517 195.134 373.642 194.444C370.766 193.754 368.15 192.546 365.792 190.821C363.434 189.096 361.421 187.025 359.754 184.61C358.086 182.195 356.763 179.607 355.786 176.847C354.808 174.086 354.147 171.24 353.802 168.307C353.457 165.374 353.284 162.47 353.284 159.594L353.284 119.224L336.55 119.224L336.55 102.662L353.284 102.662L353.284 67.2952L372.607 67.2952L372.607 102.662L396.932 102.662L396.932 119.224L372.607 119.224L372.607 159.594C372.607 160.975 372.635 162.326 372.693 163.649C372.75 164.971 372.894 166.294 373.124 167.617C373.354 168.939 373.671 170.233 374.073 171.498C374.476 172.764 375.051 173.943 375.798 175.035C376.546 176.128 377.495 177.048 378.645 177.796C379.795 178.543 381.003 178.917 382.268 178.917C383.993 178.917 385.575 178.371 387.012 177.278C388.45 176.185 389.514 174.834 390.204 173.224C390.894 171.613 391.354 169.946 391.584 168.221C391.814 166.495 391.929 164.77 391.929 163.045C391.929 162.93 391.929 162.786 391.929 162.614C391.929 162.441 391.929 162.297 391.929 162.182L411.252 162.182C411.252 162.527 411.252 162.844 411.252 163.131C411.252 163.419 411.252 163.677 411.252 163.907C411.252 166.668 410.993 169.371 410.475 172.016C409.958 174.661 409.181 177.22 408.146 179.693C407.111 182.166 405.702 184.438 403.919 186.508C402.137 188.578 400.095 190.303 397.795 191.684C395.495 193.064 393.022 194.041 390.376 194.616C387.731 195.191 385.028 195.479 382.268 195.479ZM418.933 193.405L418.933 101.968L438.255 101.968L438.255 121.808C438.945 119.163 439.808 116.604 440.843 114.131C441.878 111.658 443.258 109.387 444.984 107.317C446.709 105.246 448.808 103.607 451.281 102.4C453.753 101.192 456.312 100.588 458.958 100.588C461.143 100.588 463.3 100.933 465.427 101.623C467.555 102.313 469.395 103.406 470.948 104.901C472.501 106.396 473.766 108.122 474.744 110.077C475.721 112.032 476.498 114.074 477.073 116.201C477.648 118.329 478.021 120.486 478.194 122.671C478.367 124.856 478.453 127.042 478.453 129.227L459.13 129.227C459.13 127.847 459.044 126.467 458.872 125.086C458.699 123.706 458.325 122.412 457.75 121.205C457.175 119.997 456.284 119.019 455.076 118.272C453.868 117.524 452.574 117.15 451.194 117.15C449.354 117.15 447.658 117.725 446.105 118.876C444.552 120.026 443.316 121.406 442.396 123.016C441.476 124.626 440.757 126.351 440.239 128.192C439.722 130.032 439.29 131.872 438.945 133.712C438.6 135.553 438.399 137.422 438.341 139.319C438.284 141.217 438.255 143.086 438.255 144.926L438.255 193.405L418.933 193.405Z" 20 | }, 21 | "substr": { 22 | "name": "tabler-square-rounded-letter-s", 23 | "view_box": [0, 0, 100, 100], 24 | "size": [1, 3], 25 | "fallback": " [S]", 26 | "path": "M44.85,9.05Q59.45,8.55 68.55,10.55Q77.45,12.65 82.45,17.35Q87.05,21.85 89.15,29.35Q90.85,35.65 91.25,46.35Q91.65,59.45 89.75,68.05Q88.05,76.35 83.95,81.35Q80.25,85.95 74.25,88.25Q69.05,90.25 60.95,91.05Q55.75,91.45 49.15,91.45Q42.55,91.45 37.75,90.95Q26.25,89.75 19.75,84.85Q13.75,80.35 11.25,72.15Q9.15,65.35 8.85,54.15Q8.35,40.75 10.3,32.15Q12.25,23.55 16.65,18.45Q20.95,13.75 28.15,11.45Q34.65,9.45 44.85,9.05ZM50.05,83.25Q60.75,83.25 66.65,81.95Q73.25,80.55 76.75,77.05Q80.25,73.55 81.65,66.95Q83.05,60.95 83.05,50.25Q83.05,40.15 81.85,34.15Q80.55,27.85 77.55,24.35Q74.55,20.85 69.15,19.25Q64.35,17.75 55.75,17.35Q46.25,16.95 39.25,17.75Q30.45,18.65 26.05,21.35Q22.75,23.35 20.8,26.85Q18.85,30.35 17.95,35.95Q17.05,41.55 17.05,50.25Q17.05,61.15 18.55,67.45Q20.05,73.75 23.35,77.05Q26.35,79.95 31.75,81.45Q38.25,83.25 50.05,83.25ZM44.55,29.75Q44.55,29.75 44.55,29.75Q39.15,30.85 37.75,36.35L37.75,45.55Q37.75,47.75 37.85,48.15Q38.45,50.15 39.85,51.7Q41.25,53.25 43.25,53.95Q44.25,54.25 45.4,54.35Q46.55,54.45 49.95,54.45L54.25,54.45L54.25,62.65L45.85,62.65L45.85,62.05Q45.65,60.25 44.1,59.25Q42.55,58.25 40.75,58.65Q38.95,59.05 38.05,60.95Q37.85,61.45 37.85,61.65L37.75,62.85Q37.75,64.65 38.55,66.25Q39.65,68.75 42.45,70.05L42.75,70.15Q43.75,70.65 44.8,70.75Q45.85,70.85 50.05,70.85L55.75,70.85Q58.85,69.55 60.05,68.45Q61.55,67.05 62.35,64.15L62.35,54.95Q62.35,52.75 62.25,52.35Q61.75,50.55 60.5,49.1Q59.25,47.65 57.55,46.85L57.45,46.85Q56.25,46.35 55.3,46.2Q54.35,46.05 50.65,46.05L45.85,46.05L45.85,37.85L54.25,37.85L54.25,38.45Q54.45,40.25 56,41.25Q57.55,42.25 59.35,41.85Q61.15,41.45 62.05,39.55Q62.25,39.05 62.25,38.75L62.35,37.65Q62.35,35.55 61.45,33.85Q59.95,31.05 56.55,29.95L56.45,29.95Q55.95,29.75 55.15,29.75Q53.85,29.65 50.25,29.65Q47.65,29.65 44.75,29.75L44.55,29.75Z" 27 | }, 28 | "keyboard": { 29 | "name": "tabler-keyboard", 30 | "view_box": [0, 0, 100, 100], 31 | "size": [1, 3], 32 | "fallback": " ", 33 | "path": "M15.5,21.2Q15.6,21.2 16.4,21.2Q34,21.1 50.4,21.1L84.6,21.2L85.7,21.4Q88.9,22.1 91.4,24.5Q94.5,27.3 95.2,31.4Q95.4,32 95.4,38.3L95.3,56.5Q95.4,65.6 95.3,67.3Q95.2,69 94.8,70.1L94.7,70.4Q93.7,73.5 91.3,75.7Q88.9,77.9 85.7,78.6L84.6,78.9L20.3,78.9Q15.2,78.9 14.6,78.7Q11.7,78.1 9.4,76.3Q5.7,73.5 4.7,68.6Q4.6,68 4.6,61.7L4.6,43.5Q4.6,34.4 4.7,32.7Q4.8,31 5.1,29.9L5.2,29.6Q6.4,26.2 9.2,23.9Q12,21.6 15.5,21.2ZM15.7,29.6Q15,29.8 14.35,30.35Q13.7,30.9 13.4,31.6L13.3,31.7Q13.1,32.2 13,32.9Q12.9,34 12.9,37.7L12.9,62.3Q12.9,66 13,67.1Q13.1,67.9 13.4,68.4L13.5,68.5Q13.8,69 14.1,69.25Q14.4,69.5 15.9,70.6L84.1,70.6Q85.6,69.5 85.9,69.25Q86.2,69 86.5,68.5L86.6,68.4Q86.9,67.9 87,67.1Q87.1,66 87.1,62.3L87.1,37.7Q87.1,34 87,32.9Q86.9,32.1 86.6,31.6L86.5,31.5Q86.2,31 85.9,30.7Q85.6,30.4 84.1,29.4L20.5,29.4Q16.2,29.5 15.7,29.6ZM22.4,44.6Q21.2,43.5 21.2,41.8Q21.2,40.1 22.3,38.85Q23.4,37.6 25.4,37.6Q27,37.6 28.3,39.05Q29.6,40.5 29.3,42.25Q29,44 27.8,44.95Q26.6,45.9 25.05,45.85Q23.5,45.8 22.4,44.6ZM37.7,41.8Q37.7,39.8 38.9,38.7Q40.1,37.6 41.75,37.65Q43.4,37.7 44.6,38.8Q45.8,39.9 45.8,41.9Q45.8,43.9 44.25,45.05Q42.7,46.2 40.8,45.8Q38.9,45.4 38,43.5Q37.8,43 37.8,42.8L37.7,41.8ZM54.2,41.8Q54.2,40.2 55.1,39.1Q56.2,37.6 58.4,37.6Q59.5,37.6 60.6,38.4Q62.3,39.5 62.3,41.8Q62.3,43.8 61.1,44.9Q59.9,46 58.25,45.9Q56.6,45.8 55.4,44.7Q54.2,43.6 54.2,41.8ZM71.9,44.6Q70.7,43.5 70.7,41.8Q70.7,40.1 71.8,38.85Q72.9,37.6 74.9,37.6Q76.6,37.6 77.85,39.05Q79.1,40.5 78.8,42.25Q78.5,44 77.3,44.95Q76.1,45.9 74.55,45.85Q73,45.8 71.9,44.6ZM24.6,54.2L26,54.2Q27.5,54.4 28.55,55.9Q29.6,57.4 29.3,58.9Q28.9,61 27.15,61.95Q25.4,62.9 23.55,62.1Q21.7,61.3 21.2,58.9Q20.9,57.3 21.95,55.85Q23,54.4 24.6,54.2ZM41.2,54.2Q41.6,54.1 47.35,54.1Q53.1,54.1 57.2,54.2L59.3,54.2Q61.1,55.3 61.6,55.9Q62.3,56.7 62.4,58Q62.4,59.7 61.4,60.9Q60.4,62.1 58.8,62.4Q58.6,62.4 54.9,62.4L40.7,62.3Q38.3,61.2 37.7,59.3Q37.3,57.5 38.35,55.95Q39.4,54.4 41.2,54.2ZM74.2,54.2Q72.5,54.4 71.55,55.75Q70.6,57.1 70.7,58.7Q70.8,60.3 71.95,61.35Q73.1,62.4 74.6,62.45Q76.1,62.5 77.3,61.5Q78.5,60.5 78.8,58.95Q79.1,57.4 78.05,55.9Q77,54.4 75.5,54.2L74.2,54.2Z" 34 | }, 35 | "prompt": { 36 | "name": "tabler-meteor", 37 | "view_box": [0, 0, 100, 100], 38 | "size": [1, 3], 39 | "fallback": " ", 40 | "path": "M53.2,8.55Q55,8.15 56.55,9.35Q58.1,10.55 58.1,12.35Q58.1,13.35 56.9,19.45Q56.1,23.45 55.7,25.15L70.1,17.35Q84.4,9.45 85.1,8.95Q87,8.05 88.65,8.8Q90.3,9.55 90.95,11.25Q91.6,12.95 90.7,14.55L81.9,30.35L73.5,45.55L80.7,45.55Q87.8,45.55 88.2,45.75Q90.1,46.45 90.85,48.15Q91.6,49.85 90.7,51.65Q90.4,52.25 75.9,67.15Q61.7,81.85 60.7,82.75Q54,88.95 45.2,90.55Q36.9,91.95 28.85,88.95Q20.8,85.95 15.5,79.45Q9.8,72.45 8.8,63.45Q8.4,60.05 9,55.75Q10.2,47.65 14.9,41.35Q16,39.85 17.1,38.75Q18.5,37.35 22.4,34.05L35.3,23.05Q51.1,9.65 52.3,8.85L52.6,8.75Q53,8.55 53.2,8.55ZM76.3,23.35Q71.9,25.75 63.8,30.25Q51.4,37.15 50.9,37.25Q48.6,37.75 47.1,36.25Q46,35.15 45.85,33.9Q45.7,32.65 47.3,25.25L47.6,23.55Q47.5,23.45 35.9,33.4Q24.3,43.35 23.8,43.95Q19,48.55 17.5,54.95Q16.2,60.85 18.05,66.85Q19.9,72.85 24.4,76.95Q29.3,81.35 36,82.45Q39.5,83.05 43.3,82.45Q49.9,81.35 54.8,76.85Q57.5,74.25 66.4,64.95L77.1,53.95L71.3,53.95Q67.4,53.95 66.2,53.85Q65.4,53.75 65,53.65L64.9,53.65Q63.4,53.05 62.7,51.7Q62,50.35 62.4,48.75Q62.5,48.15 69.3,35.95Q76.7,22.65 76.3,23.35L76.3,23.35ZM25.2,61.75Q24.8,56.85 27.2,52.75Q29.5,48.85 33.65,46.95Q37.8,45.05 42.1,45.75Q43.5,46.05 45.4,46.95Q48,48.15 49.9,50.05Q52.3,52.45 53.4,55.65Q55,60.85 52.8,65.75Q50.8,70.35 46.25,72.85Q41.7,75.35 36.7,74.25Q32.1,73.15 28.95,69.8Q25.8,66.45 25.2,61.75ZM38.1,54.15Q36.1,54.65 34.9,55.95Q33.5,57.45 33.5,60.1Q33.5,62.75 35.25,64.45Q37,66.15 39.6,66.15Q42.2,66.15 43.9,64.55Q45.5,63.05 45.7,60.8Q45.9,58.55 44.7,56.65Q43.5,54.75 41.1,54.15Q39.5,53.75 38.1,54.15Z" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /sweep-lib/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![deny(warnings)] 2 | #![allow(clippy::reversed_empty_ranges)] 3 | 4 | mod haystack; 5 | pub use haystack::{ 6 | Haystack, HaystackBasicPreview, HaystackDefaultView, HaystackPreview, HaystackTagged, 7 | }; 8 | 9 | mod scorer; 10 | pub use scorer::{ 11 | FuzzyScorer, KMPPattern, Positions, Score, ScoreArray, ScoreItem, ScoreIter, Scorer, 12 | SubstrScorer, 13 | }; 14 | 15 | mod rank; 16 | pub use rank::{ 17 | ALL_SCORER_BUILDERS, RankedItems, Ranker, RankerThread, ScorerBuilder, fuzzy_scorer, 18 | scorer_by_name, substr_scorer, 19 | }; 20 | 21 | mod candidate; 22 | pub use candidate::{Candidate, CandidateContext, Field, FieldRef, FieldSelector, fields_view}; 23 | 24 | mod sweep; 25 | pub use crate::sweep::{ 26 | PROMPT_DEFAULT_ICON, Sweep, SweepEvent, SweepOptions, WindowId, WindowLayout, WindowLayoutSize, 27 | sweep, 28 | }; 29 | 30 | pub mod rpc; 31 | 32 | mod widgets; 33 | pub use widgets::{Process, ProcessCommandArg, ProcessCommandBuilder, Theme}; 34 | 35 | pub mod common; 36 | 37 | pub use surf_n_term; 38 | -------------------------------------------------------------------------------- /sweep-lib/src/rank.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | FuzzyScorer, Haystack, Scorer, SubstrScorer, 3 | common::{LockExt, byte_view_concat}, 4 | scorer::{ScoreArray, ScoreItem}, 5 | }; 6 | use arrow_array::{Array, StringViewArray, builder::StringViewBuilder}; 7 | use crossbeam_channel::{Receiver, Sender, unbounded}; 8 | use std::{ 9 | collections::{HashMap, VecDeque}, 10 | iter, 11 | sync::{ 12 | Arc, LazyLock, Mutex, 13 | atomic::{AtomicBool, AtomicUsize, Ordering}, 14 | }, 15 | time::{Duration, Instant}, 16 | }; 17 | 18 | const SCORE_CHUNK_SIZE: usize = 65_536; 19 | pub static ALL_SCORER_BUILDERS: LazyLock<VecDeque<ScorerBuilder>> = LazyLock::new(|| { 20 | let mut builders = VecDeque::new(); 21 | builders.push_back(fuzzy_scorer()); 22 | builders.push_back(substr_scorer()); 23 | builders 24 | }); 25 | 26 | /// Function to create scorer with the given needle 27 | pub type ScorerBuilder = Arc<dyn Fn(&str) -> Arc<dyn Scorer> + Send + Sync>; 28 | 29 | /// Create case-insensitive fuzzy scorer builder 30 | pub fn fuzzy_scorer() -> ScorerBuilder { 31 | Arc::new(|needle: &str| { 32 | let needle: Vec<_> = needle.chars().flat_map(char::to_lowercase).collect(); 33 | Arc::new(FuzzyScorer::new(needle)) 34 | }) 35 | } 36 | 37 | /// Create case-insensitive substring scorer builder 38 | pub fn substr_scorer() -> ScorerBuilder { 39 | Arc::new(|needle: &str| { 40 | let needle: Vec<_> = needle.chars().flat_map(char::to_lowercase).collect(); 41 | Arc::new(SubstrScorer::new(needle)) 42 | }) 43 | } 44 | 45 | /// Find scorer by name, returns selected scorer builder 46 | pub fn scorer_by_name( 47 | scorers: &mut VecDeque<ScorerBuilder>, 48 | name: Option<&str>, 49 | ) -> Option<ScorerBuilder> { 50 | if scorers.is_empty() { 51 | return None; 52 | } 53 | match name { 54 | None => { 55 | scorers.rotate_left(1); 56 | scorers.iter().next().cloned() 57 | } 58 | Some(name) => scorers 59 | .iter() 60 | .enumerate() 61 | .find_map(|(index, scorer)| { 62 | let index = (scorer("").name() == name).then_some(index)?; 63 | Some((index, scorer.clone())) 64 | }) 65 | .map(|(index, scorer)| { 66 | scorers.swap(0, index); 67 | scorer 68 | }), 69 | } 70 | } 71 | 72 | pub struct Ranker { 73 | id: usize, 74 | store: Arc<Mutex<Arc<RankedItems>>>, 75 | ranker_thread: RankerThread, 76 | } 77 | 78 | impl Ranker { 79 | pub fn new(ranker_thread: RankerThread) -> Result<Self, anyhow::Error> { 80 | let store: Arc<Mutex<Arc<RankedItems>>> = Default::default(); 81 | let id = ranker_thread.next_id.fetch_add(1, Ordering::SeqCst); 82 | ranker_thread 83 | .sender 84 | .send(RankerThreadCmd::Create { 85 | id, 86 | store: store.clone(), 87 | }) 88 | .map_err(|_| anyhow::anyhow!("ranker thread is dead"))?; 89 | Ok(Self { 90 | id, 91 | store, 92 | ranker_thread, 93 | }) 94 | } 95 | 96 | fn send(&self, cmd: RankerCmd) { 97 | self.ranker_thread 98 | .sender 99 | .send(RankerThreadCmd::Cmd { id: self.id, cmd }) 100 | .expect("failed to send ranker cmd"); 101 | } 102 | 103 | /// Extend haystack with new entries 104 | pub fn haystack_extend<'a, H>( 105 | &self, 106 | ctx: &H::Context, 107 | haystack: impl IntoIterator<Item = &'a H>, 108 | ) where 109 | H: Haystack, 110 | { 111 | let mut builder = StringViewBuilder::new(); 112 | let mut string_buf = String::new(); 113 | for haystack in haystack { 114 | string_buf.clear(); 115 | haystack.haystack_scope(ctx, |ch| string_buf.push(ch)); 116 | builder.append_value(&string_buf); 117 | } 118 | self.send(RankerCmd::HaystackAppend(builder.finish())); 119 | } 120 | 121 | /// Clear haystack 122 | pub fn haystack_clear(&self) { 123 | self.send(RankerCmd::HaystackClear); 124 | } 125 | 126 | /// Set new needle 127 | pub fn needle_set(&self, needle: String) { 128 | self.send(RankerCmd::Needle(needle)); 129 | } 130 | 131 | /// Set new scorer 132 | pub fn scorer_set(&self, scorer: ScorerBuilder) { 133 | self.send(RankerCmd::Scorer(scorer)); 134 | } 135 | 136 | /// Whether to keep order of elements or sort by the best score 137 | pub fn keep_order(&self, toggle: Option<bool>) { 138 | self.send(RankerCmd::KeepOrder(toggle)); 139 | } 140 | 141 | /// Get last result 142 | pub fn result(&self) -> Arc<RankedItems> { 143 | self.store.with(|result| result.clone()) 144 | } 145 | 146 | /// Sets atomic to true once all requests before it has been processed 147 | pub fn sync(&self) -> Arc<AtomicBool> { 148 | let synced = Arc::new(AtomicBool::new(false)); 149 | self.send(RankerCmd::Sync(synced.clone())); 150 | synced 151 | } 152 | 153 | pub fn ranker_thread(&self) -> &RankerThread { 154 | &self.ranker_thread 155 | } 156 | } 157 | 158 | enum RankerCmd { 159 | HaystackClear, 160 | HaystackAppend(StringViewArray), 161 | Needle(String), 162 | Scorer(ScorerBuilder), 163 | KeepOrder(Option<bool>), 164 | Sync(Arc<AtomicBool>), 165 | } 166 | 167 | #[derive(Clone, Copy)] 168 | enum RankAction { 169 | DoNothing, // ignore 170 | Notify, // only notify 171 | Offset(usize), // rank items starting from offset 172 | CurrentMatch, // rank only current match 173 | All, // rank everything 174 | } 175 | 176 | struct RankerState { 177 | haystack_gen: usize, 178 | haystack: StringViewArray, 179 | haystack_appends: Vec<StringViewArray>, 180 | needle: String, 181 | keep_order: bool, 182 | scorer_builder: ScorerBuilder, 183 | scorer: Arc<dyn Scorer>, 184 | score: ScoreArray, 185 | rank_gen: usize, 186 | synced: Vec<Arc<AtomicBool>>, 187 | action: RankAction, 188 | result_store: Arc<Mutex<Arc<RankedItems>>>, 189 | } 190 | 191 | impl RankerState { 192 | fn new(result_store: Arc<Mutex<Arc<RankedItems>>>) -> Self { 193 | let haystack = byte_view_concat([]); 194 | let keep_order = false; 195 | let scorer_builder = fuzzy_scorer(); 196 | let scorer = scorer_builder(""); 197 | let score = scorer.score(&haystack, Ok(0), !keep_order); 198 | Self { 199 | haystack_gen: 0, 200 | haystack: byte_view_concat([]), 201 | haystack_appends: Default::default(), 202 | needle: String::new(), 203 | keep_order, 204 | scorer_builder, 205 | scorer, 206 | score, 207 | rank_gen: 0, 208 | synced: Default::default(), 209 | action: RankAction::DoNothing, 210 | result_store, 211 | } 212 | } 213 | 214 | // process ranker cmd 215 | fn process(&mut self, cmd: RankerCmd) { 216 | use RankAction::*; 217 | use RankerCmd::*; 218 | 219 | match cmd { 220 | Needle(needle_new) => { 221 | self.action = match self.action { 222 | DoNothing if needle_new == self.needle => return, 223 | DoNothing | CurrentMatch if needle_new.starts_with(&self.needle) => { 224 | CurrentMatch 225 | } 226 | _ => All, 227 | }; 228 | self.needle = needle_new; 229 | self.scorer = (self.scorer_builder)(&self.needle); 230 | } 231 | Scorer(scorer_builder_new) => { 232 | self.action = All; 233 | self.scorer_builder = scorer_builder_new; 234 | self.scorer = (self.scorer_builder)(&self.needle); 235 | } 236 | HaystackAppend(haystack_append) => { 237 | self.action = match self.action { 238 | DoNothing => Offset(self.haystack.len()), 239 | Offset(offset) => Offset(offset), 240 | _ => All, 241 | }; 242 | self.haystack_appends.push(haystack_append); 243 | } 244 | HaystackClear => { 245 | self.action = All; 246 | self.haystack_gen = self.haystack_gen.wrapping_add(1); 247 | self.haystack_appends.clear(); 248 | self.haystack = byte_view_concat([]); 249 | } 250 | KeepOrder(toggle) => { 251 | self.action = All; 252 | match toggle { 253 | None => self.keep_order = !self.keep_order, 254 | Some(value) => self.keep_order = value, 255 | } 256 | } 257 | Sync(sync) => { 258 | self.action = match self.action { 259 | DoNothing => Notify, 260 | _ => self.action, 261 | }; 262 | self.synced.push(sync); 263 | } 264 | } 265 | } 266 | 267 | // do actual ranking 268 | fn rank(&mut self) -> Arc<RankedItems> { 269 | use RankAction::*; 270 | 271 | // collect haystack 272 | if !self.haystack_appends.is_empty() { 273 | self.haystack = 274 | byte_view_concat(iter::once(&self.haystack).chain(&self.haystack_appends)); 275 | self.haystack_appends.clear(); 276 | } 277 | 278 | // rank 279 | let rank_instant = Instant::now(); 280 | self.score = match self.action { 281 | DoNothing => { 282 | return self.result_store.with(|result_store| result_store.clone()); 283 | } 284 | Notify => self.score.clone(), 285 | Offset(offset) => { 286 | // score new data 287 | self.score.merge( 288 | self.scorer.score_par( 289 | &self.haystack.slice(offset, self.haystack.len() - offset), 290 | Ok(offset as u32), 291 | false, 292 | SCORE_CHUNK_SIZE, 293 | ), 294 | !self.keep_order, 295 | ) 296 | } 297 | CurrentMatch => { 298 | // score current matches 299 | self.score 300 | .score_par(&self.scorer, !self.keep_order, SCORE_CHUNK_SIZE) 301 | } 302 | All => { 303 | // score all haystack elements 304 | self.scorer 305 | .score_par(&self.haystack, Ok(0), !self.keep_order, SCORE_CHUNK_SIZE) 306 | } 307 | }; 308 | let rank_elapsed = rank_instant.elapsed(); 309 | 310 | // update result 311 | self.rank_gen = self.rank_gen.wrapping_add(1); 312 | let result = Arc::new(RankedItems { 313 | score: self.score.clone(), 314 | scorer: self.scorer.clone(), 315 | duration: rank_elapsed, 316 | haystack_gen: self.haystack_gen, 317 | rank_gen: self.rank_gen, 318 | }); 319 | self.result_store.with_mut(|result_store| { 320 | *result_store = result.clone(); 321 | }); 322 | 323 | for sync in self.synced.drain(..) { 324 | sync.store(true, Ordering::Release); 325 | } 326 | 327 | result 328 | } 329 | } 330 | 331 | pub struct RankedItems { 332 | score: ScoreArray, 333 | scorer: Arc<dyn Scorer>, 334 | duration: Duration, 335 | haystack_gen: usize, 336 | rank_gen: usize, 337 | } 338 | 339 | impl RankedItems { 340 | /// Number of matched items 341 | pub fn len(&self) -> usize { 342 | self.score.len() 343 | } 344 | 345 | pub fn is_empty(&self) -> bool { 346 | self.score.len() == 0 347 | } 348 | 349 | /// Scorer used to score items 350 | pub fn scorer(&self) -> &Arc<dyn Scorer> { 351 | &self.scorer 352 | } 353 | 354 | /// Duration of ranking 355 | pub fn duration(&self) -> Duration { 356 | self.duration 357 | } 358 | 359 | /// Generation number 360 | pub fn generation(&self) -> (usize, usize) { 361 | (self.haystack_gen, self.rank_gen) 362 | } 363 | 364 | /// Get score result by rank index 365 | pub fn get(&self, rank_index: usize) -> Option<ScoreItem<'_>> { 366 | self.score.get(rank_index) 367 | } 368 | 369 | /// Find match index by haystack index 370 | pub fn find_match_index(&self, haystack_index: usize) -> Option<usize> { 371 | self.score 372 | .iter() 373 | .enumerate() 374 | .find_map(|(index, score)| (score.haystack_index == haystack_index).then_some(index)) 375 | } 376 | 377 | /// Iterator over all matched items 378 | pub fn iter(&self) -> impl Iterator<Item = ScoreItem<'_>> { 379 | self.score.iter() 380 | } 381 | } 382 | 383 | impl Default for RankedItems { 384 | fn default() -> Self { 385 | Self { 386 | haystack_gen: Default::default(), 387 | score: Default::default(), 388 | scorer: fuzzy_scorer()(""), 389 | duration: Default::default(), 390 | rank_gen: Default::default(), 391 | } 392 | } 393 | } 394 | 395 | impl std::fmt::Debug for RankedItems { 396 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 397 | f.debug_struct("RankerResult") 398 | .field("len", &self.len()) 399 | .field("haystack_gen", &self.haystack_gen) 400 | .field("scorer", &self.scorer) 401 | .field("duration", &self.duration) 402 | .field("rank_gen", &self.rank_gen) 403 | .finish() 404 | } 405 | } 406 | 407 | enum RankerThreadCmd { 408 | Create { 409 | id: usize, 410 | store: Arc<Mutex<Arc<RankedItems>>>, 411 | }, 412 | Cmd { 413 | id: usize, 414 | cmd: RankerCmd, 415 | }, 416 | } 417 | 418 | #[derive(Clone)] 419 | pub struct RankerThread { 420 | sender: Sender<RankerThreadCmd>, 421 | next_id: Arc<AtomicUsize>, 422 | } 423 | 424 | impl RankerThread { 425 | pub fn new<N>(notify: N) -> Self 426 | where 427 | N: Fn(usize, Arc<RankedItems>) -> bool + Send + 'static, 428 | { 429 | let (sender, receiver) = unbounded(); 430 | std::thread::Builder::new() 431 | .name("sweep-ranker".to_string()) 432 | .spawn(move || ranker_thread_main(receiver, notify)) 433 | .expect("failed to start sweep-ranker thread"); 434 | Self { 435 | sender, 436 | next_id: Default::default(), 437 | } 438 | } 439 | } 440 | 441 | fn ranker_thread_main<N>(receiver: Receiver<RankerThreadCmd>, notify: N) 442 | where 443 | N: Fn(usize, Arc<RankedItems>) -> bool, 444 | { 445 | let mut states: HashMap<usize, RankerState> = Default::default(); 446 | loop { 447 | // process all pending commands 448 | let cmd = match receiver.recv() { 449 | Ok(cmd) => cmd, 450 | Err(_) => return, 451 | }; 452 | for cmd in iter::once(cmd).chain(receiver.try_iter()) { 453 | match cmd { 454 | RankerThreadCmd::Create { id, store } => { 455 | states.insert(id, RankerState::new(store)); 456 | } 457 | RankerThreadCmd::Cmd { id, cmd } => { 458 | let Some(state) = states.get_mut(&id) else { 459 | continue; 460 | }; 461 | state.process(cmd); 462 | } 463 | } 464 | } 465 | 466 | // rank 467 | for (id, state) in states.iter_mut() { 468 | let result = state.rank(); 469 | // notify 470 | if !notify(*id, result) { 471 | return; 472 | } 473 | } 474 | 475 | // cleanup 476 | states.retain(|id, state| { 477 | let retain = Arc::strong_count(&state.result_store) > 1; 478 | if !retain { 479 | tracing::debug!(?id, "[ranker_thread_main] remove state"); 480 | } 481 | retain 482 | }); 483 | } 484 | } 485 | 486 | #[cfg(test)] 487 | mod tests { 488 | use super::*; 489 | use anyhow::Error; 490 | 491 | #[test] 492 | fn ranker_test() -> Result<(), Error> { 493 | let timeout = Duration::from_millis(100); 494 | let (send, recv) = unbounded(); 495 | let ranker_thread = RankerThread::new(move |_, result| send.send(result).is_ok()); 496 | let ranker = Ranker::new(ranker_thread)?; 497 | 498 | ranker.haystack_extend(&(), &["one", "two", "tree"]); 499 | let result = recv.recv_timeout(timeout)?; 500 | println!("{:?}", Vec::from_iter(result.iter())); 501 | assert_eq!(result.len(), 3); 502 | 503 | ranker.needle_set("o".to_string()); 504 | let result = recv.recv_timeout(timeout)?; 505 | println!("{:?}", Vec::from_iter(result.iter())); 506 | assert_eq!(result.len(), 2); 507 | 508 | ranker.needle_set("oe".to_string()); 509 | let result = recv.recv_timeout(timeout)?; 510 | println!("{:?}", Vec::from_iter(result.iter())); 511 | assert_eq!(result.len(), 1); 512 | 513 | ranker.haystack_extend(&(), &["ponee", "oe"]); 514 | let result = recv.recv_timeout(timeout)?; 515 | println!("{:?}", Vec::from_iter(result.iter())); 516 | assert_eq!(result.len(), 3); 517 | assert_eq!(result.get(0).map(|r| r.haystack_index), Some(4)); 518 | 519 | ranker.keep_order(Some(true)); 520 | let result = recv.recv_timeout(timeout)?; 521 | println!("{:?}", Vec::from_iter(result.iter())); 522 | assert_eq!(result.len(), 3); 523 | assert_eq!(result.get(0).map(|r| r.haystack_index), Some(0)); 524 | 525 | ranker.haystack_clear(); 526 | let result = recv.recv_timeout(timeout)?; 527 | assert_eq!(result.len(), 0); 528 | 529 | Ok(()) 530 | } 531 | } 532 | -------------------------------------------------------------------------------- /sweep-py/debug.py: -------------------------------------------------------------------------------- 1 | """Used to start VSCode debug target""" 2 | 3 | import asyncio 4 | from sweep.__main__ import main # type: ignore 5 | 6 | if __name__ == "__main__": 7 | asyncio.run(main()) 8 | -------------------------------------------------------------------------------- /sweep-py/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "sweep" 3 | version = "0.1.0" 4 | description = "RPC binding to sweep binary" 5 | 6 | [build-system] 7 | requires = ["setuptools", "setuptools-scm"] 8 | build-backend = "setuptools.build_meta" 9 | 10 | [tool.pyright] 11 | typeCheckingMode = "strict" 12 | reportAny = false 13 | reportExplicitAny = false 14 | 15 | [tool.ruff] 16 | target-version = "py312" 17 | 18 | [tool.ruff.lint] 19 | select = ["ANN", "UP"] 20 | ignore = ["ANN102", "ANN101", "ANN204", "ANN401", "UP038"] 21 | -------------------------------------------------------------------------------- /sweep-py/sweep/__init__.py: -------------------------------------------------------------------------------- 1 | from .sweep import * 2 | -------------------------------------------------------------------------------- /sweep-py/sweep/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # pyright: strict 3 | """Sweep apps launcher""" 4 | 5 | import asyncio 6 | import sys 7 | import os 8 | import importlib 9 | from .apps import ALL_APPS 10 | 11 | 12 | async def main() -> None: 13 | if len(sys.argv) >= 2 and sys.argv[1] in ALL_APPS: 14 | app_name = sys.argv[1] 15 | app = importlib.import_module(f".apps.{app_name}", package=__package__) 16 | return await app.main(sys.argv[2:]) 17 | sys.stderr.write(f"usage: {os.path.basename(__file__)} [APP] [APP_ARGS]*\n") 18 | sys.stderr.write("Available apps are: {}\n".format(" ".join(ALL_APPS))) 19 | sys.exit(1) 20 | 21 | 22 | if __name__ == "__main__": 23 | asyncio.run(main()) 24 | -------------------------------------------------------------------------------- /sweep-py/sweep/apps/__init__.py: -------------------------------------------------------------------------------- 1 | # pyright: strict 2 | ALL_APPS = ["bash_history", "demo", "kitty", "launcher", "path_history", "mpd"] 3 | 4 | 5 | def sweep_default_cmd() -> list[str]: 6 | """Return sweep cmd 7 | 8 | Builds from source if this module is located in the sweep repository 9 | """ 10 | from pathlib import Path 11 | 12 | cargo_file = Path(__file__).parent.parent.parent.parent / "Cargo.toml" 13 | if cargo_file.is_file(): 14 | return [ 15 | "cargo", 16 | "run", 17 | f"--manifest-path={cargo_file}", 18 | "--bin=sweep", 19 | "--", 20 | ] 21 | return ["sweep"] 22 | -------------------------------------------------------------------------------- /sweep-py/sweep/apps/bash_history.py: -------------------------------------------------------------------------------- 1 | """Interactively choose entry from bash history""" 2 | 3 | # pyright: strict 4 | from __future__ import annotations 5 | from datetime import datetime 6 | from pathlib import Path 7 | import argparse 8 | import asyncio 9 | import re 10 | import shlex 11 | from typing import Any 12 | from collections.abc import Iterable 13 | from .. import Icon, sweep, Candidate 14 | from . import sweep_default_cmd 15 | 16 | BASH_HISTORY_FILE = "~/.bash_history" 17 | DATE_RE = re.compile(r"^#(\d+)$") 18 | TERM_ICON = Icon( 19 | path="M20,19V7H4V19H20M20,3A2,2 0 0,1 22,5V19A2,2 0 0,1 20,21H4A2,2 0 0,1 2,19V5C2,3.89 2.9,3 4,3H20M13,17V15H18V17H13M9.58,13L5.57,9H8.4L11.7,12.3C12.09,12.69 12.09,13.33 11.7,13.72L8.42,17H5.59L9.58,13Z", 20 | view_box=(0, 0, 24, 24), 21 | size=(1, 3), 22 | fallback=" ", 23 | ) 24 | 25 | 26 | def history(history_file: str | None = None) -> Iterable[tuple[datetime, str]]: 27 | """List all bash history entries""" 28 | if history_file is None: 29 | history_file = BASH_HISTORY_FILE 30 | entries: dict[str, datetime] = {} 31 | entry: list[str] = [] 32 | with Path(history_file).expanduser().resolve().open() as file: 33 | date = None 34 | for line in file: 35 | match = DATE_RE.match(line) 36 | if match is None: 37 | entry.append(line) 38 | else: 39 | if date is not None: 40 | entries["".join(entry).strip()] = date 41 | date = datetime.fromtimestamp(int(match.group(1))) 42 | entry.clear() 43 | if date is not None: 44 | entries["".join(entry).strip()] = date 45 | return sorted( 46 | ((d, e) for e, d in entries.items()), key=lambda e: e[0], reverse=True 47 | ) 48 | 49 | 50 | async def main(args: list[str] | None = None) -> None: 51 | parser = argparse.ArgumentParser() 52 | parser.add_argument("--theme", help="sweep theme") 53 | parser.add_argument("--sweep", help="path to the sweep command") 54 | parser.add_argument("--tty", help="path to the tty") 55 | parser.add_argument("--query", help="initial query") 56 | parser.add_argument( 57 | "--history-file", default=BASH_HISTORY_FILE, help="path to history file" 58 | ) 59 | opts = parser.parse_args(args) 60 | 61 | candidates: list[Any] = [] 62 | for date, entry in history(opts.history_file): 63 | candidates.append( 64 | Candidate() 65 | .target_push(entry) 66 | .right_push(date.strftime(" %F %T"), active=False) 67 | .extra_update(item=entry) 68 | ) 69 | 70 | items = await sweep( 71 | candidates, 72 | sweep=shlex.split(opts.sweep) if opts.sweep else sweep_default_cmd(), 73 | prompt="HISTORY", 74 | prompt_icon=TERM_ICON, 75 | query=opts.query, 76 | theme=opts.theme, 77 | title="command history", 78 | keep_order=True, 79 | scorer="substr", 80 | tty=opts.tty, 81 | ) 82 | 83 | for item in items: 84 | print(item.extra.get("item", ""), end="") 85 | 86 | 87 | if __name__ == "__main__": 88 | asyncio.run(main()) 89 | -------------------------------------------------------------------------------- /sweep-py/sweep/apps/demo.py: -------------------------------------------------------------------------------- 1 | """Demo program that shows different functionality""" 2 | 3 | # pyright: strict 4 | from __future__ import annotations 5 | 6 | import argparse 7 | import asyncio 8 | import os 9 | import shlex 10 | from typing import Any 11 | 12 | from .. import ( 13 | Align, 14 | Bind, 15 | Candidate, 16 | Container, 17 | Field, 18 | Flex, 19 | Icon, 20 | IconFrame, 21 | Justify, 22 | ViewRef, 23 | Sweep, 24 | SweepBind, 25 | SweepEvent, 26 | SweepSelect, 27 | SweepWindow, 28 | SweepSize, 29 | Text, 30 | ) 31 | from . import sweep_default_cmd 32 | 33 | ICON_BEER = Icon( 34 | path="M8.5 10A.75.75 0 0 0 7 10v7a.75.75 0 0 0 1.5 0v-7ZM11.5 10a.75.75 0 0 0-1.5 0v7a.75.75 0 0 0 1.5 0v-7ZM14.5 10a.75.75 0 0 0-1.5 0v7a.75.75 0 0 0 1.5 0v-7ZM4 5.25A3.25 3.25 0 0 1 7.25 2h7a3.25 3.25 0 0 1 3.25 3.25V6h1.25A3.25 3.25 0 0 1 22 9.25v5.5A3.25 3.25 0 0 1 18.75 18H17.5v1.75A2.25 2.25 0 0 1 15.25 22h-9A2.25 2.25 0 0 1 4 19.75V5.25ZM16 7.5H5.5v12.25c0 .414.336.75.75.75h9a.75.75 0 0 0 .75-.75V7.5Zm1.5 9h1.25a1.75 1.75 0 0 0 1.75-1.75v-5.5a1.75 1.75 0 0 0-1.75-1.75H17.5v9ZM16 5.25a1.75 1.75 0 0 0-1.75-1.75h-7A1.75 1.75 0 0 0 5.5 5.25V6H16v-.75Z", 35 | view_box=(0, 0, 24, 24), 36 | size=(1, 3), 37 | fallback="[P]", 38 | ) 39 | ICON_COCKTAIL = Icon( 40 | path="M19.873 3.49a.75.75 0 1 0-.246-1.48l-6 1a.75.75 0 0 0-.613.593L12.736 5H5.75a.75.75 0 0 0-.75.75v4a3.25 3.25 0 0 0 3 3.24v.51c0 1.953 1.4 3.579 3.25 3.93v3.07h-2.5a.75.75 0 0 0 0 1.5h6.5a.75.75 0 0 0 0-1.5h-2.5v-3.07A4.001 4.001 0 0 0 16 13.5v-.51a3.25 3.25 0 0 0 3-3.24v-4a.75.75 0 0 0-.75-.75h-3.985l.119-.595 5.49-.915ZM17.5 8h-3.835l.3-1.5H17.5V8Zm-4.135 1.5H17.5v.25a1.75 1.75 0 0 1-1.75 1.75h-.5a.75.75 0 0 0-.75.75v1.25a2.5 2.5 0 0 1-5 0v-1.25a.75.75 0 0 0-.75-.75h-.5A1.75 1.75 0 0 1 6.5 9.75V9.5h5.335l-.82 4.103a.75.75 0 1 0 1.47.294l.88-4.397ZM12.135 8H6.5V6.5h5.935l-.3 1.5Z", 41 | view_box=(0, 0, 24, 24), 42 | size=(1, 3), 43 | fallback="[C] ", 44 | ) 45 | ICON_BACKPACK = Icon( 46 | path="M12 2a3.75 3.75 0 0 0-3.736 3.424A7.999 7.999 0 0 0 4 12.5v6.25A3.25 3.25 0 0 0 7.25 22h5.56a6.518 6.518 0 0 1-1.078-1.5H7.25a1.75 1.75 0 0 1-1.75-1.75v-3.036H8v1.536a.75.75 0 0 0 1.5 0v-1.536h1.748c.175-.613.438-1.19.774-1.714H5.5v-1.5a6.5 6.5 0 0 1 12.838-1.446 6.455 6.455 0 0 1 1.596.417 8.006 8.006 0 0 0-4.198-6.047A3.75 3.75 0 0 0 12 2Zm0 2.5c-.698 0-1.374.09-2.02.257a2.25 2.25 0 0 1 4.04 0A8.013 8.013 0 0 0 12 4.5ZM14.034 12a6.465 6.465 0 0 1 1.74-.768c.144-.239.226-.517.226-.815A2.417 2.417 0 0 0 13.583 8h-3.166A2.417 2.417 0 0 0 8 10.417C8 11.29 8.709 12 9.583 12h4.451ZM9.5 10.417c0-.507.41-.917.917-.917h3.166c.507 0 .917.41.917.917a.083.083 0 0 1-.083.083H9.583a.083.083 0 0 1-.083-.083ZM23 17.5a5.5 5.5 0 1 0-11 0 5.5 5.5 0 0 0 11 0Zm-5 .5.001 2.503a.5.5 0 1 1-1 0V18h-2.505a.5.5 0 0 1 0-1H17v-2.5a.5.5 0 1 1 1 0V17h2.497a.5.5 0 0 1 0 1H18Z", 47 | view_box=(0, 0, 24, 24), 48 | size=(1, 3), 49 | fallback="[B]", 50 | ) 51 | ICON_SOFA = Icon( 52 | path="M21 9V7C21 5.35 19.65 4 18 4H14C13.23 4 12.53 4.3 12 4.78C11.47 4.3 10.77 4 10 4H6C4.35 4 3 5.35 3 7V9C1.35 9 0 10.35 0 12V17C0 18.65 1.35 20 3 20V22H5V20H19V22H21V20C22.65 20 24 18.65 24 17V12C24 10.35 22.65 9 21 9M14 6H18C18.55 6 19 6.45 19 7V9.78C18.39 10.33 18 11.12 18 12V14H13V7C13 6.45 13.45 6 14 6M5 7C5 6.45 5.45 6 6 6H10C10.55 6 11 6.45 11 7V14H6V12C6 11.12 5.61 10.33 5 9.78V7M22 17C22 17.55 21.55 18 21 18H3C2.45 18 2 17.55 2 17V12C2 11.45 2.45 11 3 11S4 11.45 4 12V16H20V12C20 11.45 20.45 11 21 11S22 11.45 22 12V17Z", 53 | size=(4, 10), 54 | view_box=(0, 0, 24, 24), 55 | fallback="[S]", 56 | ) 57 | PANEL_RIGHT = Icon( 58 | view_box=(0, 0, 128, 128), 59 | size=(1, 3), 60 | fallback="[P]", 61 | path="M37.73 26.48L90.27 26.48Q96.79 26.48 101.41 31.11Q106.04 35.73 106.04 42.25L106.04 42.25L106.04 79.03Q106.04 85.54 101.41 90.17Q96.79 94.79 90.27 94.79L90.27 94.79L37.73 94.79Q31.21 94.79 26.59 90.17Q21.96 85.54 21.96 79.03L21.96 79.03L21.96 42.25Q21.96 35.73 26.59 31.11Q31.21 26.48 37.73 26.48L37.73 26.48ZM71.99 31.74L37.73 31.74Q33.31 31.74 30.27 34.78Q27.22 37.83 27.22 42.25L27.22 42.25L27.22 79.03Q27.22 83.44 30.27 86.49Q33.31 89.54 37.73 89.54L37.73 89.54L71.99 89.54L71.99 31.74Z", 62 | ) 63 | ICON_FOOT = Icon( 64 | view_box=(0, 0, 128, 128), 65 | size=(1, 3), 66 | path="M81.51 20.43L81.51 20.43Q84.19 20.43 86.45 21.88Q88.72 23.32 89.75 25.79Q90.78 28.26 90.26 30.84Q89.75 33.41 87.79 35.37Q85.83 37.32 83.26 37.84Q80.68 38.35 78.21 37.32Q75.74 36.29 74.30 34.03Q72.86 31.76 72.86 29.09L72.86 29.09Q72.86 25.58 75.43 23.01Q78.01 20.43 81.51 20.43ZM64.21 24.76L64.21 24.76Q66.88 24.76 68.84 26.72Q70.80 28.67 70.80 31.35Q70.80 34.03 68.84 35.99Q66.88 37.94 64.21 37.94Q61.53 37.94 59.57 35.99Q57.61 34.03 57.61 31.35Q57.61 28.67 59.57 26.72Q61.53 24.76 64.21 24.76ZM51.23 31.35L51.23 31.35Q53.08 31.35 54.32 32.59Q55.55 33.82 55.55 35.68Q55.55 37.53 54.32 38.87Q53.08 40.21 51.23 40.21Q49.37 40.21 48.14 38.87Q46.90 37.53 46.90 35.68Q46.90 33.82 48.14 32.59Q49.37 31.35 51.23 31.35ZM42.17 37.94L42.17 37.94Q44.02 37.94 45.36 39.18Q46.70 40.41 46.70 42.27Q46.70 44.12 45.36 45.46Q44.02 46.80 42.17 46.80Q40.31 46.80 39.08 45.46Q37.84 44.12 37.84 42.27Q37.84 40.41 39.08 39.18Q40.31 37.94 42.17 37.94ZM75.12 64.31L75.12 64.31Q80.07 64.31 83.26 60.70Q86.45 57.10 86.04 52.16L86.04 52.16Q85.42 47.83 82.13 45.05Q78.83 42.27 74.51 42.27L74.51 42.27L63.59 42.27Q54.73 42.27 47.62 47.73Q40.52 53.19 38.25 61.63L38.25 61.63Q37.22 64.93 38.66 67.81L38.66 67.81Q41.55 73.99 41.44 80.68Q41.34 87.38 38.66 93.15L38.66 93.15Q36.81 97.06 38.87 100.77L38.87 100.77Q41.75 105.09 46.39 107.05Q51.02 109.01 55.97 107.77L55.97 107.77Q59.67 106.95 62.56 104.58Q65.44 102.21 66.88 98.71Q68.33 95.21 67.91 91.50Q67.50 87.79 65.44 84.60Q63.38 81.41 63.59 77.49L63.59 77.49L63.59 77.49Q63.38 74.20 64.62 70.90L64.62 70.90Q67.30 64.31 75.12 64.31Z", 67 | ) 68 | MOUSE_EVENT_TAG = "my-custom-mouse-event" 69 | 70 | 71 | async def yes_or_no(sweep: Sweep[Any]) -> bool | None: 72 | yes_or_no = await sweep.quick_select( 73 | [ 74 | Candidate() 75 | .target_push("Y", face="fg=bg,bg=gruv-red-2,bold") 76 | .target_push("es") 77 | .hotkey_set("y") 78 | .tag(True), 79 | Candidate() 80 | .target_push("N", face="fg=bg,bg=gruv-green-2,bold") 81 | .target_push("o") 82 | .hotkey_set("n") 83 | .tag(False), 84 | ], 85 | prompt="Yes/No", 86 | prompt_icon=ICON_BACKPACK, 87 | theme="accent=gruv-red-2", 88 | ) 89 | if len(yes_or_no) != 1: 90 | return None 91 | return yes_or_no[0].tag 92 | 93 | 94 | async def main(args: list[str] | None = None) -> None: 95 | parser = argparse.ArgumentParser(description="Demo that uses python sweep API") 96 | parser.add_argument("--theme", help="sweep theme") 97 | parser.add_argument("--sweep", help="path to the sweep command") 98 | parser.add_argument("--tty", help="path to the tty") 99 | parser.add_argument("--log", help="log file") 100 | 101 | opts = parser.parse_args(args) 102 | 103 | os.environ["RUST_LOG"] = os.environ.get("RUST_LOG", "debug") 104 | 105 | # Bindings 106 | @Bind[Any].decorator("ctrl+q", "user.custom.action", "My awesome custom action") 107 | async def ctrl_q_action(_sweep: Any, _tag: str) -> Any | None: 108 | return ctrl_q_action 109 | 110 | # Field references 111 | ref_backpack = 1 112 | ref_cocktail = 2 113 | ref_sofa = 127 114 | fields = { 115 | ref_backpack: Field(glyph=ICON_BACKPACK, face="fg=#076678"), 116 | ref_cocktail: Field(glyph=ICON_COCKTAIL), 117 | } 118 | 119 | # Dynamic field references 120 | async def field_resolver(ref: int) -> Field | None: 121 | if ref == ref_sofa: 122 | glyph = ICON_SOFA.frame( 123 | IconFrame() 124 | .border_color("gruv-10") 125 | .fill_color("gruv-13") 126 | .border_radius(10) 127 | .padding(10) 128 | .border_width(3) 129 | ).tag(MOUSE_EVENT_TAG) 130 | view = ( 131 | Container( 132 | Flex.row().push(glyph, align=Align.CENTER).justify(Justify.CENTER) 133 | ) 134 | .vertical(Align.EXPAND) 135 | .trace_layout("sofa-layout") 136 | ) 137 | return Field(view=view) 138 | 139 | def candidate_clicked(clicked: int) -> Candidate: 140 | return ( 141 | Candidate() 142 | .target_push("You have clicked me ") 143 | .target_push( 144 | view=Text(f"{clicked}", face="fg=gruv-red-1,bold,italic").tag("clicked") 145 | ) 146 | .target_push(" times") 147 | .extra_update(clicked=clicked) 148 | ) 149 | 150 | candidates = [ 151 | # counter 152 | candidate_clicked(0).hotkey_set("1"), 153 | Candidate() 154 | .target_push("Confirm select") 155 | .extra_update(confirm=True) 156 | .hotkey_set("2"), 157 | Candidate() 158 | .target_push("Disabled text: ", active=False) 159 | .target_push("Enabled text") 160 | .hotkey_set("3"), 161 | # colored text 162 | Candidate() 163 | .target_push("Colored", face="fg=#8f3f71,bold,underline") 164 | .target_push(" ") 165 | .target_push("Text", face="fg=#fbf1c7,bg=#79740e,italic") 166 | .hotkey_set("4"), 167 | # multi line entry 168 | Candidate() 169 | .target_push("Muli line entry\n - Second Line") 170 | .right_push(glyph=PANEL_RIGHT) 171 | .right_push("right text field") 172 | .right_face_set("bg=accent/.2") 173 | .hotkey_set("5"), 174 | # direct glyph icon usage example 175 | Candidate() 176 | .target_push("Entry with beer icon: ") 177 | .target_push(glyph=ICON_BEER, face="fg=#cc241d") 178 | .hotkey_set("6"), 179 | # glyph icon used from reference 180 | Candidate() 181 | .target_push("Entry with reference to backpack: ") 182 | .target_push(ref=ref_backpack) 183 | .hotkey_set("7"), 184 | # right text 185 | Candidate() 186 | .target_push("Entry with data to the right") 187 | .right_push(ref=ref_cocktail, face="fg=#427b58") 188 | .right_push(" Have a cocktail") 189 | .hotkey_set("8"), 190 | # has preview 191 | Candidate() 192 | .target_push("Point to this item (it has a preview)") 193 | .preview_push("This an awesome item preview: \n") 194 | .preview_push(ref=ref_cocktail) 195 | .preview_push(" - cocktail\n", active=True) 196 | .preview_push(glyph=ICON_BEER) 197 | .preview_push(" - beer\n", active=True) 198 | .preview_push(glyph=ICON_BACKPACK) 199 | .preview_push(" - backpack", active=True) 200 | .hotkey_set("9"), 201 | # dynamic preview 202 | Candidate() 203 | .target_push("Item with lazily fetched preview") 204 | .preview_push("This icon is lazy loaded\n") 205 | .preview_flex_set(0.5) 206 | .preview_push(ref=ref_sofa) 207 | .hotkey_set("0"), 208 | ] 209 | 210 | result: SweepEvent[Candidate | str] | None = None 211 | uid = "demo" 212 | async with Sweep[Candidate | str]( 213 | field_resolver=field_resolver, 214 | sweep=shlex.split(opts.sweep) if opts.sweep else sweep_default_cmd(), 215 | tty=opts.tty, 216 | theme=opts.theme, 217 | log=opts.log, 218 | window_uid=None, 219 | ) as sweep: 220 | # testing spawning first window (window_uid=None) 221 | await sweep.window_switch(uid=uid) 222 | foot_ref = ViewRef( 223 | await sweep.view_register(Text(glyph=ICON_FOOT, face="fg=bg")) 224 | ) 225 | view = ( 226 | Container(Flex.row().push(foot_ref).push(Text("Nice Footer", face="fg=bg"))) 227 | .face(face="bg=accent/.8") 228 | .horizontal(Align.EXPAND) 229 | ) 230 | await sweep.footer_set(view, uid=uid) 231 | await sweep.prompt_set(icon=ICON_COCKTAIL, uid=uid) 232 | await sweep.field_register_many(fields) 233 | await sweep.bind_struct(ctrl_q_action, uid=uid) 234 | await sweep.items_extend(candidates, uid=uid) 235 | 236 | async for event in sweep: 237 | match event: 238 | case SweepSize() | SweepWindow(): 239 | continue 240 | case SweepSelect(items=items) if len(items) == 1: 241 | item = items[0] 242 | if isinstance(item, Candidate) and item.extra: 243 | match item.extra: 244 | case {"clicked": clicked}: 245 | await sweep.item_update( 246 | 0, candidate_clicked(clicked + 1) 247 | ) 248 | continue 249 | case {"confirm": True}: 250 | if not await yes_or_no(sweep): 251 | continue 252 | case _: 253 | pass 254 | case SweepBind(tag="clicked"): 255 | await sweep.item_update(0, candidate_clicked(0)) 256 | continue 257 | case _: 258 | pass 259 | result = event 260 | break 261 | 262 | print(result) 263 | 264 | 265 | if __name__ == "__main__": 266 | asyncio.run(main()) 267 | -------------------------------------------------------------------------------- /sweep-py/sweep/apps/kitty.py: -------------------------------------------------------------------------------- 1 | """Run sweep inside a newly create kitty window""" 2 | 3 | # pyright: strict 4 | from __future__ import annotations 5 | import argparse 6 | import asyncio 7 | import json 8 | import sys 9 | import shlex 10 | from typing import Any 11 | from .. import sweep 12 | from . import sweep_default_cmd 13 | 14 | 15 | async def main(args: list[str] | None = None) -> None: 16 | parser = argparse.ArgumentParser(description=__doc__) 17 | parser.add_argument("--theme", help="sweep theme") 18 | parser.add_argument("--sweep", help="path to the sweep command") 19 | parser.add_argument("--tty", help="path to the tty") 20 | parser.add_argument("--class", help="kitty window class/app_id") 21 | parser.add_argument("--title", help="kitty window title") 22 | parser.add_argument( 23 | "-p", 24 | "--prompt", 25 | default="INPUT", 26 | help="override prompt string", 27 | ) 28 | parser.add_argument( 29 | "--prompt-icon", 30 | default=None, 31 | help="set prompt icon", 32 | ) 33 | parser.add_argument( 34 | "--nth", 35 | help="comma-seprated list of fields for limiting search", 36 | ) 37 | parser.add_argument("--delimiter", help="filed delimiter") 38 | parser.add_argument("--scorer", help="default scorer") 39 | parser.add_argument( 40 | "--json", 41 | action="store_true", 42 | help="expect candidates in JSON format", 43 | ) 44 | parser.add_argument( 45 | "--no-match", 46 | choices=["nothing", "input"], 47 | help="what is returned if there is no match on enter", 48 | ) 49 | parser.add_argument( 50 | "--keep-order", 51 | help="keep order of elements (do not use ranking score)", 52 | ) 53 | parser.add_argument( 54 | "--input", type=argparse.FileType("r"), help="read input from a file" 55 | ) 56 | opts = parser.parse_args(args) 57 | 58 | candidates: list[Any] 59 | input = sys.stdin if opts.input is None else opts.input 60 | if opts.json: 61 | candidates = json.load(input) 62 | else: 63 | candidates = [] 64 | for line in input: 65 | candidates.append(line.strip()) 66 | 67 | kitty_args = ["kitty", "--title", opts.title or "sweep-menu"] 68 | kitty_class = getattr(opts, "class", None) 69 | if kitty_class: 70 | kitty_args.extend(["--class", kitty_class]) 71 | 72 | result = await sweep( 73 | candidates, 74 | sweep=[ 75 | *kitty_args, 76 | *(shlex.split(opts.sweep) if opts.sweep else sweep_default_cmd()), 77 | ], 78 | tty=opts.tty, 79 | theme=opts.theme, 80 | prompt=opts.prompt, 81 | prompt_icon=opts.prompt_icon, 82 | nth=opts.nth, 83 | delimiter=opts.delimiter, 84 | scorer=opts.scorer, 85 | keep_order=opts.keep_order, 86 | no_match=opts.no_match, 87 | tmp_socket=True, 88 | layout="full", 89 | ) 90 | 91 | if opts.json: 92 | json.dump(result, sys.stdout) 93 | else: 94 | for item in result: 95 | print(item) 96 | 97 | 98 | if __name__ == "__main__": 99 | asyncio.run(main()) 100 | -------------------------------------------------------------------------------- /sweep-py/sweep/apps/launcher.py: -------------------------------------------------------------------------------- 1 | """Application launcher 2 | 3 | Lists all available desktop entries on the system 4 | """ 5 | 6 | # pyright: strict 7 | from __future__ import annotations 8 | 9 | import argparse 10 | import asyncio 11 | import re 12 | import shlex 13 | import shutil 14 | import os 15 | import subprocess 16 | from typing import Any, NamedTuple, cast 17 | from collections.abc import Mapping 18 | 19 | from gi.repository import Gio # type: ignore 20 | 21 | from .. import Candidate, Field, Icon, sweep 22 | from . import sweep_default_cmd 23 | 24 | # material-rocket-launch-outline 25 | PROMPT_ICON = Icon( 26 | view_box=(0, 0, 24, 24), 27 | size=(1, 3), 28 | path="M13.13 22.19L11.5 18.36C13.07 17.78 14.54 17 15.9 16.09L13.13 22.19 M5.64 12.5L1.81 10.87L7.91 8.1C7 9.46 6.22 10.93 5.64 12.5M19.22 4 C19.5 4 19.75 4 19.96 4.05C20.13 5.44 19.94 8.3 16.66 11.58 C14.96 13.29 12.93 14.6 10.65 15.47L8.5 13.37C9.42 11.06 10.73 9.03 12.42 7.34 C15.18 4.58 17.64 4 19.22 4M19.22 2C17.24 2 14.24 2.69 11 5.93 C8.81 8.12 7.5 10.53 6.65 12.64C6.37 13.39 6.56 14.21 7.11 14.77L9.24 16.89 C9.62 17.27 10.13 17.5 10.66 17.5C10.89 17.5 11.13 17.44 11.36 17.35 C13.5 16.53 15.88 15.19 18.07 13C23.73 7.34 21.61 2.39 21.61 2.39 S20.7 2 19.22 2M14.54 9.46C13.76 8.68 13.76 7.41 14.54 6.63 S16.59 5.85 17.37 6.63C18.14 7.41 18.15 8.68 17.37 9.46 C16.59 10.24 15.32 10.24 14.54 9.46M8.88 16.53L7.47 15.12L8.88 16.53 M6.24 22L9.88 18.36C9.54 18.27 9.21 18.12 8.91 17.91L4.83 22H6.24M2 22 H3.41L8.18 17.24L6.76 15.83L2 20.59V22M2 19.17L6.09 15.09 C5.88 14.79 5.73 14.47 5.64 14.12L2 17.76V19.17Z", 29 | ) 30 | # fluent-box-multiple 31 | FLATPAK_ICON = Icon( 32 | size=(1, 3), 33 | view_box=(0, 0, 128, 128), 34 | fallback="[F]", 35 | path="M100.99 30.27L82.71 23.12Q77.24 21.02 71.57 23.33L71.57 23.33L53.28 30.27Q50.97 31.11 49.60 33.10Q48.24 35.10 48.24 37.62L48.24 37.62L48.24 43.30Q50.76 43.09 53.49 43.30L53.49 43.30L53.49 37.62Q53.49 35.73 55.17 35.10L55.17 35.10L73.46 28.16Q77.24 26.69 80.81 28.16L80.81 28.16L99.10 35.10Q100.78 35.73 100.78 37.62L100.78 37.62L100.78 78.40Q100.78 80.29 99.10 80.92L99.10 80.92L85.02 86.38L85.02 88.91Q85.02 90.59 84.60 92.06L84.60 92.06L100.99 85.75Q103.30 84.91 104.67 82.92Q106.04 80.92 106.04 78.40L106.04 78.40L106.04 37.62Q106.04 35.10 104.67 33.10Q103.30 31.11 100.99 30.27L100.99 30.27ZM95.95 38.46L95.95 38.46Q95.53 37.62 94.58 37.10Q93.64 36.57 92.58 36.99L92.58 36.99L78.08 42.67Q77.03 43.09 76.19 42.67L76.19 42.67L61.69 36.99Q60.22 36.36 59.06 37.41Q57.90 38.46 58.11 39.93Q58.33 41.41 59.80 42.04L59.80 42.04L74.30 47.50Q77.03 48.55 79.97 47.50L79.97 47.50L94.48 42.04Q95.53 41.62 95.95 40.56Q96.37 39.51 95.95 38.46ZM69.67 64.74L69.67 64.74Q69.25 63.89 68.31 63.37Q67.36 62.84 66.31 63.26L66.31 63.26L50.97 69.36L35.42 63.26Q34.36 62.84 33.42 63.37Q32.47 63.89 32.05 64.84Q31.63 65.79 32.05 66.84Q32.47 67.89 33.52 68.31L33.52 68.31L48.24 73.77L48.24 87.01Q48.24 88.07 48.97 88.80Q49.71 89.54 50.86 89.54Q52.02 89.54 52.76 88.80Q53.49 88.07 53.49 87.01L53.49 87.01L53.49 73.77L68.20 68.31Q69.25 67.89 69.67 66.84Q70.10 65.79 69.67 64.74ZM74.72 56.54L56.43 49.39Q50.76 47.29 45.29 49.39L45.29 49.39L27.01 56.54Q24.70 57.38 23.33 59.38Q21.96 61.37 21.96 63.89L21.96 63.89L21.96 88.91Q21.96 91.43 23.33 93.43Q24.70 95.42 27.01 96.26L27.01 96.26L45.29 103.41Q50.76 105.51 56.43 103.41L56.43 103.41L74.72 96.26Q77.03 95.42 78.40 93.43Q79.76 91.43 79.76 88.91L79.76 88.91L79.76 63.89Q79.76 61.37 78.40 59.38Q77.03 57.38 74.72 56.54L74.72 56.54ZM28.90 61.37L47.19 54.44Q50.97 52.97 54.54 54.44L54.54 54.44L72.83 61.37Q74.51 62.00 74.51 63.89L74.51 63.89L74.51 88.91Q74.51 90.80 72.83 91.43L72.83 91.43L54.54 98.36Q50.97 99.84 47.19 98.36L47.19 98.36L28.90 91.43Q27.22 90.80 27.22 88.91L27.22 88.91L27.22 63.89Q27.22 62.00 28.90 61.37L28.90 61.37Z", 36 | ) 37 | FLATPAK_REF = 0 38 | 39 | TERMINAL_ICON = Icon( 40 | size=(1, 3), 41 | view_box=(0, 0, 128, 128), 42 | fallback="[T]", 43 | path="M41.09 58.85L41.09 58.85Q41.93 58.01 42.98 58.01Q44.03 58.01 44.87 58.85L44.87 58.85L55.38 69.36Q56.22 69.99 56.22 71.15Q56.22 72.30 55.38 72.93L55.38 72.93L44.87 83.44Q44.03 84.28 42.98 84.28Q41.93 84.28 41.09 83.55Q40.25 82.81 40.25 81.66Q40.25 80.50 41.09 79.87L41.09 79.87L49.71 71.04L41.09 62.42Q40.25 61.79 40.25 60.64Q40.25 59.48 41.09 58.85ZM87.75 79.03L87.75 79.03L61.48 79.03Q60.22 79.03 59.48 79.76Q58.75 80.50 58.75 81.66Q58.75 82.81 59.48 83.55Q60.22 84.28 61.27 84.28L61.27 84.28L87.75 84.28Q88.80 84.28 89.54 83.55Q90.27 82.81 90.27 81.66Q90.27 80.50 89.54 79.76Q88.80 79.03 87.75 79.03ZM27.22 86.80L27.22 39.51Q27.22 34.26 31.11 30.37Q35.00 26.48 40.25 26.48L40.25 26.48L87.54 26.48Q93.00 26.48 96.89 30.37Q100.78 34.26 100.78 39.72L100.78 39.72L100.78 86.80Q100.78 92.27 96.89 96.16Q93.00 100.05 87.54 100.05L87.54 100.05L40.25 100.05Q35.00 100.05 31.11 96.16Q27.22 92.27 27.22 86.80L27.22 86.80ZM32.47 42.25L95.53 42.25L95.53 39.51Q95.53 36.36 93.22 34.05Q90.90 31.74 87.54 31.74L87.54 31.74L40.25 31.74Q37.10 31.74 34.78 34.05Q32.47 36.36 32.47 39.72L32.47 39.72L32.47 42.25ZM95.53 47.50L32.47 47.50L32.47 86.80Q32.47 90.17 34.78 92.48Q37.10 94.79 40.25 94.79L40.25 94.79L87.54 94.79Q90.90 94.79 93.22 92.48Q95.53 90.17 95.53 86.80L95.53 86.80L95.53 47.50Z", 44 | ) 45 | TERMINAL_REF = 1 46 | ENV_REGEX = re.compile(r"\$\{([^\}]+)\}") 47 | 48 | 49 | def expand_var(input: str, vars: Mapping[str, str]) -> str: 50 | result: list[str] = [] 51 | start = 0 52 | for match in ENV_REGEX.finditer(input): 53 | result.append(input[start : match.start()]) 54 | result.append(vars.get(match.group(1), "")) 55 | start = match.end() 56 | result.append(input[start:]) 57 | return "".join(result) 58 | 59 | 60 | class DesktopEntry(NamedTuple): 61 | CLEANUP_RE = re.compile("@@[a-zA-Z]?") 62 | URL_RE = re.compile("%[uUfF]") 63 | TERMINALS = ["xdg-terminal-exec", "kitty", "gnome-terminal", "konsole", "xterm"] 64 | 65 | app_info: Any # Gio.AppInfo https://lazka.github.io/pgi-docs/#Gio-2.0/classes/DesktopAppInfo.html#Gio.DesktopAppInfo 66 | 67 | def find_terminal(self, term: str | None = None) -> str | None: 68 | if term is not None: 69 | terms = [term, *self.TERMINALS] 70 | else: 71 | terms = self.TERMINALS 72 | for term in terms: 73 | cmd = shutil.which(term) 74 | if cmd is not None: 75 | return cmd 76 | 77 | def commandline( 78 | self, 79 | path: str | None = None, 80 | term: str | None = None, 81 | ) -> str | None: 82 | """Get command line required to launch app""" 83 | cmd: str | None = self.app_info.get_commandline() 84 | if cmd is None: 85 | return None 86 | cmd = self.CLEANUP_RE.sub("", cmd) 87 | cmd = self.URL_RE.sub(path or "", cmd).strip() 88 | if self.requires_terminal(): 89 | term = self.find_terminal(term) 90 | if term is not None: 91 | return f"{term} {cmd}" 92 | else: 93 | return cmd 94 | 95 | def requires_terminal(self) -> bool: 96 | """Whether app needs to be run in a terminal""" 97 | return self.app_info.get_boolean("Terminal") 98 | 99 | def app_id(self) -> str: 100 | """Return app_id""" 101 | return self.app_info.get_id().strip().removesuffix(".desktop") 102 | 103 | def description(self) -> str: 104 | """Return app description""" 105 | return self.app_info.get_description() or "" 106 | 107 | def is_flatpak(self) -> bool: 108 | """Whether app is a flatpak app""" 109 | cmd = self.commandline() 110 | if cmd is None: 111 | return False 112 | return cmd.find("flatpak") >= 0 113 | 114 | def should_show(self) -> bool: 115 | return not self.app_info.get_boolean("NoDisplay") 116 | 117 | def keywords(self) -> list[str]: 118 | kw: list[str] = self.app_info.get_keywords() or [] 119 | if self.is_flatpak(): 120 | kw.append("flatpak") 121 | if self.requires_terminal(): 122 | kw.append("terminal") 123 | return kw 124 | 125 | def to_candidate(self) -> Candidate: 126 | # target 127 | candidate = Candidate().target_push(self.app_info.get_display_name()) 128 | 129 | # preview 130 | header_face = "bg=fg/.1" 131 | candidate.preview_flex_set(1.0) 132 | description = self.description() 133 | if description: 134 | candidate.preview_push(description + "\n", face="bg=accent/.2") 135 | candidate.preview_push("\n") 136 | candidate.preview_push(" Application Id\n", face=header_face) 137 | candidate.preview_push(self.app_id() + "\n") 138 | keywords = self.keywords() 139 | if keywords: 140 | candidate.preview_push(" Keywords\n", face=header_face) 141 | candidate.preview_push(f"{' '.join(self.keywords())}\n", active=True) 142 | cmd = self.commandline() 143 | if cmd is not None: 144 | candidate.preview_push(" Command Line\n", face=header_face) 145 | candidate.preview_push(cmd + "\n", active=True) 146 | candidate.preview_push(" Desktop File\n", face=header_face) 147 | candidate.preview_push(self.app_info.get_filename() + "\n") 148 | 149 | # right 150 | if self.is_flatpak(): 151 | candidate.right_push(ref=FLATPAK_REF) 152 | if self.requires_terminal(): 153 | candidate.right_push(ref=TERMINAL_REF) 154 | 155 | # TODO: maybe add another screen for actions `self.app_info.list_actions()` 156 | 157 | return candidate 158 | 159 | @staticmethod 160 | def get_all() -> list[DesktopEntry]: 161 | apps: list[DesktopEntry] = [] 162 | for app_info in cast(list[Any], Gio.AppInfo.get_all()): # type: ignore 163 | app = DesktopEntry(app_info) 164 | if not app.should_show(): 165 | continue 166 | apps.append(app) 167 | apps.sort(key=lambda entry: entry.app_info.get_display_name()) 168 | return apps 169 | 170 | 171 | async def main(args: list[str] | None = None) -> None: 172 | parser = argparse.ArgumentParser( 173 | formatter_class=argparse.RawDescriptionHelpFormatter, 174 | description=__doc__, 175 | ) 176 | parser.add_argument("--theme", help="sweep theme") 177 | parser.add_argument("--sweep", help="path to the sweep command") 178 | parser.add_argument("--tty", help="path to the tty") 179 | parser.add_argument("--log", help="log file") 180 | parser.add_argument("--spawner", help="command line will be passed to spawner") 181 | parser.add_argument( 182 | "--no-window", 183 | action="store_true", 184 | help="do not create new terminal window", 185 | ) 186 | parser.add_argument( 187 | "--action", 188 | choices=["print", "launch"], 189 | default="print", 190 | help="what to do with selected desktop entry", 191 | ) 192 | opts = parser.parse_args(args) 193 | 194 | sweep_args: dict[str, Any] = {} 195 | sweep_cmd: list[str] = [] 196 | if not opts.no_window: 197 | sweep_args.update(layout="full", tmp_socket=True) 198 | sweep_cmd.extend(["kitty", "--class", "org.aslpavel.sweep.launcher"]) 199 | sweep_cmd.extend(shlex.split(opts.sweep) if opts.sweep else sweep_default_cmd()) 200 | 201 | items = await sweep( 202 | DesktopEntry.get_all(), 203 | sweep=sweep_cmd, 204 | fields={ 205 | FLATPAK_REF: Field(glyph=FLATPAK_ICON), 206 | TERMINAL_REF: Field(glyph=TERMINAL_ICON), 207 | }, 208 | scorer="substr", 209 | tty=opts.tty, 210 | theme=opts.theme, 211 | prompt="Launch", 212 | prompt_icon=PROMPT_ICON, 213 | log=opts.log, 214 | title="Sweep Launcher", 215 | **sweep_args, 216 | ) 217 | if not items: 218 | return 219 | item = items[0] 220 | match opts.action: 221 | case _ if opts.spawner is not None: 222 | cmd = item.commandline() 223 | env = os.environ | {"APP_ID": item.app_id()} 224 | if cmd is not None: 225 | args = [expand_var(arg, env) for arg in shlex.split(opts.spawner)] 226 | args.extend(shlex.split(cmd)) 227 | subprocess.check_call(args, shell=False) 228 | case "print": 229 | cmd = item.commandline() 230 | if cmd is not None: 231 | print(cmd) 232 | case "launch": 233 | item.app_info.launch() 234 | case _: 235 | pass 236 | 237 | 238 | if __name__ == "__main__": 239 | asyncio.run(main()) 240 | -------------------------------------------------------------------------------- /sweep-py/sweep/apps/path_history.py: -------------------------------------------------------------------------------- 1 | """Simple tool to maintain and navigate visited path history""" 2 | 3 | # pyright: strict 4 | from __future__ import annotations 5 | from collections import deque 6 | from datetime import datetime 7 | from pathlib import Path 8 | import argparse 9 | import asyncio 10 | import fcntl 11 | import io 12 | import os 13 | import re 14 | import shlex 15 | import time 16 | from typing import TypedDict 17 | from collections.abc import Callable, Iterator 18 | from dataclasses import dataclass 19 | from .. import Sweep, SweepBind, Icon, SweepSelect 20 | from . import sweep_default_cmd 21 | 22 | 23 | PATH_HISTORY_FILE = "~/.path_history" 24 | DEFAULT_SOFT_LIMIT = 65536 25 | DEFAULT_IGNORE = re.compile( 26 | "|".join( 27 | [ 28 | "\\.git", 29 | "\\.hg", 30 | "\\.venv", 31 | "__pycache__", 32 | "\\.DS_Store", 33 | "\\.mypy_cache", 34 | "\\.pytest_cache", 35 | "\\.hypothesis", 36 | "target", 37 | ".*\\.elc", 38 | ".*\\.pyo", 39 | ".*\\.pyc", 40 | ] 41 | ) 42 | ) 43 | 44 | # folder-clock-outline (Material Design) 45 | HISTORY_ICON = Icon( 46 | path="M15,12H16.5V16.25L19.36,17.94L18.61,19.16L15,17V12M19,8H3V18H9.29" 47 | "C9.1,17.37 9,16.7 9,16A7,7 0 0,1 16,9C17.07,9 18.09,9.24 19,9.67V8" 48 | "M3,20C1.89,20 1,19.1 1,18V6A2,2 0 0,1 3,4H9L11,6H19A2,2 0 0,1 21,8" 49 | "V11.1C22.24,12.36 23,14.09 23,16A7,7 0 0,1 16,23C13.62,23 11.5,21.81 10.25,20" 50 | "H3M16,11A5,5 0 0,0 11,16A5,5 0 0,0 16,21A5,5 0 0,0 21,16A5,5 0 0,0 16,11Z", 51 | view_box=(0, 0, 24, 24), 52 | size=(1, 3), 53 | fallback=" ", 54 | ) 55 | 56 | # folder-search-outline (Material Design) 57 | SEARCH_ICON = Icon( 58 | path="M16.5,12C19,12 21,14 21,16.5C21,17.38 20.75,18.21 20.31,18.9L23.39,22" 59 | "L22,23.39L18.88,20.32C18.19,20.75 17.37,21 16.5,21C14,21 12,19 12,16.5" 60 | "C12,14 14,12 16.5,12M16.5,14A2.5,2.5 0 0,0 14,16.5A2.5,2.5 0 0,0 16.5,19" 61 | "A2.5,2.5 0 0,0 19,16.5A2.5,2.5 0 0,0 16.5,14M19,8H3V18H10.17" 62 | "C10.34,18.72 10.63,19.39 11,20H3C1.89,20 1,19.1 1,18V6C1,4.89 1.89,4 3,4" 63 | "H9L11,6H19A2,2 0 0,1 21,8V11.81C20.42,11.26 19.75,10.81 19,10.5V8Z", 64 | view_box=(0, 0, 24, 24), 65 | size=(1, 3), 66 | fallback=" ", 67 | ) 68 | 69 | 70 | @dataclass 71 | class PathHistoryEntry: 72 | path: Path 73 | count: int 74 | atime: int 75 | 76 | 77 | @dataclass 78 | class PathHistory: 79 | mtime: int | None 80 | entries: dict[Path, PathHistoryEntry] 81 | 82 | def __iter__(self) -> Iterator[PathHistoryEntry]: 83 | return iter(self.entries.values()) 84 | 85 | 86 | class PathHistoryStore: 87 | """Access and modify path history""" 88 | 89 | def __init__(self, history_path: str = PATH_HISTORY_FILE): 90 | self.history_path = Path(history_path).expanduser().resolve() 91 | 92 | def load(self) -> PathHistory: 93 | """Load path history""" 94 | if not self.history_path.exists(): 95 | return PathHistory(None, {}) 96 | with self.history_path.open("r") as file: 97 | try: 98 | fcntl.lockf(file, fcntl.LOCK_SH) 99 | content = io.StringIO(file.read()) 100 | finally: 101 | fcntl.lockf(file, fcntl.LOCK_UN) 102 | 103 | mtime = int(content.readline().strip() or "0") 104 | paths: dict[Path, PathHistoryEntry] = {} 105 | for line in content: 106 | count_str, timestamp, path_str = line.split("\t") 107 | count = int(count_str) 108 | date = int(timestamp) 109 | path = Path(path_str.strip("\n")) 110 | paths[path] = PathHistoryEntry(path, count, date) 111 | return PathHistory(mtime, paths) 112 | 113 | def update(self, update: Callable[[int, PathHistory], bool]) -> None: 114 | """AddTo/Update path history""" 115 | while True: 116 | now = int(time.time()) 117 | history = self.load() 118 | mtime_last = history.mtime 119 | if not update(now, history): 120 | return 121 | 122 | content = io.StringIO() 123 | content.write(f"{now}\n") 124 | for entry in history: 125 | content.write(f"{entry.count}\t{entry.atime}\t{entry.path}\n") 126 | 127 | with self.history_path.open("a+") as file: 128 | try: 129 | fcntl.lockf(file, fcntl.LOCK_EX) 130 | # check if file was modified after loading 131 | file.seek(0) 132 | mtime_now = int(file.readline().strip() or "0") 133 | if mtime_now != mtime_last: 134 | continue 135 | file.seek(0) 136 | file.truncate(0) 137 | file.write(content.getvalue()) 138 | return 139 | finally: 140 | fcntl.lockf(file, fcntl.LOCK_UN) 141 | 142 | def add(self, path: Path) -> None: 143 | """Add/Update path in the history""" 144 | 145 | def update_add(now: int, history: PathHistory) -> bool: 146 | entry = history.entries.get(path, PathHistoryEntry(path, 0, now)) 147 | if history.mtime == entry.atime: 148 | # last update was for the same path, do not update 149 | return False 150 | history.entries[path] = PathHistoryEntry(path, entry.count + 1, now) 151 | return True 152 | 153 | path = Path(path).expanduser().resolve() 154 | if path.exists(): 155 | self.update(update_add) 156 | 157 | def cleanup(self) -> None: 158 | """Remove paths from the history which no longer exist""" 159 | 160 | def update_cleanup(_: int, history: PathHistory) -> bool: 161 | updated = False 162 | for entry in list(history): 163 | exists = False 164 | try: 165 | exists = entry.path.exists() 166 | except PermissionError: 167 | pass 168 | if not exists: 169 | history.entries.pop(entry.path) 170 | updated = True 171 | return updated 172 | 173 | self.update(update_cleanup) 174 | 175 | 176 | class PathItem(TypedDict): 177 | entry: list[tuple[str, bool]] 178 | path: str 179 | 180 | 181 | class FileNode: 182 | __slots__ = ["path", "is_dir", "_children"] 183 | 184 | path: Path 185 | is_dir: bool 186 | _children: dict[str, FileNode] | None 187 | 188 | def __init__(self, path: Path) -> None: 189 | self.path = path 190 | self.is_dir = self.path.is_dir() 191 | self._children = None if self.is_dir else {} 192 | 193 | @property 194 | def children(self) -> dict[str, FileNode]: 195 | if self._children is not None: 196 | return self._children 197 | self._children = {} 198 | try: 199 | for path in self.path.iterdir(): 200 | if DEFAULT_IGNORE.match(path.name): 201 | continue 202 | self._children[path.name] = FileNode(path) 203 | except (PermissionError, FileNotFoundError): 204 | pass 205 | return self._children 206 | 207 | def get(self, name: str) -> FileNode | None: 208 | return self.children.get(name) 209 | 210 | def find(self, path: Path) -> FileNode | None: 211 | node = self 212 | for name in path.parts: 213 | node_next = node.get(name) 214 | if node_next is None: 215 | return None 216 | node = node_next 217 | return node 218 | 219 | def candidates(self, limit: int | None = None) -> Iterator[PathItem]: 220 | limit = DEFAULT_SOFT_LIMIT if limit is None else limit 221 | parts_len = len(self.path.parts) 222 | max_depth = None 223 | count = 0 224 | 225 | queue: deque[tuple[FileNode, int]] = deque([(self, 0)]) 226 | while queue: 227 | node, depth = queue.popleft() 228 | if max_depth and depth > max_depth: 229 | break 230 | for item in sorted(node.children.values(), key=FileNode._sort_key): 231 | tag = "/" if item.is_dir else "" 232 | path_relative = "/".join(item.path.parts[parts_len:]) 233 | queue.append((item, depth + 1)) 234 | 235 | count += 1 236 | yield PathItem( 237 | entry=[(f"{path_relative}{tag}", True)], path=path_relative 238 | ) 239 | 240 | if count >= limit: 241 | max_depth = depth 242 | 243 | def _sort_key(self) -> tuple[int, int, Path]: 244 | hidden = 1 if self.path.name.startswith(".") else 0 245 | not_dir = 0 if self.is_dir else 1 246 | return (hidden, not_dir, self.path) 247 | 248 | def __str__(self) -> str: 249 | return str(self.path) 250 | 251 | def __repr__(self) -> str: 252 | return f'FileNode("{self.path}")' 253 | 254 | 255 | def collapse_path(path: Path) -> Path: 256 | """Collapse long paths with ellipsis""" 257 | home = Path.home().parts 258 | parts = path.parts 259 | if home == parts[: len(home)]: 260 | parts = ("~", *parts[len(home) :]) 261 | if len(parts) > 5: 262 | parts = (parts[0], "\u2026") + parts[-4:] 263 | return Path().joinpath(*parts) 264 | 265 | 266 | KEY_LIST = "path.search_in_directory" 267 | KEY_PARENT = "path.parent_directory" # only triggered when input is empty 268 | KEY_HISTORY = "path.history" 269 | KEY_OPEN = "path.current_direcotry" 270 | KEY_ALL: dict[str, tuple[list[str], str]] = { 271 | KEY_LIST: (["ctrl+i", "tab"], "Navigate to currently pointed path"), 272 | KEY_PARENT: (["backspace"], "Go to the parent directory"), 273 | KEY_HISTORY: (["alt+."], "Open path history"), 274 | KEY_OPEN: (["ctrl+o"], "Return currently listed directory"), 275 | } 276 | 277 | 278 | class PathSelector: 279 | __slots__ = ["sweep", "history", "path", "path_cache", "show_path_task"] 280 | sweep: Sweep[PathItem] 281 | history: PathHistoryStore 282 | path: Path | None 283 | path_cache: FileNode 284 | show_path_task: asyncio.Task[None] | None 285 | 286 | def __init__(self, sweep: Sweep[PathItem], history: PathHistoryStore) -> None: 287 | self.sweep = sweep 288 | self.history = history 289 | # None - history mode 290 | # Path - path mode 291 | self.path: Path | None = None 292 | self.path_cache = FileNode(Path("/")) 293 | self.show_path_task = None 294 | 295 | async def show_history(self, reset_needle: bool = True) -> None: 296 | """Show history""" 297 | # load history items 298 | history = self.history.load() 299 | items: list[tuple[int, int, Path]] = [] 300 | count_max = 0 301 | for entry in history: 302 | items.append((entry.count, entry.atime, entry.path)) 303 | count_max = max(count_max, entry.count) 304 | items.sort(reverse=True) 305 | count_align = len(str(count_max)) + 1 306 | 307 | # create candidates 308 | cwd = str(Path.cwd()) 309 | candidates: list[PathItem] = [ 310 | PathItem(entry=[(f"{' ' * count_align}{cwd}", True)], path=cwd) 311 | ] 312 | for count, _, path in items: 313 | path_str = str(path) 314 | if path_str == cwd: 315 | continue 316 | item = PathItem( 317 | entry=[(str(count).ljust(count_align), False), (path_str, True)], 318 | path=path_str, 319 | ) 320 | candidates.append(item) 321 | 322 | # update sweep 323 | await self.sweep.prompt_set("PATH HISTORY", HISTORY_ICON) 324 | if reset_needle: 325 | await self.sweep.query_set("") 326 | await self.sweep.items_clear() 327 | await self.sweep.items_extend(candidates) 328 | 329 | async def show_path(self, reset_needle: bool = True) -> None: 330 | """Show current path""" 331 | if self.path is None: 332 | return 333 | if self.show_path_task is not None: 334 | self.show_path_task.cancel() 335 | if reset_needle: 336 | await self.sweep.query_set("") 337 | await self.sweep.prompt_set(str(collapse_path(self.path)), SEARCH_ICON) 338 | node = self.path_cache.find(self.path.relative_to("/")) 339 | if node is not None: 340 | await self.sweep.items_clear() 341 | # extending items without blocking 342 | loop = asyncio.get_running_loop() 343 | self.show_path_task = loop.create_task( 344 | self.sweep.items_extend(node.candidates()) 345 | ) 346 | 347 | async def run(self, path: Path | None = None) -> Path | None: 348 | """Run path selector 349 | 350 | If path is provided it will start in path mode otherwise in history mode 351 | """ 352 | for name, (keys, desc) in KEY_ALL.items(): 353 | for key in keys: 354 | await self.sweep.bind(key, name, desc) 355 | 356 | if path and path.is_dir(): 357 | self.path = path 358 | await self.show_path(reset_needle=False) 359 | else: 360 | await self.show_history(reset_needle=False) 361 | 362 | async for event in self.sweep: 363 | if isinstance(event, SweepSelect) and event.items: 364 | path = Path(event.items[0]["path"]) 365 | if self.path is None: 366 | return path 367 | return self.path / path 368 | 369 | elif isinstance(event, SweepBind): 370 | # list directory under cursor 371 | if event.tag == KEY_LIST: 372 | needle = (await self.sweep.query_get()).strip() 373 | entry = await self.sweep.items_current() 374 | if ( 375 | needle.startswith("/") 376 | or needle.startswith("~") 377 | or entry is None 378 | ): 379 | self.path, needle = get_path_and_query(needle) 380 | await self.sweep.query_set(needle) 381 | await self.show_path(reset_needle=False) 382 | else: 383 | path = Path(entry["path"]) 384 | if self.path is None: 385 | self.path = path 386 | await self.show_path() 387 | elif (self.path / path).is_dir(): 388 | self.path /= path 389 | await self.show_path() 390 | 391 | # list parent directory, list current directory in history mode 392 | elif event.tag == KEY_PARENT: 393 | if self.path is None: 394 | self.path = Path.cwd() 395 | else: 396 | self.path = self.path.parent 397 | await self.show_path() 398 | 399 | # switch to history mode 400 | elif event.tag == KEY_HISTORY: 401 | self.path = None 402 | await self.show_history() 403 | 404 | # return current directory 405 | elif event.tag == KEY_OPEN: 406 | if self.path: 407 | # open current directory 408 | return self.path 409 | # history mode 410 | entry = await self.sweep.items_current() 411 | if entry is None: 412 | continue 413 | path = Path(entry["path"]) 414 | if path.is_dir(): 415 | return path 416 | return None 417 | 418 | 419 | def get_path_and_query(input: str) -> tuple[Path, str]: 420 | """Find longest existing prefix path and remaining query""" 421 | parts = list(Path(input).parts) 422 | query: list[str] = [] 423 | path = Path() 424 | while parts: 425 | path = Path(*parts).expanduser() 426 | if path.is_dir(): 427 | break 428 | query.append(parts.pop()) 429 | return path.resolve(), str(os.path.sep.join(reversed(query))) 430 | 431 | 432 | class ReadLine: 433 | """Extract required info from bash READLINE_{LINE|POINT}""" 434 | 435 | readline: str 436 | readpoint: int 437 | prefix: str 438 | suffix: str 439 | query: str | None 440 | path: Path | None 441 | 442 | def __init__(self, readline: str, point: int) -> None: 443 | self.readline = readline 444 | self.readpoint = point 445 | 446 | start = readline.rfind(" ", 0, point) + 1 447 | end = readline.find(" ", point) 448 | end = end if end > 0 else len(readline) 449 | 450 | self.prefix = readline[:start] 451 | self.suffix = readline[end:] 452 | self.path, self.query = get_path_and_query(readline[start:end]) 453 | 454 | def format(self, path: Path | None) -> str: 455 | if path is not None: 456 | path_str = str(path) 457 | readline = f"{self.prefix} " if self.prefix else "" 458 | readline += path_str 459 | readline += f" {self.suffix}" if self.suffix else "" 460 | point = len(self.prefix) 461 | mark = point + len(path_str) 462 | else: 463 | readline = self.readline 464 | point = self.readpoint 465 | mark = self.readpoint 466 | return f'READLINE_LINE="{readline}"\nREADLINE_POINT={point}\nREADLINE_MARK={mark}\n' 467 | 468 | 469 | async def main(args: list[str] | None = None) -> None: 470 | """Maintain and navigate visited path history""" 471 | parser = argparse.ArgumentParser(description=__doc__) 472 | parser.add_argument( 473 | "--history-file", 474 | default=PATH_HISTORY_FILE, 475 | help="path history file", 476 | ) 477 | subparsers = parser.add_subparsers(dest="command", required=True) 478 | parser_add = subparsers.add_parser("add", help="add/update path in the history") 479 | parser_add.add_argument("path", nargs="?", help="target path") 480 | subparsers.add_parser("list", help="list all entries in the history") 481 | subparsers.add_parser("cleanup", help="cleanup history by checking they exist") 482 | parser_select = subparsers.add_parser( 483 | "select", help="interactively select path from the history or its subpaths" 484 | ) 485 | parser_select.add_argument("--theme", help="sweep theme") 486 | parser_select.add_argument("--sweep", help="path to the sweep command") 487 | parser_select.add_argument("--tty", help="path to the tty") 488 | parser_select.add_argument("--query", help="initial query") 489 | parser_select.add_argument("--log", help="path to the log file") 490 | parser_select.add_argument( 491 | "--readline", 492 | action="store_true", 493 | help="complete based on readline variable", 494 | ) 495 | opts = parser.parse_args(args) 496 | 497 | path_history = PathHistoryStore(opts.history_file) 498 | 499 | if opts.command == "add": 500 | path = opts.path or os.getcwd() 501 | path_history.add(Path(path)) 502 | 503 | elif opts.command == "cleanup": 504 | path_history.cleanup() 505 | 506 | elif opts.command == "list": 507 | items: list[tuple[int, int, Path]] = [] 508 | for entry in path_history.load(): 509 | items.append((entry.count, entry.atime, entry.path)) 510 | items.sort(reverse=True) 511 | for count, timestamp, path in items: 512 | date = datetime.fromtimestamp(timestamp).strftime("[%F %T]") 513 | print(f"{count:<5} {date} {path}") 514 | 515 | elif opts.command == "select": 516 | readline: ReadLine | None 517 | if opts.readline: 518 | readline = ReadLine( 519 | os.environ.get("READLINE_LINE", ""), 520 | int(os.environ.get("READLINE_POINT", "0")), 521 | ) 522 | query = readline.query 523 | path = readline.path 524 | else: 525 | readline = None 526 | query = opts.query 527 | path = None 528 | 529 | result = None 530 | async with Sweep[PathItem]( 531 | sweep=shlex.split(opts.sweep) if opts.sweep else sweep_default_cmd(), 532 | tty=opts.tty, 533 | theme=opts.theme, 534 | title="path history", 535 | query=query, 536 | log=opts.log, 537 | ) as sweep: 538 | selector = PathSelector(sweep, path_history) 539 | result = await selector.run(path) 540 | 541 | if readline is not None: 542 | print(readline.format(result)) 543 | elif result is not None: 544 | print(result) 545 | 546 | 547 | if __name__ == "__main__": 548 | asyncio.run(main()) 549 | -------------------------------------------------------------------------------- /sweep-py/sweep/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aslpavel/sweep-rs/df51db07262edf4d983c23a7b34c101192667a65/sweep-py/sweep/py.typed -------------------------------------------------------------------------------- /sweep-py/sweep/test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import socket 5 | import unittest 6 | import warnings 7 | from typing import Any 8 | 9 | from .sweep import Event, RpcError, RpcPeer, RpcRequest 10 | 11 | 12 | # ------------------------------------------------------------------------------ 13 | # Tests 14 | # ------------------------------------------------------------------------------ 15 | class Tests(unittest.IsolatedAsyncioTestCase): 16 | async def test_event(self) -> None: 17 | total: int = 0 18 | once: int = 0 19 | bad_count: int = 0 20 | 21 | def total_handler(value: int) -> bool: 22 | nonlocal total 23 | total += value 24 | return True 25 | 26 | def once_handler(value: int) -> bool: 27 | nonlocal once 28 | once += value 29 | return False 30 | 31 | def bad_handler(_: int) -> bool: 32 | nonlocal bad_count 33 | bad_count += 1 34 | raise RuntimeError() 35 | 36 | event = Event[int]() 37 | event.on(total_handler) 38 | event.on(once_handler) 39 | event.on(bad_handler) 40 | 41 | with warnings.catch_warnings(): 42 | warnings.simplefilter("ignore") 43 | event(5) 44 | self.assertEqual(5, total) 45 | self.assertEqual(5, once) 46 | self.assertEqual(1, bad_count) 47 | event(3) 48 | self.assertEqual(8, total) 49 | self.assertEqual(5, once) 50 | self.assertEqual(1, bad_count) 51 | 52 | f = asyncio.ensure_future(event) 53 | await asyncio.sleep(0.01) # yield 54 | self.assertFalse(f.done()) 55 | event(6) 56 | await asyncio.sleep(0.01) # yield 57 | self.assertEqual(14, total) 58 | self.assertTrue(f.done()) 59 | self.assertEqual(6, await f) 60 | 61 | async def test_rpc(self) -> None: 62 | def send_handler(value: int) -> int: 63 | send(value) 64 | return value 65 | 66 | async def sleep() -> str: 67 | await asyncio.sleep(0.01) 68 | return "done" 69 | 70 | async def add(a: Any, b: Any) -> Any: 71 | return a + b 72 | 73 | a = RpcPeer() 74 | a.register("name", lambda: "a") 75 | a.register("add", add) 76 | send = Event[int]() 77 | a.register("send", send_handler) 78 | a.register("sleep", sleep) 79 | 80 | b = RpcPeer() 81 | b.register("name", lambda: "b") 82 | 83 | # connect 84 | a_sock, b_sock = socket.socketpair() 85 | a_serve = a.serve(*(await asyncio.open_unix_connection(sock=a_sock))) 86 | b_serve = b.serve(*(await asyncio.open_unix_connection(sock=b_sock))) 87 | serve = asyncio.gather(a_serve, b_serve) 88 | 89 | # events iter 90 | events: list[RpcRequest] = [] 91 | 92 | async def event_iter() -> None: 93 | async for event in a: 94 | events.append(event) 95 | 96 | events_task = asyncio.ensure_future(event_iter()) 97 | event = asyncio.ensure_future(a.events) 98 | await asyncio.sleep(0.01) # yield 99 | 100 | # errors 101 | with self.assertRaisesRegex(RpcError, "Method not found.*"): 102 | await b.call("blablabla") 103 | with self.assertRaisesRegex(RpcError, "Invalid params.*"): 104 | await b.call("name", 1) 105 | with self.assertRaisesRegex(RpcError, "cannot mix.*"): 106 | await b.call("add", 1, b=2) 107 | 108 | # basic calls 109 | self.assertEqual("a", await b.call("name")) 110 | self.assertEqual("b", await a.call("name")) 111 | self.assertFalse(event.done()) 112 | 113 | # mixed calls with args and kwargs 114 | self.assertEqual(3, await b.call("add", 1, 2)) 115 | self.assertEqual(3, await b.call("add", a=1, b=2)) 116 | self.assertEqual("ab", await b.add(a="a", b="b")) 117 | 118 | # events 119 | s = asyncio.ensure_future(send) 120 | self.assertEqual(127, await b.send(value=127)) 121 | self.assertEqual(127, await s) 122 | s = asyncio.ensure_future(send) 123 | b.notify("send", 17) 124 | self.assertEqual(17, await s) 125 | self.assertTrue(event.done()) 126 | send_event = RpcRequest("send", [17], None) 127 | self.assertEqual(send_event, await event) 128 | self.assertEqual([send_event], events) 129 | b.notify("other", arg="something") 130 | other_event = RpcRequest("other", {"arg": "something"}, None) 131 | await asyncio.sleep(0.01) # yield 132 | self.assertEqual([send_event, other_event], events) 133 | self.assertFalse(events_task.done()) 134 | 135 | # asynchronous handler 136 | self.assertEqual("done", await b.call("sleep")) 137 | 138 | # terminate peers 139 | a.terminate() 140 | b.terminate() 141 | await asyncio.sleep(0.01) # yield 142 | self.assertTrue(events_task.cancelled()) 143 | await serve 144 | 145 | 146 | if __name__ == "__main__": 147 | unittest.main() 148 | --------------------------------------------------------------------------------