omdev 0.0.0.dev463__py3-none-any.whl → 0.0.0.dev464__py3-none-any.whl
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.
- omdev/tui/rich/__init__.py +1 -0
- omdev/tui/textual/__init__.py +16 -0
- omdev/tui/textual/autocomplete/LICENSE +21 -0
- omdev/tui/textual/autocomplete/__init__.py +33 -0
- omdev/tui/textual/autocomplete/matching.py +226 -0
- omdev/tui/textual/autocomplete/paths.py +202 -0
- omdev/tui/textual/autocomplete/widget.py +612 -0
- {omdev-0.0.0.dev463.dist-info → omdev-0.0.0.dev464.dist-info}/METADATA +2 -2
- {omdev-0.0.0.dev463.dist-info → omdev-0.0.0.dev464.dist-info}/RECORD +13 -8
- {omdev-0.0.0.dev463.dist-info → omdev-0.0.0.dev464.dist-info}/WHEEL +0 -0
- {omdev-0.0.0.dev463.dist-info → omdev-0.0.0.dev464.dist-info}/entry_points.txt +0 -0
- {omdev-0.0.0.dev463.dist-info → omdev-0.0.0.dev464.dist-info}/licenses/LICENSE +0 -0
- {omdev-0.0.0.dev463.dist-info → omdev-0.0.0.dev464.dist-info}/top_level.txt +0 -0
omdev/tui/rich/__init__.py
CHANGED
|
@@ -9,6 +9,7 @@ with _lang.auto_proxy_init(globals()):
|
|
|
9
9
|
from rich import console # noqa
|
|
10
10
|
from rich import live # noqa
|
|
11
11
|
from rich import markdown # noqa
|
|
12
|
+
from rich import repr # noqa
|
|
12
13
|
from rich import text # noqa
|
|
13
14
|
from rich.console import Console # noqa
|
|
14
15
|
from rich.live import Live # noqa
|
omdev/tui/textual/__init__.py
CHANGED
|
@@ -8,13 +8,16 @@ with _lang.auto_proxy_init(globals()):
|
|
|
8
8
|
|
|
9
9
|
from textual import app # noqa
|
|
10
10
|
from textual import binding # noqa
|
|
11
|
+
from textual import constants # noqa
|
|
11
12
|
from textual import containers # noqa
|
|
12
13
|
from textual import content # noqa
|
|
13
14
|
from textual import driver # noqa
|
|
14
15
|
from textual import events # noqa
|
|
16
|
+
from textual import geometry # noqa
|
|
15
17
|
from textual import markup # noqa
|
|
16
18
|
from textual import message # noqa
|
|
17
19
|
from textual import messages # noqa
|
|
20
|
+
from textual import on # noqa
|
|
18
21
|
from textual import pad # noqa
|
|
19
22
|
from textual import reactive # noqa
|
|
20
23
|
from textual import screen # noqa
|
|
@@ -116,6 +119,16 @@ with _lang.auto_proxy_init(globals()):
|
|
|
116
119
|
from textual.events import Show # noqa
|
|
117
120
|
from textual.events import Timer as TimerEvent # noqa
|
|
118
121
|
from textual.events import Unmount # noqa
|
|
122
|
+
from textual.geometry import NULL_OFFSET # noqa
|
|
123
|
+
from textual.geometry import NULL_REGION # noqa
|
|
124
|
+
from textual.geometry import NULL_SIZE # noqa
|
|
125
|
+
from textual.geometry import NULL_SPACING # noqa
|
|
126
|
+
from textual.geometry import Offset # noqa
|
|
127
|
+
from textual.geometry import Region # noqa
|
|
128
|
+
from textual.geometry import Size # noqa
|
|
129
|
+
from textual.geometry import Spacing # noqa
|
|
130
|
+
from textual.geometry import SpacingDimensions # noqa
|
|
131
|
+
from textual.geometry import clamp # noqa
|
|
119
132
|
from textual.markup import MarkupError # noqa
|
|
120
133
|
from textual.markup import MarkupTokenizer # noqa
|
|
121
134
|
from textual.markup import StyleTokenizer # noqa
|
|
@@ -197,6 +210,9 @@ with _lang.auto_proxy_init(globals()):
|
|
|
197
210
|
from textual.widgets import Tooltip # noqa
|
|
198
211
|
from textual.widgets import Tree # noqa
|
|
199
212
|
from textual.widgets import Welcome # noqa
|
|
213
|
+
from textual.widgets.option_list import OptionDoesNotExist # noqa
|
|
214
|
+
from textual.widgets.option_list import Option # noqa
|
|
215
|
+
from textual.widgets.option_list import DuplicateID # noqa
|
|
200
216
|
|
|
201
217
|
##
|
|
202
218
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023 Darren Burns
|
|
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.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# MIT License
|
|
2
|
+
#
|
|
3
|
+
# Copyright (c) 2023 Darren Burns
|
|
4
|
+
#
|
|
5
|
+
# Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
|
|
6
|
+
# documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
|
|
7
|
+
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit
|
|
8
|
+
# persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
9
|
+
#
|
|
10
|
+
# The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
|
|
11
|
+
# Software.
|
|
12
|
+
#
|
|
13
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
|
|
14
|
+
# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
15
|
+
# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
|
16
|
+
# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
17
|
+
#
|
|
18
|
+
# https://github.com/darrenburns/textual-autocomplete/tree/0344cd3eb3383cbbd80e01b035ed808ce53cef4d
|
|
19
|
+
from .matching import ( # noqa
|
|
20
|
+
FuzzySearch,
|
|
21
|
+
FuzzyMatcher,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
from .paths import ( # noqa
|
|
25
|
+
PathAutoComplete,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
from .widget import ( # noqa
|
|
29
|
+
AutoCompleteItem,
|
|
30
|
+
AutoCompleteItemHit,
|
|
31
|
+
AutoCompleteList,
|
|
32
|
+
AutoComplete,
|
|
33
|
+
)
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
import operator
|
|
3
|
+
import re
|
|
4
|
+
import typing as ta
|
|
5
|
+
|
|
6
|
+
import rich.repr
|
|
7
|
+
from textual.cache import LRUCache
|
|
8
|
+
from textual.content import Content
|
|
9
|
+
from textual.style import Style
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
##
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FuzzySearch:
|
|
16
|
+
"""
|
|
17
|
+
Performs a fuzzy search.
|
|
18
|
+
|
|
19
|
+
Unlike a regex solution, this will finds all possible matches.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
case_sensitive: bool = False,
|
|
25
|
+
*,
|
|
26
|
+
cache_size: int = 1024 * 4,
|
|
27
|
+
) -> None:
|
|
28
|
+
"""
|
|
29
|
+
Initialize fuzzy search.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
case_sensitive: Is the match case sensitive?
|
|
33
|
+
cache_size: Number of queries to cache.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
self.case_sensitive = case_sensitive
|
|
37
|
+
self.cache: LRUCache[tuple[str, str], tuple[float, ta.Sequence[int]]] = LRUCache(cache_size)
|
|
38
|
+
|
|
39
|
+
def match(self, query: str, candidate: str) -> tuple[float, ta.Sequence[int]]:
|
|
40
|
+
"""
|
|
41
|
+
Match against a query.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
query: The fuzzy query.
|
|
45
|
+
candidate: A candidate to check,.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
A pair of (score, tuple of offsets). `(0, ())` for no result.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
cache_key = (query, candidate)
|
|
52
|
+
if cache_key in self.cache:
|
|
53
|
+
return self.cache[cache_key]
|
|
54
|
+
default: tuple[float, ta.Sequence[int]] = (0.0, [])
|
|
55
|
+
result = max(self._match(query, candidate), key=operator.itemgetter(0), default=default)
|
|
56
|
+
self.cache[cache_key] = result
|
|
57
|
+
return result
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
@functools.lru_cache(maxsize=1024)
|
|
61
|
+
def get_first_letters(cls, candidate: str) -> frozenset[int]:
|
|
62
|
+
return frozenset({match.start() for match in re.finditer(r'\w+', candidate)})
|
|
63
|
+
|
|
64
|
+
def score(self, candidate: str, positions: ta.Sequence[int]) -> float:
|
|
65
|
+
"""
|
|
66
|
+
Score a search.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
search: Search object.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
Score.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
first_letters = self.get_first_letters(candidate)
|
|
76
|
+
# This is a heuristic, and can be tweaked for better results.
|
|
77
|
+
# Boost first letter matches.
|
|
78
|
+
offset_count = len(positions)
|
|
79
|
+
score: float = offset_count + len(first_letters.intersection(positions))
|
|
80
|
+
|
|
81
|
+
groups = 1
|
|
82
|
+
last_offset, *offsets = positions
|
|
83
|
+
for offset in offsets:
|
|
84
|
+
if offset != last_offset + 1:
|
|
85
|
+
groups += 1
|
|
86
|
+
last_offset = offset
|
|
87
|
+
|
|
88
|
+
# Boost to favor less groups
|
|
89
|
+
normalized_groups = (offset_count - (groups - 1)) / offset_count
|
|
90
|
+
score *= 1 + (normalized_groups * normalized_groups)
|
|
91
|
+
return score
|
|
92
|
+
|
|
93
|
+
def _match(self, query: str, candidate: str) -> ta.Iterable[tuple[float, ta.Sequence[int]]]:
|
|
94
|
+
letter_positions: list[list[int]] = []
|
|
95
|
+
position = 0
|
|
96
|
+
|
|
97
|
+
if not self.case_sensitive:
|
|
98
|
+
candidate = candidate.lower()
|
|
99
|
+
query = query.lower()
|
|
100
|
+
score = self.score
|
|
101
|
+
if query in candidate:
|
|
102
|
+
# Quick exit when the query exists as a substring
|
|
103
|
+
query_location = candidate.rfind(query)
|
|
104
|
+
offsets = list(range(query_location, query_location + len(query)))
|
|
105
|
+
yield (
|
|
106
|
+
score(candidate, offsets) * (2.0 if candidate == query else 1.5),
|
|
107
|
+
offsets,
|
|
108
|
+
)
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
for offset, letter in enumerate(query):
|
|
112
|
+
last_index = len(candidate) - offset
|
|
113
|
+
positions: list[int] = []
|
|
114
|
+
letter_positions.append(positions)
|
|
115
|
+
index = position
|
|
116
|
+
while (location := candidate.find(letter, index)) != -1:
|
|
117
|
+
positions.append(location)
|
|
118
|
+
index = location + 1
|
|
119
|
+
if index >= last_index:
|
|
120
|
+
break
|
|
121
|
+
if not positions:
|
|
122
|
+
yield (0.0, ())
|
|
123
|
+
return
|
|
124
|
+
position = positions[0] + 1
|
|
125
|
+
|
|
126
|
+
possible_offsets: list[list[int]] = []
|
|
127
|
+
query_length = len(query)
|
|
128
|
+
|
|
129
|
+
def get_offsets(offsets: list[int], positions_index: int) -> None:
|
|
130
|
+
"""
|
|
131
|
+
Recursively match offsets.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
offsets: A list of offsets.
|
|
135
|
+
positions_index: Index of query letter.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
for offset in letter_positions[positions_index]:
|
|
139
|
+
if not offsets or offset > offsets[-1]:
|
|
140
|
+
new_offsets = [*offsets, offset]
|
|
141
|
+
if len(new_offsets) == query_length:
|
|
142
|
+
possible_offsets.append(new_offsets)
|
|
143
|
+
else:
|
|
144
|
+
get_offsets(new_offsets, positions_index + 1)
|
|
145
|
+
|
|
146
|
+
get_offsets([], 0)
|
|
147
|
+
|
|
148
|
+
for offsets in possible_offsets:
|
|
149
|
+
yield score(candidate, offsets), offsets
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@rich.repr.auto
|
|
153
|
+
class FuzzyMatcher:
|
|
154
|
+
"""A fuzzy matcher."""
|
|
155
|
+
|
|
156
|
+
def __init__(
|
|
157
|
+
self,
|
|
158
|
+
query: str,
|
|
159
|
+
*,
|
|
160
|
+
match_style: Style | None = None,
|
|
161
|
+
case_sensitive: bool = False,
|
|
162
|
+
) -> None:
|
|
163
|
+
"""
|
|
164
|
+
Initialize the fuzzy matching object.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
query: A query as typed in by the user.
|
|
168
|
+
match_style: The style to use to highlight matched portions of a string.
|
|
169
|
+
case_sensitive: Should matching be case sensitive?
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
self._query = query
|
|
173
|
+
self._match_style = Style(reverse=True) if match_style is None else match_style
|
|
174
|
+
self._case_sensitive = case_sensitive
|
|
175
|
+
self.fuzzy_search = FuzzySearch()
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def query(self) -> str:
|
|
179
|
+
"""The query string to look for."""
|
|
180
|
+
|
|
181
|
+
return self._query
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def match_style(self) -> Style:
|
|
185
|
+
"""The style that will be used to highlight hits in the matched text."""
|
|
186
|
+
|
|
187
|
+
return self._match_style
|
|
188
|
+
|
|
189
|
+
@property
|
|
190
|
+
def case_sensitive(self) -> bool:
|
|
191
|
+
"""Is this matcher case sensitive?"""
|
|
192
|
+
|
|
193
|
+
return self._case_sensitive
|
|
194
|
+
|
|
195
|
+
def match(self, candidate: str) -> float:
|
|
196
|
+
"""
|
|
197
|
+
Match the candidate against the query.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
candidate: Candidate string to match against the query.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
Strength of the match from 0 to 1.
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
return self.fuzzy_search.match(self.query, candidate)[0]
|
|
207
|
+
|
|
208
|
+
def highlight(self, candidate: str) -> Content:
|
|
209
|
+
"""
|
|
210
|
+
Highlight the candidate with the fuzzy match.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
candidate: The candidate string to match against the query.
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
A [`Text`][rich.text.Text] object with highlighted matches.
|
|
217
|
+
"""
|
|
218
|
+
|
|
219
|
+
content = Content.from_markup(candidate)
|
|
220
|
+
score, offsets = self.fuzzy_search.match(self.query, candidate)
|
|
221
|
+
if not score:
|
|
222
|
+
return content
|
|
223
|
+
for offset in offsets:
|
|
224
|
+
if not candidate[offset].isspace():
|
|
225
|
+
content = content.stylize(self._match_style, offset, offset + 1)
|
|
226
|
+
return content
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import pathlib
|
|
3
|
+
import typing as ta
|
|
4
|
+
|
|
5
|
+
from textual.cache import LRUCache
|
|
6
|
+
from textual.content import Content
|
|
7
|
+
from textual.widgets import Input
|
|
8
|
+
|
|
9
|
+
from .widget import AutoComplete
|
|
10
|
+
from .widget import AutoCompleteItem
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
##
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PathAutoCompleteItem(AutoCompleteItem):
|
|
17
|
+
def __init__(self, completion: str, path: pathlib.Path) -> None:
|
|
18
|
+
super().__init__(completion)
|
|
19
|
+
|
|
20
|
+
self.path = path
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def default_path_input_sort_key(item: PathAutoCompleteItem) -> tuple[bool, bool, str]:
|
|
24
|
+
"""
|
|
25
|
+
Sort key function for results within the dropdown.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
item: The PathAutoCompleteItem to get a sort key for.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
A tuple of (is_dotfile, is_file, lowercase_name) for sorting.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
name = item.path.name
|
|
35
|
+
is_dotfile = name.startswith('.')
|
|
36
|
+
return (not item.path.is_dir(), not is_dotfile, name.lower())
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class PathAutoComplete(AutoComplete):
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
target: Input | str,
|
|
43
|
+
path: str | pathlib.Path = '.',
|
|
44
|
+
*,
|
|
45
|
+
show_dotfiles: bool = True,
|
|
46
|
+
sort_key: ta.Callable[[PathAutoCompleteItem], ta.Any] = default_path_input_sort_key,
|
|
47
|
+
folder_prefix: Content = Content('📂'),
|
|
48
|
+
file_prefix: Content = Content('📄'),
|
|
49
|
+
prevent_default_enter: bool = True,
|
|
50
|
+
prevent_default_tab: bool = True,
|
|
51
|
+
cache_size: int = 100,
|
|
52
|
+
name: str | None = None,
|
|
53
|
+
id: str | None = None, # noqa
|
|
54
|
+
classes: str | None = None,
|
|
55
|
+
disabled: bool = False,
|
|
56
|
+
) -> None:
|
|
57
|
+
"""
|
|
58
|
+
An autocomplete widget for filesystem paths.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
target: The target input widget to autocomplete.
|
|
62
|
+
path: The base path to autocomplete from.
|
|
63
|
+
show_dotfiles: Whether to show dotfiles (files/dirs starting with ".").
|
|
64
|
+
sort_key: Function to sort the dropdown items.
|
|
65
|
+
folder_prefix: The prefix for folder items (e.g. 📂).
|
|
66
|
+
file_prefix: The prefix for file items (e.g. 📄).
|
|
67
|
+
prevent_default_enter: Whether to prevent the default enter behavior.
|
|
68
|
+
prevent_default_tab: Whether to prevent the default tab behavior.
|
|
69
|
+
cache_size: The number of directories to cache.
|
|
70
|
+
name: The name of the widget.
|
|
71
|
+
id: The DOM node id of the widget.
|
|
72
|
+
classes: The CSS classes of the widget.
|
|
73
|
+
disabled: Whether the widget is disabled.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
super().__init__(
|
|
77
|
+
target,
|
|
78
|
+
None,
|
|
79
|
+
prevent_default_enter=prevent_default_enter,
|
|
80
|
+
prevent_default_tab=prevent_default_tab,
|
|
81
|
+
name=name,
|
|
82
|
+
id=id,
|
|
83
|
+
classes=classes,
|
|
84
|
+
disabled=disabled,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
self.path = pathlib.Path(path) if isinstance(path, str) else path
|
|
88
|
+
self.show_dotfiles = show_dotfiles
|
|
89
|
+
self.sort_key = sort_key
|
|
90
|
+
self.folder_prefix = folder_prefix
|
|
91
|
+
self.file_prefix = file_prefix
|
|
92
|
+
self._directory_cache: LRUCache[str, list[os.DirEntry[str]]] = LRUCache(cache_size)
|
|
93
|
+
|
|
94
|
+
def get_candidates(self, target_state: AutoComplete.TargetState) -> list[AutoCompleteItem]:
|
|
95
|
+
"""
|
|
96
|
+
Get the candidates for the current path segment.
|
|
97
|
+
|
|
98
|
+
This is called each time the input changes or the cursor position changes/
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
current_input = target_state.text[: target_state.cursor_position]
|
|
102
|
+
|
|
103
|
+
if '/' in current_input:
|
|
104
|
+
last_slash_index = current_input.rindex('/')
|
|
105
|
+
path_segment = current_input[:last_slash_index] or '/'
|
|
106
|
+
directory = self.path / path_segment if path_segment != '/' else self.path
|
|
107
|
+
else:
|
|
108
|
+
directory = self.path
|
|
109
|
+
|
|
110
|
+
# Use the directory path as the cache key
|
|
111
|
+
cache_key = str(directory)
|
|
112
|
+
cached_entries = self._directory_cache.get(cache_key)
|
|
113
|
+
|
|
114
|
+
if cached_entries is not None:
|
|
115
|
+
entries = cached_entries
|
|
116
|
+
else:
|
|
117
|
+
try:
|
|
118
|
+
entries = list(os.scandir(directory))
|
|
119
|
+
self._directory_cache[cache_key] = entries
|
|
120
|
+
except OSError:
|
|
121
|
+
return []
|
|
122
|
+
|
|
123
|
+
results: list[PathAutoCompleteItem] = []
|
|
124
|
+
for entry in entries:
|
|
125
|
+
# Only include the entry name, not the full path
|
|
126
|
+
completion = entry.name
|
|
127
|
+
if not self.show_dotfiles and completion.startswith('.'):
|
|
128
|
+
continue
|
|
129
|
+
if entry.is_dir():
|
|
130
|
+
completion += '/'
|
|
131
|
+
results.append(PathAutoCompleteItem(completion, path=pathlib.Path(entry.path)))
|
|
132
|
+
|
|
133
|
+
results.sort(key=self.sort_key)
|
|
134
|
+
folder_prefix = self.folder_prefix
|
|
135
|
+
file_prefix = self.file_prefix
|
|
136
|
+
return [
|
|
137
|
+
AutoCompleteItem(
|
|
138
|
+
item.main,
|
|
139
|
+
prefix=folder_prefix if item.path.is_dir() else file_prefix,
|
|
140
|
+
)
|
|
141
|
+
for item in results
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
def get_search_string(self, target_state: AutoComplete.TargetState) -> str:
|
|
145
|
+
"""Return only the current path segment for searching in the dropdown."""
|
|
146
|
+
|
|
147
|
+
current_input = target_state.text[: target_state.cursor_position]
|
|
148
|
+
|
|
149
|
+
if '/' in current_input:
|
|
150
|
+
last_slash_index = current_input.rindex('/')
|
|
151
|
+
search_string = current_input[last_slash_index + 1:]
|
|
152
|
+
return search_string
|
|
153
|
+
else:
|
|
154
|
+
return current_input
|
|
155
|
+
|
|
156
|
+
def apply_completion(self, value: str, state: AutoComplete.TargetState) -> None:
|
|
157
|
+
"""Apply the completion by replacing only the current path segment."""
|
|
158
|
+
|
|
159
|
+
target = self.target
|
|
160
|
+
current_input = state.text
|
|
161
|
+
cursor_position = state.cursor_position
|
|
162
|
+
|
|
163
|
+
# There's a slash before the cursor, so we only want to replace the text after the last slash with the selected
|
|
164
|
+
# value
|
|
165
|
+
try:
|
|
166
|
+
replace_start_index = current_input.rindex('/', 0, cursor_position)
|
|
167
|
+
except ValueError:
|
|
168
|
+
# No slashes, so we do a full replacement
|
|
169
|
+
new_value = value
|
|
170
|
+
new_cursor_position = len(value)
|
|
171
|
+
else:
|
|
172
|
+
# Keep everything before and including the slash before the cursor.
|
|
173
|
+
path_prefix = current_input[: replace_start_index + 1]
|
|
174
|
+
new_value = path_prefix + value
|
|
175
|
+
new_cursor_position = len(path_prefix) + len(value)
|
|
176
|
+
|
|
177
|
+
with self.prevent(Input.Changed):
|
|
178
|
+
target.value = new_value
|
|
179
|
+
target.cursor_position = new_cursor_position
|
|
180
|
+
|
|
181
|
+
def post_completion(self) -> None:
|
|
182
|
+
if not self.target.value.endswith('/'):
|
|
183
|
+
self.action_hide()
|
|
184
|
+
|
|
185
|
+
def should_show_dropdown(self, search_string: str) -> bool:
|
|
186
|
+
return (
|
|
187
|
+
super().should_show_dropdown(search_string) or
|
|
188
|
+
(
|
|
189
|
+
(search_string == '' and self.target.value != '') and
|
|
190
|
+
self.option_list.option_count > 1
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
def clear_directory_cache(self) -> None:
|
|
195
|
+
"""
|
|
196
|
+
Clear the directory cache. If you know that the contents of the directory have changed, you can call this method
|
|
197
|
+
to invalidate the cache.
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
self._directory_cache.clear()
|
|
201
|
+
target_state = self._get_target_state()
|
|
202
|
+
self._rebuild_options(target_state, self.get_search_string(target_state))
|
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
import dataclasses as dc
|
|
2
|
+
import operator
|
|
3
|
+
import typing as ta
|
|
4
|
+
|
|
5
|
+
from rich.text import Text
|
|
6
|
+
from textual import events
|
|
7
|
+
from textual import on
|
|
8
|
+
from textual.app import ComposeResult
|
|
9
|
+
from textual.binding import Binding
|
|
10
|
+
from textual.binding import BindingType
|
|
11
|
+
from textual.content import Content
|
|
12
|
+
from textual.css.query import NoMatches
|
|
13
|
+
from textual.geometry import Offset
|
|
14
|
+
from textual.geometry import Region
|
|
15
|
+
from textual.geometry import Spacing
|
|
16
|
+
from textual.style import Style
|
|
17
|
+
from textual.widget import Widget
|
|
18
|
+
from textual.widgets import Input
|
|
19
|
+
from textual.widgets import OptionList
|
|
20
|
+
from textual.widgets.option_list import Option
|
|
21
|
+
|
|
22
|
+
from omlish import check
|
|
23
|
+
|
|
24
|
+
from .matching import FuzzySearch
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
##
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AutoCompleteItem(Option):
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
main: str | Content,
|
|
34
|
+
prefix: str | Content | None = None,
|
|
35
|
+
id: str | None = None, # noqa
|
|
36
|
+
disabled: bool = False,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""
|
|
39
|
+
A single option appearing in the autocompletion dropdown. Each option has up to 3 columns. Note that this is not
|
|
40
|
+
a widget, it's simply a data structure for describing dropdown items.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
prefix: The prefix will often contain an icon/symbol, the main (middle) column contains the text that
|
|
44
|
+
represents this option.
|
|
45
|
+
main: The main text representing this option - this will be highlighted by default. In an IDE, the `main`
|
|
46
|
+
(middle) column might contain the name of a function or method.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
self.main = Content(main) if isinstance(main, str) else main
|
|
50
|
+
self.prefix = Content(prefix) if isinstance(prefix, str) else prefix
|
|
51
|
+
left = self.prefix
|
|
52
|
+
prompt = self.main
|
|
53
|
+
if left:
|
|
54
|
+
prompt = Content.assemble(left, self.main)
|
|
55
|
+
|
|
56
|
+
super().__init__(prompt, id, disabled)
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def value(self) -> str:
|
|
60
|
+
return self.main.plain
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class AutoCompleteItemHit(AutoCompleteItem):
|
|
64
|
+
"""
|
|
65
|
+
A dropdown item which matches the current search string - in other words AutoComplete.match has returned a score
|
|
66
|
+
greater than 0 for this item.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class AutoCompleteList(OptionList):
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class AutoComplete(Widget):
|
|
75
|
+
BINDINGS: ta.ClassVar[list[BindingType]] = [
|
|
76
|
+
Binding('escape', 'hide', 'Hide dropdown', show=False),
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
DEFAULT_CSS = """
|
|
80
|
+
AutoComplete {
|
|
81
|
+
height: auto;
|
|
82
|
+
width: auto;
|
|
83
|
+
max-height: 12;
|
|
84
|
+
display: none;
|
|
85
|
+
background: $surface;
|
|
86
|
+
overlay: screen;
|
|
87
|
+
|
|
88
|
+
& AutoCompleteList {
|
|
89
|
+
width: auto;
|
|
90
|
+
height: auto;
|
|
91
|
+
border: none;
|
|
92
|
+
padding: 0;
|
|
93
|
+
margin: 0;
|
|
94
|
+
scrollbar-size-vertical: 1;
|
|
95
|
+
text-wrap: nowrap;
|
|
96
|
+
color: $foreground;
|
|
97
|
+
background: transparent;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
& .autocomplete--highlight-match {
|
|
101
|
+
text-style: bold;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
}
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
COMPONENT_CLASSES: ta.ClassVar[set[str]] = {
|
|
108
|
+
'autocomplete--highlight-match',
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
class Matcher(ta.Protocol):
|
|
112
|
+
def __call__(self, query: str, candidate: str) -> tuple[float, ta.Sequence[int]]:
|
|
113
|
+
"""
|
|
114
|
+
Match a query (search string) against a candidate (dropdown item value).
|
|
115
|
+
|
|
116
|
+
Returns a tuple of (score, offsets) where score is a float between 0 and 1, used for sorting the matches,
|
|
117
|
+
and offsets is a tuple of integers representing the indices of the characters in the candidate string that
|
|
118
|
+
match the query.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
...
|
|
122
|
+
|
|
123
|
+
@dc.dataclass()
|
|
124
|
+
class TargetState:
|
|
125
|
+
text: str
|
|
126
|
+
"""The content in the target widget."""
|
|
127
|
+
|
|
128
|
+
cursor_position: int
|
|
129
|
+
"""The cursor position in the target widget."""
|
|
130
|
+
|
|
131
|
+
def __init__(
|
|
132
|
+
self,
|
|
133
|
+
target: Input | str,
|
|
134
|
+
candidates: ta.Sequence[AutoCompleteItem | str] | ta.Callable[[TargetState], list[AutoCompleteItem]] | None = None, # noqa
|
|
135
|
+
*,
|
|
136
|
+
prevent_default_enter: bool = True,
|
|
137
|
+
prevent_default_tab: bool = True,
|
|
138
|
+
name: str | None = None,
|
|
139
|
+
id: str | None = None, # noqa
|
|
140
|
+
classes: str | None = None,
|
|
141
|
+
disabled: bool = False,
|
|
142
|
+
matcher: Matcher | None = None,
|
|
143
|
+
) -> None:
|
|
144
|
+
"""
|
|
145
|
+
An autocomplete widget.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
target: An Input instance or a selector string used to query an Input instance. If a selector is used,
|
|
149
|
+
remember that widgets are not available until the widget has been mounted (don't use the selector in
|
|
150
|
+
`compose` - use it in `on_mount` instead).
|
|
151
|
+
candidates: The candidates to match on, or a function which returns the candidates to match on. If set to
|
|
152
|
+
None, the candidates will be fetched by directly calling the `get_candidates` method, which is what
|
|
153
|
+
you'll probably want to do if you're subclassing AutoComplete and supplying your own custom
|
|
154
|
+
`get_candidates` method.
|
|
155
|
+
prevent_default_enter: Prevent the default enter behavior. If True, when you select a dropdown option using
|
|
156
|
+
the enter key, the default behavior (e.g. submitting an Input) will be prevented.
|
|
157
|
+
prevent_default_tab: Prevent the default tab behavior. If True, when you select a dropdown option using the
|
|
158
|
+
tab key, the default behavior (e.g. moving focus to the next widget) will be prevented.
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
|
|
162
|
+
|
|
163
|
+
self._target = target
|
|
164
|
+
|
|
165
|
+
# Users can supply strings as a convenience for the simplest cases, so let's convert them to DropdownItems.
|
|
166
|
+
if isinstance(candidates, ta.Sequence):
|
|
167
|
+
self.candidates = [
|
|
168
|
+
candidate
|
|
169
|
+
if isinstance(candidate, AutoCompleteItem)
|
|
170
|
+
else AutoCompleteItem(main=candidate)
|
|
171
|
+
for candidate in candidates
|
|
172
|
+
]
|
|
173
|
+
else:
|
|
174
|
+
self.candidates = candidates
|
|
175
|
+
|
|
176
|
+
self.prevent_default_enter = prevent_default_enter
|
|
177
|
+
self.prevent_default_tab = prevent_default_tab
|
|
178
|
+
self._target_state = AutoComplete.TargetState('', 0)
|
|
179
|
+
self._previous_terminal_cursor_position = (0, 0)
|
|
180
|
+
if matcher is None:
|
|
181
|
+
matcher = FuzzySearch().match
|
|
182
|
+
self._matcher = matcher
|
|
183
|
+
|
|
184
|
+
candidates: list[AutoCompleteItem] | ta.Callable[[TargetState], list[AutoCompleteItem]] | None
|
|
185
|
+
"""The candidates to match on, or a function which returns the candidates to match on."""
|
|
186
|
+
|
|
187
|
+
prevent_default_enter: bool
|
|
188
|
+
"""
|
|
189
|
+
Prevent the default enter behavior. If True, when you select a dropdown option using the enter key, the default
|
|
190
|
+
behavior (e.g. submitting an Input) will be prevented.
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
prevent_default_tab: bool
|
|
194
|
+
"""
|
|
195
|
+
Prevent the default tab behavior. If True, when you select a dropdown option using the tab key, the default
|
|
196
|
+
behavior (e.g. moving focus to the next widget) will be prevented.
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
_target_state: TargetState
|
|
200
|
+
"""Cached state of the target Input."""
|
|
201
|
+
|
|
202
|
+
_matcher: Matcher
|
|
203
|
+
"""The default implementation used by AutoComplete.match."""
|
|
204
|
+
|
|
205
|
+
_previous_terminal_cursor_position: tuple[int, int]
|
|
206
|
+
"""Tracks the last known cursor position in the terminal."""
|
|
207
|
+
|
|
208
|
+
def compose(self) -> ComposeResult:
|
|
209
|
+
option_list = AutoCompleteList()
|
|
210
|
+
option_list.can_focus = False
|
|
211
|
+
yield option_list
|
|
212
|
+
|
|
213
|
+
def on_mount(self) -> None:
|
|
214
|
+
# Subscribe to the target widget's reactive attributes.
|
|
215
|
+
self.target.message_signal.subscribe(self, self._listen_to_messages) # type: ignore
|
|
216
|
+
self._subscribe_to_target()
|
|
217
|
+
self._handle_target_update()
|
|
218
|
+
|
|
219
|
+
def _realign(_=None) -> None:
|
|
220
|
+
# Only realign if the cursor position has changed (the cursor position is in screen-space, so if it remains
|
|
221
|
+
# the same, the autocomplete does not need to be realigned).
|
|
222
|
+
if (
|
|
223
|
+
self.is_attached and
|
|
224
|
+
self._previous_terminal_cursor_position != self.app.cursor_position
|
|
225
|
+
):
|
|
226
|
+
self._align_to_target()
|
|
227
|
+
self._previous_terminal_cursor_position = self.app.cursor_position
|
|
228
|
+
|
|
229
|
+
self.screen.screen_layout_refresh_signal.subscribe(self, _realign)
|
|
230
|
+
|
|
231
|
+
def _listen_to_messages(self, event: events.Event) -> None:
|
|
232
|
+
"""Listen to some events of the target widget."""
|
|
233
|
+
|
|
234
|
+
try:
|
|
235
|
+
option_list = self.option_list
|
|
236
|
+
except NoMatches:
|
|
237
|
+
# This can happen if the event is an Unmount event during application shutdown.
|
|
238
|
+
return
|
|
239
|
+
|
|
240
|
+
if isinstance(event, events.Key) and option_list.option_count:
|
|
241
|
+
displayed = self.display
|
|
242
|
+
highlighted = option_list.highlighted or 0
|
|
243
|
+
if event.key == 'down':
|
|
244
|
+
# Check if there's only one item and it matches the search string
|
|
245
|
+
if option_list.option_count == 1:
|
|
246
|
+
search_string = self.get_search_string(self._get_target_state())
|
|
247
|
+
first_option = option_list.get_option_at_index(0).prompt
|
|
248
|
+
text_from_option = (
|
|
249
|
+
first_option.plain
|
|
250
|
+
if isinstance(first_option, Text)
|
|
251
|
+
else first_option
|
|
252
|
+
)
|
|
253
|
+
if text_from_option == search_string:
|
|
254
|
+
# Don't prevent default behavior in this case
|
|
255
|
+
return
|
|
256
|
+
|
|
257
|
+
# If you press `down` while in an Input and the autocomplete is currently hidden, then we should show
|
|
258
|
+
# the dropdown.
|
|
259
|
+
event.prevent_default()
|
|
260
|
+
event.stop()
|
|
261
|
+
if displayed:
|
|
262
|
+
highlighted = (highlighted + 1) % option_list.option_count
|
|
263
|
+
else:
|
|
264
|
+
self.display = True
|
|
265
|
+
highlighted = 0
|
|
266
|
+
|
|
267
|
+
option_list.highlighted = highlighted
|
|
268
|
+
|
|
269
|
+
elif event.key == 'up':
|
|
270
|
+
if displayed:
|
|
271
|
+
event.prevent_default()
|
|
272
|
+
event.stop()
|
|
273
|
+
highlighted = (highlighted - 1) % option_list.option_count
|
|
274
|
+
option_list.highlighted = highlighted
|
|
275
|
+
|
|
276
|
+
elif event.key == 'enter':
|
|
277
|
+
if self.prevent_default_enter and displayed:
|
|
278
|
+
event.prevent_default()
|
|
279
|
+
event.stop()
|
|
280
|
+
self._complete(option_index=highlighted)
|
|
281
|
+
|
|
282
|
+
elif event.key == 'tab':
|
|
283
|
+
if self.prevent_default_tab and displayed:
|
|
284
|
+
event.prevent_default()
|
|
285
|
+
event.stop()
|
|
286
|
+
self._complete(option_index=highlighted)
|
|
287
|
+
|
|
288
|
+
elif event.key == 'escape':
|
|
289
|
+
if displayed:
|
|
290
|
+
event.prevent_default()
|
|
291
|
+
event.stop()
|
|
292
|
+
self.action_hide()
|
|
293
|
+
|
|
294
|
+
if isinstance(event, Input.Changed):
|
|
295
|
+
# We suppress Changed events from the target widget, so that we don't handle change events as a result of
|
|
296
|
+
# performing a completion.
|
|
297
|
+
self._handle_target_update()
|
|
298
|
+
|
|
299
|
+
def action_hide(self) -> None:
|
|
300
|
+
self.styles.display = 'none'
|
|
301
|
+
|
|
302
|
+
def action_show(self) -> None:
|
|
303
|
+
self.styles.display = 'block'
|
|
304
|
+
|
|
305
|
+
def _complete(self, option_index: int) -> None:
|
|
306
|
+
"""
|
|
307
|
+
Do the completion (i.e. insert the selected item into the target input).
|
|
308
|
+
|
|
309
|
+
This is when the user highlights an option in the dropdown and presses tab or enter.
|
|
310
|
+
"""
|
|
311
|
+
|
|
312
|
+
if not self.display or self.option_list.option_count == 0:
|
|
313
|
+
return
|
|
314
|
+
|
|
315
|
+
option_list = self.option_list
|
|
316
|
+
highlighted = option_index
|
|
317
|
+
option = ta.cast(AutoCompleteItem, option_list.get_option_at_index(highlighted))
|
|
318
|
+
highlighted_value = option.value
|
|
319
|
+
with self.prevent(Input.Changed):
|
|
320
|
+
self.apply_completion(highlighted_value, self._get_target_state())
|
|
321
|
+
self.post_completion()
|
|
322
|
+
|
|
323
|
+
def post_completion(self) -> None:
|
|
324
|
+
"""This method is called after a completion is applied. By default, it simply hides the dropdown."""
|
|
325
|
+
|
|
326
|
+
self.action_hide()
|
|
327
|
+
|
|
328
|
+
def apply_completion(self, value: str, state: TargetState) -> None:
|
|
329
|
+
"""
|
|
330
|
+
Apply the completion to the target widget.
|
|
331
|
+
|
|
332
|
+
This method updates the state of the target widget to the reflect the value the user has chosen from the
|
|
333
|
+
dropdown list.
|
|
334
|
+
"""
|
|
335
|
+
|
|
336
|
+
target = self.target
|
|
337
|
+
target.value = ''
|
|
338
|
+
target.insert_text_at_cursor(value)
|
|
339
|
+
|
|
340
|
+
# We need to rebuild here because we've prevented the Changed events from being sent to the target widget,
|
|
341
|
+
# meaning AutoComplete won't spot intercept that message, and would not trigger a rebuild like it normally does
|
|
342
|
+
# when a Changed event is received.
|
|
343
|
+
new_target_state = self._get_target_state()
|
|
344
|
+
self._rebuild_options(new_target_state, self.get_search_string(new_target_state))
|
|
345
|
+
|
|
346
|
+
@property
|
|
347
|
+
def target(self) -> Input:
|
|
348
|
+
"""The resolved target widget."""
|
|
349
|
+
|
|
350
|
+
if isinstance(self._target, Input):
|
|
351
|
+
return self._target
|
|
352
|
+
|
|
353
|
+
else:
|
|
354
|
+
target = self.screen.query_one(self._target)
|
|
355
|
+
return check.isinstance(target, Input)
|
|
356
|
+
|
|
357
|
+
def _subscribe_to_target(self) -> None:
|
|
358
|
+
"""Attempt to subscribe to the target widget, if it's available."""
|
|
359
|
+
|
|
360
|
+
target = self.target
|
|
361
|
+
self.watch(target, 'has_focus', self._handle_focus_change)
|
|
362
|
+
self.watch(target, 'selection', self._align_and_rebuild)
|
|
363
|
+
|
|
364
|
+
def _align_and_rebuild(self) -> None:
|
|
365
|
+
self._align_to_target()
|
|
366
|
+
self._target_state = self._get_target_state()
|
|
367
|
+
search_string = self.get_search_string(self._target_state)
|
|
368
|
+
self._rebuild_options(self._target_state, search_string)
|
|
369
|
+
|
|
370
|
+
def _align_to_target(self) -> None:
|
|
371
|
+
"""
|
|
372
|
+
Align the dropdown to the position of the cursor within the target widget, and constrain it to be within the
|
|
373
|
+
screen.
|
|
374
|
+
"""
|
|
375
|
+
|
|
376
|
+
x, y = self.target.cursor_screen_offset
|
|
377
|
+
dropdown = self.option_list
|
|
378
|
+
width, height = dropdown.outer_size
|
|
379
|
+
|
|
380
|
+
# Constrain the dropdown within the screen.
|
|
381
|
+
x, y, _width, _height = Region(x - 1, y + 1, width, height).constrain(
|
|
382
|
+
'inside',
|
|
383
|
+
'none',
|
|
384
|
+
Spacing.all(0),
|
|
385
|
+
self.screen.scrollable_content_region,
|
|
386
|
+
)
|
|
387
|
+
self.absolute_offset = Offset(x, y)
|
|
388
|
+
|
|
389
|
+
def _get_target_state(self) -> TargetState:
|
|
390
|
+
"""Get the state of the target widget."""
|
|
391
|
+
|
|
392
|
+
target = self.target
|
|
393
|
+
return AutoComplete.TargetState(
|
|
394
|
+
text=target.value,
|
|
395
|
+
cursor_position=target.cursor_position,
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
def _handle_focus_change(self, has_focus: bool) -> None:
|
|
399
|
+
"""Called when the focus of the target widget changes."""
|
|
400
|
+
|
|
401
|
+
if not has_focus:
|
|
402
|
+
self.action_hide()
|
|
403
|
+
else:
|
|
404
|
+
target_state = self._get_target_state()
|
|
405
|
+
search_string = self.get_search_string(target_state)
|
|
406
|
+
self._rebuild_options(target_state, search_string)
|
|
407
|
+
|
|
408
|
+
def _handle_target_update(self) -> None:
|
|
409
|
+
"""
|
|
410
|
+
Called when the state (text or cursor position) of the target is updated.
|
|
411
|
+
|
|
412
|
+
Here we align the dropdown to the target, determine if it should be visible, and rebuild the options in it.
|
|
413
|
+
"""
|
|
414
|
+
|
|
415
|
+
self._target_state = self._get_target_state()
|
|
416
|
+
search_string = self.get_search_string(self._target_state)
|
|
417
|
+
|
|
418
|
+
# Determine visibility after the user makes a change in the target widget (e.g. typing in a character in the
|
|
419
|
+
# Input).
|
|
420
|
+
self._rebuild_options(self._target_state, search_string)
|
|
421
|
+
self._align_to_target()
|
|
422
|
+
|
|
423
|
+
if self.should_show_dropdown(search_string):
|
|
424
|
+
self.action_show()
|
|
425
|
+
else:
|
|
426
|
+
self.action_hide()
|
|
427
|
+
|
|
428
|
+
def should_show_dropdown(self, search_string: str) -> bool:
|
|
429
|
+
"""
|
|
430
|
+
Determine whether to show or hide the dropdown based on the current state.
|
|
431
|
+
|
|
432
|
+
This method can be overridden to customize the visibility behavior.
|
|
433
|
+
|
|
434
|
+
Args:
|
|
435
|
+
search_string: The current search string.
|
|
436
|
+
|
|
437
|
+
Returns:
|
|
438
|
+
bool: True if the dropdown should be shown, False otherwise.
|
|
439
|
+
"""
|
|
440
|
+
|
|
441
|
+
option_list = self.option_list
|
|
442
|
+
option_count = option_list.option_count
|
|
443
|
+
|
|
444
|
+
if len(search_string) == 0 or option_count == 0:
|
|
445
|
+
return False
|
|
446
|
+
elif option_count == 1:
|
|
447
|
+
first_option = option_list.get_option_at_index(0).prompt
|
|
448
|
+
text_from_option = (
|
|
449
|
+
first_option.plain if isinstance(first_option, Text) else first_option
|
|
450
|
+
)
|
|
451
|
+
return text_from_option != search_string
|
|
452
|
+
else:
|
|
453
|
+
return True
|
|
454
|
+
|
|
455
|
+
def _rebuild_options(self, target_state: TargetState, search_string: str) -> None:
|
|
456
|
+
"""
|
|
457
|
+
Rebuild the options in the dropdown.
|
|
458
|
+
|
|
459
|
+
Args:
|
|
460
|
+
target_state: The state of the target widget.
|
|
461
|
+
"""
|
|
462
|
+
|
|
463
|
+
option_list = self.option_list
|
|
464
|
+
option_list.clear_options()
|
|
465
|
+
if self.target.has_focus:
|
|
466
|
+
matches = self._compute_matches(target_state, search_string)
|
|
467
|
+
if matches:
|
|
468
|
+
option_list.add_options(matches)
|
|
469
|
+
option_list.highlighted = 0
|
|
470
|
+
|
|
471
|
+
def get_search_string(self, target_state: TargetState) -> str:
|
|
472
|
+
"""
|
|
473
|
+
This value will be passed to the match function.
|
|
474
|
+
|
|
475
|
+
This could be, for example, the text in the target widget, or a substring of that text.
|
|
476
|
+
|
|
477
|
+
Returns:
|
|
478
|
+
The search string that will be used to filter the dropdown options.
|
|
479
|
+
"""
|
|
480
|
+
|
|
481
|
+
return target_state.text[: target_state.cursor_position]
|
|
482
|
+
|
|
483
|
+
def _compute_matches(self, target_state: TargetState, search_string: str) -> list[AutoCompleteItem]:
|
|
484
|
+
"""
|
|
485
|
+
Compute the matches based on the target state.
|
|
486
|
+
|
|
487
|
+
Args:
|
|
488
|
+
target_state: The state of the target widget.
|
|
489
|
+
|
|
490
|
+
Returns:
|
|
491
|
+
The matches to display in the dropdown.
|
|
492
|
+
"""
|
|
493
|
+
|
|
494
|
+
# If items is a callable, then it's a factory function that returns the candidates. Otherwise, it's a list of
|
|
495
|
+
# candidates.
|
|
496
|
+
candidates = self.get_candidates(target_state)
|
|
497
|
+
matches = self.get_matches(target_state, candidates, search_string)
|
|
498
|
+
return matches
|
|
499
|
+
|
|
500
|
+
def get_candidates(self, target_state: TargetState) -> list[AutoCompleteItem]:
|
|
501
|
+
"""Get the candidates to match against."""
|
|
502
|
+
|
|
503
|
+
candidates = self.candidates
|
|
504
|
+
if isinstance(candidates, ta.Sequence):
|
|
505
|
+
return list(candidates)
|
|
506
|
+
elif candidates is None:
|
|
507
|
+
raise NotImplementedError(
|
|
508
|
+
'You must implement get_candidates in your AutoComplete subclass, because candidates is None',
|
|
509
|
+
)
|
|
510
|
+
else:
|
|
511
|
+
# candidates is a callable
|
|
512
|
+
return candidates(target_state)
|
|
513
|
+
|
|
514
|
+
def get_matches(
|
|
515
|
+
self,
|
|
516
|
+
target_state: TargetState,
|
|
517
|
+
candidates: list[AutoCompleteItem],
|
|
518
|
+
search_string: str,
|
|
519
|
+
) -> list[AutoCompleteItem]:
|
|
520
|
+
"""
|
|
521
|
+
Given the state of the target widget, return the DropdownItems which match the query string and should be appear
|
|
522
|
+
in the dropdown.
|
|
523
|
+
|
|
524
|
+
Args:
|
|
525
|
+
target_state: The state of the target widget.
|
|
526
|
+
candidates: The candidates to match against.
|
|
527
|
+
search_string: The search string to match against.
|
|
528
|
+
|
|
529
|
+
Returns:
|
|
530
|
+
The matches to display in the dropdown.
|
|
531
|
+
"""
|
|
532
|
+
|
|
533
|
+
if not search_string:
|
|
534
|
+
return candidates
|
|
535
|
+
|
|
536
|
+
matches_and_scores: list[tuple[AutoCompleteItem, float]] = []
|
|
537
|
+
append_score = matches_and_scores.append
|
|
538
|
+
match = self.match
|
|
539
|
+
|
|
540
|
+
for candidate in candidates:
|
|
541
|
+
candidate_string = candidate.value
|
|
542
|
+
score, offsets = match(search_string, candidate_string)
|
|
543
|
+
if score > 0:
|
|
544
|
+
highlighted = self.apply_highlights(candidate.main, offsets)
|
|
545
|
+
highlighted_item = AutoCompleteItemHit(
|
|
546
|
+
main=highlighted,
|
|
547
|
+
prefix=candidate.prefix,
|
|
548
|
+
id=candidate.id,
|
|
549
|
+
disabled=candidate.disabled,
|
|
550
|
+
)
|
|
551
|
+
append_score((highlighted_item, score))
|
|
552
|
+
|
|
553
|
+
matches_and_scores.sort(key=operator.itemgetter(1), reverse=True)
|
|
554
|
+
matches = [match for match, _ in matches_and_scores]
|
|
555
|
+
return matches
|
|
556
|
+
|
|
557
|
+
def match(self, query: str, candidate: str) -> tuple[float, ta.Sequence[int]]:
|
|
558
|
+
"""
|
|
559
|
+
Match a query (search string) against a candidate (dropdown item value).
|
|
560
|
+
|
|
561
|
+
Returns a tuple of (score, offsets) where score is a float between 0 and 1, used for sorting the matches, and
|
|
562
|
+
offsets is a tuple of integers representing the indices of the characters in the candidate string that match the
|
|
563
|
+
query.
|
|
564
|
+
|
|
565
|
+
So, if the query is "hello" and the candidate is "hello world", and the offsets will be (0,1,2,3,4). The score
|
|
566
|
+
can be anything you want - and the highest score will be at the top of the list by default.
|
|
567
|
+
|
|
568
|
+
The offsets will be highlighted in the dropdown list.
|
|
569
|
+
|
|
570
|
+
A score of 0 means no match, and such candidates will not be shown in the dropdown.
|
|
571
|
+
|
|
572
|
+
Args:
|
|
573
|
+
query: The search string.
|
|
574
|
+
candidate: The candidate string (dropdown item value).
|
|
575
|
+
|
|
576
|
+
Returns:
|
|
577
|
+
A tuple of (score, offsets).
|
|
578
|
+
"""
|
|
579
|
+
|
|
580
|
+
return self._matcher(query, candidate)
|
|
581
|
+
|
|
582
|
+
def apply_highlights(self, candidate: Content, offsets: ta.Sequence[int]) -> Content:
|
|
583
|
+
"""
|
|
584
|
+
Highlight the candidate with the fuzzy match offsets.
|
|
585
|
+
|
|
586
|
+
Args:
|
|
587
|
+
candidate: The candidate which matched the query. Note that this may already have its own styling applied.
|
|
588
|
+
offsets: The offsets to highlight.
|
|
589
|
+
Returns:
|
|
590
|
+
A [rich.text.Text][`Text`] object with highlighted matches.
|
|
591
|
+
"""
|
|
592
|
+
|
|
593
|
+
# TODO - let's have styles which account for the cursor too
|
|
594
|
+
match_style = Style.from_rich_style(
|
|
595
|
+
self.get_component_rich_style('autocomplete--highlight-match', partial=True),
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
plain = candidate.plain
|
|
599
|
+
for offset in offsets:
|
|
600
|
+
if not plain[offset].isspace():
|
|
601
|
+
candidate = candidate.stylize(match_style, offset, offset + 1)
|
|
602
|
+
|
|
603
|
+
return candidate
|
|
604
|
+
|
|
605
|
+
@property
|
|
606
|
+
def option_list(self) -> AutoCompleteList:
|
|
607
|
+
return self.query_one(AutoCompleteList)
|
|
608
|
+
|
|
609
|
+
@on(OptionList.OptionSelected, 'AutoCompleteList')
|
|
610
|
+
def _apply_completion(self, event: OptionList.OptionSelected) -> None:
|
|
611
|
+
# Handles click events on dropdown items.
|
|
612
|
+
self._complete(event.option_index)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: omdev
|
|
3
|
-
Version: 0.0.0.
|
|
3
|
+
Version: 0.0.0.dev464
|
|
4
4
|
Summary: omdev
|
|
5
5
|
Author: wrmsr
|
|
6
6
|
License-Expression: BSD-3-Clause
|
|
@@ -14,7 +14,7 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
14
14
|
Requires-Python: >=3.13
|
|
15
15
|
Description-Content-Type: text/markdown
|
|
16
16
|
License-File: LICENSE
|
|
17
|
-
Requires-Dist: omlish==0.0.0.
|
|
17
|
+
Requires-Dist: omlish==0.0.0.dev464
|
|
18
18
|
Provides-Extra: all
|
|
19
19
|
Requires-Dist: black~=25.9; extra == "all"
|
|
20
20
|
Requires-Dist: pycparser~=2.23; extra == "all"
|
|
@@ -344,15 +344,20 @@ omdev/tui/apps/irc/main.py,sha256=ptsSjKE3LDmPERCExivcjTN7SOfYY7WVnIlDXSL_XQA,51
|
|
|
344
344
|
omdev/tui/apps/markdown/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
345
345
|
omdev/tui/apps/markdown/__main__.py,sha256=Xy-G2-8Ymx8QMBbRzA4LoiAMZqvtC944mMjFEWd69CA,182
|
|
346
346
|
omdev/tui/apps/markdown/cli.py,sha256=K1vH7f3ZqLv4xTPluhJBEZH8nx8n42_vXIALEV07Q50,469
|
|
347
|
-
omdev/tui/rich/__init__.py,sha256=
|
|
347
|
+
omdev/tui/rich/__init__.py,sha256=ZmStqeMrES5FiHLCysKRcg3vvmrIfksIZknDIGD1o0E,809
|
|
348
348
|
omdev/tui/rich/console2.py,sha256=BYYLbbD65If9TvfPI6qUcMQKUWJbuWwykEzPplvkf6A,342
|
|
349
349
|
omdev/tui/rich/markdown2.py,sha256=fBcjG_34XzUf4WclBL_MxvBj5NUwvLCANhHCx3R0akw,6139
|
|
350
|
-
omdev/tui/textual/__init__.py,sha256=
|
|
350
|
+
omdev/tui/textual/__init__.py,sha256=2InVQxcIgL5-JcfsQWp_FoQcTLm6TJdZAd-WSwuoIYo,10621
|
|
351
351
|
omdev/tui/textual/app2.py,sha256=QNh8dX9lXtvWkUOLX5x6ucJCYqDoKD78VDpaX4ZcGS8,225
|
|
352
352
|
omdev/tui/textual/drivers2.py,sha256=ZVxI9n6cyczjrdjqKOAjE51pF0yppACJOVmqLaWuJuM,1402
|
|
353
|
-
omdev
|
|
354
|
-
omdev
|
|
355
|
-
omdev
|
|
356
|
-
omdev
|
|
357
|
-
omdev
|
|
358
|
-
omdev-0.0.0.
|
|
353
|
+
omdev/tui/textual/autocomplete/LICENSE,sha256=E4XIgwSRB-UmqQi5b3HvRUfmpR9vfi99rvxGjnkS6BI,1069
|
|
354
|
+
omdev/tui/textual/autocomplete/__init__.py,sha256=m0EGewct7SoATrTcsCSmeRQyPucP5Sqew5qZOIQUgKI,1442
|
|
355
|
+
omdev/tui/textual/autocomplete/matching.py,sha256=joxUxF4jfs47E4JK0DAo_l0lwoNe9mU6iJzxI2FlVYI,6801
|
|
356
|
+
omdev/tui/textual/autocomplete/paths.py,sha256=Z3ZlTkPZezKBaFgB23d6IFJXanJc8OueBm-S0kExdRM,7242
|
|
357
|
+
omdev/tui/textual/autocomplete/widget.py,sha256=1UgWqDT0d9wD6w7MNaZBjgj0o9FohYXydifocPErdks,22629
|
|
358
|
+
omdev-0.0.0.dev464.dist-info/licenses/LICENSE,sha256=B_hVtavaA8zCYDW99DYdcpDLKz1n3BBRjZrcbv8uG8c,1451
|
|
359
|
+
omdev-0.0.0.dev464.dist-info/METADATA,sha256=vlahtbj1bv8e_wIrAp2kUn7cfKwzLIhqroKpLgeryfo,5170
|
|
360
|
+
omdev-0.0.0.dev464.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
361
|
+
omdev-0.0.0.dev464.dist-info/entry_points.txt,sha256=dHLXFmq5D9B8qUyhRtFqTGWGxlbx3t5ejedjrnXNYLU,33
|
|
362
|
+
omdev-0.0.0.dev464.dist-info/top_level.txt,sha256=1nr7j30fEWgLYHW3lGR9pkdHkb7knv1U1ES1XRNVQ6k,6
|
|
363
|
+
omdev-0.0.0.dev464.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|