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.
@@ -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,7 @@
1
+ """
2
+ Femto - A tiny nano-style terminal text editor.
3
+ """
4
+
5
+ __version__ = "0.0.1"
6
+ __author__ = "Femto Contributors"
7
+ __app_name__ = "Femto"
@@ -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}")