fzp 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fzp-0.1.0/LICENSE +21 -0
- fzp-0.1.0/PKG-INFO +174 -0
- fzp-0.1.0/README.md +148 -0
- fzp-0.1.0/pyproject.toml +49 -0
- fzp-0.1.0/pyproject.toml.orig +44 -0
- fzp-0.1.0/src/fzp/__init__.py +9 -0
- fzp-0.1.0/src/fzp/cli.py +122 -0
- fzp-0.1.0/src/fzp/files.py +56 -0
- fzp-0.1.0/src/fzp/fuzzy.py +144 -0
- fzp-0.1.0/src/fzp/setup.py +109 -0
- fzp-0.1.0/src/fzp/tui.py +235 -0
fzp-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 r1cardohj
|
|
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.
|
fzp-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fzp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A minimal fzf clone in Python: custom fuzzy scoring + prompt_toolkit interactive UI
|
|
5
|
+
Keywords: fzf,fuzzy,finder,cli,terminal
|
|
6
|
+
Author: r1cardohj
|
|
7
|
+
Author-email: r1cardohj <2413302357@qq.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Topic :: Utilities
|
|
19
|
+
Requires-Dist: click>=8.5.1
|
|
20
|
+
Requires-Dist: prompt-toolkit>=3.0.53
|
|
21
|
+
Requires-Python: >=3.12
|
|
22
|
+
Project-URL: Homepage, https://github.com/r1cardohj/fzp
|
|
23
|
+
Project-URL: Repository, https://github.com/r1cardohj/fzp
|
|
24
|
+
Project-URL: Issues, https://github.com/r1cardohj/fzp/issues
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# fzp
|
|
28
|
+
|
|
29
|
+
A minimal [fzf](https://github.com/junegunn/fzf) clone written in Python. The first version (MVP) focuses on the **core algorithm** and a minimal usable interaction: feed it file paths or piped input, fuzzy search, press Enter to print the result.
|
|
30
|
+
|
|
31
|
+

|
|
32
|
+

|
|
33
|
+

|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install fzp
|
|
39
|
+
# Or install an isolated CLI with uv:
|
|
40
|
+
uv tool install fzp
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Quick Start
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
fzp # scan the current directory, print the selected path
|
|
47
|
+
ls | fzp # read candidates from a pipe
|
|
48
|
+
vim $(ls | fzp) # use with command substitution
|
|
49
|
+
fzp -q cli # start with an initial query
|
|
50
|
+
fzp setup # install the Ctrl-T key-binding into ~/.bashrc
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### From source
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
git clone https://github.com/r1cardohj/fzp.git && cd fzp
|
|
57
|
+
uv sync
|
|
58
|
+
|
|
59
|
+
uv run fzp # scan the current directory, print the selected path to stdout
|
|
60
|
+
ls | uv run fzp # read candidates from a pipe
|
|
61
|
+
vim $(ls | uv run fzp) # use with command substitution
|
|
62
|
+
uv run fzp -q cli # start with an initial query
|
|
63
|
+
uv run fzp setup # install the Ctrl-T key-binding into ~/.bashrc
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Shell Integration (`fzp setup`)
|
|
67
|
+
|
|
68
|
+
Just like `fzf`'s key bindings, `fzp setup` writes a small block into your shell rc file
|
|
69
|
+
(the file is chosen from `$SHELL`: `.bashrc` for bash, `.zshrc` for zsh) and prints a
|
|
70
|
+
reminder to re-source it:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
fzp setup # write to ~/.bashrc (or ~/.zshrc) and hint: source ~/.bashrc
|
|
74
|
+
fzp setup --show # print the snippet that would be written, don't touch any file
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
After restarting your shell (or `source ~/.bashrc`), press **Ctrl-T** on the command line
|
|
78
|
+
to open fzp: the selected path is pasted at the cursor, followed by a space, ready for the
|
|
79
|
+
next argument. Ending the line with `^` appends the selection instead of replacing
|
|
80
|
+
(`cat notes.md ^` + Ctrl-T → `cat notes.md src/fzp/cli.py`).
|
|
81
|
+
|
|
82
|
+
The block is wrapped in `# >>> fzp key-bindings >>>` / `# <<< fzp key-bindings <<<`
|
|
83
|
+
markers, so re-running `fzp setup` is idempotent (it replaces the old block in place),
|
|
84
|
+
and deleting the block uninstalls the binding.
|
|
85
|
+
|
|
86
|
+
> Only bash gets a live binding for now (`bind -x '\C-t'` via readline); on zsh the block
|
|
87
|
+
> is written but inert until zsh integration lands.
|
|
88
|
+
|
|
89
|
+
Key bindings:
|
|
90
|
+
|
|
91
|
+
| Key | Action |
|
|
92
|
+
| --- | --- |
|
|
93
|
+
| `↑` / `Ctrl-P` | Move up |
|
|
94
|
+
| `↓` / `Ctrl-N` / `Ctrl-J` | Move down |
|
|
95
|
+
| `PgUp` / `PgDn` | Page up / down |
|
|
96
|
+
| `Enter` | Confirm, print the selected item to stdout |
|
|
97
|
+
| `Esc` / `Ctrl-C` / `Ctrl-D` / `Ctrl-G` | Cancel, exit code 130 |
|
|
98
|
+
|
|
99
|
+
The UI is drawn inline below the current cursor, with up to 10 visible candidates. It does
|
|
100
|
+
not clear the terminal or enter the alternate screen, and the picker is erased when done.
|
|
101
|
+
The UI is drawn on stderr, and stdout only carries the final result, so it works seamlessly with pipes and `$( )`.
|
|
102
|
+
All UI text is in English (`❯ ` prompt, `<no match>`, `1/3 (4 items)` status bar).
|
|
103
|
+
Matched characters are highlighted with a **text color** (bright green + underline) — no background color or reverse video;
|
|
104
|
+
the selected line is shown in bright cyan and bold.
|
|
105
|
+
|
|
106
|
+
## Core Algorithm (`src/fzp/fuzzy.py`)
|
|
107
|
+
|
|
108
|
+
No third-party fuzzy library is used. The approach is inspired by fzf v1:
|
|
109
|
+
|
|
110
|
+
1. **Subsequence matching**: a candidate matches if the pattern's characters appear in order; case-insensitive.
|
|
111
|
+
2. **Two alignments, pick the better one**: forward greedy (each character takes its earliest occurrence, favoring consecutive matches) and backward greedy (each character takes its latest occurrence, favoring matches near the end of the filename); the higher-scoring one wins.
|
|
112
|
+
3. **Scoring rules**:
|
|
113
|
+
|
|
114
|
+
| Rule | Score |
|
|
115
|
+
| --- | --- |
|
|
116
|
+
| Each matched character | +16 |
|
|
117
|
+
| Match at the start of the string | +16 |
|
|
118
|
+
| Match right after a delimiter (`/ . _ - : ; , |`, space, etc.) or a camelCase / letter-digit boundary | +8 |
|
|
119
|
+
| Adjacent to the previous match | +16 |
|
|
120
|
+
| Exact case match | +2 |
|
|
121
|
+
| Each skipped gap between matches | -3, then -1 for each additional skipped character |
|
|
122
|
+
|
|
123
|
+
4. **Sorting**: by score descending; ties prefer shorter items and earlier input order; an empty query preserves the input order (same as fzf).
|
|
124
|
+
|
|
125
|
+
So `app` ranks `app.py` above `a_p_p.py`:
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
>>> from fzp import fuzzy_filter
|
|
129
|
+
>>> [item for item, _ in fuzzy_filter("app", ["a_p_p.py", "src/app.py", "app.py"])]
|
|
130
|
+
['app.py', 'src/app.py', 'a_p_p.py']
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Project Structure
|
|
134
|
+
|
|
135
|
+
```
|
|
136
|
+
src/fzp/
|
|
137
|
+
├── cli.py # click entrypoint: pipe detection, directory scan, result output
|
|
138
|
+
├── fuzzy.py # core fuzzy matching + scoring + sorting (pure functions, easy to test)
|
|
139
|
+
├── files.py # scans the current directory when there's no piped input
|
|
140
|
+
├── setup.py # shell integration and idempotent rc-file installation
|
|
141
|
+
└── tui.py # prompt_toolkit interactive UI (query line / result list / status bar)
|
|
142
|
+
tests/ # pytest: algorithm, directory scan, CLI
|
|
143
|
+
scripts/pty_smoke.py # smoke test driving a real interactive session via pty
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Development
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
uv run pytest # unit tests
|
|
150
|
+
uv run python scripts/pty_smoke.py # interactive smoke test (requires a pty)
|
|
151
|
+
uv build # build the sdist and wheel
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## Release
|
|
155
|
+
|
|
156
|
+
Releases use [PyPI Trusted Publishing](https://docs.pypi.org/trusted-publishers/),
|
|
157
|
+
so no long-lived API token is stored in GitHub. After registering the pending
|
|
158
|
+
publisher described in [RELEASING.md](RELEASING.md), push a version tag:
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
git tag v0.1.0
|
|
162
|
+
git push origin v0.1.0
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
The release workflow runs the test matrix, validates the built packages, and
|
|
166
|
+
publishes them to PyPI.
|
|
167
|
+
|
|
168
|
+
## Roadmap
|
|
169
|
+
|
|
170
|
+
- zsh integration (`bindkey` widget) to match the bash Ctrl-T binding
|
|
171
|
+
- Multi-select (Tab to toggle, `--multi`)
|
|
172
|
+
- Result preview (`--preview`)
|
|
173
|
+
- Candidate caching / incremental reading for huge inputs
|
|
174
|
+
- DP (Smith-Waterman) scoring to get even closer to fzf v2
|
fzp-0.1.0/README.md
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# fzp
|
|
2
|
+
|
|
3
|
+
A minimal [fzf](https://github.com/junegunn/fzf) clone written in Python. The first version (MVP) focuses on the **core algorithm** and a minimal usable interaction: feed it file paths or piped input, fuzzy search, press Enter to print the result.
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+

|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install fzp
|
|
13
|
+
# Or install an isolated CLI with uv:
|
|
14
|
+
uv tool install fzp
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quick Start
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
fzp # scan the current directory, print the selected path
|
|
21
|
+
ls | fzp # read candidates from a pipe
|
|
22
|
+
vim $(ls | fzp) # use with command substitution
|
|
23
|
+
fzp -q cli # start with an initial query
|
|
24
|
+
fzp setup # install the Ctrl-T key-binding into ~/.bashrc
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### From source
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
git clone https://github.com/r1cardohj/fzp.git && cd fzp
|
|
31
|
+
uv sync
|
|
32
|
+
|
|
33
|
+
uv run fzp # scan the current directory, print the selected path to stdout
|
|
34
|
+
ls | uv run fzp # read candidates from a pipe
|
|
35
|
+
vim $(ls | uv run fzp) # use with command substitution
|
|
36
|
+
uv run fzp -q cli # start with an initial query
|
|
37
|
+
uv run fzp setup # install the Ctrl-T key-binding into ~/.bashrc
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Shell Integration (`fzp setup`)
|
|
41
|
+
|
|
42
|
+
Just like `fzf`'s key bindings, `fzp setup` writes a small block into your shell rc file
|
|
43
|
+
(the file is chosen from `$SHELL`: `.bashrc` for bash, `.zshrc` for zsh) and prints a
|
|
44
|
+
reminder to re-source it:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
fzp setup # write to ~/.bashrc (or ~/.zshrc) and hint: source ~/.bashrc
|
|
48
|
+
fzp setup --show # print the snippet that would be written, don't touch any file
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
After restarting your shell (or `source ~/.bashrc`), press **Ctrl-T** on the command line
|
|
52
|
+
to open fzp: the selected path is pasted at the cursor, followed by a space, ready for the
|
|
53
|
+
next argument. Ending the line with `^` appends the selection instead of replacing
|
|
54
|
+
(`cat notes.md ^` + Ctrl-T → `cat notes.md src/fzp/cli.py`).
|
|
55
|
+
|
|
56
|
+
The block is wrapped in `# >>> fzp key-bindings >>>` / `# <<< fzp key-bindings <<<`
|
|
57
|
+
markers, so re-running `fzp setup` is idempotent (it replaces the old block in place),
|
|
58
|
+
and deleting the block uninstalls the binding.
|
|
59
|
+
|
|
60
|
+
> Only bash gets a live binding for now (`bind -x '\C-t'` via readline); on zsh the block
|
|
61
|
+
> is written but inert until zsh integration lands.
|
|
62
|
+
|
|
63
|
+
Key bindings:
|
|
64
|
+
|
|
65
|
+
| Key | Action |
|
|
66
|
+
| --- | --- |
|
|
67
|
+
| `↑` / `Ctrl-P` | Move up |
|
|
68
|
+
| `↓` / `Ctrl-N` / `Ctrl-J` | Move down |
|
|
69
|
+
| `PgUp` / `PgDn` | Page up / down |
|
|
70
|
+
| `Enter` | Confirm, print the selected item to stdout |
|
|
71
|
+
| `Esc` / `Ctrl-C` / `Ctrl-D` / `Ctrl-G` | Cancel, exit code 130 |
|
|
72
|
+
|
|
73
|
+
The UI is drawn inline below the current cursor, with up to 10 visible candidates. It does
|
|
74
|
+
not clear the terminal or enter the alternate screen, and the picker is erased when done.
|
|
75
|
+
The UI is drawn on stderr, and stdout only carries the final result, so it works seamlessly with pipes and `$( )`.
|
|
76
|
+
All UI text is in English (`❯ ` prompt, `<no match>`, `1/3 (4 items)` status bar).
|
|
77
|
+
Matched characters are highlighted with a **text color** (bright green + underline) — no background color or reverse video;
|
|
78
|
+
the selected line is shown in bright cyan and bold.
|
|
79
|
+
|
|
80
|
+
## Core Algorithm (`src/fzp/fuzzy.py`)
|
|
81
|
+
|
|
82
|
+
No third-party fuzzy library is used. The approach is inspired by fzf v1:
|
|
83
|
+
|
|
84
|
+
1. **Subsequence matching**: a candidate matches if the pattern's characters appear in order; case-insensitive.
|
|
85
|
+
2. **Two alignments, pick the better one**: forward greedy (each character takes its earliest occurrence, favoring consecutive matches) and backward greedy (each character takes its latest occurrence, favoring matches near the end of the filename); the higher-scoring one wins.
|
|
86
|
+
3. **Scoring rules**:
|
|
87
|
+
|
|
88
|
+
| Rule | Score |
|
|
89
|
+
| --- | --- |
|
|
90
|
+
| Each matched character | +16 |
|
|
91
|
+
| Match at the start of the string | +16 |
|
|
92
|
+
| Match right after a delimiter (`/ . _ - : ; , |`, space, etc.) or a camelCase / letter-digit boundary | +8 |
|
|
93
|
+
| Adjacent to the previous match | +16 |
|
|
94
|
+
| Exact case match | +2 |
|
|
95
|
+
| Each skipped gap between matches | -3, then -1 for each additional skipped character |
|
|
96
|
+
|
|
97
|
+
4. **Sorting**: by score descending; ties prefer shorter items and earlier input order; an empty query preserves the input order (same as fzf).
|
|
98
|
+
|
|
99
|
+
So `app` ranks `app.py` above `a_p_p.py`:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
>>> from fzp import fuzzy_filter
|
|
103
|
+
>>> [item for item, _ in fuzzy_filter("app", ["a_p_p.py", "src/app.py", "app.py"])]
|
|
104
|
+
['app.py', 'src/app.py', 'a_p_p.py']
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Project Structure
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
src/fzp/
|
|
111
|
+
├── cli.py # click entrypoint: pipe detection, directory scan, result output
|
|
112
|
+
├── fuzzy.py # core fuzzy matching + scoring + sorting (pure functions, easy to test)
|
|
113
|
+
├── files.py # scans the current directory when there's no piped input
|
|
114
|
+
├── setup.py # shell integration and idempotent rc-file installation
|
|
115
|
+
└── tui.py # prompt_toolkit interactive UI (query line / result list / status bar)
|
|
116
|
+
tests/ # pytest: algorithm, directory scan, CLI
|
|
117
|
+
scripts/pty_smoke.py # smoke test driving a real interactive session via pty
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Development
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
uv run pytest # unit tests
|
|
124
|
+
uv run python scripts/pty_smoke.py # interactive smoke test (requires a pty)
|
|
125
|
+
uv build # build the sdist and wheel
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Release
|
|
129
|
+
|
|
130
|
+
Releases use [PyPI Trusted Publishing](https://docs.pypi.org/trusted-publishers/),
|
|
131
|
+
so no long-lived API token is stored in GitHub. After registering the pending
|
|
132
|
+
publisher described in [RELEASING.md](RELEASING.md), push a version tag:
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
git tag v0.1.0
|
|
136
|
+
git push origin v0.1.0
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
The release workflow runs the test matrix, validates the built packages, and
|
|
140
|
+
publishes them to PyPI.
|
|
141
|
+
|
|
142
|
+
## Roadmap
|
|
143
|
+
|
|
144
|
+
- zsh integration (`bindkey` widget) to match the bash Ctrl-T binding
|
|
145
|
+
- Multi-select (Tab to toggle, `--multi`)
|
|
146
|
+
- Result preview (`--preview`)
|
|
147
|
+
- Candidate caching / incremental reading for huge inputs
|
|
148
|
+
- DP (Smith-Waterman) scoring to get even closer to fzf v2
|
fzp-0.1.0/pyproject.toml
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "fzp"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A minimal fzf clone in Python: custom fuzzy scoring + prompt_toolkit interactive UI"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
keywords = [
|
|
9
|
+
"fzf",
|
|
10
|
+
"fuzzy",
|
|
11
|
+
"finder",
|
|
12
|
+
"cli",
|
|
13
|
+
"terminal",
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Programming Language :: Python :: 3.14",
|
|
24
|
+
"Topic :: Utilities",
|
|
25
|
+
]
|
|
26
|
+
requires-python = ">=3.12"
|
|
27
|
+
dependencies = [
|
|
28
|
+
"click>=8.5.1",
|
|
29
|
+
"prompt-toolkit>=3.0.53",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[[project.authors]]
|
|
33
|
+
name = "r1cardohj"
|
|
34
|
+
email = "2413302357@qq.com"
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://github.com/r1cardohj/fzp"
|
|
38
|
+
Repository = "https://github.com/r1cardohj/fzp"
|
|
39
|
+
Issues = "https://github.com/r1cardohj/fzp/issues"
|
|
40
|
+
|
|
41
|
+
[project.scripts]
|
|
42
|
+
fzp = "fzp.cli:main"
|
|
43
|
+
|
|
44
|
+
[dependency-groups]
|
|
45
|
+
dev = ["pytest>=9.0.1"]
|
|
46
|
+
|
|
47
|
+
[build-system]
|
|
48
|
+
requires = ["uv_build>=0.11.23,<0.12.0"]
|
|
49
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "fzp"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A minimal fzf clone in Python: custom fuzzy scoring + prompt_toolkit interactive UI"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
keywords = ["fzf", "fuzzy", "finder", "cli", "terminal"]
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Development Status :: 3 - Alpha",
|
|
11
|
+
"Environment :: Console",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"Operating System :: OS Independent",
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Programming Language :: Python :: 3.12",
|
|
16
|
+
"Programming Language :: Python :: 3.13",
|
|
17
|
+
"Programming Language :: Python :: 3.14",
|
|
18
|
+
"Topic :: Utilities",
|
|
19
|
+
]
|
|
20
|
+
authors = [
|
|
21
|
+
{ name = "r1cardohj", email = "2413302357@qq.com" }
|
|
22
|
+
]
|
|
23
|
+
requires-python = ">=3.12"
|
|
24
|
+
dependencies = [
|
|
25
|
+
"click>=8.5.1",
|
|
26
|
+
"prompt-toolkit>=3.0.53",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://github.com/r1cardohj/fzp"
|
|
31
|
+
Repository = "https://github.com/r1cardohj/fzp"
|
|
32
|
+
Issues = "https://github.com/r1cardohj/fzp/issues"
|
|
33
|
+
|
|
34
|
+
[project.scripts]
|
|
35
|
+
fzp = "fzp.cli:main"
|
|
36
|
+
|
|
37
|
+
[dependency-groups]
|
|
38
|
+
dev = [
|
|
39
|
+
"pytest>=9.0.1",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[build-system]
|
|
43
|
+
requires = ["uv_build>=0.11.23,<0.12.0"]
|
|
44
|
+
build-backend = "uv_build"
|
fzp-0.1.0/src/fzp/cli.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""fzp 命令行入口。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from .files import read_candidates, walk_files
|
|
11
|
+
from .fuzzy import fuzzy_filter
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _has_tty() -> bool:
|
|
15
|
+
streams = (sys.stdin, sys.stdout, sys.stderr)
|
|
16
|
+
return any(stream is not None and stream.isatty() for stream in streams)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@click.group(
|
|
20
|
+
invoke_without_command=True,
|
|
21
|
+
no_args_is_help=False,
|
|
22
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
23
|
+
)
|
|
24
|
+
@click.version_option(package_name="fzp", prog_name="fzp")
|
|
25
|
+
@click.option("-q", "--query", default="", help="Initial query string.")
|
|
26
|
+
@click.option(
|
|
27
|
+
"--hidden/--no-hidden",
|
|
28
|
+
default=False,
|
|
29
|
+
show_default=True,
|
|
30
|
+
help="Include hidden files when scanning the current directory.",
|
|
31
|
+
)
|
|
32
|
+
@click.pass_context
|
|
33
|
+
def main(ctx: click.Context, query: str, hidden: bool) -> None:
|
|
34
|
+
"""fzp - a tiny fzf in Python: fuzzy-search files or piped input.
|
|
35
|
+
|
|
36
|
+
\b
|
|
37
|
+
Examples:
|
|
38
|
+
fzp # scan the current directory, print the selection
|
|
39
|
+
ls | fzp # read candidates from a pipe
|
|
40
|
+
vim $(ls | fzp) # hand the selection to another command
|
|
41
|
+
fzp setup # install Ctrl-T key-binding into your ~/.bashrc
|
|
42
|
+
"""
|
|
43
|
+
if ctx.invoked_subcommand is not None:
|
|
44
|
+
return
|
|
45
|
+
_run_picker(query, hidden)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _run_picker(query: str, hidden: bool) -> None:
|
|
49
|
+
items: list[str] = []
|
|
50
|
+
if sys.stdin is not None and not sys.stdin.isatty():
|
|
51
|
+
items = list(read_candidates(sys.stdin))
|
|
52
|
+
|
|
53
|
+
if not items:
|
|
54
|
+
items = walk_files(".", hidden=hidden)
|
|
55
|
+
|
|
56
|
+
items = [item for item in items if item.strip()]
|
|
57
|
+
if not items:
|
|
58
|
+
raise click.ClickException("No candidates found")
|
|
59
|
+
|
|
60
|
+
# 没有终端(比如在脚本里跑)时退化成纯过滤,方便测试和自动化
|
|
61
|
+
if not _has_tty():
|
|
62
|
+
for item, _ in fuzzy_filter(query, items):
|
|
63
|
+
click.echo(item)
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
from .tui import Picker # 延迟导入:非交互模式不需要 prompt_toolkit
|
|
67
|
+
|
|
68
|
+
selected = Picker(items, query=query).pick()
|
|
69
|
+
if selected is None:
|
|
70
|
+
raise SystemExit(130)
|
|
71
|
+
click.echo(selected)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@main.command()
|
|
75
|
+
@click.option(
|
|
76
|
+
"--show",
|
|
77
|
+
"show_only",
|
|
78
|
+
is_flag=True,
|
|
79
|
+
help="Print the snippet that would be written, don't touch any file.",
|
|
80
|
+
)
|
|
81
|
+
@click.option(
|
|
82
|
+
"--rc",
|
|
83
|
+
"rc_path",
|
|
84
|
+
type=click.Path(path_type=Path),
|
|
85
|
+
default=None,
|
|
86
|
+
help="Write to this rc file instead of the one detected from $SHELL.",
|
|
87
|
+
)
|
|
88
|
+
@click.pass_context
|
|
89
|
+
def setup(ctx: click.Context, show_only: bool, rc_path: Path | None) -> None:
|
|
90
|
+
"""Install shell integration: press Ctrl-T on the command line to open fzp.
|
|
91
|
+
|
|
92
|
+
Writes a key-binding block (idempotent, wrapped in marker comments) into your
|
|
93
|
+
shell rc file. bash gets a real Ctrl-T binding; other shells keep the block
|
|
94
|
+
inert until their integration lands.
|
|
95
|
+
"""
|
|
96
|
+
from . import setup as setup_mod
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
rc = rc_path or setup_mod.detect_shell_rc()
|
|
100
|
+
except RuntimeError as exc:
|
|
101
|
+
raise click.ClickException(str(exc)) from exc
|
|
102
|
+
|
|
103
|
+
if show_only:
|
|
104
|
+
old = rc.read_text(encoding="utf-8") if rc.exists() else ""
|
|
105
|
+
click.echo(setup_mod.render_preview(old), nl=False)
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
setup_mod.setup_key_bindings(rc)
|
|
110
|
+
except OSError as exc:
|
|
111
|
+
raise click.ClickException(f"Failed to write {rc}: {exc}") from exc
|
|
112
|
+
|
|
113
|
+
click.secho(f"✔ Shell integration written to {rc}", fg="green")
|
|
114
|
+
click.secho(
|
|
115
|
+
" Restart your shell, or run: "
|
|
116
|
+
+ click.style(f"source {rc}", fg="cyan", bold=True),
|
|
117
|
+
)
|
|
118
|
+
click.secho(" Then press Ctrl-T on the command line to fuzzy-pick a file.", dim=True)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
if __name__ == "__main__": # python -m fzp.cli
|
|
122
|
+
main()
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""没有管道输入时,扫描当前目录下的文件,给 fzf 式的体验。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Iterator
|
|
7
|
+
|
|
8
|
+
# 明显没有意义的目录,直接跳过
|
|
9
|
+
SKIP_DIRS = frozenset(
|
|
10
|
+
{
|
|
11
|
+
".git",
|
|
12
|
+
".hg",
|
|
13
|
+
".svn",
|
|
14
|
+
".venv",
|
|
15
|
+
"venv",
|
|
16
|
+
"node_modules",
|
|
17
|
+
"__pycache__",
|
|
18
|
+
".mypy_cache",
|
|
19
|
+
".pytest_cache",
|
|
20
|
+
".ruff_cache",
|
|
21
|
+
".tox",
|
|
22
|
+
".idea",
|
|
23
|
+
".gradle",
|
|
24
|
+
"target",
|
|
25
|
+
"dist",
|
|
26
|
+
"build",
|
|
27
|
+
}
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
MAX_FILES = 200_000
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def walk_files(root: str = ".", hidden: bool = False) -> list[str]:
|
|
34
|
+
"""返回 root 下所有文件的相对路径,顺序稳定(目录/文件名排序)。"""
|
|
35
|
+
results: list[str] = []
|
|
36
|
+
root = os.path.abspath(root)
|
|
37
|
+
|
|
38
|
+
for current, dirs, files in os.walk(root):
|
|
39
|
+
if not hidden:
|
|
40
|
+
dirs[:] = [d for d in dirs if not d.startswith(".")]
|
|
41
|
+
files = [f for f in files if not f.startswith(".")]
|
|
42
|
+
dirs[:] = sorted(d for d in dirs if d not in SKIP_DIRS)
|
|
43
|
+
for name in sorted(files):
|
|
44
|
+
# 统一用 POSIX 分隔符,Windows 下也输出 src/app.py。
|
|
45
|
+
path = os.path.relpath(os.path.join(current, name), root)
|
|
46
|
+
results.append(path.replace(os.sep, "/"))
|
|
47
|
+
if len(results) >= MAX_FILES:
|
|
48
|
+
return results
|
|
49
|
+
|
|
50
|
+
return results
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def read_candidates(stream) -> Iterator[str]:
|
|
54
|
+
"""把 stdin 的每一行当成一个候选项。"""
|
|
55
|
+
for line in stream:
|
|
56
|
+
yield line.rstrip("\n")
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""fzp 的核心:fuzzy 匹配 + 打分。
|
|
2
|
+
|
|
3
|
+
不依赖任何第三方 fuzzy 库,算法思路参考 fzf v1:
|
|
4
|
+
|
|
5
|
+
1. 子序列匹配(大小写不敏感)。
|
|
6
|
+
2. 取两种对齐方式里得分更高的那个:
|
|
7
|
+
- 正向贪心:每个 pattern 字符取最早出现的位置(容易得到连续命中);
|
|
8
|
+
- 反向贪心:每个 pattern 字符取最晚出现的位置(容易贴近文件名)。
|
|
9
|
+
3. 按下面的规则打分:
|
|
10
|
+
- 每命中一个字符 +SCORE_MATCH
|
|
11
|
+
- 命中在串首 +BONUS_HEAD
|
|
12
|
+
- 命中在分隔符 / 驼峰 / 数字字母边界 +BONUS_BOUNDARY
|
|
13
|
+
- 与前一个命中相邻 +BONUS_CONSECUTIVE
|
|
14
|
+
- 大小写也一致 +BONUS_CASE_MATCH
|
|
15
|
+
- 中间跳过字符 -PENALTY_GAP_START - n * PENALTY_GAP_EXTENSION
|
|
16
|
+
|
|
17
|
+
连续命中 + 词首命中的组合得分最高,所以 "app" 会优先命中 `app.py`,
|
|
18
|
+
而不是 `a_pp_p.py`。
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from typing import Sequence
|
|
25
|
+
|
|
26
|
+
SCORE_MATCH = 16
|
|
27
|
+
BONUS_BOUNDARY = SCORE_MATCH // 2 # 分隔符/驼峰之后
|
|
28
|
+
BONUS_HEAD = 2 * BONUS_BOUNDARY # 串首命中
|
|
29
|
+
BONUS_CONSECUTIVE = SCORE_MATCH # 连续命中
|
|
30
|
+
BONUS_CASE_MATCH = 2 # 大小写一致
|
|
31
|
+
PENALTY_GAP_START = 3 # 第一次跳字符
|
|
32
|
+
PENALTY_GAP_EXTENSION = 1 # 之后每多跳一个字符
|
|
33
|
+
|
|
34
|
+
# 词边界分隔符
|
|
35
|
+
SEPARATORS = frozenset("/\\._-:;,| \t[](){}<>+=$#@!*?\"'`~&%")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class Match:
|
|
40
|
+
"""一次成功匹配:总分 + 命中字符下标(用于高亮)。"""
|
|
41
|
+
|
|
42
|
+
score: int
|
|
43
|
+
positions: tuple[int, ...]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _position_bonus(text: str, index: int) -> int:
|
|
47
|
+
"""命中 text[index] 的“位置加成”。"""
|
|
48
|
+
if index == 0:
|
|
49
|
+
return BONUS_HEAD
|
|
50
|
+
prev, cur = text[index - 1], text[index]
|
|
51
|
+
if prev in SEPARATORS:
|
|
52
|
+
return BONUS_BOUNDARY
|
|
53
|
+
if prev.islower() and cur.isupper(): # camelCase
|
|
54
|
+
return BONUS_BOUNDARY
|
|
55
|
+
if prev.isdigit() != cur.isdigit() and cur.isalnum(): # a1 / 1a
|
|
56
|
+
return BONUS_BOUNDARY
|
|
57
|
+
return 0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _forward_positions(lowered: str, haystack: str) -> list[int] | None:
|
|
61
|
+
"""每个字符取最早出现的位置。"""
|
|
62
|
+
positions: list[int] = []
|
|
63
|
+
start = 0
|
|
64
|
+
for char in lowered:
|
|
65
|
+
found = haystack.find(char, start)
|
|
66
|
+
if found < 0:
|
|
67
|
+
return None
|
|
68
|
+
positions.append(found)
|
|
69
|
+
start = found + 1
|
|
70
|
+
return positions
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _backward_positions(lowered: str, haystack: str) -> list[int] | None:
|
|
74
|
+
"""每个字符取最晚出现的位置(从右往左贪心)。"""
|
|
75
|
+
positions = [0] * len(lowered)
|
|
76
|
+
limit = len(haystack)
|
|
77
|
+
for i in range(len(lowered) - 1, -1, -1):
|
|
78
|
+
found = haystack.rfind(lowered[i], 0, limit)
|
|
79
|
+
if found < 0:
|
|
80
|
+
return None
|
|
81
|
+
positions[i] = found
|
|
82
|
+
limit = found
|
|
83
|
+
return positions
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _score(text: str, pattern: str, positions: Sequence[int]) -> int:
|
|
87
|
+
score = 0
|
|
88
|
+
previous = -2
|
|
89
|
+
for index, char in zip(positions, pattern):
|
|
90
|
+
score += SCORE_MATCH
|
|
91
|
+
if index == previous + 1:
|
|
92
|
+
score += BONUS_CONSECUTIVE
|
|
93
|
+
elif previous >= 0:
|
|
94
|
+
gap = index - previous - 1
|
|
95
|
+
score -= PENALTY_GAP_START + PENALTY_GAP_EXTENSION * (gap - 1)
|
|
96
|
+
score += _position_bonus(text, index)
|
|
97
|
+
if text[index] == char:
|
|
98
|
+
score += BONUS_CASE_MATCH
|
|
99
|
+
previous = index
|
|
100
|
+
return score
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def fuzzy_match(pattern: str, text: str) -> Match | None:
|
|
104
|
+
"""pattern 是 text 的子序列则返回 Match,否则 None。空 pattern 恒命中。"""
|
|
105
|
+
if not pattern:
|
|
106
|
+
return Match(0, ())
|
|
107
|
+
|
|
108
|
+
lowered = pattern.lower()
|
|
109
|
+
haystack = text.lower()
|
|
110
|
+
if len(lowered) > len(haystack):
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
candidates = [
|
|
114
|
+
positions
|
|
115
|
+
for positions in (
|
|
116
|
+
_forward_positions(lowered, haystack),
|
|
117
|
+
_backward_positions(lowered, haystack),
|
|
118
|
+
)
|
|
119
|
+
if positions is not None
|
|
120
|
+
]
|
|
121
|
+
if not candidates:
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
best = max(candidates, key=lambda positions: _score(text, pattern, positions))
|
|
125
|
+
return Match(_score(text, pattern, best), tuple(best))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def fuzzy_filter(pattern: str, items: Sequence[str]) -> list[tuple[str, Match]]:
|
|
129
|
+
"""过滤 + 排序,返回 [(原始条目, Match), ...]。
|
|
130
|
+
|
|
131
|
+
空 pattern 保持输入顺序(和 fzf 一样),非空 pattern 按分数降序,
|
|
132
|
+
同分时更短、更靠前的条目优先。
|
|
133
|
+
"""
|
|
134
|
+
if not pattern:
|
|
135
|
+
return [(item, Match(0, ())) for item in items]
|
|
136
|
+
|
|
137
|
+
scored: list[tuple[int, int, int, str, Match]] = []
|
|
138
|
+
for order, item in enumerate(items):
|
|
139
|
+
match = fuzzy_match(pattern, item)
|
|
140
|
+
if match is not None:
|
|
141
|
+
scored.append((-match.score, len(item), order, item, match))
|
|
142
|
+
|
|
143
|
+
scored.sort()
|
|
144
|
+
return [(item, match) for _, _, _, item, match in scored]
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""`fzp setup`:把 key-bindings(默认 Ctrl-T)写入 shell 配置文件。
|
|
2
|
+
|
|
3
|
+
机制与 fzf 的 `key-bindings.bash` 相同:readline 收到 Ctrl-T 时调用
|
|
4
|
+
`__fzp_select`,它先保存当前行内容,清空命令行后运行 fzp,选中结果
|
|
5
|
+
(相对路径)再填回命令行(默认补一个空格,可用 ^ 前缀追加模式)。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import shlex
|
|
11
|
+
import shutil
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
HEADER = "# >>> fzp key-bindings >>>"
|
|
16
|
+
FOOTER = "# <<< fzp key-bindings <<<"
|
|
17
|
+
|
|
18
|
+
# 注意:这段脚本由 bash 执行(readline 只支持 bash),
|
|
19
|
+
# 里面用条件判断保证在 zsh 下安静地不生效(zsh 用户请用 fzf 的 zsh 集成)。
|
|
20
|
+
SHELL_SNIPPET = r"""# >>> fzp key-bindings >>>
|
|
21
|
+
# Auto-generated by `fzp setup`. Remove this block to uninstall.
|
|
22
|
+
if [ -n "$BASH_VERSION" ]; then
|
|
23
|
+
__fzp_select() {
|
|
24
|
+
local item
|
|
25
|
+
item="$(__FZP_COMMAND__)" || return
|
|
26
|
+
if [ -n "$item" ]; then
|
|
27
|
+
local prefix=${READLINE_LINE:0:READLINE_POINT}
|
|
28
|
+
local suffix=${READLINE_LINE:READLINE_POINT}
|
|
29
|
+
if [[ "$prefix" == *\^ ]]; then
|
|
30
|
+
READLINE_LINE="${prefix%\^} ${item}${suffix}"
|
|
31
|
+
READLINE_POINT=$(( ${#READLINE_LINE} - ${#suffix} ))
|
|
32
|
+
else
|
|
33
|
+
READLINE_LINE="${prefix}${item} ${suffix}"
|
|
34
|
+
READLINE_POINT=$(( ${#READLINE_LINE} - ${#suffix} ))
|
|
35
|
+
fi
|
|
36
|
+
fi
|
|
37
|
+
}
|
|
38
|
+
bind -x '"\C-t": __fzp_select'
|
|
39
|
+
fi
|
|
40
|
+
# <<< fzp key-bindings <<<"""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def build_shell_snippet(command: str | None = None) -> str:
|
|
44
|
+
"""生成绑定脚本;命令使用安装时的绝对路径,避免交互 shell 的 PATH 差异。"""
|
|
45
|
+
return SHELL_SNIPPET.replace(
|
|
46
|
+
"__FZP_COMMAND__", command or detect_fzp_command()
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def detect_shell_rc(home: Path | None = None) -> Path:
|
|
51
|
+
"""根据 $SHELL 找到对应的 rc 文件,写不进去的 shell 直接报错。"""
|
|
52
|
+
import os
|
|
53
|
+
|
|
54
|
+
home = home or Path.home()
|
|
55
|
+
shell_path = os.environ.get("SHELL", "")
|
|
56
|
+
shell_name = Path(shell_path).name
|
|
57
|
+
candidates = {
|
|
58
|
+
"bash": home / ".bashrc",
|
|
59
|
+
"zsh": home / ".zshrc",
|
|
60
|
+
}
|
|
61
|
+
rc = candidates.get(shell_name)
|
|
62
|
+
if rc is None:
|
|
63
|
+
raise RuntimeError(
|
|
64
|
+
f"Unsupported shell {shell_path!r} (supported: bash, zsh). "
|
|
65
|
+
"You can still use fzp without shell integration."
|
|
66
|
+
)
|
|
67
|
+
return rc
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def inject_snippet(content: str, snippet: str | None = None) -> str:
|
|
71
|
+
"""把 snippet 注入到内容中:已存在则整体替换(幂等),否则追加到末尾。"""
|
|
72
|
+
snippet = snippet or build_shell_snippet()
|
|
73
|
+
if HEADER in content:
|
|
74
|
+
start = content.index(HEADER)
|
|
75
|
+
end = content.index(FOOTER) + len(FOOTER)
|
|
76
|
+
return content[:start] + snippet + content[end:]
|
|
77
|
+
if content and not content.endswith("\n"):
|
|
78
|
+
content += "\n"
|
|
79
|
+
return content + "\n" + snippet + "\n"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def setup_key_bindings(rc: Path | None = None, home: Path | None = None) -> Path:
|
|
83
|
+
"""写入 rc 文件,返回 rc 路径。重复执行是幂等的。"""
|
|
84
|
+
rc = rc or detect_shell_rc(home)
|
|
85
|
+
old = rc.read_text(encoding="utf-8") if rc.exists() else ""
|
|
86
|
+
rc.write_text(inject_snippet(old), encoding="utf-8")
|
|
87
|
+
return rc
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def render_preview(content: str) -> str:
|
|
91
|
+
"""预览将要写入 rc 的内容(setup --show / --preview 用)。"""
|
|
92
|
+
return inject_snippet(content)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def detect_fzp_command() -> str:
|
|
96
|
+
"""返回在交互 shell 中也能工作的 fzp 命令,并进行 shell quoting。
|
|
97
|
+
|
|
98
|
+
直接运行入口脚本时记录它的绝对路径;通过 ``python -m fzp.cli``
|
|
99
|
+
安装时则记录当前解释器,避免依赖虚拟环境是否已加入 PATH。
|
|
100
|
+
"""
|
|
101
|
+
argv0 = Path(sys.argv[0])
|
|
102
|
+
if argv0.name in {"fzp", "fzp.exe"} and argv0.is_file():
|
|
103
|
+
return shlex.quote(str(argv0.resolve()))
|
|
104
|
+
|
|
105
|
+
executable = shutil.which("fzp")
|
|
106
|
+
if executable:
|
|
107
|
+
return shlex.quote(str(Path(executable).resolve()))
|
|
108
|
+
|
|
109
|
+
return f"{shlex.quote(sys.executable)} -m fzp.cli"
|
fzp-0.1.0/src/fzp/tui.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""极简 fzf 式交互界面(prompt_toolkit)。界面文案统一用英文。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Sequence
|
|
7
|
+
|
|
8
|
+
from prompt_toolkit.application import Application, get_app
|
|
9
|
+
from prompt_toolkit.buffer import Buffer
|
|
10
|
+
from prompt_toolkit.input import create_input
|
|
11
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
12
|
+
from prompt_toolkit.layout import HSplit, Layout, VSplit, Window
|
|
13
|
+
from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
|
|
14
|
+
from prompt_toolkit.output import create_output
|
|
15
|
+
from prompt_toolkit.styles import Style
|
|
16
|
+
|
|
17
|
+
from .fuzzy import Match, fuzzy_filter
|
|
18
|
+
|
|
19
|
+
STYLE = Style.from_dict(
|
|
20
|
+
{
|
|
21
|
+
"prompt": "fg:#5f87d7 bold",
|
|
22
|
+
"pointer": "fg:ansibrightcyan bold",
|
|
23
|
+
# 选中的行:换颜色 + 加粗(不用背景色)
|
|
24
|
+
"current": "fg:ansibrightcyan bold",
|
|
25
|
+
# fuzzy 命中的字符:纯文字高亮(换前景色 + 加粗,不用背景色)
|
|
26
|
+
"match": "fg:ansibrightgreen bold",
|
|
27
|
+
"current match": "fg:ansibrightgreen bold underline",
|
|
28
|
+
"empty": "fg:#808080",
|
|
29
|
+
"info": "fg:#808080",
|
|
30
|
+
}
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# 界面文案集中在这里,保持全英文
|
|
34
|
+
PROMPT = "❯ "
|
|
35
|
+
NO_MATCH = "<no match>"
|
|
36
|
+
MAX_VISIBLE_RESULTS = 10
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def status_text(position: int, matches: int, total: int) -> str:
|
|
40
|
+
"""状态栏:当前第几个/匹配数/总数。"""
|
|
41
|
+
if matches == 0:
|
|
42
|
+
return f" 0/{total} ({total} items)"
|
|
43
|
+
return f" {position}/{matches} ({total} items)"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Picker:
|
|
47
|
+
"""在候选列表里 fuzzy 搜索、上下选择、回车确认。
|
|
48
|
+
|
|
49
|
+
pick() 返回选中的条目;用户按 Ctrl-C / Esc 时返回 None。
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(self, items: Sequence[str], query: str = "") -> None:
|
|
53
|
+
self.items = list(items)
|
|
54
|
+
self.results: list[tuple[str, Match]] = []
|
|
55
|
+
self.pointer = 0
|
|
56
|
+
self.offset = 0
|
|
57
|
+
self.app: Application[str] | None = None
|
|
58
|
+
|
|
59
|
+
self.query_buffer = Buffer(multiline=False)
|
|
60
|
+
self.query_buffer.text = query
|
|
61
|
+
self.query_buffer.on_text_changed += self._on_query_changed
|
|
62
|
+
|
|
63
|
+
prompt = Window(
|
|
64
|
+
FormattedTextControl([("class:prompt", PROMPT)]),
|
|
65
|
+
width=len(PROMPT),
|
|
66
|
+
height=1,
|
|
67
|
+
)
|
|
68
|
+
query_line = VSplit(
|
|
69
|
+
[prompt, Window(BufferControl(buffer=self.query_buffer), height=1)]
|
|
70
|
+
)
|
|
71
|
+
self.results_window = Window(
|
|
72
|
+
FormattedTextControl(self._render_results),
|
|
73
|
+
wrap_lines=False,
|
|
74
|
+
)
|
|
75
|
+
status = Window(
|
|
76
|
+
FormattedTextControl(self._render_status), height=1, style="class:info"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
layout = Layout(HSplit([query_line, self.results_window, status]))
|
|
80
|
+
self.app = Application(
|
|
81
|
+
layout=layout,
|
|
82
|
+
key_bindings=self._key_bindings(),
|
|
83
|
+
full_screen=False,
|
|
84
|
+
erase_when_done=True,
|
|
85
|
+
mouse_support=False,
|
|
86
|
+
style=STYLE,
|
|
87
|
+
input=create_input(always_prefer_tty=True),
|
|
88
|
+
output=create_output(self._ui_stream()),
|
|
89
|
+
)
|
|
90
|
+
self._refresh()
|
|
91
|
+
|
|
92
|
+
# ------------------------------------------------------------------ 状态
|
|
93
|
+
|
|
94
|
+
def _ui_stream(self):
|
|
95
|
+
"""界面画到 stderr,stdout 只留给最终结果(方便管道/命令替换)。"""
|
|
96
|
+
if sys.stderr is not None and sys.stderr.isatty():
|
|
97
|
+
return sys.stderr
|
|
98
|
+
return sys.stdout
|
|
99
|
+
|
|
100
|
+
def _visible_rows(self) -> int:
|
|
101
|
+
"""候选区高度:最多 10 行,少候选时不撑出多余空白。"""
|
|
102
|
+
if not self.results:
|
|
103
|
+
return 1
|
|
104
|
+
try:
|
|
105
|
+
rows = get_app().output.get_size().rows
|
|
106
|
+
except Exception:
|
|
107
|
+
rows = 24
|
|
108
|
+
return max(1, min(len(self.results), MAX_VISIBLE_RESULTS, rows - 2))
|
|
109
|
+
|
|
110
|
+
def _on_query_changed(self, _buffer: Buffer) -> None:
|
|
111
|
+
self._refresh()
|
|
112
|
+
|
|
113
|
+
def _refresh(self) -> None:
|
|
114
|
+
self.results = fuzzy_filter(self.query_buffer.text, self.items)
|
|
115
|
+
self.pointer = 0
|
|
116
|
+
self.offset = 0
|
|
117
|
+
if self.app is not None:
|
|
118
|
+
self.app.invalidate()
|
|
119
|
+
|
|
120
|
+
def _current(self) -> str | None:
|
|
121
|
+
if not self.results:
|
|
122
|
+
return None
|
|
123
|
+
return self.results[self.pointer][0]
|
|
124
|
+
|
|
125
|
+
def _move(self, delta: int) -> None:
|
|
126
|
+
if not self.results:
|
|
127
|
+
return
|
|
128
|
+
self.pointer = min(max(self.pointer + delta, 0), len(self.results) - 1)
|
|
129
|
+
visible = self._visible_rows()
|
|
130
|
+
if self.pointer < self.offset:
|
|
131
|
+
self.offset = self.pointer
|
|
132
|
+
elif self.pointer >= self.offset + visible:
|
|
133
|
+
self.offset = self.pointer - visible + 1
|
|
134
|
+
|
|
135
|
+
# ------------------------------------------------------------------ 渲染
|
|
136
|
+
|
|
137
|
+
@staticmethod
|
|
138
|
+
def _fragments(text: str, positions: tuple[int, ...], selected: bool):
|
|
139
|
+
"""把一行拆成普通字符 / 命中字符(用于高亮)。"""
|
|
140
|
+
plain = "class:current" if selected else ""
|
|
141
|
+
hit = "class:current class:match" if selected else "class:match"
|
|
142
|
+
fragments = []
|
|
143
|
+
last = 0
|
|
144
|
+
for position in positions:
|
|
145
|
+
if position > last:
|
|
146
|
+
fragments.append((plain, text[last:position]))
|
|
147
|
+
fragments.append((hit, text[position]))
|
|
148
|
+
last = position + 1
|
|
149
|
+
if last < len(text):
|
|
150
|
+
fragments.append((plain, text[last:]))
|
|
151
|
+
return fragments
|
|
152
|
+
|
|
153
|
+
def _render_results(self):
|
|
154
|
+
visible = self._visible_rows()
|
|
155
|
+
|
|
156
|
+
if not self.results:
|
|
157
|
+
return [("class:empty", f" {NO_MATCH}")]
|
|
158
|
+
|
|
159
|
+
self.offset = max(0, min(self.offset, len(self.results) - 1))
|
|
160
|
+
if self.pointer < self.offset:
|
|
161
|
+
self.offset = self.pointer
|
|
162
|
+
elif self.pointer >= self.offset + visible:
|
|
163
|
+
self.offset = self.pointer - visible + 1
|
|
164
|
+
|
|
165
|
+
lines = []
|
|
166
|
+
end = min(self.offset + visible, len(self.results))
|
|
167
|
+
for index in range(self.offset, end):
|
|
168
|
+
text, match = self.results[index]
|
|
169
|
+
selected = index == self.pointer
|
|
170
|
+
marker = "> " if selected else " "
|
|
171
|
+
lines.append(
|
|
172
|
+
(
|
|
173
|
+
"class:current class:pointer" if selected else "class:pointer",
|
|
174
|
+
marker,
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
lines.extend(self._fragments(text, match.positions, selected))
|
|
178
|
+
if index != end - 1:
|
|
179
|
+
lines.append(("", "\n"))
|
|
180
|
+
return lines
|
|
181
|
+
|
|
182
|
+
def _render_status(self):
|
|
183
|
+
return status_text(self.pointer + 1, len(self.results), len(self.items))
|
|
184
|
+
|
|
185
|
+
# -------------------------------------------------------------- 键位绑定
|
|
186
|
+
|
|
187
|
+
def _key_bindings(self) -> KeyBindings:
|
|
188
|
+
bindings = KeyBindings()
|
|
189
|
+
|
|
190
|
+
@bindings.add("down")
|
|
191
|
+
@bindings.add("c-n")
|
|
192
|
+
@bindings.add("c-j")
|
|
193
|
+
def _down(event) -> None:
|
|
194
|
+
self._move(1)
|
|
195
|
+
|
|
196
|
+
@bindings.add("up")
|
|
197
|
+
@bindings.add("c-p")
|
|
198
|
+
def _up(event) -> None:
|
|
199
|
+
self._move(-1)
|
|
200
|
+
|
|
201
|
+
@bindings.add("pageup")
|
|
202
|
+
def _page_up(event) -> None:
|
|
203
|
+
self._move(-self._visible_rows())
|
|
204
|
+
|
|
205
|
+
@bindings.add("pagedown")
|
|
206
|
+
def _page_down(event) -> None:
|
|
207
|
+
self._move(self._visible_rows())
|
|
208
|
+
|
|
209
|
+
@bindings.add("enter")
|
|
210
|
+
def _accept(event) -> None:
|
|
211
|
+
self._exit(self._current())
|
|
212
|
+
|
|
213
|
+
@bindings.add("c-c")
|
|
214
|
+
@bindings.add("c-d")
|
|
215
|
+
@bindings.add("c-g")
|
|
216
|
+
@bindings.add("escape")
|
|
217
|
+
def _abort(event) -> None:
|
|
218
|
+
self._exit(None)
|
|
219
|
+
|
|
220
|
+
return bindings
|
|
221
|
+
|
|
222
|
+
def _exit(self, result: str | None) -> None:
|
|
223
|
+
"""幂等退出:快速连按退出键时避免重复调用 Application.exit() 抛异常。"""
|
|
224
|
+
assert self.app is not None
|
|
225
|
+
if self.app.future is not None and not self.app.future.done():
|
|
226
|
+
self.app.exit(result=result)
|
|
227
|
+
|
|
228
|
+
# ------------------------------------------------------------------- 入口
|
|
229
|
+
|
|
230
|
+
def pick(self) -> str | None:
|
|
231
|
+
assert self.app is not None
|
|
232
|
+
try:
|
|
233
|
+
return self.app.run()
|
|
234
|
+
except (EOFError, KeyboardInterrupt):
|
|
235
|
+
return None
|