tlex 1.0.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.
tlex-1.0.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Viki | Vignesh
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.
tlex-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,137 @@
1
+ Metadata-Version: 2.4
2
+ Name: tlex
3
+ Version: 1.0.0
4
+ Summary: A safe line editor and viewer for the terminal.
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Dynamic: license-file
10
+
11
+ # tlex
12
+
13
+ **A safe line editor and viewer for the terminal.**
14
+
15
+ tlex is built around one idea: you should never change a file by accident.
16
+ Opening a file is read-only viewing. A change only happens when you deliberately
17
+ open **one line** in a small popup, edit it, and confirm with Enter.
18
+ Everything is undoable, and nothing is written until you say so.
19
+
20
+ It looks like old vim: plain text, no colour clutter, a bar on the current line,
21
+ and `:wq` to save and quit. Pure Python, standard library only.
22
+
23
+ ```
24
+ alpha
25
+ beta <- current line (bar)
26
+ gamma
27
+ ~
28
+
29
+ ┌─────────────────────────────────────────────────────────────┬──────┬───────────┐
30
+ │ notes.txt [+] │ VIEW │ Line: 2/3 │
31
+ ├──────────────┬──────────────┬──────────────┬────────────────┴──────┴───────────┤
32
+ │ edit [e] │ insert [i] │ delete [d] │ copy [y] ... │
33
+ └──────────────┴──────────────┴──────────────┴───────────────────────────────────┘
34
+ ```
35
+
36
+ ## Features
37
+
38
+ - **Safe by design:** view mode is read-only; unknown keys do nothing.
39
+ - **One line at a time:** edit happens in a popup placed on the line itself.
40
+ - **Insert between lines:** `i` adds a new line below the current one and opens it for editing. Esc removes it again.
41
+ - **Undo** for every change; **delete and paste ask first** (`[y]` = yes, any other key = no).
42
+ - **Copy and paste:** copy a line to the system clipboard, put it back with `p`, or paste from the terminal (multi-line paste is detected as one block and asks before inserting).
43
+ - **Atomic saves:** written to a temp file and renamed, permissions kept. CRLF files and a missing final newline are preserved.
44
+ - **`:q` protects your work:** it refuses to quit with unsaved changes; `:q!` discards on purpose.
45
+ - **Configurable:** keys (several per operation), hint table, padding and popup colour are all set in one block at the top of the file.
46
+ - **Optional line numbers** (off by default), read-only mode, UTF-8 and wide characters.
47
+
48
+ ## Install
49
+
50
+ ### With pipx
51
+
52
+ ```bash
53
+ pipx install git+https://github.com/datatalisman/tlex
54
+ ```
55
+
56
+ ### From a clone of the repo
57
+
58
+ ```bash
59
+ git clone https://github.com/datatalisman/tlex
60
+ cd tlex
61
+ ./install.sh
62
+ ```
63
+
64
+ ### Uninstall
65
+
66
+ ```bash
67
+ ./uninstall.sh # from the repo directory
68
+ # or, if you used pipx directly:
69
+ pipx uninstall tlex
70
+ ```
71
+
72
+ **Requirements:** Python 3 and a terminal on Linux or macOS (it uses `curses`; Windows is not supported).
73
+ For copying to the system clipboard it uses `wl-copy`, `xclip` or `xsel` if available, and falls back to the terminal's OSC 52 clipboard.
74
+
75
+ ## Usage
76
+
77
+ ```bash
78
+ tlex notes.txt # edit (a missing file starts as a new file)
79
+ tlex -R notes.txt # view only, no editing possible
80
+ ```
81
+
82
+ ### View mode keys
83
+
84
+ | Key | Action |
85
+ |---|---|
86
+ | `Up` `Down` `PageUp` `PageDown` | Move |
87
+ | `Home` / `g`, `End` / `G` | Top / bottom |
88
+ | `e`, `Enter` | Edit the current line (popup, cursor at the start) |
89
+ | `i` | Insert a new line below, then edit it |
90
+ | `d` | Delete the current line (asks first) |
91
+ | `y`, `c` | Copy the current line |
92
+ | `p` | Put the copied line below the current one |
93
+ | `u` | Undo |
94
+ | `n` | Toggle line numbers |
95
+ | `:` | Command line |
96
+
97
+ ### Edit / insert popup
98
+
99
+ | Key | Action |
100
+ |---|---|
101
+ | `Enter` | Save the line |
102
+ | `Esc` | Discard (for `i`, the new line disappears) |
103
+ | `Left` `Right` `Home` `End` | Move inside the line |
104
+ | `Backspace` `Delete` | Erase |
105
+ | terminal paste | Insert at the cursor (multi-line text is joined with spaces) |
106
+
107
+ Up and Down are ignored in the popup on purpose, so you cannot leave the line by accident.
108
+
109
+ ### Commands (press `:`)
110
+
111
+ | Command | Action |
112
+ |---|---|
113
+ | `:w` | Write |
114
+ | `:wq` | Write and quit |
115
+ | `:q` | Quit (refuses if there are unsaved changes) |
116
+ | `:q!` | Quit and discard changes |
117
+ | `:N` | Go to line N |
118
+
119
+ `Esc` followed by `:` works as in vim.
120
+
121
+ ## Configuration
122
+
123
+ Everything is in the **CONFIG** block at the top of `tlex.py`:
124
+
125
+ - **`KEYS`:** operation → list of keys. Add as many keys as you like per operation, for example `"copy": ["y", "c"]`. Keys are characters (`"e"`, `":"`) or names (`Enter`, `Esc`, `Tab`, `Space`, `Backspace`, `Delete`, `Up`, `Down`, `Left`, `Right`, `PageUp`, `PageDown`, `Home`, `End`, `Ctrl-S`).
126
+ - **`HINTS`:** the cells of the hint table at the bottom. They are generated from `KEYS`, so they update when you change a key. `HINT_SHOW_ALL_KEYS = True` shows every key of an operation (`[y/c]`) instead of the first one.
127
+ - **`info_box_padding_top`, `_bottom`, `_left`, `_right`:** blank space around the info box.
128
+ - **`popup_theme`:** colour of the popup, chosen from `POPUP_THEMES` (traditional Japanese colours): `ruri`, `kikyo`, `suo`, `rokusho`, `shinbashi`, `botan`, `fujimurasaki`, `yamabuki`. Add your own as `name: (background hex, text hex, description)`.
129
+
130
+ After editing the config, reinstall (run `./install.sh` again) so the installed copy picks up the change.
131
+
132
+ ## Safety notes
133
+
134
+ - tlex never modifies the file until you run `:w` or `:wq`.
135
+ - Writes go to a temporary file in the same directory and are renamed over the original, so a crash cannot leave a half-written file.
136
+ - The undo history lives only while the editor is open.
137
+ - Files with bytes that are not valid UTF-8 are kept byte for byte; those bytes are shown as `·`.
tlex-1.0.0/README.md ADDED
@@ -0,0 +1,127 @@
1
+ # tlex
2
+
3
+ **A safe line editor and viewer for the terminal.**
4
+
5
+ tlex is built around one idea: you should never change a file by accident.
6
+ Opening a file is read-only viewing. A change only happens when you deliberately
7
+ open **one line** in a small popup, edit it, and confirm with Enter.
8
+ Everything is undoable, and nothing is written until you say so.
9
+
10
+ It looks like old vim: plain text, no colour clutter, a bar on the current line,
11
+ and `:wq` to save and quit. Pure Python, standard library only.
12
+
13
+ ```
14
+ alpha
15
+ beta <- current line (bar)
16
+ gamma
17
+ ~
18
+
19
+ ┌─────────────────────────────────────────────────────────────┬──────┬───────────┐
20
+ │ notes.txt [+] │ VIEW │ Line: 2/3 │
21
+ ├──────────────┬──────────────┬──────────────┬────────────────┴──────┴───────────┤
22
+ │ edit [e] │ insert [i] │ delete [d] │ copy [y] ... │
23
+ └──────────────┴──────────────┴──────────────┴───────────────────────────────────┘
24
+ ```
25
+
26
+ ## Features
27
+
28
+ - **Safe by design:** view mode is read-only; unknown keys do nothing.
29
+ - **One line at a time:** edit happens in a popup placed on the line itself.
30
+ - **Insert between lines:** `i` adds a new line below the current one and opens it for editing. Esc removes it again.
31
+ - **Undo** for every change; **delete and paste ask first** (`[y]` = yes, any other key = no).
32
+ - **Copy and paste:** copy a line to the system clipboard, put it back with `p`, or paste from the terminal (multi-line paste is detected as one block and asks before inserting).
33
+ - **Atomic saves:** written to a temp file and renamed, permissions kept. CRLF files and a missing final newline are preserved.
34
+ - **`:q` protects your work:** it refuses to quit with unsaved changes; `:q!` discards on purpose.
35
+ - **Configurable:** keys (several per operation), hint table, padding and popup colour are all set in one block at the top of the file.
36
+ - **Optional line numbers** (off by default), read-only mode, UTF-8 and wide characters.
37
+
38
+ ## Install
39
+
40
+ ### With pipx
41
+
42
+ ```bash
43
+ pipx install git+https://github.com/datatalisman/tlex
44
+ ```
45
+
46
+ ### From a clone of the repo
47
+
48
+ ```bash
49
+ git clone https://github.com/datatalisman/tlex
50
+ cd tlex
51
+ ./install.sh
52
+ ```
53
+
54
+ ### Uninstall
55
+
56
+ ```bash
57
+ ./uninstall.sh # from the repo directory
58
+ # or, if you used pipx directly:
59
+ pipx uninstall tlex
60
+ ```
61
+
62
+ **Requirements:** Python 3 and a terminal on Linux or macOS (it uses `curses`; Windows is not supported).
63
+ For copying to the system clipboard it uses `wl-copy`, `xclip` or `xsel` if available, and falls back to the terminal's OSC 52 clipboard.
64
+
65
+ ## Usage
66
+
67
+ ```bash
68
+ tlex notes.txt # edit (a missing file starts as a new file)
69
+ tlex -R notes.txt # view only, no editing possible
70
+ ```
71
+
72
+ ### View mode keys
73
+
74
+ | Key | Action |
75
+ |---|---|
76
+ | `Up` `Down` `PageUp` `PageDown` | Move |
77
+ | `Home` / `g`, `End` / `G` | Top / bottom |
78
+ | `e`, `Enter` | Edit the current line (popup, cursor at the start) |
79
+ | `i` | Insert a new line below, then edit it |
80
+ | `d` | Delete the current line (asks first) |
81
+ | `y`, `c` | Copy the current line |
82
+ | `p` | Put the copied line below the current one |
83
+ | `u` | Undo |
84
+ | `n` | Toggle line numbers |
85
+ | `:` | Command line |
86
+
87
+ ### Edit / insert popup
88
+
89
+ | Key | Action |
90
+ |---|---|
91
+ | `Enter` | Save the line |
92
+ | `Esc` | Discard (for `i`, the new line disappears) |
93
+ | `Left` `Right` `Home` `End` | Move inside the line |
94
+ | `Backspace` `Delete` | Erase |
95
+ | terminal paste | Insert at the cursor (multi-line text is joined with spaces) |
96
+
97
+ Up and Down are ignored in the popup on purpose, so you cannot leave the line by accident.
98
+
99
+ ### Commands (press `:`)
100
+
101
+ | Command | Action |
102
+ |---|---|
103
+ | `:w` | Write |
104
+ | `:wq` | Write and quit |
105
+ | `:q` | Quit (refuses if there are unsaved changes) |
106
+ | `:q!` | Quit and discard changes |
107
+ | `:N` | Go to line N |
108
+
109
+ `Esc` followed by `:` works as in vim.
110
+
111
+ ## Configuration
112
+
113
+ Everything is in the **CONFIG** block at the top of `tlex.py`:
114
+
115
+ - **`KEYS`:** operation → list of keys. Add as many keys as you like per operation, for example `"copy": ["y", "c"]`. Keys are characters (`"e"`, `":"`) or names (`Enter`, `Esc`, `Tab`, `Space`, `Backspace`, `Delete`, `Up`, `Down`, `Left`, `Right`, `PageUp`, `PageDown`, `Home`, `End`, `Ctrl-S`).
116
+ - **`HINTS`:** the cells of the hint table at the bottom. They are generated from `KEYS`, so they update when you change a key. `HINT_SHOW_ALL_KEYS = True` shows every key of an operation (`[y/c]`) instead of the first one.
117
+ - **`info_box_padding_top`, `_bottom`, `_left`, `_right`:** blank space around the info box.
118
+ - **`popup_theme`:** colour of the popup, chosen from `POPUP_THEMES` (traditional Japanese colours): `ruri`, `kikyo`, `suo`, `rokusho`, `shinbashi`, `botan`, `fujimurasaki`, `yamabuki`. Add your own as `name: (background hex, text hex, description)`.
119
+
120
+ After editing the config, reinstall (run `./install.sh` again) so the installed copy picks up the change.
121
+
122
+ ## Safety notes
123
+
124
+ - tlex never modifies the file until you run `:w` or `:wq`.
125
+ - Writes go to a temporary file in the same directory and are renamed over the original, so a crash cannot leave a half-written file.
126
+ - The undo history lives only while the editor is open.
127
+ - Files with bytes that are not valid UTF-8 are kept byte for byte; those bytes are shown as `·`.
@@ -0,0 +1,17 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "tlex"
7
+ version = "1.0.0"
8
+ description = "A safe line editor and viewer for the terminal."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+
13
+ [project.scripts]
14
+ tlex = "app.main:main"
15
+
16
+ [tool.setuptools.packages.find]
17
+ where = ["src"]
tlex-1.0.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0"
@@ -0,0 +1,838 @@
1
+ #!/usr/bin/env python3
2
+ """tlex - a safe line editor / viewer.
3
+
4
+ Nothing changes unless you explicitly ask for it:
5
+ * VIEW mode is read-only; unknown keys do nothing.
6
+ * Only ONE line can be edited at a time, in a popup on that line.
7
+ * Every change is undoable; deletes and pastes ask first.
8
+ * Writes are atomic (temp file + rename); :q refuses to drop unsaved work.
9
+
10
+ Usage: tlex.py [-R] FILE (-R = read-only viewer)
11
+
12
+ All keys, the hint cells, the info-box padding and the popup colour are
13
+ configured in the CONFIG section right below. Commands (type ':'):
14
+ :w write :wq write + quit :q quit :q! quit, discard :N go to line N
15
+ Terminal paste (Ctrl+Shift+V) in view mode inserts the pasted lines below.
16
+ """
17
+ import argparse
18
+ import base64
19
+ import curses
20
+ import locale
21
+ import os
22
+ import shutil
23
+ import subprocess
24
+ import sys
25
+ import tempfile
26
+ import unicodedata
27
+
28
+ # =====================================================================
29
+ # CONFIG - change anything in this block
30
+ # =====================================================================
31
+
32
+ # ---- Key mapping: operation -> list of keys (as many as you like) ----
33
+ # A key is a single character ("e", "G", ":") or a name:
34
+ # Enter Esc Tab Space Backspace Delete Up Down Left Right PageUp PageDown Home End
35
+ # or Ctrl-<letter>, e.g. "Ctrl-S".
36
+ KEYS = {
37
+ # view mode
38
+ "edit": ["e", "enter"],
39
+ "insert": ["i"], # new line below the current one, then edit it
40
+ "delete": ["d"],
41
+ "copy": ["y", "c"], # example of two keys for one operation
42
+ "paste": ["p"],
43
+ "undo": ["u"],
44
+ "numbers": ["n"], # toggle line numbers
45
+ "command": [":"], # :w :wq :q :q! :N
46
+ "up": ["Up"],
47
+ "down": ["Down"],
48
+ "page_up": ["PageUp"],
49
+ "page_down": ["PageDown"],
50
+ "top": ["Home"],
51
+ "bottom": ["End"],
52
+ # answers to "are you sure?" questions (any other key = no)
53
+ "yes": ["y"],
54
+ # edit / insert popup
55
+ "save": ["Enter"],
56
+ "discard": ["Esc"],
57
+ }
58
+
59
+ # ---- Hint cells (bottom of the screen), generated from KEYS above ----
60
+ # (label, operation-name-from-KEYS) or (label, ["literal", "keys"])
61
+ HINTS = [
62
+ ("edit", "edit"),
63
+ ("insert", "insert"),
64
+ ("delete", "delete"),
65
+ ("copy", "copy"),
66
+ ("paste", "paste"),
67
+ ("undo", "undo"),
68
+ ("numbers", "numbers"),
69
+ ("command", "command"),
70
+ ("write", [":w"]),
71
+ ("quit", [":q"]),
72
+ ]
73
+ HINT_SHOW_ALL_KEYS = False # False: one key per cell (the first one); True: [y/c]
74
+ HINT_KEY_SEPARATOR = "/" # between keys when HINT_SHOW_ALL_KEYS is True
75
+
76
+ popup_margin = 1 # columns left and right of the popup (about 0.5 cm)
77
+
78
+ # ---- Info box (status + hint table at the bottom) ----
79
+ info_box_padding_top = 0 # blank rows between file content and the box
80
+ info_box_padding_bottom = 0 # blank rows below the box
81
+ info_box_padding_left = 0 # blank columns left of the box
82
+ info_box_padding_right = 0 # blank columns right of the box
83
+
84
+ # ---- Popup colour (edit / insert box) ----
85
+ # Pick one name from POPUP_THEMES. Colours are from the traditional
86
+ # Japanese colour list (Wikipedia: "Traditional colors of Japan").
87
+ popup_theme = "fujimurasaki"
88
+ POPUP_THEMES = {
89
+ # name: (background, text, colour name)
90
+ "ruri": ("#1F4788", "#EBF6F7", "瑠璃色 Ruri-iro, lapis lazuli"),
91
+ "kikyo": ("#5D3F6A", "#EBF6F7", "桔梗色 Kikyō-iro, bellflower"),
92
+ "suo": ("#7E2639", "#EBF6F7", "蘇芳 Su'ō, sappanwood"),
93
+ "rokusho": ("#407A52", "#EBF6F7", "緑青 Rokushō, verdigris"),
94
+ "shinbashi": ("#006C7F", "#EBF6F7", "新橋色 Shinbashi-iro, teal"),
95
+ "botan": ("#A4345D", "#EBF6F7", "牡丹 Bōtan, tree peony"),
96
+ "fujimurasaki": ("#875F9A", "#EBF6F7", "藤紫 Fujimurasaki, wisteria purple"),
97
+ "yamabuki": ("#FFA400", "#192236", "山吹色 Yamabuki-iro, golden yellow"),
98
+ }
99
+
100
+ # =====================================================================
101
+ # end of CONFIG
102
+ # =====================================================================
103
+
104
+ ESC = object() # a lone Escape key press
105
+ INT = object() # Ctrl-C
106
+ POPUP_ATTR = curses.A_REVERSE # fallback when there is no colour; see init_colors()
107
+
108
+ _NAMED = {
109
+ "enter": ["\n", "\r", curses.KEY_ENTER], "esc": [ESC], "tab": ["\t"], "space": [" "],
110
+ "backspace": ["\x7f", "\b", curses.KEY_BACKSPACE], "delete": [curses.KEY_DC],
111
+ "up": [curses.KEY_UP], "down": [curses.KEY_DOWN],
112
+ "left": [curses.KEY_LEFT], "right": [curses.KEY_RIGHT],
113
+ "pageup": [curses.KEY_PPAGE], "pagedown": [curses.KEY_NPAGE],
114
+ "home": [curses.KEY_HOME], "end": [curses.KEY_END],
115
+ }
116
+
117
+
118
+ def _expand(name):
119
+ n = name.strip()
120
+ low = n.lower()
121
+ if low in _NAMED:
122
+ return list(_NAMED[low])
123
+ if low.startswith("ctrl-") and len(n) == 6 and n[5].isalpha():
124
+ return [chr(ord(n[5].upper()) - 64)]
125
+ if len(n) == 1:
126
+ return [n]
127
+ raise ValueError("unknown key name %r in KEYS" % name)
128
+
129
+
130
+ def _bind():
131
+ explicit = {nm for names in KEYS.values()
132
+ for nm in ([names] if isinstance(names, str) else names)}
133
+ bound = {}
134
+ for op, names in KEYS.items():
135
+ if isinstance(names, str):
136
+ names = [names]
137
+ vals = [v for nm in names for v in _expand(nm)]
138
+ for nm in names: # e -> also E, I -> also i ...
139
+ if len(nm) == 1 and nm.isalpha() and nm.swapcase() not in explicit:
140
+ vals.append(nm.swapcase())
141
+ bound[op] = vals
142
+ return bound
143
+
144
+ _BOUND = _bind()
145
+
146
+
147
+ def is_key(op, k):
148
+ """True if the pressed key k is bound to operation op."""
149
+ return k in _BOUND[op]
150
+
151
+
152
+ def keys_text(op_or_keys):
153
+ keys = KEYS[op_or_keys] if isinstance(op_or_keys, str) else op_or_keys
154
+ if isinstance(keys, str):
155
+ keys = [keys]
156
+ return HINT_KEY_SEPARATOR.join(keys)
157
+
158
+
159
+ def hint_cells():
160
+ cells = []
161
+ for label, ok in HINTS:
162
+ keys = KEYS[ok] if isinstance(ok, str) else ok
163
+ if isinstance(keys, str):
164
+ keys = [keys]
165
+ cells.append((label, HINT_KEY_SEPARATOR.join(keys) if HINT_SHOW_ALL_KEYS else keys[0]))
166
+ return cells
167
+
168
+
169
+ # ---------------------------------------------------------------- text helpers
170
+ def cw(ch):
171
+ """Display width of one character."""
172
+ if unicodedata.combining(ch) or unicodedata.category(ch) in ("Mn", "Me", "Cf"):
173
+ return 0
174
+ return 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1
175
+
176
+
177
+ def width(s):
178
+ return sum(cw(c) for c in s)
179
+
180
+
181
+ def clip(s, n):
182
+ """Longest prefix of s that fits in n terminal cells."""
183
+ out, used = [], 0
184
+ for c in s:
185
+ w = cw(c)
186
+ if used + w > n:
187
+ break
188
+ out.append(c)
189
+ used += w
190
+ return "".join(out)
191
+
192
+
193
+ def show(s):
194
+ """Make text safe to draw: tabs -> », control chars -> · (1 cell each)."""
195
+ out = []
196
+ for c in s:
197
+ if c == "\t":
198
+ out.append("»")
199
+ elif unicodedata.category(c) in ("Cc", "Cs"):
200
+ out.append("·")
201
+ else:
202
+ out.append(c)
203
+ return "".join(out)
204
+
205
+
206
+ def strip_controls(s):
207
+ return "".join(c for c in s if c == "\t" or unicodedata.category(c) not in ("Cc", "Cs"))
208
+
209
+
210
+ def safe_add(win, y, x, s, attr=0):
211
+ try:
212
+ win.addstr(y, x, s, attr)
213
+ except curses.error:
214
+ pass
215
+
216
+
217
+ def safe_move(win, y, x):
218
+ try:
219
+ win.move(y, x)
220
+ except curses.error:
221
+ pass
222
+
223
+
224
+ def cursor(n):
225
+ try:
226
+ curses.curs_set(n)
227
+ except curses.error:
228
+ pass
229
+
230
+
231
+ # ---------------------------------------------------------------- colours
232
+ def hex_to_256(h):
233
+ """Nearest xterm-256 colour index for '#RRGGBB'."""
234
+ r, g, b = (int(h[i:i + 2], 16) for i in (1, 3, 5))
235
+ lv = [0, 95, 135, 175, 215, 255]
236
+ near = lambda v: min(range(6), key=lambda i: abs(lv[i] - v))
237
+ ri, gi, bi = near(r), near(g), near(b)
238
+ cube = 16 + 36 * ri + 6 * gi + bi
239
+ dc = (r - lv[ri]) ** 2 + (g - lv[gi]) ** 2 + (b - lv[bi]) ** 2
240
+ gx = max(0, min(23, round(((r + g + b) / 3 - 8) / 10)))
241
+ gv = 8 + 10 * gx
242
+ dg = (r - gv) ** 2 + (g - gv) ** 2 + (b - gv) ** 2
243
+ return 232 + gx if dg < dc else cube
244
+
245
+
246
+ def init_colors():
247
+ global POPUP_ATTR
248
+ if not curses.has_colors():
249
+ return
250
+ try:
251
+ curses.start_color()
252
+ bg, fg, _ = POPUP_THEMES[popup_theme]
253
+ if curses.COLORS >= 256:
254
+ curses.init_pair(1, hex_to_256(fg), hex_to_256(bg))
255
+ else:
256
+ curses.init_pair(1, curses.COLOR_WHITE, curses.COLOR_BLUE)
257
+ POPUP_ATTR = curses.color_pair(1)
258
+ except (curses.error, KeyError):
259
+ pass
260
+
261
+
262
+ # ---------------------------------------------------------------- input
263
+ def read_key(win):
264
+ """Return a char, a curses KEY_* int, ESC, INT, or ('paste', text)."""
265
+ win.timeout(-1)
266
+ try:
267
+ k = win.get_wch()
268
+ except KeyboardInterrupt:
269
+ return INT
270
+ except curses.error:
271
+ return None
272
+ if k != "\x1b":
273
+ return k
274
+
275
+ # ESC: either a lone Esc press or the start of a bracketed paste
276
+ win.timeout(30)
277
+ seq = ""
278
+ try:
279
+ while len(seq) < 5:
280
+ c = win.get_wch()
281
+ if not isinstance(c, str):
282
+ break
283
+ seq += c
284
+ if not "[200~".startswith(seq):
285
+ break
286
+ except curses.error:
287
+ pass
288
+ finally:
289
+ win.timeout(-1)
290
+ if seq != "[200~":
291
+ return ESC
292
+
293
+ end = "\x1b[201~"
294
+ data = ""
295
+ win.timeout(1000)
296
+ try:
297
+ while not data.endswith(end):
298
+ c = win.get_wch()
299
+ if isinstance(c, str):
300
+ data += c
301
+ except curses.error:
302
+ pass
303
+ finally:
304
+ win.timeout(-1)
305
+ if data.endswith(end):
306
+ data = data[: -len(end)]
307
+ return ("paste", data)
308
+
309
+
310
+ def is_enter(k):
311
+ return k in ("\n", "\r", curses.KEY_ENTER)
312
+
313
+
314
+ def is_backspace(k):
315
+ return k in ("\x7f", "\b", curses.KEY_BACKSPACE)
316
+
317
+
318
+ # ---------------------------------------------------------------- clipboard
319
+ def to_clipboard(text):
320
+ """Copy to the system clipboard. Returns a short description or None."""
321
+ data = text.encode("utf-8", "surrogateescape")
322
+ for cmd in (["wl-copy"], ["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"]):
323
+ if shutil.which(cmd[0]):
324
+ try:
325
+ subprocess.run(cmd, input=data, timeout=2, check=True,
326
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
327
+ return "clipboard"
328
+ except (OSError, subprocess.SubprocessError):
329
+ pass
330
+ try: # OSC 52: ask the terminal to set the clipboard
331
+ sys.stdout.write("\x1b]52;c;%s\x07" % base64.b64encode(data).decode())
332
+ sys.stdout.flush()
333
+ return "terminal clipboard"
334
+ except (OSError, ValueError):
335
+ return None
336
+
337
+
338
+ # ---------------------------------------------------------------- popup editor
339
+ def popup_edit(scr, title, text, row):
340
+ """Edit one line in a coloured box drawn ON screen row `row` (the line itself).
341
+ Returns the new text, or None if discarded."""
342
+ rows, cols = scr.getmaxyx()
343
+ w = min(cols, max(24, cols - 2 * popup_margin))
344
+ win = curses.newwin(3, w, max(0, min(row - 1, rows - 3)), max(0, (cols - w) // 2))
345
+ win.keypad(True)
346
+ win.bkgd(" ", POPUP_ATTR)
347
+ hint = " [%s] save [%s] discard " % (keys_text("save"), keys_text("discard"))
348
+ inner = max(1, w - 4)
349
+ buf = list(text)
350
+ cur = off = 0
351
+ cursor(1)
352
+ try:
353
+ while True:
354
+ if cur < off:
355
+ off = cur
356
+ while width(buf[off:cur]) >= inner:
357
+ off += 1
358
+ win.erase()
359
+ win.box()
360
+ safe_add(win, 0, 2, clip(title, w - 4), curses.A_BOLD)
361
+ safe_add(win, 1, 2, clip(show(buf[off:]), inner))
362
+ safe_add(win, 2, 2, clip(hint, w - 4))
363
+ safe_move(win, 1, 2 + width(buf[off:cur]))
364
+ win.refresh()
365
+
366
+ k = read_key(win)
367
+ if is_key("discard", k):
368
+ return None
369
+ elif is_key("save", k):
370
+ return "".join(buf)
371
+ elif k == curses.KEY_LEFT:
372
+ cur = max(0, cur - 1)
373
+ elif k == curses.KEY_RIGHT:
374
+ cur = min(len(buf), cur + 1)
375
+ elif k in (curses.KEY_HOME, "\x01"):
376
+ cur = 0
377
+ elif k in (curses.KEY_END, "\x05"):
378
+ cur = len(buf)
379
+ elif is_backspace(k):
380
+ if cur > 0:
381
+ del buf[cur - 1]
382
+ cur -= 1
383
+ elif k == curses.KEY_DC:
384
+ if cur < len(buf):
385
+ del buf[cur]
386
+ elif isinstance(k, tuple): # paste: multi-line text is joined with spaces
387
+ p = strip_controls(k[1].replace("\r\n", "\n").replace("\r", "\n").replace("\n", " "))
388
+ buf[cur:cur] = list(p)
389
+ cur += len(p)
390
+ elif isinstance(k, str) and len(k) == 1 and (k.isprintable() or k == "\t"):
391
+ buf.insert(cur, k)
392
+ cur += 1
393
+ # everything else (Up/Down, F-keys, Ctrl-C ...) is ignored on purpose
394
+ finally:
395
+ cursor(0)
396
+
397
+
398
+ # ---------------------------------------------------------------- editor
399
+ class Editor:
400
+ def __init__(self, path, readonly=False):
401
+ self.path = path
402
+ self.readonly = readonly
403
+ self.msg = ""
404
+ self.cur = 0
405
+ self.top = 0
406
+ self.undo = []
407
+ self.register = []
408
+ self.numbers = False # line numbers: off by default
409
+ self.body_h = 1
410
+ self.geo = {"box": False, "body_h": 1, "status": (0, 0, 1)}
411
+
412
+ try:
413
+ with open(path, "rb") as f:
414
+ raw = f.read()
415
+ except FileNotFoundError:
416
+ raw = None
417
+
418
+ if raw is None:
419
+ self.lines, self.eol, self.final_nl, self.orig_empty = [""], "\n", True, True
420
+ self.msg = "New file"
421
+ else:
422
+ text = raw.decode("utf-8", "surrogateescape")
423
+ crlf = text.count("\r\n")
424
+ self.eol = "\r\n" if crlf > text.count("\n") - crlf else "\n"
425
+ self.final_nl = text.endswith("\n") or text == ""
426
+ self.orig_empty = text == ""
427
+ body = text[:-1] if text.endswith("\n") else text
428
+ lines = body.split("\n")
429
+ if self.eol == "\r\n":
430
+ lines = [l[:-1] if l.endswith("\r") else l for l in lines]
431
+ self.lines = lines
432
+ self.saved = list(self.lines)
433
+
434
+ # -- state
435
+ def modified(self):
436
+ return self.lines != self.saved
437
+
438
+ def push_undo(self):
439
+ self.undo.append((list(self.lines), self.cur))
440
+ del self.undo[:-200]
441
+
442
+ def deny_if_readonly(self):
443
+ if self.readonly:
444
+ self.msg = "Read-only (started with -R)"
445
+ return self.readonly
446
+
447
+ # -- layout
448
+ def compute_layout(self, rows, cols):
449
+ pt, pb = max(0, info_box_padding_top), max(0, info_box_padding_bottom)
450
+ pl, pr = max(0, info_box_padding_left), max(0, info_box_padding_right)
451
+ cells = hint_cells()
452
+ iw = cols - pl - pr - 2
453
+ if iw >= 30 and cells:
454
+ need = max(len(l) + len(k) + 6 for l, k in cells) # " label [key] "
455
+ n = len(cells)
456
+ fit = max(1, min(n, (iw + 1) // (need + 1)))
457
+ nrows = -(-n // fit)
458
+ ncols = -(-n // nrows)
459
+ for r in (nrows, nrows + 1): # prefer a grid with no empty cells
460
+ if r <= n and n % r == 0 and n // r <= fit:
461
+ nrows, ncols = r, n // r
462
+ break
463
+ box_h = 2 * nrows + 3 # top rule, status, rule, (row + rule) per hint row, bottom
464
+ body_h = rows - (pt + box_h + pb)
465
+ if body_h >= 3:
466
+ return {"box": True, "body_h": body_h, "x0": pl, "y0": body_h + pt,
467
+ "iw": iw, "ncols": ncols, "nrows": nrows, "status": (0, 0, 1)}
468
+ return {"box": False, "body_h": max(1, rows - 2), "status": (0, 0, 1)}
469
+
470
+ # -- drawing
471
+ def draw(self, scr):
472
+ scr.erase()
473
+ rows, cols = scr.getmaxyx()
474
+ self.geo = self.compute_layout(rows, cols)
475
+ h = self.body_h = self.geo["body_h"]
476
+ if self.cur < self.top:
477
+ self.top = self.cur
478
+ if self.cur >= self.top + h:
479
+ self.top = self.cur - h + 1
480
+ gw = len(str(len(self.lines)))
481
+ for i in range(h):
482
+ n = self.top + i
483
+ if n >= len(self.lines):
484
+ safe_add(scr, i, 1, "~")
485
+ continue
486
+ gutter = " %*d " % (gw, n + 1) if self.numbers else " "
487
+ avail = max(1, cols - len(gutter))
488
+ text = show(self.lines[n].expandtabs(4))
489
+ if width(text) > avail:
490
+ text = clip(text, avail - 1) + ">"
491
+ pad = " " * max(0, avail - width(text))
492
+ attr = curses.A_REVERSE if n == self.cur else curses.A_NORMAL
493
+ safe_add(scr, i, 0, gutter + text + pad, attr)
494
+ self.draw_footer(scr, rows, cols)
495
+ scr.refresh()
496
+
497
+ def draw_footer(self, scr, rows, cols):
498
+ """Info box: status row (file | VIEW | Line) on top, hint cells at the bottom."""
499
+ g = self.geo
500
+ bold = curses.A_BOLD
501
+ mid = " VIEW "
502
+ right = " Line: %d/%d " % (self.cur + 1, len(self.lines))
503
+ cells = hint_cells()
504
+
505
+ if not g["box"]: # small terminal: plain status row + one hint line
506
+ lw = max(1, cols - 1 - len(mid) - len(right) - 2)
507
+ g["status"] = (rows - 2, 0, lw)
508
+ self.draw_status(scr, self.msg or None)
509
+ safe_add(scr, rows - 2, lw, "\u2502" + mid + "\u2502" + right, bold)
510
+ x = 1
511
+ for label, keys in cells:
512
+ w = len(label) + len(keys) + 3
513
+ if x + w > cols - 1:
514
+ break
515
+ safe_add(scr, rows - 1, x, label)
516
+ safe_add(scr, rows - 1, x + len(label) + 1, "[%s]" % keys, bold)
517
+ x += w + 2
518
+ return
519
+
520
+ x0, y0, iw, ncols, nrows = g["x0"], g["y0"], g["iw"], g["ncols"], g["nrows"]
521
+ lw = iw - len(mid) - len(right) - 2
522
+ status_joints = {lw, lw + 1 + len(mid)}
523
+ base, extra = divmod(iw - (ncols - 1), ncols)
524
+ widths = [base + (1 if i < extra else 0) for i in range(ncols)]
525
+ grid_joints, pos = set(), 0
526
+ for wd in widths[:-1]:
527
+ pos += wd
528
+ grid_joints.add(pos)
529
+ pos += 1
530
+
531
+ def rule(l, r, up, down):
532
+ mid_ = "".join("\u253c" if i in up and i in down else "\u2534" if i in up
533
+ else "\u252c" if i in down else "\u2500" for i in range(iw))
534
+ return l + mid_ + r
535
+
536
+ safe_add(scr, y0, x0, rule("\u250c", "\u2510", set(), status_joints))
537
+ ys = y0 + 1
538
+ safe_add(scr, ys, x0, "\u2502")
539
+ g["status"] = (ys, x0 + 1, lw)
540
+ self.draw_status(scr, self.msg or None)
541
+ safe_add(scr, ys, x0 + 1 + lw, "\u2502" + mid + "\u2502" + right + "\u2502", bold)
542
+ safe_add(scr, y0 + 2, x0, rule("\u251c", "\u2524", status_joints, grid_joints))
543
+ for r in range(nrows):
544
+ y = y0 + 3 + 2 * r
545
+ safe_add(scr, y, x0, "\u2502")
546
+ x = x0 + 1
547
+ for c in range(ncols):
548
+ wd = widths[c]
549
+ idx = r * ncols + c
550
+ if idx < len(cells):
551
+ label, keys = cells[idx]
552
+ kt = "[%s]" % keys
553
+ safe_add(scr, y, x + 1, clip(label, max(0, wd - len(kt) - 3)))
554
+ safe_add(scr, y, x + wd - 1 - len(kt), kt, bold)
555
+ x += wd
556
+ safe_add(scr, y, x, "\u2502")
557
+ x += 1
558
+ if r < nrows - 1:
559
+ safe_add(scr, y + 1, x0, rule("\u251c", "\u2524", grid_joints, grid_joints))
560
+ safe_add(scr, y0 + 2 + 2 * nrows, x0, rule("\u2514", "\u2518", grid_joints, set()))
561
+
562
+ def draw_status(self, scr, text=None, attr=None):
563
+ """Draw the left status cell (file name, or a message / prompt). Returns text x."""
564
+ y, x, lw = self.geo["status"]
565
+ if text is None:
566
+ text = os.path.basename(self.path) + (" [+]" if self.modified() else "") + \
567
+ (" [RO]" if self.readonly else "")
568
+ attr = curses.A_BOLD if attr is None else attr
569
+ elif attr is None:
570
+ attr = curses.A_NORMAL
571
+ cell = " " + clip(text, lw - 1)
572
+ cell += " " * (lw - width(cell))
573
+ safe_add(scr, y, x, cell, attr)
574
+ return x + 1
575
+
576
+ # -- small prompts in the status cell
577
+ def confirm(self, scr, text):
578
+ """Yes only on the 'yes' key; any other key means no."""
579
+ self.draw_status(scr, "%s [%s]" % (text, keys_text("yes")), curses.A_BOLD)
580
+ scr.refresh()
581
+ return is_key("yes", read_key(scr))
582
+
583
+ def prompt(self, scr, prefix):
584
+ buf = []
585
+ cursor(1)
586
+ try:
587
+ while True:
588
+ line = prefix + "".join(buf)
589
+ cx = self.draw_status(scr, line, curses.A_BOLD)
590
+ safe_move(scr, self.geo["status"][0], cx + width(line))
591
+ scr.refresh()
592
+ k = read_key(scr)
593
+ if k is ESC:
594
+ return None
595
+ if is_enter(k):
596
+ return "".join(buf)
597
+ if is_backspace(k):
598
+ if buf:
599
+ buf.pop()
600
+ else:
601
+ return None
602
+ elif isinstance(k, str) and len(k) == 1 and k.isprintable():
603
+ buf.append(k)
604
+ finally:
605
+ cursor(0)
606
+
607
+ # -- actions
608
+ def move(self, d):
609
+ self.cur = max(0, min(len(self.lines) - 1, self.cur + d))
610
+
611
+ def edit(self, scr):
612
+ if self.deny_if_readonly():
613
+ return
614
+ old = self.lines[self.cur]
615
+ new = popup_edit(scr, " Edit line %d " % (self.cur + 1), old, self.cur - self.top)
616
+ scr.clear()
617
+ if new is None or new == old:
618
+ return
619
+ self.push_undo()
620
+ self.lines[self.cur] = new
621
+ self.msg = "Line %d changed" % (self.cur + 1)
622
+
623
+ def insert(self, scr):
624
+ """Insert an empty line below the current one and edit it in place.
625
+ 'discard' removes it again; 'save' keeps it with the typed content."""
626
+ if self.deny_if_readonly():
627
+ return
628
+ old_cur = self.cur
629
+ pos = old_cur + 1
630
+ self.push_undo()
631
+ self.lines.insert(pos, "")
632
+ self.cur = pos
633
+ self.draw(scr)
634
+ new = popup_edit(scr, " New line %d " % (pos + 1), "", self.cur - self.top)
635
+ scr.clear()
636
+ if new is None:
637
+ self.lines.pop(pos)
638
+ self.undo.pop()
639
+ self.cur = old_cur
640
+ return
641
+ self.lines[pos] = new
642
+ self.msg = "Line inserted"
643
+
644
+ def delete(self, scr):
645
+ if self.deny_if_readonly():
646
+ return
647
+ if not self.confirm(scr, "Delete line %d?" % (self.cur + 1)):
648
+ return
649
+ self.push_undo()
650
+ del self.lines[self.cur]
651
+ if not self.lines:
652
+ self.lines = [""]
653
+ self.cur = min(self.cur, len(self.lines) - 1)
654
+ self.msg = "Line deleted"
655
+
656
+ def copy(self):
657
+ self.register = [self.lines[self.cur]]
658
+ where = to_clipboard(self.lines[self.cur])
659
+ self.msg = "Copied line %d%s" % (self.cur + 1, " (to %s)" % where if where else "")
660
+
661
+ def paste(self):
662
+ if self.deny_if_readonly():
663
+ return
664
+ if not self.register:
665
+ self.msg = "Nothing copied yet (or paste from the terminal)"
666
+ return
667
+ self.insert_lines(list(self.register))
668
+
669
+ def insert_lines(self, new):
670
+ self.push_undo()
671
+ pos = self.cur + 1
672
+ self.lines[pos:pos] = new
673
+ self.cur = pos + len(new) - 1
674
+ self.msg = "%d line(s) inserted" % len(new)
675
+
676
+ def paste_text(self, scr, text):
677
+ if self.deny_if_readonly():
678
+ return
679
+ text = text.replace("\r\n", "\n").replace("\r", "\n")
680
+ new = text.split("\n")
681
+ if len(new) > 1 and new[-1] == "":
682
+ new.pop()
683
+ new = [strip_controls(l) for l in new]
684
+ if self.confirm(scr, "Paste %d line(s) below line %d?" % (len(new), self.cur + 1)):
685
+ self.insert_lines(new)
686
+
687
+ def do_undo(self):
688
+ if self.deny_if_readonly():
689
+ return
690
+ if not self.undo:
691
+ self.msg = "Nothing to undo"
692
+ return
693
+ self.lines, self.cur = self.undo.pop()
694
+ self.msg = "Undone"
695
+
696
+ def save(self):
697
+ if self.deny_if_readonly():
698
+ return False
699
+ if self.lines == [""]:
700
+ data = "" if self.orig_empty else self.eol
701
+ else:
702
+ data = self.eol.join(self.lines) + (self.eol if self.final_nl else "")
703
+ target = os.path.realpath(self.path)
704
+ try:
705
+ fd, tmp = tempfile.mkstemp(dir=os.path.dirname(target) or ".", prefix=".tlex-")
706
+ try:
707
+ with os.fdopen(fd, "wb") as f:
708
+ f.write(data.encode("utf-8", "surrogateescape"))
709
+ f.flush()
710
+ os.fsync(f.fileno())
711
+ if os.path.exists(target):
712
+ shutil.copymode(target, tmp)
713
+ else:
714
+ um = os.umask(0)
715
+ os.umask(um)
716
+ os.chmod(tmp, 0o666 & ~um)
717
+ os.replace(tmp, target)
718
+ except BaseException:
719
+ try:
720
+ os.unlink(tmp)
721
+ except OSError:
722
+ pass
723
+ raise
724
+ except OSError as e:
725
+ self.msg = "Write failed: %s" % e
726
+ return False
727
+ self.saved = list(self.lines)
728
+ self.msg = "Wrote %d lines to %s" % (len(self.lines), self.path)
729
+ return True
730
+
731
+ def command(self, scr):
732
+ """Run a ':' command. Returns True if the editor should quit."""
733
+ s = self.prompt(scr, ":")
734
+ if s is None:
735
+ return False
736
+ s = s.strip().lower()
737
+ if s == "w":
738
+ self.save()
739
+ elif s in ("wq", "x"):
740
+ return self.save()
741
+ elif s == "q":
742
+ if self.modified():
743
+ self.msg = "Unsaved changes: :wq to save, :q! to discard"
744
+ return False
745
+ return True
746
+ elif s == "q!":
747
+ return True
748
+ elif s == "$":
749
+ self.cur = len(self.lines) - 1
750
+ elif s.isdigit():
751
+ self.cur = max(0, min(len(self.lines) - 1, int(s) - 1))
752
+ elif s:
753
+ self.msg = "Not an editor command: " + s
754
+ return False
755
+
756
+ # -- main loop
757
+ def run(self, scr):
758
+ scr.keypad(True)
759
+ init_colors()
760
+ cursor(0)
761
+ sys.stdout.write("\x1b[?2004h") # bracketed paste on
762
+ sys.stdout.flush()
763
+ try:
764
+ self.loop(scr)
765
+ finally:
766
+ sys.stdout.write("\x1b[?2004l")
767
+ sys.stdout.flush()
768
+
769
+ def loop(self, scr):
770
+ while True:
771
+ self.draw(scr)
772
+ k = read_key(scr)
773
+ self.msg = ""
774
+ page = max(1, self.body_h - 1)
775
+
776
+ if k is INT:
777
+ self.msg = "Ctrl-C ignored. Use :q to quit"
778
+ elif isinstance(k, tuple):
779
+ self.paste_text(scr, k[1])
780
+ elif is_key("up", k):
781
+ self.move(-1)
782
+ elif is_key("down", k):
783
+ self.move(1)
784
+ elif is_key("page_up", k):
785
+ self.move(-page)
786
+ elif is_key("page_down", k):
787
+ self.move(page)
788
+ elif is_key("top", k):
789
+ self.cur = 0
790
+ elif is_key("bottom", k):
791
+ self.cur = len(self.lines) - 1
792
+ elif is_key("edit", k):
793
+ self.edit(scr)
794
+ elif is_key("insert", k):
795
+ self.insert(scr)
796
+ elif is_key("copy", k):
797
+ self.copy()
798
+ elif is_key("paste", k):
799
+ self.paste()
800
+ elif is_key("delete", k):
801
+ self.delete(scr)
802
+ elif is_key("undo", k):
803
+ self.do_undo()
804
+ elif is_key("numbers", k):
805
+ self.numbers = not self.numbers
806
+ self.msg = "Line numbers " + ("on" if self.numbers else "off")
807
+ elif is_key("command", k):
808
+ if self.command(scr):
809
+ return
810
+ elif k is ESC or k == curses.KEY_RESIZE or k is None:
811
+ pass
812
+ elif isinstance(k, str):
813
+ self.msg = "View mode is read-only (%s edit, %s insert, %s command)" % (
814
+ KEYS["edit"][0], KEYS["insert"][0], KEYS["command"][0])
815
+
816
+
817
+ def main():
818
+ ap = argparse.ArgumentParser(description="tlex - safe line editor / viewer")
819
+ ap.add_argument("file")
820
+ ap.add_argument("-R", "--readonly", action="store_true", help="view only, no editing")
821
+ a = ap.parse_args()
822
+ if popup_theme not in POPUP_THEMES:
823
+ sys.exit("tlex: popup_theme %r unknown; choose one of: %s"
824
+ % (popup_theme, ", ".join(POPUP_THEMES)))
825
+ for _, ok in HINTS:
826
+ if isinstance(ok, str) and ok not in KEYS:
827
+ sys.exit("tlex: HINTS refers to unknown operation %r" % ok)
828
+ try:
829
+ ed = Editor(a.file, a.readonly)
830
+ except OSError as e:
831
+ sys.exit("tlex: %s" % e)
832
+ locale.setlocale(locale.LC_ALL, "")
833
+ os.environ.setdefault("ESCDELAY", "25")
834
+ curses.wrapper(ed.run)
835
+
836
+
837
+ if __name__ == "__main__":
838
+ main()
@@ -0,0 +1,137 @@
1
+ Metadata-Version: 2.4
2
+ Name: tlex
3
+ Version: 1.0.0
4
+ Summary: A safe line editor and viewer for the terminal.
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Dynamic: license-file
10
+
11
+ # tlex
12
+
13
+ **A safe line editor and viewer for the terminal.**
14
+
15
+ tlex is built around one idea: you should never change a file by accident.
16
+ Opening a file is read-only viewing. A change only happens when you deliberately
17
+ open **one line** in a small popup, edit it, and confirm with Enter.
18
+ Everything is undoable, and nothing is written until you say so.
19
+
20
+ It looks like old vim: plain text, no colour clutter, a bar on the current line,
21
+ and `:wq` to save and quit. Pure Python, standard library only.
22
+
23
+ ```
24
+ alpha
25
+ beta <- current line (bar)
26
+ gamma
27
+ ~
28
+
29
+ ┌─────────────────────────────────────────────────────────────┬──────┬───────────┐
30
+ │ notes.txt [+] │ VIEW │ Line: 2/3 │
31
+ ├──────────────┬──────────────┬──────────────┬────────────────┴──────┴───────────┤
32
+ │ edit [e] │ insert [i] │ delete [d] │ copy [y] ... │
33
+ └──────────────┴──────────────┴──────────────┴───────────────────────────────────┘
34
+ ```
35
+
36
+ ## Features
37
+
38
+ - **Safe by design:** view mode is read-only; unknown keys do nothing.
39
+ - **One line at a time:** edit happens in a popup placed on the line itself.
40
+ - **Insert between lines:** `i` adds a new line below the current one and opens it for editing. Esc removes it again.
41
+ - **Undo** for every change; **delete and paste ask first** (`[y]` = yes, any other key = no).
42
+ - **Copy and paste:** copy a line to the system clipboard, put it back with `p`, or paste from the terminal (multi-line paste is detected as one block and asks before inserting).
43
+ - **Atomic saves:** written to a temp file and renamed, permissions kept. CRLF files and a missing final newline are preserved.
44
+ - **`:q` protects your work:** it refuses to quit with unsaved changes; `:q!` discards on purpose.
45
+ - **Configurable:** keys (several per operation), hint table, padding and popup colour are all set in one block at the top of the file.
46
+ - **Optional line numbers** (off by default), read-only mode, UTF-8 and wide characters.
47
+
48
+ ## Install
49
+
50
+ ### With pipx
51
+
52
+ ```bash
53
+ pipx install git+https://github.com/datatalisman/tlex
54
+ ```
55
+
56
+ ### From a clone of the repo
57
+
58
+ ```bash
59
+ git clone https://github.com/datatalisman/tlex
60
+ cd tlex
61
+ ./install.sh
62
+ ```
63
+
64
+ ### Uninstall
65
+
66
+ ```bash
67
+ ./uninstall.sh # from the repo directory
68
+ # or, if you used pipx directly:
69
+ pipx uninstall tlex
70
+ ```
71
+
72
+ **Requirements:** Python 3 and a terminal on Linux or macOS (it uses `curses`; Windows is not supported).
73
+ For copying to the system clipboard it uses `wl-copy`, `xclip` or `xsel` if available, and falls back to the terminal's OSC 52 clipboard.
74
+
75
+ ## Usage
76
+
77
+ ```bash
78
+ tlex notes.txt # edit (a missing file starts as a new file)
79
+ tlex -R notes.txt # view only, no editing possible
80
+ ```
81
+
82
+ ### View mode keys
83
+
84
+ | Key | Action |
85
+ |---|---|
86
+ | `Up` `Down` `PageUp` `PageDown` | Move |
87
+ | `Home` / `g`, `End` / `G` | Top / bottom |
88
+ | `e`, `Enter` | Edit the current line (popup, cursor at the start) |
89
+ | `i` | Insert a new line below, then edit it |
90
+ | `d` | Delete the current line (asks first) |
91
+ | `y`, `c` | Copy the current line |
92
+ | `p` | Put the copied line below the current one |
93
+ | `u` | Undo |
94
+ | `n` | Toggle line numbers |
95
+ | `:` | Command line |
96
+
97
+ ### Edit / insert popup
98
+
99
+ | Key | Action |
100
+ |---|---|
101
+ | `Enter` | Save the line |
102
+ | `Esc` | Discard (for `i`, the new line disappears) |
103
+ | `Left` `Right` `Home` `End` | Move inside the line |
104
+ | `Backspace` `Delete` | Erase |
105
+ | terminal paste | Insert at the cursor (multi-line text is joined with spaces) |
106
+
107
+ Up and Down are ignored in the popup on purpose, so you cannot leave the line by accident.
108
+
109
+ ### Commands (press `:`)
110
+
111
+ | Command | Action |
112
+ |---|---|
113
+ | `:w` | Write |
114
+ | `:wq` | Write and quit |
115
+ | `:q` | Quit (refuses if there are unsaved changes) |
116
+ | `:q!` | Quit and discard changes |
117
+ | `:N` | Go to line N |
118
+
119
+ `Esc` followed by `:` works as in vim.
120
+
121
+ ## Configuration
122
+
123
+ Everything is in the **CONFIG** block at the top of `tlex.py`:
124
+
125
+ - **`KEYS`:** operation → list of keys. Add as many keys as you like per operation, for example `"copy": ["y", "c"]`. Keys are characters (`"e"`, `":"`) or names (`Enter`, `Esc`, `Tab`, `Space`, `Backspace`, `Delete`, `Up`, `Down`, `Left`, `Right`, `PageUp`, `PageDown`, `Home`, `End`, `Ctrl-S`).
126
+ - **`HINTS`:** the cells of the hint table at the bottom. They are generated from `KEYS`, so they update when you change a key. `HINT_SHOW_ALL_KEYS = True` shows every key of an operation (`[y/c]`) instead of the first one.
127
+ - **`info_box_padding_top`, `_bottom`, `_left`, `_right`:** blank space around the info box.
128
+ - **`popup_theme`:** colour of the popup, chosen from `POPUP_THEMES` (traditional Japanese colours): `ruri`, `kikyo`, `suo`, `rokusho`, `shinbashi`, `botan`, `fujimurasaki`, `yamabuki`. Add your own as `name: (background hex, text hex, description)`.
129
+
130
+ After editing the config, reinstall (run `./install.sh` again) so the installed copy picks up the change.
131
+
132
+ ## Safety notes
133
+
134
+ - tlex never modifies the file until you run `:w` or `:wq`.
135
+ - Writes go to a temporary file in the same directory and are renamed over the original, so a crash cannot leave a half-written file.
136
+ - The undo history lives only while the editor is open.
137
+ - Files with bytes that are not valid UTF-8 are kept byte for byte; those bytes are shown as `·`.
@@ -0,0 +1,10 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/app/__init__.py
5
+ src/app/main.py
6
+ src/tlex.egg-info/PKG-INFO
7
+ src/tlex.egg-info/SOURCES.txt
8
+ src/tlex.egg-info/dependency_links.txt
9
+ src/tlex.egg-info/entry_points.txt
10
+ src/tlex.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ tlex = app.main:main
@@ -0,0 +1 @@
1
+ app