ipython-postfix-completion 0.2.0__tar.gz → 0.2.1__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.
Files changed (19) hide show
  1. {ipython_postfix_completion-0.2.0 → ipython_postfix_completion-0.2.1}/CHANGELOG.md +15 -0
  2. {ipython_postfix_completion-0.2.0 → ipython_postfix_completion-0.2.1}/MANIFEST.in +1 -0
  3. ipython_postfix_completion-0.2.1/PKG-INFO +115 -0
  4. ipython_postfix_completion-0.2.1/README.md +80 -0
  5. ipython_postfix_completion-0.2.1/docs/REFERENCE.md +150 -0
  6. {ipython_postfix_completion-0.2.0 → ipython_postfix_completion-0.2.1}/ipython_postfix_completion/__init__.py +141 -16
  7. ipython_postfix_completion-0.2.1/ipython_postfix_completion.egg-info/PKG-INFO +115 -0
  8. {ipython_postfix_completion-0.2.0 → ipython_postfix_completion-0.2.1}/ipython_postfix_completion.egg-info/SOURCES.txt +1 -0
  9. {ipython_postfix_completion-0.2.0 → ipython_postfix_completion-0.2.1}/pyproject.toml +1 -1
  10. {ipython_postfix_completion-0.2.0 → ipython_postfix_completion-0.2.1}/tests/test_postfix_completion.py +79 -7
  11. ipython_postfix_completion-0.2.0/PKG-INFO +0 -282
  12. ipython_postfix_completion-0.2.0/README.md +0 -247
  13. ipython_postfix_completion-0.2.0/ipython_postfix_completion.egg-info/PKG-INFO +0 -282
  14. {ipython_postfix_completion-0.2.0 → ipython_postfix_completion-0.2.1}/LICENSE +0 -0
  15. {ipython_postfix_completion-0.2.0 → ipython_postfix_completion-0.2.1}/ipython_postfix_completion.egg-info/dependency_links.txt +0 -0
  16. {ipython_postfix_completion-0.2.0 → ipython_postfix_completion-0.2.1}/ipython_postfix_completion.egg-info/requires.txt +0 -0
  17. {ipython_postfix_completion-0.2.0 → ipython_postfix_completion-0.2.1}/ipython_postfix_completion.egg-info/top_level.txt +0 -0
  18. {ipython_postfix_completion-0.2.0 → ipython_postfix_completion-0.2.1}/setup.cfg +0 -0
  19. {ipython_postfix_completion-0.2.0 → ipython_postfix_completion-0.2.1}/tests/conftest.py +0 -0
@@ -2,6 +2,21 @@
2
2
 
3
3
  All notable changes to this project are documented here.
4
4
 
5
+ ## [0.2.1] - 2026-10-05
6
+
7
+ ### Added
8
+
9
+ - Added built-in `.for`, `.fori`, `.ef`, `.type`, and `.range` templates.
10
+ - Added editable, sequential Tab stops for loop and conditional templates,
11
+ with Shift+Tab navigation to previous stops.
12
+ - Wrapped integer literals in `range(...)` for `.for` and `.fori` templates.
13
+
14
+ ### Changed
15
+
16
+ - Updated the built-in `.if` template to include an editable `pass` body.
17
+ - Simplified the README and moved detailed usage and maintenance notes to
18
+ `docs/REFERENCE.md`.
19
+
5
20
  ## [0.2.0] - 2026-08-09
6
21
 
7
22
  ### Added
@@ -2,3 +2,4 @@ include LICENSE
2
2
  include README.md
3
3
  include CHANGELOG.md
4
4
  recursive-include tests *.py
5
+ recursive-include docs *.md
@@ -0,0 +1,115 @@
1
+ Metadata-Version: 2.4
2
+ Name: ipython-postfix-completion
3
+ Version: 0.2.1
4
+ Summary: Configurable postfix completion extension for IPython.
5
+ Author: IPython Postfix Completion Contributors
6
+ License-Expression: BSD-3-Clause
7
+ Project-URL: Homepage, https://github.com/fishandsheep/ipython-postfix-completion
8
+ Project-URL: Repository, https://github.com/fishandsheep/ipython-postfix-completion
9
+ Project-URL: Issues, https://github.com/fishandsheep/ipython-postfix-completion/issues
10
+ Project-URL: Changelog, https://github.com/fishandsheep/ipython-postfix-completion/blob/main/CHANGELOG.md
11
+ Keywords: ipython,completion,postfix,extension
12
+ Classifier: Framework :: IPython
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Requires-Python: >=3.11
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Requires-Dist: ipython<10,>=9.0
25
+ Requires-Dist: traitlets>=5.13
26
+ Provides-Extra: test
27
+ Requires-Dist: pytest>=7; extra == "test"
28
+ Provides-Extra: dev
29
+ Requires-Dist: build; extra == "dev"
30
+ Requires-Dist: pip-audit>=2.7; extra == "dev"
31
+ Requires-Dist: ruff>=0.8; extra == "dev"
32
+ Requires-Dist: twine; extra == "dev"
33
+ Requires-Dist: ipython-postfix-completion[test]; extra == "dev"
34
+ Dynamic: license-file
35
+
36
+ # IPython Postfix Completion
37
+
38
+ Complete Python expressions with postfix templates in IPython: type an
39
+ expression, a dot, and a template name, then press Tab.
40
+
41
+ ## Install
42
+
43
+ ```bash
44
+ uv tool install ipython --with ipython-postfix-completion
45
+ ```
46
+
47
+ Start IPython and load the extension:
48
+
49
+ ```python
50
+ %load_ext ipython_postfix_completion
51
+ ```
52
+
53
+ ## Common Templates
54
+
55
+ `items.for<Tab>` creates a loop. Tab selects `item`, then `pass`; type to
56
+ replace a selection.
57
+
58
+ ```python
59
+ for item in items:
60
+ pass
61
+ ```
62
+
63
+ An integer literal is wrapped in `range(...)`, so `10.for<Tab>` produces
64
+ `for item in range(10):`.
65
+
66
+ `items.fori<Tab>` creates an indexed loop. Tab selects `i`, `value`, then
67
+ `pass`. For an integer literal, it uses `enumerate(range(n))`.
68
+
69
+ `condition.if<Tab>` creates an `if` block with a selected `pass`:
70
+
71
+ ```python
72
+ if condition:
73
+ pass
74
+ ```
75
+
76
+ `condition.ef<Tab>` creates `if`/`elif`/`else` branches. Tab selects each
77
+ placeholder in order: `pass`, `cond`, `pass`, `pass`. The `cond` selection
78
+ does not include its colon.
79
+
80
+ Other built-ins: `print`, `len`, `not`, `par`, `var`, `await`, `return`,
81
+ `while`, `raise`, `yield`, `str`, `list`, `set`, `dict`, `tuple`, `type`, and
82
+ `range`. Use `%postfix_template list` to see their expansions.
83
+
84
+ Shift+Tab moves back to the previous placeholder. Custom Tab navigation is
85
+ supported in terminal IPython.
86
+
87
+ ## Load Automatically
88
+
89
+ The default configuration file is:
90
+
91
+ ```text
92
+ ~/.ipython/profile_default/ipython_config.py
93
+ ```
94
+
95
+ Create it with:
96
+
97
+ ```bash
98
+ ipython profile create
99
+ ```
100
+
101
+ Add the extension and any persistent templates there:
102
+
103
+ ```python
104
+ c.InteractiveShellApp.extensions = ["ipython_postfix_completion"]
105
+ c.PostfixCompletionConfig.templates = {
106
+ "debug": "print({expr}=)",
107
+ }
108
+ ```
109
+
110
+ `IPYTHONDIR` and `--ipython-dir` can change the configuration directory. Use
111
+ `ipython locate profile default` to find the active profile. See the [official
112
+ IPython configuration guide](https://ipython.readthedocs.io/en/stable/development/config.html).
113
+
114
+ For runtime template commands, detailed behavior, and contributor or release
115
+ steps, see [the extended guide](docs/REFERENCE.md).
@@ -0,0 +1,80 @@
1
+ # IPython Postfix Completion
2
+
3
+ Complete Python expressions with postfix templates in IPython: type an
4
+ expression, a dot, and a template name, then press Tab.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ uv tool install ipython --with ipython-postfix-completion
10
+ ```
11
+
12
+ Start IPython and load the extension:
13
+
14
+ ```python
15
+ %load_ext ipython_postfix_completion
16
+ ```
17
+
18
+ ## Common Templates
19
+
20
+ `items.for<Tab>` creates a loop. Tab selects `item`, then `pass`; type to
21
+ replace a selection.
22
+
23
+ ```python
24
+ for item in items:
25
+ pass
26
+ ```
27
+
28
+ An integer literal is wrapped in `range(...)`, so `10.for<Tab>` produces
29
+ `for item in range(10):`.
30
+
31
+ `items.fori<Tab>` creates an indexed loop. Tab selects `i`, `value`, then
32
+ `pass`. For an integer literal, it uses `enumerate(range(n))`.
33
+
34
+ `condition.if<Tab>` creates an `if` block with a selected `pass`:
35
+
36
+ ```python
37
+ if condition:
38
+ pass
39
+ ```
40
+
41
+ `condition.ef<Tab>` creates `if`/`elif`/`else` branches. Tab selects each
42
+ placeholder in order: `pass`, `cond`, `pass`, `pass`. The `cond` selection
43
+ does not include its colon.
44
+
45
+ Other built-ins: `print`, `len`, `not`, `par`, `var`, `await`, `return`,
46
+ `while`, `raise`, `yield`, `str`, `list`, `set`, `dict`, `tuple`, `type`, and
47
+ `range`. Use `%postfix_template list` to see their expansions.
48
+
49
+ Shift+Tab moves back to the previous placeholder. Custom Tab navigation is
50
+ supported in terminal IPython.
51
+
52
+ ## Load Automatically
53
+
54
+ The default configuration file is:
55
+
56
+ ```text
57
+ ~/.ipython/profile_default/ipython_config.py
58
+ ```
59
+
60
+ Create it with:
61
+
62
+ ```bash
63
+ ipython profile create
64
+ ```
65
+
66
+ Add the extension and any persistent templates there:
67
+
68
+ ```python
69
+ c.InteractiveShellApp.extensions = ["ipython_postfix_completion"]
70
+ c.PostfixCompletionConfig.templates = {
71
+ "debug": "print({expr}=)",
72
+ }
73
+ ```
74
+
75
+ `IPYTHONDIR` and `--ipython-dir` can change the configuration directory. Use
76
+ `ipython locate profile default` to find the active profile. See the [official
77
+ IPython configuration guide](https://ipython.readthedocs.io/en/stable/development/config.html).
78
+
79
+ For runtime template commands, detailed behavior, and contributor or release
80
+ steps, see [the extended guide](docs/REFERENCE.md).
@@ -0,0 +1,150 @@
1
+ # Extended Reference
2
+
3
+ This guide contains detailed template, configuration, Tab behavior, development,
4
+ and release information. For installation and common usage, see the [README](../README.md).
5
+
6
+ ## Template Reference
7
+
8
+ Templates take the expression to the left of the dot as `{expr}`. `{indent}` is
9
+ the leading indentation of the input line. These are the built-in templates:
10
+
11
+ | Name | Expansion |
12
+ | --- | --- |
13
+ | `print` | `print({expr})` |
14
+ | `len` | `len({expr})` |
15
+ | `not` | `not {expr}` |
16
+ | `par` | `({expr})` |
17
+ | `var` | `key = {expr}`; selects `key` |
18
+ | `await` | `await {expr}` |
19
+ | `return` | `return {expr}` |
20
+ | `if` | `if {expr}:` with an indented `pass` |
21
+ | `while` | `while {expr}:` with an indented blank body |
22
+ | `for` | `for item in {expr}:` with an indented `pass` |
23
+ | `fori` | `for i, value in enumerate({expr}):` with an indented `pass` |
24
+ | `ef` | `if {expr}:`, `elif cond:`, and `else:` branches, each with `pass` |
25
+ | `raise` | `raise {expr}` |
26
+ | `yield` | `yield {expr}` |
27
+ | `str` | `str({expr})` |
28
+ | `list` | `list({expr})` |
29
+ | `set` | `set({expr})` |
30
+ | `dict` | `dict({expr})` |
31
+ | `tuple` | `tuple({expr})` |
32
+ | `type` | `type({expr})` |
33
+ | `range` | `range({expr})` |
34
+
35
+ Integer literals used with `.for` or `.fori` are wrapped in `range(...)`.
36
+ Signed and underscored integer literals are recognized. A variable such as `n`
37
+ is left unchanged because completion cannot know its runtime type. Thus
38
+ `10.fori` uses `enumerate(range(10))`, while `items.fori` uses
39
+ `enumerate(items)`.
40
+
41
+ When a partial name matches multiple templates, the completion menu lists each
42
+ match. In particular, `.f` can show both `for` and `fori`; Tab on the exact
43
+ `.if` suffix expands `if`.
44
+
45
+ ## Placeholder and Tab Behavior
46
+
47
+ For built-in `.var`, `.for`, `.if`, `.fori`, and `.ef` templates, Tab accepts
48
+ the selected placeholder and advances to the next one. Shift+Tab selects the
49
+ previous placeholder. Typing replaces the selected text. After the final stop,
50
+ Tab accepts it and places the cursor after the expansion. Enter accepts the
51
+ selected `.var` name; press Enter again to submit the input.
52
+
53
+ The built-in placeholder behavior is enabled only when the corresponding
54
+ template retains its built-in definition. Overriding a built-in template
55
+ disables its special selection behavior.
56
+
57
+ ### Smart Tab Jump
58
+
59
+ Smart Tab jump is enabled by default in terminal IPython. When the cursor is
60
+ immediately before a valid Python closing token, Tab moves over it without
61
+ changing source text. Repeated presses exit nested constructs:
62
+
63
+ ```text
64
+ "hello|" -> "hello"|
65
+ print("hello|") -> print("hello"|) -> print("hello")|
66
+ print(f"{name|}") -> print(f"{name}|") -> print(f"{name}"|) -> print(f"{name}")|
67
+ items[index|] -> items[index]|
68
+ list[dict[str, int|]] -> list[dict[str, int]|] -> list[dict[str, int]]|
69
+ {"name": value|} -> {"name": value}|
70
+ ```
71
+
72
+ `|` marks the cursor and is not typed. Supported closers are single and triple
73
+ quotes plus `)`, `]`, and `}`. Detection follows Python tokens, including
74
+ multiline input, string prefixes, and f-string expressions. Tab processes an
75
+ active placeholder and an exact postfix template first, then falls back to
76
+ IPython completion, closer movement, or indentation. Ambiguous `< >`, colon,
77
+ and comma are not treated as closers.
78
+
79
+ Disable smart Tab jump while retaining postfix completion with:
80
+
81
+ ```python
82
+ c.PostfixCompletionConfig.smart_tab_jump = False
83
+ ```
84
+
85
+ ## Runtime Template Commands
86
+
87
+ Add or override a template for the current IPython session:
88
+
89
+ ```python
90
+ %postfix_template add debug "print({expr}=)"
91
+ %postfix_template add forin "for item in {expr}:\n{indent} pass"
92
+ ```
93
+
94
+ Disable, reset one, or reset all runtime changes:
95
+
96
+ ```python
97
+ %postfix_template remove tuple
98
+ %postfix_template reset forin
99
+ %postfix_template reset --all
100
+ ```
101
+
102
+ Runtime changes last only for the current session. Use
103
+ `%postfix_template list` to see built-in, custom, and disabled entries.
104
+
105
+ Persistent configuration belongs in
106
+ `~/.ipython/profile_default/ipython_config.py` by default. Create the profile
107
+ with `ipython profile create`; locate the active path with
108
+ `ipython locate profile default`. `IPYTHONDIR` or `--ipython-dir` can change
109
+ the IPython directory. See the [official IPython configuration guide](https://ipython.readthedocs.io/en/stable/development/config.html).
110
+
111
+ Template names must match `[A-Za-z_][A-Za-z0-9_]*`. Templates must include
112
+ `{expr}` and may also use `{indent}`. No other fields, conversions, or format
113
+ specifiers are allowed.
114
+
115
+ ## Development
116
+
117
+ Run tests and style checks:
118
+
119
+ ```bash
120
+ uv run --extra test pytest -q
121
+ uv run --extra dev ruff check .
122
+ uv run --extra dev ruff format --check .
123
+ ```
124
+
125
+ Run IPython against the current checkout without installing into system Python:
126
+
127
+ ```bash
128
+ uv run --with ipython --with-editable . ipython
129
+ ```
130
+
131
+ Then load `ipython_postfix_completion` in that IPython session.
132
+
133
+ Build and validate release artifacts:
134
+
135
+ ```bash
136
+ uv run --extra dev python -m build
137
+ uv run --extra dev python -m twine check dist/*
138
+ ```
139
+
140
+ ## Publishing
141
+
142
+ Releases use GitHub Actions and PyPI Trusted Publishing. Configure the PyPI
143
+ publisher for the `fishandsheep/ipython-postfix-completion` repository, the
144
+ `publish.yml` workflow, and the `pypi` environment. Update the version in
145
+ `pyproject.toml`, push the change, then create and push the matching `v` tag.
146
+ The workflow checks the tag against the project version, runs tests, builds and
147
+ checks distributions, and publishes them. Published PyPI versions and release
148
+ tags are immutable; use a new version for fixes.
149
+
150
+ See [CHANGELOG.md](../CHANGELOG.md) for release history.
@@ -35,6 +35,19 @@ _NO_MAGIC_ATTR = object()
35
35
  _ACTIVE_STATE: PostfixState | None = None
36
36
  _VAR_TEMPLATE = "key = {expr}"
37
37
  _VAR_PLACEHOLDER = "key"
38
+ _FOR_TEMPLATE = "for item in {expr}:\n{indent} pass"
39
+ _FORI_TEMPLATE = "for i, value in enumerate({expr}):\n{indent} pass"
40
+ _IF_TEMPLATE = "if {expr}:\n{indent} pass"
41
+ _EF_TEMPLATE = (
42
+ "if {expr}:\n{indent} pass\n{indent}elif cond:\n{indent} pass"
43
+ "\n{indent}else:\n{indent} pass"
44
+ )
45
+ _TEMPLATE_PLACEHOLDERS = {
46
+ "for": (_FOR_TEMPLATE, ("item", "pass")),
47
+ "fori": (_FORI_TEMPLATE, ("i", "value", "pass")),
48
+ "if": (_IF_TEMPLATE, ("pass",)),
49
+ "ef": (_EF_TEMPLATE, ("pass", "cond", "pass", "pass")),
50
+ }
38
51
  _PLACEHOLDER_ATTR = "_postfix_completion_placeholder"
39
52
  _OPENING_BRACKETS = {"(": ")", "[": "]", "{": "}"}
40
53
  _CLOSING_BRACKETS = {closer: opener for opener, closer in _OPENING_BRACKETS.items()}
@@ -59,8 +72,11 @@ DEFAULT_TEMPLATES: dict[str, str] = {
59
72
  "var": _VAR_TEMPLATE,
60
73
  "await": "await {expr}",
61
74
  "return": "return {expr}",
62
- "if": "if {expr}:\n{indent} ",
75
+ "if": _IF_TEMPLATE,
63
76
  "while": "while {expr}:\n{indent} ",
77
+ "for": _FOR_TEMPLATE,
78
+ "fori": _FORI_TEMPLATE,
79
+ "ef": _EF_TEMPLATE,
64
80
  "raise": "raise {expr}",
65
81
  "yield": "yield {expr}",
66
82
  "str": "str({expr})",
@@ -68,6 +84,8 @@ DEFAULT_TEMPLATES: dict[str, str] = {
68
84
  "set": "set({expr})",
69
85
  "dict": "dict({expr})",
70
86
  "tuple": "tuple({expr})",
87
+ "type": "type({expr})",
88
+ "range": "range({expr})",
71
89
  }
72
90
 
73
91
 
@@ -143,6 +161,30 @@ def _render_template(template: str, expr: str, indent: str = "") -> str:
143
161
  return _FORMATTER.format(template, expr=expr, indent=indent)
144
162
 
145
163
 
164
+ def _is_integer_literal(expr: str) -> bool:
165
+ try:
166
+ node = ast.parse(expr, mode="eval").body
167
+ except SyntaxError:
168
+ return False
169
+ if isinstance(node, ast.Constant):
170
+ return type(node.value) is int
171
+ return (
172
+ isinstance(node, ast.UnaryOp)
173
+ and isinstance(node.op, (ast.UAdd, ast.USub))
174
+ and _is_integer_literal(ast.unparse(node.operand))
175
+ )
176
+
177
+
178
+ def _render_candidate(name: str, template: str, expr: str, indent: str = "") -> str:
179
+ wraps_integer = _is_integer_literal(expr) and (
180
+ (name == "for" and template == _FOR_TEMPLATE)
181
+ or (name == "fori" and template == _FORI_TEMPLATE)
182
+ )
183
+ if wraps_integer:
184
+ expr = f"range({expr})"
185
+ return _render_template(template, expr, indent)
186
+
187
+
146
188
  @dataclass
147
189
  class PostfixState:
148
190
  matcher: Callable[[CompletionContext], dict]
@@ -244,7 +286,7 @@ def _effective_templates(state: PostfixState | None = None) -> dict[str, str]:
244
286
  def _apply_template(
245
287
  name: str, expr: str, indent: str = "", state: PostfixState | None = None
246
288
  ) -> str:
247
- return _render_template(_effective_templates(state)[name], expr, indent)
289
+ return _render_candidate(name, _effective_templates(state)[name], expr, indent)
248
290
 
249
291
 
250
292
  def _postfix_candidates(
@@ -264,7 +306,7 @@ def _postfix_candidates(
264
306
  if (name == prefix if exact else name.startswith(prefix))
265
307
  ]
266
308
  return f"{expr}.{prefix}", [
267
- _render_template(templates[name], expr, indent) for name in names
309
+ _render_candidate(name, templates[name], expr, indent) for name in names
268
310
  ]
269
311
 
270
312
 
@@ -278,7 +320,7 @@ def _template_name(
278
320
  for name, template in _effective_templates(state).items():
279
321
  if not name.startswith(prefix):
280
322
  continue
281
- if _render_template(template, expr, indent) == expansion:
323
+ if _render_candidate(name, template, expr, indent) == expansion:
282
324
  return name
283
325
  return None
284
326
 
@@ -306,7 +348,28 @@ def _expand_buffer(buffer) -> bool:
306
348
  buffer.delete_before_cursor(len(matched_fragment))
307
349
  expansion_start = buffer.cursor_position
308
350
  buffer.insert_text(candidates[0])
351
+ placeholder_config = (
352
+ _TEMPLATE_PLACEHOLDERS.get(parsed[2]) if parsed is not None else None
353
+ )
309
354
  if (
355
+ parsed is not None
356
+ and placeholder_config is not None
357
+ and _effective_templates().get(parsed[2]) == placeholder_config[0]
358
+ ):
359
+ search_from = 0
360
+ ranges = []
361
+ for placeholder_text in placeholder_config[1]:
362
+ start = candidates[0].index(placeholder_text, search_from)
363
+ end = start + len(placeholder_text)
364
+ ranges.append((start, end))
365
+ search_from = end
366
+ _select_placeholders(
367
+ buffer,
368
+ expansion_start,
369
+ ranges,
370
+ buffer.cursor_position,
371
+ )
372
+ elif (
310
373
  parsed is not None
311
374
  and parsed[2] == "var"
312
375
  and _effective_templates().get("var") == _VAR_TEMPLATE
@@ -321,35 +384,93 @@ def _expand_buffer(buffer) -> bool:
321
384
 
322
385
 
323
386
  def _select_placeholder(buffer, start: int, end: int, final_cursor: int) -> None:
387
+ _select_placeholders(buffer, start, [(start, end)], final_cursor, absolute=True)
388
+
389
+
390
+ def _select_placeholders(
391
+ buffer,
392
+ start: int,
393
+ ranges: list[tuple[int, int]],
394
+ final_cursor: int,
395
+ *,
396
+ absolute: bool = False,
397
+ index: int = 0,
398
+ ) -> None:
324
399
  from prompt_toolkit.selection import SelectionState, SelectionType
325
400
 
326
- buffer.cursor_position = end
327
- buffer.selection_state = SelectionState(start, SelectionType.CHARACTERS)
401
+ if not absolute:
402
+ ranges = [(start + left, start + right) for left, right in ranges]
403
+ setattr(buffer, _PLACEHOLDER_ATTR, (ranges, index, final_cursor))
404
+ left, right = ranges[index]
405
+ buffer.cursor_position = right
406
+ buffer.selection_state = SelectionState(left, SelectionType.CHARACTERS)
328
407
  buffer.selection_state.enter_shift_mode()
329
- setattr(buffer, _PLACEHOLDER_ATTR, (start, end, final_cursor))
330
408
 
331
409
 
332
410
  def _has_active_placeholder(buffer) -> bool:
333
411
  placeholder = getattr(buffer, _PLACEHOLDER_ATTR, None)
334
412
  selection = buffer.selection_state
335
- if placeholder is None or selection is None:
413
+ if placeholder is None:
336
414
  return False
337
- start, end, _ = placeholder
338
- return (
339
- selection.shift_mode
415
+ ranges, index, _ = placeholder
416
+ start, end = ranges[index]
417
+ return (selection is None and len(ranges) > 1) or (
418
+ selection is not None
419
+ and selection.shift_mode
340
420
  and selection.original_cursor_position == start
341
421
  and buffer.cursor_position == end
342
- and buffer.text[start:end] == _VAR_PLACEHOLDER
422
+ and (len(ranges) > 1 or buffer.text[start:end] == _VAR_PLACEHOLDER)
343
423
  )
344
424
 
345
425
 
346
426
  def _accept_placeholder(buffer) -> bool:
347
427
  if not _has_active_placeholder(buffer):
348
428
  return False
349
- _, _, final_cursor = getattr(buffer, _PLACEHOLDER_ATTR)
350
- buffer.exit_selection()
351
- buffer.cursor_position = final_cursor
352
- delattr(buffer, _PLACEHOLDER_ATTR)
429
+ ranges, index, final_cursor = _update_placeholder_after_edit(buffer)
430
+ if buffer.selection_state is not None:
431
+ buffer.exit_selection()
432
+ if index + 1 < len(ranges):
433
+ _select_placeholders(
434
+ buffer, 0, ranges, final_cursor, absolute=True, index=index + 1
435
+ )
436
+ else:
437
+ delattr(buffer, _PLACEHOLDER_ATTR)
438
+ buffer.cursor_position = final_cursor
439
+ return True
440
+
441
+
442
+ def _update_placeholder_after_edit(buffer):
443
+ ranges, index, final_cursor = getattr(buffer, _PLACEHOLDER_ATTR)
444
+ if buffer.selection_state is None:
445
+ start, old_end = ranges[index]
446
+ new_end = buffer.cursor_position
447
+ delta = new_end - old_end
448
+ final_cursor += delta
449
+ ranges = [
450
+ (start, new_end)
451
+ if stop == index
452
+ else (left + delta, right + delta)
453
+ if stop > index
454
+ else (left, right)
455
+ for stop, (left, right) in enumerate(ranges)
456
+ ]
457
+ setattr(buffer, _PLACEHOLDER_ATTR, (ranges, index, final_cursor))
458
+ return ranges, index, final_cursor
459
+
460
+
461
+ def _reverse_placeholder(buffer) -> bool:
462
+ if not _has_active_placeholder(buffer):
463
+ return False
464
+ ranges, index, final_cursor = _update_placeholder_after_edit(buffer)
465
+ previous_index = max(0, index - 1)
466
+ _select_placeholders(
467
+ buffer,
468
+ 0,
469
+ ranges,
470
+ final_cursor,
471
+ absolute=True,
472
+ index=previous_index,
473
+ )
353
474
  return True
354
475
 
355
476
 
@@ -657,6 +778,10 @@ def _make_key_bindings(state: PostfixState | None = None):
657
778
  def accept_placeholder(event) -> None:
658
779
  _accept_placeholder(event.current_buffer)
659
780
 
781
+ @key_bindings.add("s-tab", filter=has_active_placeholder)
782
+ def reverse_placeholder(event) -> None:
783
+ _reverse_placeholder(event.current_buffer)
784
+
660
785
  @key_bindings.add("enter", filter=has_active_placeholder)
661
786
  def accept_placeholder_with_enter(event) -> None:
662
787
  _accept_placeholder(event.current_buffer)