Skip to content

Commit aff0136

Browse files
committed
During live config, keep header and footer visible despite many items.
Also fix page up and page down key handling behavior.
1 parent 91bbc03 commit aff0136

File tree

1 file changed

+106
-132
lines changed

1 file changed

+106
-132
lines changed

git_delete_merged_branches/_multiselect.py

Lines changed: 106 additions & 132 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,7 @@
33

44
from abc import ABC
55
from dataclasses import dataclass
6-
from functools import partial
7-
from typing import Any, Callable, List, Optional
8-
from unittest.mock import patch
6+
from typing import Any, List, Optional, Tuple
97

108
from prompt_toolkit.application import Application
119
from prompt_toolkit.buffer import Buffer
@@ -14,15 +12,17 @@
1412
from prompt_toolkit.formatted_text.base import StyleAndTextTuples
1513
from prompt_toolkit.key_binding import KeyBindings
1614
from prompt_toolkit.layout import HSplit, Layout, Window
15+
from prompt_toolkit.layout.containers import VerticalAlign
1716
from prompt_toolkit.layout.controls import BufferControl
17+
from prompt_toolkit.layout.dimension import Dimension
1818
from prompt_toolkit.layout.processors import Processor, Transformation, TransformationInput
1919
from prompt_toolkit.search import SearchState
2020
from prompt_toolkit.widgets import SearchToolbar
2121

2222
from ._messenger import Messenger
2323

2424

25-
class _LineRenderProcessor(Processor):
25+
class _ItemRenderProcessor(Processor):
2626
"""
2727
A Prompt Toolkit input processor for Buffer that formats lines for display to the user.
2828
"""
@@ -31,22 +31,16 @@ def __init__(self, prompt: '_MultiSelectPrompt'):
3131
self._prompt = prompt
3232

3333
def apply_transformation(self, transformation_input: TransformationInput) -> Transformation:
34-
line_info = self._prompt.lines[transformation_input.lineno]
35-
line_is_item = isinstance(line_info, self._prompt.ItemLine)
34+
line_info = self._prompt.item_lines[transformation_input.lineno]
3635

3736
new_fragments: StyleAndTextTuples = []
3837

39-
if line_is_item:
40-
highlighted = transformation_input.lineno == self._prompt.get_cursor_line()
41-
cursor = '▶' if highlighted else ' '
42-
checkmark = 'x' if line_info.selected else ' '
43-
fallback_style = (self._prompt.highlighted_style
44-
if highlighted else self._prompt.neutral_style)
45-
new_fragments.append((fallback_style, f'{cursor} [{checkmark}] '))
46-
elif isinstance(line_info, self._prompt.HeaderLine):
47-
fallback_style = self._prompt.header_style
48-
else:
49-
fallback_style = self._prompt.neutral_style
38+
highlighted = transformation_input.lineno == self._prompt.get_cursor_line()
39+
cursor = '▶' if highlighted else ' '
40+
checkmark = 'x' if line_info.selected else ' '
41+
fallback_style = (self._prompt.highlighted_style
42+
if highlighted else self._prompt.neutral_style)
43+
new_fragments.append((fallback_style, f'{cursor} [{checkmark}] '))
5044

5145
# Apply new style where adequate
5246
for fragment in transformation_input.fragments:
@@ -55,17 +49,35 @@ def apply_transformation(self, transformation_input: TransformationInput) -> Tra
5549
# NOTE: The idea is to respect search result markers (that have been inserted
5650
# by HighlightSearchProcessor or HighlightIncrementalSearchProcessor)
5751
# in item text, and only there.
58-
if line_is_item:
59-
new_style = old_style or fallback_style
60-
else:
61-
new_style = fallback_style
62-
52+
new_style = old_style or fallback_style
6353
new_fragments.append((new_style, text))
6454

6555
# Add right padding
66-
if line_is_item:
67-
padding_width = 2 + (self._prompt.peak_item_label_length - len(line_info.text))
68-
new_fragments.append((fallback_style, ' ' * padding_width))
56+
padding_width = 2 + (self._prompt.peak_item_label_length - len(line_info.text))
57+
new_fragments.append((fallback_style, ' ' * padding_width))
58+
59+
return Transformation(fragments=new_fragments)
60+
61+
62+
class _NonItemRenderProcessor(Processor):
63+
"""
64+
A Prompt Toolkit input processor for Buffer that formats lines for display to the user.
65+
"""
66+
67+
def __init__(self, prompt: '_MultiSelectPrompt', lines: List):
68+
self._prompt = prompt
69+
self._lines = lines
70+
71+
def apply_transformation(self, transformation_input: TransformationInput) -> Transformation:
72+
line_info = self._lines[transformation_input.lineno]
73+
74+
if isinstance(line_info, self._prompt.HeaderLine):
75+
new_style = self._prompt.header_style
76+
else:
77+
new_style = self._prompt.neutral_style
78+
79+
new_fragments: StyleAndTextTuples = [(new_style, text)
80+
for _old_style, text in transformation_input.fragments]
6981

7082
return Transformation(fragments=new_fragments)
7183

@@ -121,8 +133,6 @@ class HeaderLine(_LineBase):
121133

122134
@dataclass
123135
class ItemLine(_LineBase):
124-
item_index: int
125-
line_index: int
126136
value: Any
127137
selected: bool
128138

@@ -138,24 +148,32 @@ def __init__(self,
138148
self._initial_cursor_item_index: int = initial_cursor_index
139149
self._min_selection_count: int = min_selection_count
140150

141-
self._items: List[_MultiSelectPrompt._ItemBase] = []
142151
self.peak_item_label_length: int = 0
143-
self.lines: [_MultiSelectPrompt.ItemLine] = []
152+
self.item_lines: [_MultiSelectPrompt.ItemLine] = []
153+
self._header_lines: [_MultiSelectPrompt.HeaderLine] = []
154+
self._footer_lines: [_MultiSelectPrompt.PlainLine] = []
144155

156+
self._item_selection_window: Optional[Window] = None
145157
self._buffer: Optional[Buffer] = None
146158
self._document: Optional[Document] = None
147159
self._accepted_selection: List[Any] = None
148160

149161
def _move_cursor_one_page_vertically(self, upwards: bool):
150-
page_height_in_lines = 10
151-
current_line_index = self.get_cursor_line()
152-
153-
if upwards:
154-
new_line_index = max(self._items[0].line_index,
155-
current_line_index - page_height_in_lines)
162+
render_cursor_line = self._item_selection_window.render_info.cursor_position.y
163+
page_height_in_lines = self._item_selection_window.render_info.window_height
164+
165+
if upwards and render_cursor_line > 0:
166+
new_line_index = self.get_cursor_line() - render_cursor_line
167+
elif not upwards and render_cursor_line < page_height_in_lines - 1:
168+
new_line_index = self.get_cursor_line() + (page_height_in_lines - render_cursor_line
169+
- 1)
156170
else:
157-
new_line_index = min(self._items[-1].line_index,
158-
current_line_index + page_height_in_lines)
171+
current_line_index = self.get_cursor_line()
172+
if upwards:
173+
new_line_index = max(0, current_line_index - page_height_in_lines)
174+
else:
175+
new_line_index = min(
176+
len(self.item_lines) - 1, current_line_index + page_height_in_lines)
159177

160178
self._move_cursor_to_line(new_line_index)
161179

@@ -168,25 +186,17 @@ def get_cursor_line(self):
168186
return row
169187

170188
def _move_cursor_one_step_vertically(self, upwards: bool):
171-
if len(self._items) < 2:
189+
if len(self.item_lines) < 2:
172190
return
173191

174192
current_line_index = self.get_cursor_line()
175193

176-
if upwards:
177-
if current_line_index == 0:
178-
return
179-
candidates = range(current_line_index - 1, 0, -1)
180-
else:
181-
if current_line_index == len(self.lines) - 1:
182-
return
183-
candidates = range(current_line_index + 1, len(self.lines) - 1, +1)
194+
# Can we even move any further in that direction?
195+
if ((upwards and current_line_index == 0)
196+
or (not upwards and current_line_index == len(self.item_lines) - 1)):
197+
return
184198

185-
for candidate_line_index in candidates:
186-
line_info = self.lines[candidate_line_index]
187-
if isinstance(line_info, self.ItemLine):
188-
self._move_cursor_to_line(candidate_line_index)
189-
break
199+
self._move_cursor_to_line(current_line_index + (-1 if upwards else 1))
190200

191201
def _on_move_line_down(self, _event):
192202
self._move_cursor_one_step_vertically(upwards=False)
@@ -201,14 +211,14 @@ def _on_move_page_down(self, _event):
201211
self._move_cursor_one_page_vertically(upwards=False)
202212

203213
def _on_move_to_first(self, _event):
204-
self._move_cursor_to_line(self._items[0].line_index)
214+
self._move_cursor_to_line(0)
205215

206216
def _on_move_to_last(self, _event):
207-
self._move_cursor_to_line(self._items[-1].line_index)
217+
self._move_cursor_to_line(len(self.item_lines) - 1)
208218

209219
def _on_toggle(self, _event):
210220
line_index = self.get_cursor_line()
211-
line_info = self.lines[line_index]
221+
line_info = self.item_lines[line_index]
212222
line_info.selected = not line_info.selected
213223

214224
def _on_accept(self, event):
@@ -222,25 +232,20 @@ def _on_abort(self, event):
222232
event.app.exit()
223233

224234
def add_header(self, text):
225-
self.lines.append(self.HeaderLine(text))
235+
self._header_lines.append(self.HeaderLine(text))
226236

227237
def add_item(self, value: Any, label: str = None, selected: bool = False):
228238
if label is None:
229239
label = str(value)
230240

231241
self.peak_item_label_length = max(self.peak_item_label_length, len(label))
232242

233-
item_line = self.ItemLine(selected=selected,
234-
item_index=len(self._items),
235-
line_index=len(self.lines),
236-
text=label,
237-
value=value)
243+
item_line = self.ItemLine(selected=selected, text=label, value=value)
238244

239-
self.lines.append(item_line)
240-
self._items.append(item_line)
245+
self.item_lines.append(item_line)
241246

242-
def add_text(self, text):
243-
self.lines.append(self.PlainLine(text))
247+
def add_footer(self, text):
248+
self._footer_lines.append(self.PlainLine(text))
244249

245250
def _create_key_bindings(self):
246251
key_bindings = KeyBindings()
@@ -265,81 +270,50 @@ def _create_key_bindings(self):
265270

266271
return key_bindings
267272

268-
def _create_layout(self):
273+
def _create_text_display_window_for(self, lines: List[_LineBase]) -> Window:
274+
document = Document(text='\n'.join(line.text for line in lines))
275+
buffer = Buffer(read_only=True, document=document)
276+
buffer_control = BufferControl(
277+
buffer=buffer, input_processors=[_NonItemRenderProcessor(prompt=self, lines=lines)])
278+
return Window(buffer_control,
279+
wrap_lines=True,
280+
height=Dimension(min=len(lines), max=len(lines)))
281+
282+
def _create_layout(self) -> Tuple[Layout, Window]:
283+
header = self._create_text_display_window_for(self._header_lines)
284+
footer = self._create_text_display_window_for(self._footer_lines)
285+
269286
search = SearchToolbar(ignore_case=True)
270287
buffer_control = BufferControl(buffer=self._buffer,
271-
input_processors=[_LineRenderProcessor(prompt=self)],
288+
input_processors=[_ItemRenderProcessor(prompt=self)],
272289
preview_search=True,
273290
search_buffer_control=search.control)
274-
hsplit = HSplit([Window(buffer_control, always_hide_cursor=True, wrap_lines=True), search])
275-
return Layout(hsplit)
291+
window_height = Dimension(min=1,
292+
max=self._document.line_count,
293+
preferred=self._document.line_count)
294+
item_selection_window = Window(buffer_control,
295+
always_hide_cursor=True,
296+
wrap_lines=True,
297+
height=window_height)
298+
item_selection_window_plus_search = HSplit([item_selection_window, search])
299+
hsplit = HSplit([header, item_selection_window_plus_search, footer],
300+
padding=1,
301+
align=VerticalAlign.TOP)
302+
return Layout(hsplit, focused_element=item_selection_window), item_selection_window
276303

277304
def _collect_selected_values(self):
278-
return [item.value for item in self._items if item.selected]
279-
280-
def _create_document_class(self, prompt: '_MultiSelectPrompt') -> Document:
281-
282-
class ItemOnlySearchDocument(Document):
283-
"""A Document that suppresses search results from non-item lines"""
284-
285-
def _skip_non_item_matches(self, func: Callable, count: int) -> Optional[int]:
286-
while True:
287-
index = func(count=count)
288-
if index is None:
289-
return None
290-
291-
effective_index = index + self.cursor_position
292-
row, _col = self.translate_index_to_position(effective_index)
293-
if isinstance(prompt.lines[row], prompt.ItemLine):
294-
return index
295-
296-
count += 1 # i.e. retry with next match
297-
298-
# override
299-
def find(self,
300-
sub: str,
301-
in_current_line: bool = False,
302-
include_current_position: bool = False,
303-
ignore_case: bool = False,
304-
count: int = 1) -> Optional[int]:
305-
func = partial(super().find,
306-
sub=sub,
307-
in_current_line=in_current_line,
308-
include_current_position=include_current_position,
309-
ignore_case=ignore_case)
310-
return self._skip_non_item_matches(func, count)
311-
312-
# override
313-
def find_backwards(
314-
self,
315-
sub: str,
316-
in_current_line: bool = False,
317-
ignore_case: bool = False,
318-
count: int = 1,
319-
) -> Optional[int]:
320-
func = partial(super().find_backwards,
321-
sub=sub,
322-
in_current_line=in_current_line,
323-
ignore_case=ignore_case)
324-
return self._skip_non_item_matches(func, count)
325-
326-
return ItemOnlySearchDocument
305+
return [item.value for item in self.item_lines if item.selected]
327306

328307
def get_selected_values(self) -> List[Any]:
329-
document_class = self._create_document_class(prompt=self)
330-
self._document = document_class(text='\n'.join(line.text for line in self.lines))
308+
self._document = Document(text='\n'.join(line.text for line in self.item_lines))
309+
self._buffer = _LineJumpingBuffer(read_only=True, document=self._document)
310+
layout, self._item_selection_window = self._create_layout()
311+
app = Application(key_bindings=self._create_key_bindings(), layout=layout)
331312

332-
# Prompt Toolkit's Buffer is calling "Document(..)" internally,
333-
# and this patch will make it use our CustomSearchDocument everywhere.
334-
with patch('prompt_toolkit.buffer.Document', document_class):
335-
self._buffer = _LineJumpingBuffer(read_only=True, document=self._document)
336-
app = Application(key_bindings=self._create_key_bindings(),
337-
layout=self._create_layout())
313+
self._move_cursor_to_line(self._initial_cursor_item_index)
314+
app.run()
338315

339-
self._move_cursor_to_line(self._items[self._initial_cursor_item_index].line_index)
340-
app.run()
341-
342-
return self._accepted_selection
316+
return self._accepted_selection
343317

344318

345319
def multiselect(messenger: Messenger, options: List[str], initial_selection: List[int], title: str,
@@ -354,11 +328,11 @@ def multiselect(messenger: Messenger, options: List[str], initial_selection: Lis
354328
)
355329

356330
menu.add_header(title)
357-
menu.add_text('')
331+
358332
for i, option in enumerate(options):
359333
menu.add_item(option, selected=i in initial_selection)
360-
menu.add_text('')
361-
menu.add_text(help)
334+
335+
menu.add_footer(help)
362336

363337
messenger.produce_air()
364338

0 commit comments

Comments
 (0)