s-py-editor 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.
- s_py_editor-0.1.0/PKG-INFO +66 -0
- s_py_editor-0.1.0/README.md +57 -0
- s_py_editor-0.1.0/pyproject.toml +17 -0
- s_py_editor-0.1.0/setup.cfg +4 -0
- s_py_editor-0.1.0/src/s_py_editor/__init__.py +0 -0
- s_py_editor-0.1.0/src/s_py_editor/__main__.py +479 -0
- s_py_editor-0.1.0/src/s_py_editor.egg-info/PKG-INFO +66 -0
- s_py_editor-0.1.0/src/s_py_editor.egg-info/SOURCES.txt +10 -0
- s_py_editor-0.1.0/src/s_py_editor.egg-info/dependency_links.txt +1 -0
- s_py_editor-0.1.0/src/s_py_editor.egg-info/entry_points.txt +2 -0
- s_py_editor-0.1.0/src/s_py_editor.egg-info/requires.txt +2 -0
- s_py_editor-0.1.0/src/s_py_editor.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: s_py_editor
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A terminal-based Python editor with a sidebar, loop runner, and more.
|
|
5
|
+
Requires-Python: >=3.9
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: prompt_toolkit>=3.0
|
|
8
|
+
Requires-Dist: pygments>=2.0
|
|
9
|
+
|
|
10
|
+
# s-py-editor
|
|
11
|
+
|
|
12
|
+
A terminal-based Python code editor with syntax highlighting, auto-completion,
|
|
13
|
+
a file sidebar, and the ability to run code (selections, loops, or whole files)
|
|
14
|
+
without leaving the editor.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pipx install s-py-editor
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Or with pip:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install s-py-editor
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
spy # start with empty buffer
|
|
32
|
+
spy myfile.py # open a file
|
|
33
|
+
spy . # start in a directory (sidebar shows it)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Key bindings
|
|
37
|
+
|
|
38
|
+
| Key | Action |
|
|
39
|
+
|-----|--------|
|
|
40
|
+
| `F5` / `Ctrl+R` | Run selection → enclosing loop → whole file |
|
|
41
|
+
| `Ctrl+S` | Save the buffer |
|
|
42
|
+
| `Ctrl+A` | Select all |
|
|
43
|
+
| `Ctrl+B` | Toggle sidebar focus |
|
|
44
|
+
| `Ctrl+E` | Toggle output popup |
|
|
45
|
+
| `Ctrl+X` | Stop running code |
|
|
46
|
+
| `Ctrl+Q` / `Ctrl+C` | Quit |
|
|
47
|
+
|
|
48
|
+
## Sidebar
|
|
49
|
+
|
|
50
|
+
When the sidebar is focused (`Ctrl+B`):
|
|
51
|
+
|
|
52
|
+
- `↑` / `↓` — move selection
|
|
53
|
+
- `→` / `←` — expand / collapse folders
|
|
54
|
+
- `Enter` — open file or toggle folder
|
|
55
|
+
- `Ctrl+R` — refresh directory listing
|
|
56
|
+
- `Tab` — focus the editor
|
|
57
|
+
|
|
58
|
+
## Requirements
|
|
59
|
+
|
|
60
|
+
- Python 3.9+
|
|
61
|
+
- `prompt_toolkit>=3.0`
|
|
62
|
+
- `pygments>=2.0`
|
|
63
|
+
|
|
64
|
+
## License
|
|
65
|
+
|
|
66
|
+
MIT (or your choice — update this line)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# s-py-editor
|
|
2
|
+
|
|
3
|
+
A terminal-based Python code editor with syntax highlighting, auto-completion,
|
|
4
|
+
a file sidebar, and the ability to run code (selections, loops, or whole files)
|
|
5
|
+
without leaving the editor.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pipx install s-py-editor
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or with pip:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install s-py-editor
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
spy # start with empty buffer
|
|
23
|
+
spy myfile.py # open a file
|
|
24
|
+
spy . # start in a directory (sidebar shows it)
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Key bindings
|
|
28
|
+
|
|
29
|
+
| Key | Action |
|
|
30
|
+
|-----|--------|
|
|
31
|
+
| `F5` / `Ctrl+R` | Run selection → enclosing loop → whole file |
|
|
32
|
+
| `Ctrl+S` | Save the buffer |
|
|
33
|
+
| `Ctrl+A` | Select all |
|
|
34
|
+
| `Ctrl+B` | Toggle sidebar focus |
|
|
35
|
+
| `Ctrl+E` | Toggle output popup |
|
|
36
|
+
| `Ctrl+X` | Stop running code |
|
|
37
|
+
| `Ctrl+Q` / `Ctrl+C` | Quit |
|
|
38
|
+
|
|
39
|
+
## Sidebar
|
|
40
|
+
|
|
41
|
+
When the sidebar is focused (`Ctrl+B`):
|
|
42
|
+
|
|
43
|
+
- `↑` / `↓` — move selection
|
|
44
|
+
- `→` / `←` — expand / collapse folders
|
|
45
|
+
- `Enter` — open file or toggle folder
|
|
46
|
+
- `Ctrl+R` — refresh directory listing
|
|
47
|
+
- `Tab` — focus the editor
|
|
48
|
+
|
|
49
|
+
## Requirements
|
|
50
|
+
|
|
51
|
+
- Python 3.9+
|
|
52
|
+
- `prompt_toolkit>=3.0`
|
|
53
|
+
- `pygments>=2.0`
|
|
54
|
+
|
|
55
|
+
## License
|
|
56
|
+
|
|
57
|
+
MIT (or your choice — update this line)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "s_py_editor"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A terminal-based Python editor with a sidebar, loop runner, and more."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"prompt_toolkit>=3.0",
|
|
13
|
+
"pygments>=2.0",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.scripts]
|
|
17
|
+
spy = "s_py_editor.__main__:main"
|
|
File without changes
|
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import os
|
|
3
|
+
import io
|
|
4
|
+
import ast
|
|
5
|
+
import threading
|
|
6
|
+
import traceback
|
|
7
|
+
|
|
8
|
+
from prompt_toolkit import Application
|
|
9
|
+
from prompt_toolkit.buffer import Buffer
|
|
10
|
+
from prompt_toolkit.completion import WordCompleter
|
|
11
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
12
|
+
from prompt_toolkit.layout.containers import (
|
|
13
|
+
HSplit, VSplit, Window, FloatContainer, Float, ConditionalContainer
|
|
14
|
+
)
|
|
15
|
+
from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
|
|
16
|
+
from prompt_toolkit.layout.layout import Layout
|
|
17
|
+
from prompt_toolkit.layout.menus import CompletionsMenu
|
|
18
|
+
from prompt_toolkit.lexers import PygmentsLexer
|
|
19
|
+
from prompt_toolkit.filters import Condition, has_focus
|
|
20
|
+
from prompt_toolkit.selection import SelectionState
|
|
21
|
+
from prompt_toolkit.styles import Style
|
|
22
|
+
from pygments.lexers.python import PythonLexer
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ---------- Config ----------
|
|
26
|
+
|
|
27
|
+
FILE_PATH = sys.argv[1] if len(sys.argv) > 1 else None
|
|
28
|
+
ROOT_DIR = os.getcwd()
|
|
29
|
+
SIDEBAR_WIDTH = 32
|
|
30
|
+
|
|
31
|
+
PYTHON_WORDS = [
|
|
32
|
+
'print', 'import', 'from', 'def', 'class', 'return',
|
|
33
|
+
'for', 'while', 'if', 'elif', 'else', 'try', 'except',
|
|
34
|
+
'finally', 'with', 'as', 'lambda', 'yield', 'pass',
|
|
35
|
+
'break', 'continue', 'raise', 'assert', 'del', 'global',
|
|
36
|
+
'nonlocal', 'True', 'False', 'None', 'and', 'or', 'not',
|
|
37
|
+
'in', 'is', 'self', 'len', 'range', 'str', 'int', 'float',
|
|
38
|
+
'list', 'dict', 'set', 'tuple', 'open', 'enumerate', 'zip',
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ---------- Buffer ----------
|
|
43
|
+
|
|
44
|
+
buffer = Buffer(
|
|
45
|
+
completer=WordCompleter(PYTHON_WORDS, ignore_case=True),
|
|
46
|
+
complete_while_typing=True,
|
|
47
|
+
name='main',
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
if FILE_PATH and os.path.exists(FILE_PATH):
|
|
51
|
+
with open(FILE_PATH, 'r') as f:
|
|
52
|
+
buffer.text = f.read()
|
|
53
|
+
buffer.cursor_position = 0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ---------- State ----------
|
|
57
|
+
|
|
58
|
+
status = {'message': 'Ready'}
|
|
59
|
+
|
|
60
|
+
exec_state = {
|
|
61
|
+
'namespace': {'__name__': '__main__'},
|
|
62
|
+
'output': '',
|
|
63
|
+
'running': False,
|
|
64
|
+
'thread': None,
|
|
65
|
+
'stop_event': threading.Event(),
|
|
66
|
+
'show_output': False,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
tree_state = {
|
|
70
|
+
'root': ROOT_DIR,
|
|
71
|
+
'expanded': set(),
|
|
72
|
+
'selected': 0,
|
|
73
|
+
'items': [],
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def rebuild_tree():
|
|
78
|
+
items = []
|
|
79
|
+
|
|
80
|
+
def walk(path, depth):
|
|
81
|
+
try:
|
|
82
|
+
entries = os.listdir(path)
|
|
83
|
+
except PermissionError:
|
|
84
|
+
return
|
|
85
|
+
entries = [e for e in entries if not e.startswith('.')]
|
|
86
|
+
entries.sort(key=lambda x: (not os.path.isdir(os.path.join(path, x)), x.lower()))
|
|
87
|
+
for name in entries:
|
|
88
|
+
full = os.path.join(path, name)
|
|
89
|
+
is_dir = os.path.isdir(full)
|
|
90
|
+
is_exp = full in tree_state['expanded']
|
|
91
|
+
items.append((full, depth, is_dir, is_exp))
|
|
92
|
+
if is_dir and is_exp:
|
|
93
|
+
walk(full, depth + 1)
|
|
94
|
+
|
|
95
|
+
walk(tree_state['root'], 0)
|
|
96
|
+
tree_state['items'] = items
|
|
97
|
+
if tree_state['selected'] >= len(items):
|
|
98
|
+
tree_state['selected'] = max(0, len(items) - 1)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
rebuild_tree()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ---------- Rendering ----------
|
|
105
|
+
|
|
106
|
+
def get_status():
|
|
107
|
+
path = FILE_PATH or '<untitled>'
|
|
108
|
+
run = ' [RUNNING]' if exec_state['running'] else ''
|
|
109
|
+
return (
|
|
110
|
+
f' {path} | {status["message"]}{run} | '
|
|
111
|
+
f'Ctrl+B sidebar Ctrl+A sel-all F5 run Ctrl+S save '
|
|
112
|
+
f'Ctrl+X stop Ctrl+E output Ctrl+Q quit '
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def get_sidebar_text():
|
|
117
|
+
result = []
|
|
118
|
+
for i, (path, depth, is_dir, is_exp) in enumerate(tree_state['items']):
|
|
119
|
+
name = os.path.basename(path) or path
|
|
120
|
+
indent = ' ' * depth
|
|
121
|
+
if is_dir:
|
|
122
|
+
arrow = '▼ ' if is_exp else '▶ '
|
|
123
|
+
line = f'{indent}{arrow}{name}/'
|
|
124
|
+
else:
|
|
125
|
+
line = f'{indent} {name}'
|
|
126
|
+
style = 'class:sidebar.selected' if i == tree_state['selected'] else 'class:sidebar'
|
|
127
|
+
padded = line[:SIDEBAR_WIDTH].ljust(SIDEBAR_WIDTH)
|
|
128
|
+
result.append((style, padded + '\n'))
|
|
129
|
+
if not result:
|
|
130
|
+
result.append(('class:sidebar', '(empty)'))
|
|
131
|
+
return result
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ---------- Loop detection ----------
|
|
135
|
+
|
|
136
|
+
def enclosing_loop_source(text, cursor_index):
|
|
137
|
+
try:
|
|
138
|
+
tree = ast.parse(text)
|
|
139
|
+
except SyntaxError:
|
|
140
|
+
return None
|
|
141
|
+
lines = text.splitlines(keepends=True)
|
|
142
|
+
|
|
143
|
+
def offset_of_line(n):
|
|
144
|
+
return sum(len(l) for l in lines[:n - 1])
|
|
145
|
+
|
|
146
|
+
target = None
|
|
147
|
+
for node in ast.walk(tree):
|
|
148
|
+
if isinstance(node, (ast.For, ast.While, ast.AsyncFor)):
|
|
149
|
+
start_off = offset_of_line(node.lineno)
|
|
150
|
+
end_line = getattr(node, 'end_lineno', node.lineno) or node.lineno
|
|
151
|
+
end_off = offset_of_line(end_line + 1)
|
|
152
|
+
if start_off <= cursor_index <= end_off:
|
|
153
|
+
target = node
|
|
154
|
+
if target is None:
|
|
155
|
+
return None
|
|
156
|
+
try:
|
|
157
|
+
return ast.get_source_segment(text, target)
|
|
158
|
+
except Exception:
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ---------- Code execution ----------
|
|
163
|
+
|
|
164
|
+
def run_code(code, label):
|
|
165
|
+
if exec_state['running']:
|
|
166
|
+
status['message'] = 'Already running — Ctrl+X to stop'
|
|
167
|
+
return
|
|
168
|
+
exec_state['running'] = True
|
|
169
|
+
exec_state['output'] = ''
|
|
170
|
+
exec_state['show_output'] = True
|
|
171
|
+
exec_state['stop_event'].clear()
|
|
172
|
+
status['message'] = f'Running {label}...'
|
|
173
|
+
|
|
174
|
+
def worker():
|
|
175
|
+
buf = io.StringIO()
|
|
176
|
+
old_out, old_err = sys.stdout, sys.stderr
|
|
177
|
+
sys.stdout = sys.stderr = buf
|
|
178
|
+
try:
|
|
179
|
+
compiled = compile(code, f'<{label}>', 'exec')
|
|
180
|
+
exec(compiled, exec_state['namespace'])
|
|
181
|
+
except SystemExit:
|
|
182
|
+
pass
|
|
183
|
+
except BaseException:
|
|
184
|
+
traceback.print_exc(file=buf)
|
|
185
|
+
finally:
|
|
186
|
+
sys.stdout, sys.stderr = old_out, old_err
|
|
187
|
+
raw = buf.getvalue()
|
|
188
|
+
exec_state['output'] = (
|
|
189
|
+
f"=== ran ({label}) ===\n{code}\n"
|
|
190
|
+
f"=== stdout / stderr ===\n"
|
|
191
|
+
f"{raw if raw else '(no print output)'}"
|
|
192
|
+
)
|
|
193
|
+
exec_state['running'] = False
|
|
194
|
+
status['message'] = f'{label} finished'
|
|
195
|
+
try:
|
|
196
|
+
app.invalidate()
|
|
197
|
+
except Exception:
|
|
198
|
+
pass
|
|
199
|
+
|
|
200
|
+
t = threading.Thread(target=worker, daemon=True)
|
|
201
|
+
exec_state['thread'] = t
|
|
202
|
+
t.start()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def get_output_text():
|
|
206
|
+
if exec_state['running']:
|
|
207
|
+
return 'Running... (Ctrl+X to request stop)'
|
|
208
|
+
return exec_state['output'] or '(no output yet — press F5 to run)'
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# ---------- Controls ----------
|
|
212
|
+
|
|
213
|
+
buffer_control = BufferControl(buffer=buffer, lexer=PygmentsLexer(PythonLexer))
|
|
214
|
+
editor_window = Window(buffer_control, wrap_lines=False)
|
|
215
|
+
|
|
216
|
+
output_window = Window(
|
|
217
|
+
FormattedTextControl(get_output_text),
|
|
218
|
+
style='class:output',
|
|
219
|
+
wrap_lines=True,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# ---------- Sidebar key bindings ----------
|
|
224
|
+
|
|
225
|
+
sidebar_kb = KeyBindings()
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@sidebar_kb.add('up')
|
|
229
|
+
def _sb_up(event):
|
|
230
|
+
if tree_state['selected'] > 0:
|
|
231
|
+
tree_state['selected'] -= 1
|
|
232
|
+
event.app.invalidate()
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@sidebar_kb.add('down')
|
|
236
|
+
def _sb_down(event):
|
|
237
|
+
if tree_state['selected'] < len(tree_state['items']) - 1:
|
|
238
|
+
tree_state['selected'] += 1
|
|
239
|
+
event.app.invalidate()
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@sidebar_kb.add('enter')
|
|
243
|
+
def _sb_enter(event):
|
|
244
|
+
global FILE_PATH
|
|
245
|
+
if not tree_state['items']:
|
|
246
|
+
return
|
|
247
|
+
path, depth, is_dir, is_exp = tree_state['items'][tree_state['selected']]
|
|
248
|
+
if is_dir:
|
|
249
|
+
if is_exp:
|
|
250
|
+
tree_state['expanded'].discard(path)
|
|
251
|
+
else:
|
|
252
|
+
tree_state['expanded'].add(path)
|
|
253
|
+
rebuild_tree()
|
|
254
|
+
else:
|
|
255
|
+
try:
|
|
256
|
+
with open(path, 'r') as f:
|
|
257
|
+
buffer.text = f.read()
|
|
258
|
+
buffer.cursor_position = 0
|
|
259
|
+
FILE_PATH = path
|
|
260
|
+
status['message'] = f'Opened {os.path.basename(path)}'
|
|
261
|
+
event.app.layout.focus(buffer_control)
|
|
262
|
+
except Exception as e:
|
|
263
|
+
status['message'] = f'Open failed: {e}'
|
|
264
|
+
event.app.invalidate()
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@sidebar_kb.add('right')
|
|
268
|
+
def _sb_right(event):
|
|
269
|
+
if not tree_state['items']:
|
|
270
|
+
return
|
|
271
|
+
path, depth, is_dir, is_exp = tree_state['items'][tree_state['selected']]
|
|
272
|
+
if is_dir and not is_exp:
|
|
273
|
+
tree_state['expanded'].add(path)
|
|
274
|
+
rebuild_tree()
|
|
275
|
+
elif is_dir and is_exp:
|
|
276
|
+
if tree_state['selected'] + 1 < len(tree_state['items']):
|
|
277
|
+
nxt = tree_state['items'][tree_state['selected'] + 1]
|
|
278
|
+
if nxt[1] == depth + 1:
|
|
279
|
+
tree_state['selected'] += 1
|
|
280
|
+
event.app.invalidate()
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@sidebar_kb.add('left')
|
|
284
|
+
def _sb_left(event):
|
|
285
|
+
if not tree_state['items']:
|
|
286
|
+
return
|
|
287
|
+
path, depth, is_dir, is_exp = tree_state['items'][tree_state['selected']]
|
|
288
|
+
if is_dir and is_exp:
|
|
289
|
+
tree_state['expanded'].discard(path)
|
|
290
|
+
rebuild_tree()
|
|
291
|
+
elif depth > 0:
|
|
292
|
+
parent = os.path.dirname(path)
|
|
293
|
+
for i, (p, _, _, _) in enumerate(tree_state['items']):
|
|
294
|
+
if p == parent:
|
|
295
|
+
tree_state['selected'] = i
|
|
296
|
+
break
|
|
297
|
+
event.app.invalidate()
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
@sidebar_kb.add('tab')
|
|
301
|
+
def _sb_tab(event):
|
|
302
|
+
event.app.layout.focus(buffer_control)
|
|
303
|
+
status['message'] = 'Editor focused'
|
|
304
|
+
event.app.invalidate()
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
@sidebar_kb.add('c-r')
|
|
308
|
+
def _sb_refresh(event):
|
|
309
|
+
rebuild_tree()
|
|
310
|
+
status['message'] = 'Sidebar refreshed'
|
|
311
|
+
event.app.invalidate()
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
sidebar_control = FormattedTextControl(
|
|
315
|
+
get_sidebar_text,
|
|
316
|
+
focusable=True,
|
|
317
|
+
key_bindings=sidebar_kb,
|
|
318
|
+
)
|
|
319
|
+
sidebar_window = Window(
|
|
320
|
+
sidebar_control,
|
|
321
|
+
width=SIDEBAR_WIDTH,
|
|
322
|
+
style='class:sidebar',
|
|
323
|
+
wrap_lines=False,
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
# ---------- Layout ----------
|
|
328
|
+
|
|
329
|
+
body = VSplit([
|
|
330
|
+
sidebar_window,
|
|
331
|
+
HSplit([
|
|
332
|
+
editor_window,
|
|
333
|
+
Window(FormattedTextControl(get_status), height=1, style='reverse'),
|
|
334
|
+
]),
|
|
335
|
+
])
|
|
336
|
+
|
|
337
|
+
root_container = FloatContainer(
|
|
338
|
+
content=body,
|
|
339
|
+
floats=[
|
|
340
|
+
Float(
|
|
341
|
+
xcursor=True,
|
|
342
|
+
ycursor=True,
|
|
343
|
+
content=CompletionsMenu(max_height=10, scroll_offset=1),
|
|
344
|
+
),
|
|
345
|
+
Float(
|
|
346
|
+
content=ConditionalContainer(
|
|
347
|
+
output_window,
|
|
348
|
+
filter=Condition(lambda: exec_state['show_output']),
|
|
349
|
+
),
|
|
350
|
+
top=2, right=2, width=60, height=15,
|
|
351
|
+
),
|
|
352
|
+
],
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
# ---------- Global key bindings ----------
|
|
357
|
+
|
|
358
|
+
kb = KeyBindings()
|
|
359
|
+
not_sidebar = ~has_focus(sidebar_control)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
@kb.add('c-b')
|
|
363
|
+
def toggle_sidebar(event):
|
|
364
|
+
try:
|
|
365
|
+
current = event.app.layout.current_control
|
|
366
|
+
except Exception:
|
|
367
|
+
current = None
|
|
368
|
+
if current is sidebar_control:
|
|
369
|
+
event.app.layout.focus(buffer_control)
|
|
370
|
+
status['message'] = 'Editor focused'
|
|
371
|
+
else:
|
|
372
|
+
event.app.layout.focus(sidebar_control)
|
|
373
|
+
status['message'] = 'Sidebar — arrows/Enter, Tab to edit, Ctrl+R refresh'
|
|
374
|
+
event.app.invalidate()
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
@kb.add('c-a', filter=not_sidebar)
|
|
378
|
+
def select_all(event):
|
|
379
|
+
"""Ctrl+A: select the entire buffer."""
|
|
380
|
+
if buffer.text:
|
|
381
|
+
# Anchor at offset 0, move cursor to the end → full selection
|
|
382
|
+
buffer.selection_state = SelectionState(original_cursor_position=0)
|
|
383
|
+
buffer.cursor_position = len(buffer.text)
|
|
384
|
+
status['message'] = 'Selected all — Backspace to clear, F5 to run'
|
|
385
|
+
else:
|
|
386
|
+
status['message'] = 'Buffer is empty'
|
|
387
|
+
event.app.invalidate()
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
@kb.add('c-s')
|
|
391
|
+
def save(event):
|
|
392
|
+
global FILE_PATH
|
|
393
|
+
if FILE_PATH is None:
|
|
394
|
+
FILE_PATH = 'untitled.py'
|
|
395
|
+
try:
|
|
396
|
+
with open(FILE_PATH, 'w') as f:
|
|
397
|
+
f.write(buffer.text)
|
|
398
|
+
status['message'] = f'Saved to {FILE_PATH}'
|
|
399
|
+
except Exception as e:
|
|
400
|
+
status['message'] = f'Save failed: {e}'
|
|
401
|
+
event.app.invalidate()
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _do_run(event):
|
|
405
|
+
doc = buffer.document
|
|
406
|
+
sel = doc.selection_range()
|
|
407
|
+
if sel and sel[0] != sel[1]:
|
|
408
|
+
start, end = sel
|
|
409
|
+
code = doc.text[start:end]
|
|
410
|
+
label = 'selection'
|
|
411
|
+
else:
|
|
412
|
+
loop_src = enclosing_loop_source(doc.text, doc.cursor_position)
|
|
413
|
+
if loop_src:
|
|
414
|
+
code = loop_src
|
|
415
|
+
label = 'enclosing loop'
|
|
416
|
+
else:
|
|
417
|
+
code = doc.text
|
|
418
|
+
label = 'file'
|
|
419
|
+
status['message'] = f'Running {label}'
|
|
420
|
+
run_code(code, label)
|
|
421
|
+
event.app.invalidate()
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
kb.add('f5')(_do_run)
|
|
425
|
+
kb.add('c-r', filter=not_sidebar)(_do_run)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
@kb.add('c-e')
|
|
429
|
+
def toggle_output(event):
|
|
430
|
+
exec_state['show_output'] = not exec_state['show_output']
|
|
431
|
+
event.app.invalidate()
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
@kb.add('c-x')
|
|
435
|
+
def stop_run(event):
|
|
436
|
+
if exec_state['running']:
|
|
437
|
+
exec_state['stop_event'].set()
|
|
438
|
+
status['message'] = 'Stop requested'
|
|
439
|
+
else:
|
|
440
|
+
status['message'] = 'Nothing running'
|
|
441
|
+
event.app.invalidate()
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
@kb.add('c-q')
|
|
445
|
+
def quit_(event):
|
|
446
|
+
event.app.exit()
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
@kb.add('c-c')
|
|
450
|
+
def quit_c(event):
|
|
451
|
+
event.app.exit()
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
# ---------- Style ----------
|
|
455
|
+
|
|
456
|
+
style = Style.from_dict({
|
|
457
|
+
'sidebar': 'bg:#1e1e2e #c0c0d0',
|
|
458
|
+
'sidebar.selected': 'bg:#4444aa #ffffff bold',
|
|
459
|
+
'output': 'bg:#1a1a2a #dddddd',
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
# ---------- Application ----------
|
|
464
|
+
|
|
465
|
+
layout = Layout(root_container, focused_element=buffer_control)
|
|
466
|
+
app = Application(
|
|
467
|
+
layout=layout,
|
|
468
|
+
key_bindings=kb,
|
|
469
|
+
full_screen=True,
|
|
470
|
+
style=style,
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def main():
|
|
475
|
+
app.run()
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
if __name__ == '__main__':
|
|
479
|
+
main()
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: s_py_editor
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A terminal-based Python editor with a sidebar, loop runner, and more.
|
|
5
|
+
Requires-Python: >=3.9
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: prompt_toolkit>=3.0
|
|
8
|
+
Requires-Dist: pygments>=2.0
|
|
9
|
+
|
|
10
|
+
# s-py-editor
|
|
11
|
+
|
|
12
|
+
A terminal-based Python code editor with syntax highlighting, auto-completion,
|
|
13
|
+
a file sidebar, and the ability to run code (selections, loops, or whole files)
|
|
14
|
+
without leaving the editor.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pipx install s-py-editor
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Or with pip:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install s-py-editor
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
spy # start with empty buffer
|
|
32
|
+
spy myfile.py # open a file
|
|
33
|
+
spy . # start in a directory (sidebar shows it)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Key bindings
|
|
37
|
+
|
|
38
|
+
| Key | Action |
|
|
39
|
+
|-----|--------|
|
|
40
|
+
| `F5` / `Ctrl+R` | Run selection → enclosing loop → whole file |
|
|
41
|
+
| `Ctrl+S` | Save the buffer |
|
|
42
|
+
| `Ctrl+A` | Select all |
|
|
43
|
+
| `Ctrl+B` | Toggle sidebar focus |
|
|
44
|
+
| `Ctrl+E` | Toggle output popup |
|
|
45
|
+
| `Ctrl+X` | Stop running code |
|
|
46
|
+
| `Ctrl+Q` / `Ctrl+C` | Quit |
|
|
47
|
+
|
|
48
|
+
## Sidebar
|
|
49
|
+
|
|
50
|
+
When the sidebar is focused (`Ctrl+B`):
|
|
51
|
+
|
|
52
|
+
- `↑` / `↓` — move selection
|
|
53
|
+
- `→` / `←` — expand / collapse folders
|
|
54
|
+
- `Enter` — open file or toggle folder
|
|
55
|
+
- `Ctrl+R` — refresh directory listing
|
|
56
|
+
- `Tab` — focus the editor
|
|
57
|
+
|
|
58
|
+
## Requirements
|
|
59
|
+
|
|
60
|
+
- Python 3.9+
|
|
61
|
+
- `prompt_toolkit>=3.0`
|
|
62
|
+
- `pygments>=2.0`
|
|
63
|
+
|
|
64
|
+
## License
|
|
65
|
+
|
|
66
|
+
MIT (or your choice — update this line)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/s_py_editor/__init__.py
|
|
4
|
+
src/s_py_editor/__main__.py
|
|
5
|
+
src/s_py_editor.egg-info/PKG-INFO
|
|
6
|
+
src/s_py_editor.egg-info/SOURCES.txt
|
|
7
|
+
src/s_py_editor.egg-info/dependency_links.txt
|
|
8
|
+
src/s_py_editor.egg-info/entry_points.txt
|
|
9
|
+
src/s_py_editor.egg-info/requires.txt
|
|
10
|
+
src/s_py_editor.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
s_py_editor
|