femto-editor 0.0.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.
- femto_editor-0.0.1/LICENSE +21 -0
- femto_editor-0.0.1/PKG-INFO +107 -0
- femto_editor-0.0.1/README.md +65 -0
- femto_editor-0.0.1/femto/__init__.py +7 -0
- femto_editor-0.0.1/femto/__main__.py +60 -0
- femto_editor-0.0.1/femto/app.py +330 -0
- femto_editor-0.0.1/femto/buffer.py +135 -0
- femto_editor-0.0.1/femto/config.py +43 -0
- femto_editor-0.0.1/femto/cursor.py +63 -0
- femto_editor-0.0.1/femto/history.py +64 -0
- femto_editor-0.0.1/femto/keys.py +61 -0
- femto_editor-0.0.1/femto/layout.py +67 -0
- femto_editor-0.0.1/femto/prompt.py +95 -0
- femto_editor-0.0.1/femto/renderer.py +172 -0
- femto_editor-0.0.1/femto_editor.egg-info/PKG-INFO +107 -0
- femto_editor-0.0.1/femto_editor.egg-info/SOURCES.txt +21 -0
- femto_editor-0.0.1/femto_editor.egg-info/dependency_links.txt +1 -0
- femto_editor-0.0.1/femto_editor.egg-info/entry_points.txt +2 -0
- femto_editor-0.0.1/femto_editor.egg-info/requires.txt +3 -0
- femto_editor-0.0.1/femto_editor.egg-info/top_level.txt +1 -0
- femto_editor-0.0.1/pyproject.toml +35 -0
- femto_editor-0.0.1/setup.cfg +4 -0
- femto_editor-0.0.1/tests/test_femto.py +152 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Femto Contributors
|
|
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,107 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: femto-editor
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Femto - a tiny nano-style terminal text editor in pure Python
|
|
5
|
+
Author: Femto Contributors
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2024 Femto Contributors
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Homepage, https://github.com/codewithzaqar/femto
|
|
29
|
+
Keywords: editor,terminal,curses,nano,text-editor,tui
|
|
30
|
+
Classifier: Development Status :: 4 - Beta
|
|
31
|
+
Classifier: Environment :: Console :: Curses
|
|
32
|
+
Classifier: Intended Audience :: Developers
|
|
33
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
34
|
+
Classifier: Operating System :: OS Independent
|
|
35
|
+
Classifier: Programming Language :: Python :: 3
|
|
36
|
+
Classifier: Topic :: Text Editors :: Text Processing
|
|
37
|
+
Requires-Python: >=3.8
|
|
38
|
+
Description-Content-Type: text/markdown
|
|
39
|
+
License-File: LICENSE
|
|
40
|
+
Requires-Dist: windows-curses; sys_platform == "win32"
|
|
41
|
+
Dynamic: license-file
|
|
42
|
+
|
|
43
|
+
# Femto v0.0.1
|
|
44
|
+
|
|
45
|
+
Femto is a tiny, nano-style terminal text editor written in **pure Python**.
|
|
46
|
+
It ships with zero runtime dependencies on POSIX system (standard-library `curses` only) and automatically pulls `windows-curses` on Windows.
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
# From source
|
|
52
|
+
pip install .
|
|
53
|
+
|
|
54
|
+
# Or directly from the repository
|
|
55
|
+
pip install git+https://github.com/codewithzaqar/femto
|
|
56
|
+
|
|
57
|
+
# Developers: run without installing
|
|
58
|
+
python -m femto myfile.txt
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
After installation the `femto` command is available globally.
|
|
62
|
+
|
|
63
|
+
## Usage
|
|
64
|
+
```bash
|
|
65
|
+
femto [file] # open or create a file
|
|
66
|
+
femto --tab=size 2 x.py # override tab width
|
|
67
|
+
femto --no-wrap long.log # horizontal scrolling instead of soft wrap
|
|
68
|
+
femto --version
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Keybindings
|
|
72
|
+
|
|
73
|
+
|Key|Action|Key|Action|
|
|
74
|
+
|---|---|---|---|
|
|
75
|
+
|`Ctrl+X`|Exit (asks to save)|`Ctrl+W`|Search / find next|
|
|
76
|
+
|`Ctrl+S`|Save (Save-As if unnamed)|`Ctrl+T`|Go to line|
|
|
77
|
+
|`Ctrl+Z`/`Ctrl+Y`|Undo / Redo|`Ctrl+G`/`Esc`|Cancel prompt|
|
|
78
|
+
|`Arrows`|Move|`Ctrl+<-/->`|Word jump|
|
|
79
|
+
|`Home/End`, `Ctrl+A/E`|Line start/end|`PgUp/PgDn`|Page scroll|
|
|
80
|
+
|`Tab`/`Shift+Tab`|Indent / unindent|`Enter`|New line|
|
|
81
|
+
|
|
82
|
+
## Configuration (`~/.femtorc` or `./.femtorc`)
|
|
83
|
+
|
|
84
|
+
```ini
|
|
85
|
+
# comment
|
|
86
|
+
tab_size = 4
|
|
87
|
+
smooth_scroll_margin = 3
|
|
88
|
+
soft_wrap = true
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Running the tests
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
python -m unittest discover -s tests -v
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Building a release
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
pip install build twine
|
|
101
|
+
python -m build # creates dist/femto_editor-0.0.1-*.whl
|
|
102
|
+
python -m twine upload dist/* # publish to PyPI
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## License
|
|
106
|
+
|
|
107
|
+
MIT - see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Femto v0.0.1
|
|
2
|
+
|
|
3
|
+
Femto is a tiny, nano-style terminal text editor written in **pure Python**.
|
|
4
|
+
It ships with zero runtime dependencies on POSIX system (standard-library `curses` only) and automatically pulls `windows-curses` on Windows.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
# From source
|
|
10
|
+
pip install .
|
|
11
|
+
|
|
12
|
+
# Or directly from the repository
|
|
13
|
+
pip install git+https://github.com/codewithzaqar/femto
|
|
14
|
+
|
|
15
|
+
# Developers: run without installing
|
|
16
|
+
python -m femto myfile.txt
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
After installation the `femto` command is available globally.
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
```bash
|
|
23
|
+
femto [file] # open or create a file
|
|
24
|
+
femto --tab=size 2 x.py # override tab width
|
|
25
|
+
femto --no-wrap long.log # horizontal scrolling instead of soft wrap
|
|
26
|
+
femto --version
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Keybindings
|
|
30
|
+
|
|
31
|
+
|Key|Action|Key|Action|
|
|
32
|
+
|---|---|---|---|
|
|
33
|
+
|`Ctrl+X`|Exit (asks to save)|`Ctrl+W`|Search / find next|
|
|
34
|
+
|`Ctrl+S`|Save (Save-As if unnamed)|`Ctrl+T`|Go to line|
|
|
35
|
+
|`Ctrl+Z`/`Ctrl+Y`|Undo / Redo|`Ctrl+G`/`Esc`|Cancel prompt|
|
|
36
|
+
|`Arrows`|Move|`Ctrl+<-/->`|Word jump|
|
|
37
|
+
|`Home/End`, `Ctrl+A/E`|Line start/end|`PgUp/PgDn`|Page scroll|
|
|
38
|
+
|`Tab`/`Shift+Tab`|Indent / unindent|`Enter`|New line|
|
|
39
|
+
|
|
40
|
+
## Configuration (`~/.femtorc` or `./.femtorc`)
|
|
41
|
+
|
|
42
|
+
```ini
|
|
43
|
+
# comment
|
|
44
|
+
tab_size = 4
|
|
45
|
+
smooth_scroll_margin = 3
|
|
46
|
+
soft_wrap = true
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Running the tests
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
python -m unittest discover -s tests -v
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Building a release
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install build twine
|
|
59
|
+
python -m build # creates dist/femto_editor-0.0.1-*.whl
|
|
60
|
+
python -m twine upload dist/* # publish to PyPI
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## License
|
|
64
|
+
|
|
65
|
+
MIT - see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Command-line entry point for Femto.
|
|
3
|
+
|
|
4
|
+
Enables:
|
|
5
|
+
python -m femto [file]
|
|
6
|
+
femto [file] (console script installed by pip)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
from femto import __version__, __app_name__
|
|
13
|
+
from femto.app import Application
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_parser():
|
|
17
|
+
parser = argparse.ArgumentParser(
|
|
18
|
+
prog="femto",
|
|
19
|
+
description="Femto - a tiny nano-style terminal text editor.",
|
|
20
|
+
)
|
|
21
|
+
parser.add_argument(
|
|
22
|
+
"filename", nargs="?", default=None,
|
|
23
|
+
help="file to open (created on save if it does not exist)",
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"--version", action="version",
|
|
27
|
+
version=f"{__app_name__} {__version__}",
|
|
28
|
+
)
|
|
29
|
+
parser.add_argument(
|
|
30
|
+
"--tab-size", type=int, default=None, metavar="N",
|
|
31
|
+
help="spaces inserted by Tab (overrides .femtorc)",
|
|
32
|
+
)
|
|
33
|
+
parser.add_argument(
|
|
34
|
+
"--scroll-margin", type=int, default=None, metavar="N",
|
|
35
|
+
help="smooth-scroll margin in rows (overrides .femtorc)",
|
|
36
|
+
)
|
|
37
|
+
parser.add_argument(
|
|
38
|
+
"--no-wrap", action="store_true",
|
|
39
|
+
help="disable soft line wrapping (use horizontal scrolling)",
|
|
40
|
+
)
|
|
41
|
+
return parser
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def main(argv=None):
|
|
45
|
+
args = build_parser().parse_args(argv)
|
|
46
|
+
|
|
47
|
+
app = Application(args.filename)
|
|
48
|
+
if args.tab_size is not None and args.tab_size >= 1:
|
|
49
|
+
app.config.tab_size = args.tab_size
|
|
50
|
+
if args.scroll_margin is not None and args.scroll_margin >= 0:
|
|
51
|
+
app.config.smooth_scroll_margin = args.scroll_margin
|
|
52
|
+
if args.no_wrap:
|
|
53
|
+
app.config.soft_wrap = False
|
|
54
|
+
|
|
55
|
+
app.run()
|
|
56
|
+
return 0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
if __name__ == "__main__":
|
|
60
|
+
sys.exit(main())
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Main Application Controller for Femto.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import signal
|
|
6
|
+
import curses
|
|
7
|
+
|
|
8
|
+
from femto.buffer import Buffer
|
|
9
|
+
from femto.cursor import Cursor
|
|
10
|
+
from femto.renderer import Renderer
|
|
11
|
+
from femto.layout import get_visual_position, get_logical_from_visual
|
|
12
|
+
from femto.prompt import Prompt
|
|
13
|
+
from femto.history import History
|
|
14
|
+
from femto.config import Config
|
|
15
|
+
from femto.keys import Key, is_backspace, is_enter
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _ignore_suspend():
|
|
19
|
+
"""Ignore SIGTSTP where it exists (POSIX); no-op on Windows."""
|
|
20
|
+
sig = getattr(signal, "SIGTSTP", None)
|
|
21
|
+
if sig is None:
|
|
22
|
+
return
|
|
23
|
+
try:
|
|
24
|
+
signal.signal(sig, signal.SIG_IGN)
|
|
25
|
+
except (OSError, ValueError):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Mode:
|
|
30
|
+
NORMAL = "normal"
|
|
31
|
+
SAVE_AS = "save_as"
|
|
32
|
+
SEARCH = "search"
|
|
33
|
+
GOTO_LINE = "goto_line"
|
|
34
|
+
EXIT_CONFIRM = "exit_confirm"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Application:
|
|
38
|
+
def __init__(self, filename=None):
|
|
39
|
+
self.config = Config()
|
|
40
|
+
self.buffer = Buffer(self.config)
|
|
41
|
+
self.buffer.load_file(filename)
|
|
42
|
+
self.cursor = Cursor()
|
|
43
|
+
self.renderer = None
|
|
44
|
+
self.message = ""
|
|
45
|
+
self.running = True
|
|
46
|
+
self.mode = Mode.NORMAL
|
|
47
|
+
self.prompt = Prompt()
|
|
48
|
+
self.history = History()
|
|
49
|
+
self.pending_exit = False
|
|
50
|
+
self.last_search = ""
|
|
51
|
+
self.last_found_pos = None
|
|
52
|
+
|
|
53
|
+
def _snapshot(self):
|
|
54
|
+
self.history.push(self.buffer.lines, self.cursor.x, self.cursor.y)
|
|
55
|
+
|
|
56
|
+
# ── input routing ─────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
def handle_input(self, key, screen_rows, screen_cols):
|
|
59
|
+
if self.mode == Mode.SAVE_AS:
|
|
60
|
+
self._handle_save_as(key)
|
|
61
|
+
elif self.mode == Mode.SEARCH:
|
|
62
|
+
self._handle_search(key)
|
|
63
|
+
elif self.mode == Mode.GOTO_LINE:
|
|
64
|
+
self._handle_goto_line(key)
|
|
65
|
+
elif self.mode == Mode.EXIT_CONFIRM:
|
|
66
|
+
self._handle_exit_confirm(key)
|
|
67
|
+
else:
|
|
68
|
+
self._handle_normal(key, screen_rows, screen_cols)
|
|
69
|
+
|
|
70
|
+
# ── SAVE AS ───────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
def _handle_save_as(self, key):
|
|
73
|
+
result = self.prompt.handle_key(key)
|
|
74
|
+
if result == 'confirmed':
|
|
75
|
+
filename = self.prompt.text.strip()
|
|
76
|
+
if not filename:
|
|
77
|
+
return
|
|
78
|
+
self.buffer.filename = filename
|
|
79
|
+
if self.buffer.save():
|
|
80
|
+
self.message = f"Saved: {filename}" # ASCII-safe
|
|
81
|
+
else:
|
|
82
|
+
self.message = "Error: could not save file."
|
|
83
|
+
self._exit_prompt_mode()
|
|
84
|
+
if self.pending_exit:
|
|
85
|
+
self.running = False
|
|
86
|
+
elif result == 'cancelled':
|
|
87
|
+
self._exit_prompt_mode()
|
|
88
|
+
self.message = "Save cancelled."
|
|
89
|
+
|
|
90
|
+
# ── SEARCH ────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
def _handle_search(self, key):
|
|
93
|
+
result = self.prompt.handle_key(key)
|
|
94
|
+
if result == 'confirmed':
|
|
95
|
+
term = self.prompt.text
|
|
96
|
+
if term:
|
|
97
|
+
self.last_search = term
|
|
98
|
+
self._find_text(term)
|
|
99
|
+
self._exit_prompt_mode()
|
|
100
|
+
elif result == 'cancelled':
|
|
101
|
+
self._exit_prompt_mode()
|
|
102
|
+
|
|
103
|
+
def _find_text(self, term):
|
|
104
|
+
if self.last_found_pos == (self.cursor.x, self.cursor.y):
|
|
105
|
+
start_x = self.cursor.x + 1
|
|
106
|
+
else:
|
|
107
|
+
start_x = self.cursor.x
|
|
108
|
+
result = self.buffer.find_text(term, start_x, self.cursor.y)
|
|
109
|
+
if result:
|
|
110
|
+
x, y = result
|
|
111
|
+
self.cursor.set_pos(x, y, self.buffer.get_line_length,
|
|
112
|
+
self.buffer.max_y)
|
|
113
|
+
self.last_found_pos = (x, y)
|
|
114
|
+
self.message = f"Found: {term}"
|
|
115
|
+
else:
|
|
116
|
+
self.message = f"Not found: {term}"
|
|
117
|
+
self.last_found_pos = None
|
|
118
|
+
|
|
119
|
+
# ── GO TO LINE ────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
def _handle_goto_line(self, key):
|
|
122
|
+
result = self.prompt.handle_key(key)
|
|
123
|
+
if result == 'confirmed':
|
|
124
|
+
raw = self.prompt.text.strip()
|
|
125
|
+
try:
|
|
126
|
+
target = int(raw)
|
|
127
|
+
target_y = max(0, min(target - 1, self.buffer.max_y))
|
|
128
|
+
self.cursor.set_pos(0, target_y,
|
|
129
|
+
self.buffer.get_line_length,
|
|
130
|
+
self.buffer.max_y)
|
|
131
|
+
self.message = f"Line {target_y + 1}/{self.buffer.max_y + 1}"
|
|
132
|
+
except ValueError:
|
|
133
|
+
self.message = "Invalid line number."
|
|
134
|
+
self._exit_prompt_mode()
|
|
135
|
+
elif result == 'cancelled':
|
|
136
|
+
self._exit_prompt_mode()
|
|
137
|
+
|
|
138
|
+
# ── EXIT CONFIRM ──────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
def _handle_exit_confirm(self, key):
|
|
141
|
+
if key in (ord('y'), ord('Y')):
|
|
142
|
+
if self.buffer.filename:
|
|
143
|
+
if self.buffer.save():
|
|
144
|
+
self.running = False
|
|
145
|
+
else:
|
|
146
|
+
self.message = "Error: could not save file."
|
|
147
|
+
self.mode = Mode.NORMAL
|
|
148
|
+
else:
|
|
149
|
+
self.mode = Mode.SAVE_AS
|
|
150
|
+
self.prompt.start("Save As: ")
|
|
151
|
+
self.pending_exit = True
|
|
152
|
+
elif key in (ord('n'), ord('N')):
|
|
153
|
+
self.running = False
|
|
154
|
+
elif key in (ord('c'), ord('C'), Key.CTRL_G, Key.ESCAPE):
|
|
155
|
+
self.mode = Mode.NORMAL
|
|
156
|
+
self.message = "Exit cancelled."
|
|
157
|
+
|
|
158
|
+
# ── NORMAL mode ───────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
def _handle_normal(self, key, screen_rows, screen_cols):
|
|
161
|
+
self.message = ""
|
|
162
|
+
buf = self.buffer
|
|
163
|
+
cur = self.cursor
|
|
164
|
+
max_x = buf.get_line_length
|
|
165
|
+
max_y = buf.max_y
|
|
166
|
+
|
|
167
|
+
if key == Key.CTRL_X:
|
|
168
|
+
if buf.modified:
|
|
169
|
+
self.mode = Mode.EXIT_CONFIRM
|
|
170
|
+
self.message = "Save modified buffer?"
|
|
171
|
+
else:
|
|
172
|
+
self.running = False
|
|
173
|
+
return
|
|
174
|
+
if key == Key.CTRL_S:
|
|
175
|
+
if buf.filename:
|
|
176
|
+
if buf.save():
|
|
177
|
+
self.message = "File saved."
|
|
178
|
+
else:
|
|
179
|
+
self.message = "Error: could not save file."
|
|
180
|
+
else:
|
|
181
|
+
self.mode = Mode.SAVE_AS
|
|
182
|
+
self.prompt.start("Save As: ")
|
|
183
|
+
return
|
|
184
|
+
if key == Key.CTRL_W:
|
|
185
|
+
self.mode = Mode.SEARCH
|
|
186
|
+
self.prompt.start("Search: ", self.last_search)
|
|
187
|
+
return
|
|
188
|
+
if key == Key.CTRL_T:
|
|
189
|
+
self.mode = Mode.GOTO_LINE
|
|
190
|
+
self.prompt.start("Go To Line: ")
|
|
191
|
+
return
|
|
192
|
+
if key == Key.CTRL_Z:
|
|
193
|
+
self._do_undo(); return
|
|
194
|
+
if key == Key.CTRL_Y:
|
|
195
|
+
self._do_redo(); return
|
|
196
|
+
|
|
197
|
+
if key == Key.CTRL_LEFT:
|
|
198
|
+
cur.x = buf.get_prev_word_pos(cur.y, cur.x)
|
|
199
|
+
elif key == Key.CTRL_RIGHT:
|
|
200
|
+
cur.x = buf.get_next_word_pos(cur.y, cur.x)
|
|
201
|
+
elif key == Key.PAGE_UP:
|
|
202
|
+
self._page(-1, screen_rows, screen_cols)
|
|
203
|
+
elif key == Key.PAGE_DOWN:
|
|
204
|
+
self._page(1, screen_rows, screen_cols)
|
|
205
|
+
elif key == Key.HOME or key == Key.CTRL_A:
|
|
206
|
+
cur.home()
|
|
207
|
+
elif key == Key.END or key == Key.CTRL_E:
|
|
208
|
+
cur.end(max_x(cur.y))
|
|
209
|
+
elif key == Key.ARROW_UP:
|
|
210
|
+
cur.move(0, -1, max_x, max_y)
|
|
211
|
+
elif key == Key.ARROW_DOWN:
|
|
212
|
+
cur.move(0, 1, max_x, max_y)
|
|
213
|
+
elif key == Key.ARROW_LEFT:
|
|
214
|
+
if cur.x > 0:
|
|
215
|
+
cur.x -= 1
|
|
216
|
+
elif cur.y > 0:
|
|
217
|
+
cur.y -= 1
|
|
218
|
+
cur.x = max_x(cur.y)
|
|
219
|
+
elif key == Key.ARROW_RIGHT:
|
|
220
|
+
if cur.x < max_x(cur.y):
|
|
221
|
+
cur.x += 1
|
|
222
|
+
elif cur.y < max_y:
|
|
223
|
+
cur.y += 1
|
|
224
|
+
cur.x = 0
|
|
225
|
+
elif key == Key.TAB:
|
|
226
|
+
self._snapshot()
|
|
227
|
+
cur.x = buf.insert_tab(cur.y, cur.x)
|
|
228
|
+
elif key == Key.SHIFT_TAB:
|
|
229
|
+
self._snapshot()
|
|
230
|
+
cur.x = buf.remove_tab(cur.y, cur.x)
|
|
231
|
+
elif is_backspace(key):
|
|
232
|
+
self._snapshot()
|
|
233
|
+
cur.x, cur.y = buf.backspace(cur.x, cur.y)
|
|
234
|
+
elif key == Key.DELETE:
|
|
235
|
+
self._snapshot()
|
|
236
|
+
buf.delete_char(cur.x, cur.y)
|
|
237
|
+
elif is_enter(key):
|
|
238
|
+
self._snapshot()
|
|
239
|
+
buf.insert_newline(cur.x, cur.y)
|
|
240
|
+
cur.y += 1
|
|
241
|
+
cur.x = 0
|
|
242
|
+
elif 32 <= key <= 126:
|
|
243
|
+
self._snapshot()
|
|
244
|
+
buf.insert_char(cur.x, cur.y, chr(key))
|
|
245
|
+
cur.x += 1
|
|
246
|
+
|
|
247
|
+
def _page(self, direction, screen_rows, screen_cols):
|
|
248
|
+
"""Move one viewport up/down, respecting soft wrap."""
|
|
249
|
+
if self.config.soft_wrap:
|
|
250
|
+
_, vy = get_visual_position(
|
|
251
|
+
self.cursor.x, self.cursor.y, self.buffer.lines,
|
|
252
|
+
screen_cols, True)
|
|
253
|
+
target = max(0, vy + direction * screen_rows)
|
|
254
|
+
y = get_logical_from_visual(target, self.buffer.lines,
|
|
255
|
+
screen_cols, True)
|
|
256
|
+
else:
|
|
257
|
+
y = self.cursor.y + direction * screen_rows
|
|
258
|
+
self.cursor.set_pos(self.cursor.x, y,
|
|
259
|
+
self.buffer.get_line_length, self.buffer.max_y)
|
|
260
|
+
|
|
261
|
+
# ── Undo / Redo ───────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
def _do_undo(self):
|
|
264
|
+
result = self.history.undo(self.buffer.lines,
|
|
265
|
+
self.cursor.x, self.cursor.y)
|
|
266
|
+
if result:
|
|
267
|
+
lines, x, y = result
|
|
268
|
+
self.buffer.lines = lines
|
|
269
|
+
self.buffer.modified = True
|
|
270
|
+
self.cursor.set_pos(x, y, self.buffer.get_line_length,
|
|
271
|
+
self.buffer.max_y)
|
|
272
|
+
self.message = "Undo."
|
|
273
|
+
else:
|
|
274
|
+
self.message = "Nothing to undo."
|
|
275
|
+
|
|
276
|
+
def _do_redo(self):
|
|
277
|
+
result = self.history.redo(self.buffer.lines,
|
|
278
|
+
self.cursor.x, self.cursor.y)
|
|
279
|
+
if result:
|
|
280
|
+
lines, x, y = result
|
|
281
|
+
self.buffer.lines = lines
|
|
282
|
+
self.buffer.modified = True
|
|
283
|
+
self.cursor.set_pos(x, y, self.buffer.get_line_length,
|
|
284
|
+
self.buffer.max_y)
|
|
285
|
+
self.message = "Redo."
|
|
286
|
+
else:
|
|
287
|
+
self.message = "Nothing to redo."
|
|
288
|
+
|
|
289
|
+
def _exit_prompt_mode(self):
|
|
290
|
+
self.prompt.deactivate()
|
|
291
|
+
self.mode = Mode.NORMAL
|
|
292
|
+
self.pending_exit = False
|
|
293
|
+
|
|
294
|
+
# ── Main loop ─────────────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
def main_loop(self, stdscr):
|
|
297
|
+
_ignore_suspend()
|
|
298
|
+
self.renderer = Renderer(stdscr, self.config)
|
|
299
|
+
|
|
300
|
+
while self.running:
|
|
301
|
+
screen_rows, screen_cols = self.renderer.get_dimensions()
|
|
302
|
+
|
|
303
|
+
vx, vy = get_visual_position(
|
|
304
|
+
self.cursor.x, self.cursor.y, self.buffer.lines,
|
|
305
|
+
screen_cols, self.config.soft_wrap)
|
|
306
|
+
self.cursor.update_scroll(
|
|
307
|
+
vy, vx, screen_rows, screen_cols,
|
|
308
|
+
self.config.smooth_scroll_margin, self.config.soft_wrap)
|
|
309
|
+
|
|
310
|
+
self.renderer.render(
|
|
311
|
+
self.buffer, self.cursor, message=self.message,
|
|
312
|
+
prompt=self.prompt, mode=self.mode,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
try:
|
|
316
|
+
key = stdscr.getch()
|
|
317
|
+
except KeyboardInterrupt:
|
|
318
|
+
self.running = False
|
|
319
|
+
break
|
|
320
|
+
|
|
321
|
+
if key == Key.RESIZE:
|
|
322
|
+
continue
|
|
323
|
+
|
|
324
|
+
self.handle_input(key, screen_rows, screen_cols)
|
|
325
|
+
|
|
326
|
+
def run(self):
|
|
327
|
+
try:
|
|
328
|
+
curses.wrapper(self.main_loop)
|
|
329
|
+
except Exception as e:
|
|
330
|
+
print(f"Femto crashed: {e}")
|