tedit 2.7.2__tar.gz → 2.8.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.
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tedit
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.8.0
|
|
4
4
|
Summary: TE: a cross-platform terminal text editor with mouse support and syntax highlighting
|
|
5
5
|
Project-URL: Homepage, https://github.com/alby13/TE-Text-Editor
|
|
6
6
|
Author-email: alby13 <alby13@singularityon.com>
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "tedit"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.8.0" # must be > 2.7.2
|
|
8
8
|
description = "TE: a cross-platform terminal text editor with mouse support and syntax highlighting"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# TEdit / TE - Public Version 2.
|
|
1
|
+
# TEdit / TE - Public Version 2.8.0 - Copyright 2024-26, All Rights Reserved.
|
|
2
2
|
# Created by alby13 - https://github.com/alby13/TE-Text-Editor
|
|
3
3
|
# Software provided 'as-is,' use at your own risk; creator assumes no liability.
|
|
4
4
|
|
|
@@ -23,6 +23,8 @@ class CursesFormatter(Formatter):
|
|
|
23
23
|
self.color_map = {} # token_type -> curses attr (fully pre-baked)
|
|
24
24
|
self.next_color_pair = 1
|
|
25
25
|
self.style = get_style_by_name(options.get('style', 'default'))
|
|
26
|
+
self.bg_color = options.get('bg_color', -1)
|
|
27
|
+
self.fg_color = options.get('fg_color', -1)
|
|
26
28
|
|
|
27
29
|
def setup_colors(self):
|
|
28
30
|
"""Pre-bake a curses attr for every token type explicitly styled."""
|
|
@@ -45,8 +47,20 @@ class CursesFormatter(Formatter):
|
|
|
45
47
|
pair_num = seen_styles[style_key]
|
|
46
48
|
elif self.next_color_pair <= max_pairs:
|
|
47
49
|
fg = self.hex_to_curses_color(style_info.get('color'))
|
|
50
|
+
if fg == -1:
|
|
51
|
+
fg = self.fg_color
|
|
52
|
+
|
|
53
|
+
# No color at all — skip pair allocation, mark as unstyled
|
|
54
|
+
if fg == -1:
|
|
55
|
+
attr = 0
|
|
56
|
+
if style_info.get('bold'): attr |= curses.A_BOLD
|
|
57
|
+
if style_info.get('italic'): attr |= curses.A_ITALIC
|
|
58
|
+
if style_info.get('underline'): attr |= curses.A_UNDERLINE
|
|
59
|
+
self.color_map[token] = attr
|
|
60
|
+
continue
|
|
61
|
+
|
|
48
62
|
try:
|
|
49
|
-
curses.init_pair(self.next_color_pair, fg,
|
|
63
|
+
curses.init_pair(self.next_color_pair, fg, self.bg_color)
|
|
50
64
|
pair_num = self.next_color_pair
|
|
51
65
|
self.next_color_pair += 1
|
|
52
66
|
seen_styles[style_key] = pair_num
|
|
@@ -66,8 +80,11 @@ class CursesFormatter(Formatter):
|
|
|
66
80
|
if not hex_color:
|
|
67
81
|
return -1
|
|
68
82
|
hex_color = hex_color.lstrip('#')
|
|
83
|
+
if len(hex_color) == 3:
|
|
84
|
+
hex_color = hex_color[0]*2 + hex_color[1]*2 + hex_color[2]*2
|
|
69
85
|
if len(hex_color) < 6:
|
|
70
86
|
return -1
|
|
87
|
+
|
|
71
88
|
try:
|
|
72
89
|
r = int(hex_color[0:2], 16)
|
|
73
90
|
g = int(hex_color[2:4], 16)
|
|
@@ -351,13 +368,14 @@ class ColorsMenu:
|
|
|
351
368
|
pass
|
|
352
369
|
|
|
353
370
|
class FileBrowser:
|
|
354
|
-
def __init__(self, stdscr, start_dir=None):
|
|
371
|
+
def __init__(self, stdscr, start_dir=None, is_save_dialog=False):
|
|
355
372
|
self.stdscr = stdscr
|
|
356
373
|
self.current_dir = start_dir or os.getcwd()
|
|
357
374
|
self.selected_item = 0
|
|
358
375
|
self.top_item = 0
|
|
359
376
|
self.items =[]
|
|
360
377
|
self.show_all_files = False
|
|
378
|
+
self.is_save_dialog = is_save_dialog
|
|
361
379
|
self.refresh_items()
|
|
362
380
|
|
|
363
381
|
TEXT_EXTENSIONS = {
|
|
@@ -452,7 +470,8 @@ class FileBrowser:
|
|
|
452
470
|
pass
|
|
453
471
|
|
|
454
472
|
# Draw title
|
|
455
|
-
|
|
473
|
+
title_prefix = " Save File: " if getattr(self, 'is_save_dialog', False) else " File Browser: "
|
|
474
|
+
title = f"{title_prefix}{os.path.basename(self.current_dir) or self.current_dir} "
|
|
456
475
|
title_x = start_x + (browser_width - len(title)) // 2
|
|
457
476
|
try:
|
|
458
477
|
self.stdscr.addstr(start_y - 1, title_x, title, curses.A_BOLD)
|
|
@@ -533,7 +552,10 @@ class FileBrowser:
|
|
|
533
552
|
pass
|
|
534
553
|
|
|
535
554
|
# Draw instructions
|
|
536
|
-
|
|
555
|
+
if getattr(self, 'is_save_dialog', False):
|
|
556
|
+
instructions = "↑↓: Navigate | Enter: Navigate/Overwrite | S: Save New File Here | Esc: Cancel"
|
|
557
|
+
else:
|
|
558
|
+
instructions = "↑↓: Navigate | Enter: Open | Esc: Cancel | Backspace: Parent | T: Toggle file type"
|
|
537
559
|
try:
|
|
538
560
|
self.stdscr.addstr(start_y + browser_height + 1, start_x,
|
|
539
561
|
instructions[:browser_width], curses.A_DIM)
|
|
@@ -550,6 +572,16 @@ class FileBrowser:
|
|
|
550
572
|
elif key == curses.KEY_DOWN:
|
|
551
573
|
if self.selected_item < len(self.items) - 1:
|
|
552
574
|
self.selected_item += 1
|
|
575
|
+
elif key == curses.KEY_PPAGE: # Page Up
|
|
576
|
+
height, _ = self.stdscr.getmaxyx()
|
|
577
|
+
page_size = max(1, height - 8 - 2) # matches content_height in draw()
|
|
578
|
+
self.selected_item = max(0, self.selected_item - page_size)
|
|
579
|
+
|
|
580
|
+
elif key == curses.KEY_NPAGE: # Page Down
|
|
581
|
+
height, _ = self.stdscr.getmaxyx()
|
|
582
|
+
page_size = max(1, height - 8 - 2)
|
|
583
|
+
self.selected_item = min(len(self.items) - 1, self.selected_item + page_size)
|
|
584
|
+
|
|
553
585
|
elif key in (curses.KEY_BACKSPACE, 127, 8):
|
|
554
586
|
parent_dir = os.path.dirname(self.current_dir)
|
|
555
587
|
if parent_dir != self.current_dir:
|
|
@@ -557,11 +589,13 @@ class FileBrowser:
|
|
|
557
589
|
self.refresh_items()
|
|
558
590
|
self.selected_item = 0
|
|
559
591
|
self.top_item = 0
|
|
560
|
-
elif key in (ord('t'), ord('T')):
|
|
592
|
+
elif key in (ord('t'), ord('T')):
|
|
561
593
|
self.show_all_files = not self.show_all_files
|
|
562
594
|
self.refresh_items()
|
|
563
595
|
self.selected_item = 0
|
|
564
596
|
self.top_item = 0
|
|
597
|
+
elif key in (ord('s'), ord('S')) and getattr(self, 'is_save_dialog', False):
|
|
598
|
+
return f"SAVE_PROMPT:{self.current_dir}"
|
|
565
599
|
elif key in [ord('\n'), ord('\r'), curses.KEY_ENTER, 459]:
|
|
566
600
|
if self.items and self.selected_item < len(self.items):
|
|
567
601
|
selected = self.items[self.selected_item]
|
|
@@ -609,6 +643,10 @@ class FileBrowser:
|
|
|
609
643
|
click_ratio = (my - start_y - 1) / max(1, track_height)
|
|
610
644
|
self.top_item = int(click_ratio * max(0, len(self.items) - visible_items))
|
|
611
645
|
self.top_item = max(0, min(self.top_item, len(self.items) - visible_items))
|
|
646
|
+
# Keep selected_item within the newly visible range
|
|
647
|
+
self.selected_item = max(self.top_item,
|
|
648
|
+
min(self.selected_item,
|
|
649
|
+
self.top_item + visible_items - 1))
|
|
612
650
|
return None
|
|
613
651
|
|
|
614
652
|
# Check if click is within browser window
|
|
@@ -654,7 +692,7 @@ class Menu:
|
|
|
654
692
|
self.submenus = {
|
|
655
693
|
"File":["New", "Open", "Save", "Save as", "Exit"],
|
|
656
694
|
"Edit":["Undo", "Redo", "Cut", "Copy", "Paste", "Find"],
|
|
657
|
-
"Menu": ["Toggle Line Numbers", "Change Theme", "Colors"],
|
|
695
|
+
"Menu": ["Toggle Line Numbers", "Word Wrap", "Change Theme", "Colors"],
|
|
658
696
|
"Help": ["User Manual", "About"],
|
|
659
697
|
"Fun":["Print to Imaginary Printer", "Play a Game", "Coffee Break Simulator", "Provide Feedback", "Procrastination NOW!", "Write it for me"]
|
|
660
698
|
}
|
|
@@ -818,6 +856,7 @@ class TextEditor:
|
|
|
818
856
|
self.redo_stack =[]
|
|
819
857
|
|
|
820
858
|
self.read_only = False
|
|
859
|
+
self.word_wrap = False
|
|
821
860
|
|
|
822
861
|
# Find / Search
|
|
823
862
|
self.last_search = "" # Remembers the last search for quick "find next"
|
|
@@ -850,7 +889,7 @@ class TextEditor:
|
|
|
850
889
|
try:
|
|
851
890
|
sample = "\n".join(self.content[:100])
|
|
852
891
|
self.lexer = guess_lexer(sample, **opts)
|
|
853
|
-
except
|
|
892
|
+
except Exception:
|
|
854
893
|
self.lexer = get_lexer_by_name("text", **opts)
|
|
855
894
|
|
|
856
895
|
def setup_colors(self):
|
|
@@ -864,7 +903,12 @@ class TextEditor:
|
|
|
864
903
|
except curses.error:
|
|
865
904
|
has_default_colors = False
|
|
866
905
|
|
|
867
|
-
self.formatter = CursesFormatter(
|
|
906
|
+
self.formatter = CursesFormatter(
|
|
907
|
+
style=self.color_theme,
|
|
908
|
+
has_default_colors=has_default_colors,
|
|
909
|
+
bg_color=self.editor_bg,
|
|
910
|
+
fg_color=self.editor_fg
|
|
911
|
+
)
|
|
868
912
|
self.formatter.setup_colors()
|
|
869
913
|
|
|
870
914
|
try:
|
|
@@ -991,8 +1035,9 @@ class TextEditor:
|
|
|
991
1035
|
self.draw_context_menu()
|
|
992
1036
|
|
|
993
1037
|
# Draw status bars with horizontal offset info
|
|
994
|
-
h_info = f" +{self.left_column}" if self.left_column > 0 else ""
|
|
995
|
-
|
|
1038
|
+
h_info = f" +{self.left_column}" if (self.left_column > 0 and not self.word_wrap) else ""
|
|
1039
|
+
wrap_info = " | WRAP" if self.word_wrap else ""
|
|
1040
|
+
status = f"File: {self.current_file or 'Untitled'} | Ln {self.cursor_y + 1}, Col {self.cursor_x + 1}{h_info}{wrap_info} | Theme: {self.color_theme}"
|
|
996
1041
|
|
|
997
1042
|
if self.menu_focus:
|
|
998
1043
|
help_text = "MENU MODE: ←→ Select Menu | ↑↓ Navigate Items | Enter: Select | Esc: Close | F9: Edit Mode | Click text to edit"
|
|
@@ -1048,140 +1093,267 @@ class TextEditor:
|
|
|
1048
1093
|
def position_cursor(self, height, width, line_num_width):
|
|
1049
1094
|
"""Comprehensive bounds checking with horizontal scroll support."""
|
|
1050
1095
|
try:
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
self.
|
|
1096
|
+
if self.word_wrap:
|
|
1097
|
+
# In wrap mode, find which visual screen_y the cursor lands on
|
|
1098
|
+
available_width = width - line_num_width - 1
|
|
1099
|
+
if available_width <= 0:
|
|
1100
|
+
return
|
|
1101
|
+
screen_y = 1
|
|
1102
|
+
found = False
|
|
1103
|
+
line_idx = self.top_line
|
|
1104
|
+
content_height = height - 4
|
|
1105
|
+
while screen_y <= content_height and line_idx < len(self.content):
|
|
1106
|
+
if screen_y >= height - 3:
|
|
1107
|
+
break
|
|
1108
|
+
raw_line = self.content[line_idx]
|
|
1109
|
+
chunks = self._wrap_line(raw_line, available_width)
|
|
1110
|
+
if line_idx == self.cursor_y:
|
|
1111
|
+
# Find which chunk the cursor column falls in
|
|
1112
|
+
for col_start, col_end in chunks:
|
|
1113
|
+
if col_start <= self.cursor_x <= col_end or col_end == len(raw_line):
|
|
1114
|
+
screen_x = line_num_width + (self.cursor_x - col_start)
|
|
1115
|
+
if screen_y < height - 3 and screen_x < width:
|
|
1116
|
+
self.stdscr.move(screen_y, screen_x)
|
|
1117
|
+
found = True
|
|
1118
|
+
break
|
|
1119
|
+
screen_y += 1
|
|
1120
|
+
break
|
|
1121
|
+
screen_y += len(chunks)
|
|
1122
|
+
line_idx += 1
|
|
1123
|
+
if not found:
|
|
1124
|
+
try:
|
|
1125
|
+
self.stdscr.move(1, line_num_width)
|
|
1126
|
+
except curses.error:
|
|
1127
|
+
pass
|
|
1059
1128
|
else:
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1129
|
+
screen_y = self.cursor_y - self.top_line + 1
|
|
1130
|
+
screen_x = self.cursor_x - self.left_column + line_num_width
|
|
1131
|
+
|
|
1132
|
+
if (screen_y >= 1 and screen_y < height - 3 and
|
|
1133
|
+
screen_x >= line_num_width and screen_x < width):
|
|
1134
|
+
self.stdscr.move(screen_y, screen_x)
|
|
1135
|
+
else:
|
|
1136
|
+
safe_y = min(max(1, screen_y), height - 4)
|
|
1137
|
+
safe_x = min(max(line_num_width, screen_x), width - 1)
|
|
1138
|
+
self.stdscr.move(safe_y, safe_x)
|
|
1064
1139
|
except curses.error:
|
|
1065
|
-
try:
|
|
1066
|
-
|
|
1140
|
+
try:
|
|
1141
|
+
self.stdscr.move(1, line_num_width)
|
|
1142
|
+
except curses.error:
|
|
1143
|
+
pass
|
|
1067
1144
|
|
|
1068
1145
|
def _ensure_cursor_visible(self):
|
|
1069
1146
|
"""Adjusts self.top_line and self.left_column to make sure the cursor is on screen."""
|
|
1070
1147
|
height, width = self.stdscr.getmaxyx()
|
|
1071
|
-
|
|
1148
|
+
|
|
1072
1149
|
# --- Vertical Scrolling ---
|
|
1073
1150
|
content_height = height - 3
|
|
1074
1151
|
if self.cursor_y < self.top_line:
|
|
1075
1152
|
self.top_line = self.cursor_y
|
|
1076
1153
|
elif self.cursor_y >= self.top_line + content_height:
|
|
1077
1154
|
self.top_line = self.cursor_y - content_height + 1
|
|
1078
|
-
|
|
1155
|
+
|
|
1156
|
+
if self.word_wrap:
|
|
1157
|
+
# In wrap mode horizontal scrolling is meaningless — keep it zeroed
|
|
1158
|
+
self.left_column = 0
|
|
1159
|
+
return
|
|
1160
|
+
|
|
1079
1161
|
# --- Horizontal Scrolling ---
|
|
1080
1162
|
line_num_width = 5 if self.show_line_numbers else 0
|
|
1081
1163
|
available_width = width - line_num_width - 1
|
|
1082
|
-
|
|
1164
|
+
|
|
1083
1165
|
if self.cursor_x < self.left_column:
|
|
1084
1166
|
self.left_column = self.cursor_x
|
|
1085
1167
|
elif self.cursor_x >= self.left_column + available_width:
|
|
1086
1168
|
self.left_column = self.cursor_x - available_width + 1
|
|
1087
1169
|
|
|
1170
|
+
def _wrap_line(self, line, available_width):
|
|
1171
|
+
"""Split a document line into screen-width chunks for word wrap.
|
|
1172
|
+
Returns a list of (start_col, end_col) slices into the original line."""
|
|
1173
|
+
if available_width <= 0:
|
|
1174
|
+
return [(0, len(line))] if line else [(0, 0)]
|
|
1175
|
+
if not line:
|
|
1176
|
+
return [(0, 0)]
|
|
1177
|
+
chunks = []
|
|
1178
|
+
start = 0
|
|
1179
|
+
while start < len(line):
|
|
1180
|
+
end = min(start + available_width, len(line))
|
|
1181
|
+
# Try to break at a word boundary if mid-word
|
|
1182
|
+
if end < len(line):
|
|
1183
|
+
break_at = line.rfind(' ', start, end)
|
|
1184
|
+
if break_at > start:
|
|
1185
|
+
end = break_at + 1 # include the space in this chunk
|
|
1186
|
+
chunks.append((start, end))
|
|
1187
|
+
start = end
|
|
1188
|
+
return chunks if chunks else [(0, 0)]
|
|
1189
|
+
|
|
1190
|
+
def _lex_line(self, line, base_text_attr):
|
|
1191
|
+
"""Return a list of (char, attr) pairs for a single document line."""
|
|
1192
|
+
if self.formatter:
|
|
1193
|
+
token_stream = lex(line, self.lexer)
|
|
1194
|
+
tokens = self.formatter.format(token_stream, None)
|
|
1195
|
+
else:
|
|
1196
|
+
tokens = [(line, base_text_attr)]
|
|
1197
|
+
result = []
|
|
1198
|
+
for text, attr in tokens:
|
|
1199
|
+
# Use base_text_attr only when the token truly has NO color assigned (attr == 0)
|
|
1200
|
+
final_attr = attr if attr != 0 else base_text_attr
|
|
1201
|
+
for ch in text:
|
|
1202
|
+
result.append((ch, final_attr))
|
|
1203
|
+
return result
|
|
1204
|
+
|
|
1088
1205
|
def draw_content(self, height, width, line_num_width):
|
|
1089
1206
|
"""Handles drawing text with syntax highlighting and selection."""
|
|
1090
1207
|
content_height = height - 4
|
|
1091
|
-
|
|
1208
|
+
|
|
1092
1209
|
if content_height <= 0 or width <= line_num_width:
|
|
1093
1210
|
return
|
|
1094
1211
|
|
|
1095
|
-
#
|
|
1212
|
+
# Base attribute from canvas color pair
|
|
1096
1213
|
if self.editor_bg != -1 or self.editor_fg != -1:
|
|
1097
1214
|
base_text_attr = curses.color_pair(self._CANVAS_PAIR)
|
|
1098
1215
|
else:
|
|
1099
1216
|
base_text_attr = 0
|
|
1100
1217
|
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1218
|
+
available_width = width - line_num_width - 1
|
|
1219
|
+
if available_width <= 0:
|
|
1220
|
+
return
|
|
1104
1221
|
|
|
1105
|
-
if self.
|
|
1106
|
-
#
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
# Split the resulting tokens back into individual lines based on '\n'
|
|
1112
|
-
current_line_tokens =[]
|
|
1113
|
-
for text, attr in all_tokens:
|
|
1114
|
-
parts = text.split('\n')
|
|
1115
|
-
for j, part in enumerate(parts):
|
|
1116
|
-
if j > 0:
|
|
1117
|
-
lines_tokens.append(current_line_tokens)
|
|
1118
|
-
current_line_tokens =[]
|
|
1119
|
-
if part:
|
|
1120
|
-
current_line_tokens.append((part, attr))
|
|
1121
|
-
lines_tokens.append(current_line_tokens)
|
|
1122
|
-
|
|
1123
|
-
# Some lexers still forcibly append a newline despite ensurenl=False
|
|
1124
|
-
if len(lines_tokens) == len(visible_lines) + 1 and not lines_tokens[-1]:
|
|
1125
|
-
lines_tokens.pop()
|
|
1126
|
-
|
|
1127
|
-
# Bulletproof Fallback: If pygments somehow changed the number of lines
|
|
1128
|
-
# (e.g., stripping blank lines), revert to line-by-line lexing to guarantee cursor sync.
|
|
1129
|
-
if len(lines_tokens) != len(visible_lines):
|
|
1130
|
-
lines_tokens =[]
|
|
1131
|
-
for line in visible_lines:
|
|
1132
|
-
token_stream = lex(line, self.lexer)
|
|
1133
|
-
lines_tokens.append(self.formatter.format(token_stream, None))
|
|
1134
|
-
else:
|
|
1135
|
-
# Fallback if no formatter
|
|
1136
|
-
lines_tokens = [[(line, base_text_attr)] for line in visible_lines]
|
|
1222
|
+
if self.word_wrap:
|
|
1223
|
+
# ── WORD WRAP MODE ────────────────────────────────────────────────
|
|
1224
|
+
# Walk document lines from top_line, emitting screen rows until full.
|
|
1225
|
+
screen_y = 1
|
|
1226
|
+
line_idx = self.top_line
|
|
1137
1227
|
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1228
|
+
while screen_y <= content_height and line_idx < len(self.content):
|
|
1229
|
+
if screen_y >= height - 3:
|
|
1230
|
+
break
|
|
1231
|
+
|
|
1232
|
+
raw_line = self.content[line_idx]
|
|
1233
|
+
chunks = self._wrap_line(raw_line, available_width)
|
|
1234
|
+
char_attrs = self._lex_line(raw_line, base_text_attr)
|
|
1235
|
+
|
|
1236
|
+
for chunk_num, (col_start, col_end) in enumerate(chunks):
|
|
1237
|
+
if screen_y > content_height or screen_y >= height - 3:
|
|
1238
|
+
break
|
|
1239
|
+
|
|
1240
|
+
# Line number only on the first visual row of a doc line
|
|
1146
1241
|
if self.show_line_numbers and line_num_width <= width:
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1242
|
+
if chunk_num == 0:
|
|
1243
|
+
try:
|
|
1244
|
+
self.stdscr.addstr(screen_y, 0,
|
|
1245
|
+
f"{line_idx + 1:4d} ", curses.A_DIM)
|
|
1246
|
+
except curses.error:
|
|
1247
|
+
pass
|
|
1248
|
+
else:
|
|
1249
|
+
try:
|
|
1250
|
+
self.stdscr.addstr(screen_y, 0,
|
|
1251
|
+
" ", 0) # blank gutter for continuation rows
|
|
1252
|
+
except curses.error:
|
|
1253
|
+
pass
|
|
1254
|
+
|
|
1255
|
+
screen_x = line_num_width
|
|
1256
|
+
for doc_x in range(col_start, col_end):
|
|
1257
|
+
if screen_x >= width - 1:
|
|
1258
|
+
break
|
|
1259
|
+
if doc_x < len(char_attrs):
|
|
1260
|
+
ch, attr = char_attrs[doc_x]
|
|
1261
|
+
else:
|
|
1262
|
+
ch, attr = ' ', base_text_attr
|
|
1263
|
+
if self.is_selected(line_idx, doc_x):
|
|
1264
|
+
attr |= curses.A_REVERSE
|
|
1265
|
+
try:
|
|
1266
|
+
self.stdscr.addstr(screen_y, screen_x, ch, attr)
|
|
1267
|
+
except curses.error:
|
|
1268
|
+
pass
|
|
1269
|
+
screen_x += 1
|
|
1270
|
+
|
|
1271
|
+
screen_y += 1
|
|
1272
|
+
|
|
1273
|
+
line_idx += 1
|
|
1274
|
+
|
|
1275
|
+
else:
|
|
1276
|
+
# ── NORMAL (HORIZONTAL SCROLL) MODE ───────────────────────────────
|
|
1277
|
+
# Lex the entire visible block for multi-line context
|
|
1278
|
+
lines_tokens = []
|
|
1279
|
+
visible_lines = self.content[self.top_line : self.top_line + content_height]
|
|
1280
|
+
|
|
1281
|
+
if self.formatter and visible_lines:
|
|
1282
|
+
visible_text = "\n".join(visible_lines)
|
|
1283
|
+
token_stream = lex(visible_text, self.lexer)
|
|
1284
|
+
all_tokens = self.formatter.format(token_stream, None)
|
|
1285
|
+
|
|
1286
|
+
current_line_tokens = []
|
|
1287
|
+
for text, attr in all_tokens:
|
|
1288
|
+
parts = text.split('\n')
|
|
1289
|
+
for j, part in enumerate(parts):
|
|
1290
|
+
if j > 0:
|
|
1291
|
+
lines_tokens.append(current_line_tokens)
|
|
1292
|
+
current_line_tokens = []
|
|
1293
|
+
if part:
|
|
1294
|
+
current_line_tokens.append((part, attr))
|
|
1295
|
+
lines_tokens.append(current_line_tokens)
|
|
1296
|
+
|
|
1297
|
+
if len(lines_tokens) == len(visible_lines) + 1 and not lines_tokens[-1]:
|
|
1298
|
+
lines_tokens.pop()
|
|
1299
|
+
|
|
1300
|
+
if len(lines_tokens) != len(visible_lines):
|
|
1301
|
+
lines_tokens = []
|
|
1302
|
+
for line in visible_lines:
|
|
1303
|
+
token_stream = lex(line, self.lexer)
|
|
1304
|
+
lines_tokens.append(self.formatter.format(token_stream, None))
|
|
1305
|
+
else:
|
|
1306
|
+
lines_tokens = [[(line, base_text_attr)] for line in visible_lines]
|
|
1307
|
+
|
|
1308
|
+
for i in range(content_height):
|
|
1309
|
+
line_idx = self.top_line + i
|
|
1310
|
+
screen_y = i + 1
|
|
1311
|
+
|
|
1312
|
+
if screen_y >= height - 3:
|
|
1313
|
+
break
|
|
1314
|
+
|
|
1315
|
+
if line_idx < len(self.content):
|
|
1316
|
+
try:
|
|
1317
|
+
if self.show_line_numbers and line_num_width <= width:
|
|
1318
|
+
self.stdscr.addstr(screen_y, 0,
|
|
1319
|
+
f"{line_idx + 1:4d} ", curses.A_DIM)
|
|
1320
|
+
|
|
1321
|
+
if available_width <= 0:
|
|
1322
|
+
continue
|
|
1323
|
+
|
|
1324
|
+
tokens = lines_tokens[i] if i < len(lines_tokens) else []
|
|
1325
|
+
|
|
1326
|
+
screen_x_pos = line_num_width
|
|
1327
|
+
line_x_pos = 0
|
|
1328
|
+
|
|
1329
|
+
for text, attr in tokens:
|
|
1330
|
+
if screen_x_pos >= width - 1:
|
|
1331
|
+
break
|
|
1332
|
+
|
|
1333
|
+
for char_to_draw in text:
|
|
1334
|
+
if line_x_pos >= self.left_column:
|
|
1335
|
+
if screen_x_pos < width - 1:
|
|
1336
|
+
final_attr = attr if attr != 0 else base_text_attr
|
|
1337
|
+
if self.is_selected(line_idx, line_x_pos):
|
|
1338
|
+
final_attr |= curses.A_REVERSE
|
|
1339
|
+
self.stdscr.addstr(screen_y, screen_x_pos,
|
|
1340
|
+
char_to_draw, final_attr)
|
|
1341
|
+
screen_x_pos += 1
|
|
1342
|
+
else:
|
|
1343
|
+
break
|
|
1344
|
+
|
|
1345
|
+
line_x_pos += 1
|
|
1346
|
+
|
|
1347
|
+
# Scroll indicators
|
|
1348
|
+
indicator_attr = curses.A_BOLD | curses.A_REVERSE
|
|
1349
|
+
full_line = self.content[line_idx]
|
|
1350
|
+
if self.left_column > 0 and len(full_line) > self.left_column:
|
|
1351
|
+
self.stdscr.addstr(screen_y, line_num_width, '‹', indicator_attr)
|
|
1352
|
+
if len(full_line) > self.left_column + available_width:
|
|
1353
|
+
self.stdscr.addstr(screen_y, width - 2, '›', indicator_attr)
|
|
1354
|
+
|
|
1355
|
+
except curses.error:
|
|
1356
|
+
continue
|
|
1185
1357
|
|
|
1186
1358
|
def open_user_manual(self):
|
|
1187
1359
|
"""Opens the user_manual.txt file in a new, read-only buffer."""
|
|
@@ -1389,12 +1561,21 @@ class TextEditor:
|
|
|
1389
1561
|
if not self.file_browser:
|
|
1390
1562
|
self.browser_mode = False # Safety exit
|
|
1391
1563
|
return
|
|
1392
|
-
|
|
1564
|
+
|
|
1565
|
+
# Define is_save before we clear
|
|
1566
|
+
is_save = getattr(self.file_browser, 'is_save_dialog', False)
|
|
1567
|
+
|
|
1393
1568
|
result = None
|
|
1394
1569
|
if key == curses.KEY_MOUSE:
|
|
1395
1570
|
try:
|
|
1396
|
-
_, mx, my, _,
|
|
1397
|
-
|
|
1571
|
+
_, mx, my, _, bstate = curses.getmouse()
|
|
1572
|
+
# Scroll wheel scrolls the list
|
|
1573
|
+
if bstate & curses.BUTTON4_PRESSED:
|
|
1574
|
+
result = self.file_browser.handle_key(curses.KEY_PPAGE)
|
|
1575
|
+
elif bstate & curses.BUTTON5_PRESSED:
|
|
1576
|
+
result = self.file_browser.handle_key(curses.KEY_NPAGE)
|
|
1577
|
+
else:
|
|
1578
|
+
result = self.file_browser.handle_mouse(mx, my)
|
|
1398
1579
|
except curses.error:
|
|
1399
1580
|
pass
|
|
1400
1581
|
else:
|
|
@@ -1405,9 +1586,21 @@ class TextEditor:
|
|
|
1405
1586
|
self.file_browser = None # Clean up the browser instance
|
|
1406
1587
|
|
|
1407
1588
|
if result == "CANCEL":
|
|
1408
|
-
self.message = "Open file cancelled."
|
|
1589
|
+
self.message = "Save cancelled." if is_save else "Open file cancelled."
|
|
1590
|
+
elif isinstance(result, str) and result.startswith("SAVE_PROMPT:"):
|
|
1591
|
+
dir_path = result.split("SAVE_PROMPT:", 1)[1]
|
|
1592
|
+
dir_name = os.path.basename(dir_path) or dir_path
|
|
1593
|
+
filename_input = self.get_user_input(f"Save As (in {dir_name}): ")
|
|
1594
|
+
if filename_input and filename_input.strip() != "":
|
|
1595
|
+
full_path = os.path.join(dir_path, filename_input.strip())
|
|
1596
|
+
self._do_save(full_path, ask_overwrite=True)
|
|
1597
|
+
else:
|
|
1598
|
+
self.message = "Save cancelled - no filename provided."
|
|
1409
1599
|
else: # A filename was returned
|
|
1410
|
-
|
|
1600
|
+
if is_save:
|
|
1601
|
+
self._do_save(result, ask_overwrite=True)
|
|
1602
|
+
else:
|
|
1603
|
+
self._load_file_content(result)
|
|
1411
1604
|
|
|
1412
1605
|
def handle_menu_input(self, key):
|
|
1413
1606
|
action = None
|
|
@@ -1429,12 +1622,9 @@ class TextEditor:
|
|
|
1429
1622
|
self.message = "Returned to editing mode."
|
|
1430
1623
|
# Place cursor at the clicked location with accurate horizontal logic
|
|
1431
1624
|
line_num_width = 5 if self.show_line_numbers else 0
|
|
1432
|
-
clicked_y =
|
|
1625
|
+
clicked_y, clicked_x = self._click_to_doc_pos(my, mx)
|
|
1433
1626
|
self.cursor_y = clicked_y
|
|
1434
|
-
|
|
1435
|
-
self.cursor_x = min(max(0, mx - line_num_width + self.left_column), len(self.content[clicked_y]))
|
|
1436
|
-
else:
|
|
1437
|
-
self.cursor_x = 0
|
|
1627
|
+
self.cursor_x = clicked_x
|
|
1438
1628
|
self.clear_selection()
|
|
1439
1629
|
return # Event handled, no more processing needed
|
|
1440
1630
|
except curses.error:
|
|
@@ -1474,8 +1664,7 @@ class TextEditor:
|
|
|
1474
1664
|
# Handle left-click actions in the content area
|
|
1475
1665
|
if my > 0 and my < height - 2:
|
|
1476
1666
|
line_num_width = 5 if self.show_line_numbers else 0
|
|
1477
|
-
clicked_y =
|
|
1478
|
-
clicked_x = min(max(0, mx - line_num_width + self.left_column), len(self.content[clicked_y]))
|
|
1667
|
+
clicked_y, clicked_x = self._click_to_doc_pos(my, mx)
|
|
1479
1668
|
|
|
1480
1669
|
if bstate & curses.BUTTON1_CLICKED or bstate & curses.BUTTON1_PRESSED:
|
|
1481
1670
|
self.clear_selection()
|
|
@@ -1530,10 +1719,22 @@ class TextEditor:
|
|
|
1530
1719
|
self.move_cursor(1, 0)
|
|
1531
1720
|
elif key == curses.KEY_LEFT:
|
|
1532
1721
|
self.clear_selection()
|
|
1533
|
-
self.
|
|
1722
|
+
if self.cursor_x == 0 and self.cursor_y > 0:
|
|
1723
|
+
# At the start of a line — jump up to the end of the previous line
|
|
1724
|
+
self.cursor_y -= 1
|
|
1725
|
+
self.cursor_x = len(self.content[self.cursor_y])
|
|
1726
|
+
self._ensure_cursor_visible()
|
|
1727
|
+
else:
|
|
1728
|
+
self.move_cursor(0, -1)
|
|
1534
1729
|
elif key == curses.KEY_RIGHT:
|
|
1535
1730
|
self.clear_selection()
|
|
1536
|
-
self.
|
|
1731
|
+
if self.cursor_x == len(self.content[self.cursor_y]) and self.cursor_y < len(self.content) - 1:
|
|
1732
|
+
# At the end of a line — jump down to the start of the next line
|
|
1733
|
+
self.cursor_y += 1
|
|
1734
|
+
self.cursor_x = 0
|
|
1735
|
+
self._ensure_cursor_visible()
|
|
1736
|
+
else:
|
|
1737
|
+
self.move_cursor(0, 1)
|
|
1537
1738
|
elif key == curses.KEY_NPAGE:
|
|
1538
1739
|
self.clear_selection()
|
|
1539
1740
|
height, _ = self.stdscr.getmaxyx()
|
|
@@ -1815,6 +2016,10 @@ class TextEditor:
|
|
|
1815
2016
|
self.show_line_numbers = not self.show_line_numbers
|
|
1816
2017
|
self.message = f"Line numbers turned {'on' if self.show_line_numbers else 'off'}."
|
|
1817
2018
|
self._ensure_cursor_visible()
|
|
2019
|
+
elif item == "Word Wrap":
|
|
2020
|
+
self.word_wrap = not self.word_wrap
|
|
2021
|
+
self.message = f"Word wrap {'on' if self.word_wrap else 'off'}."
|
|
2022
|
+
self._ensure_cursor_visible()
|
|
1818
2023
|
elif item == "Change Theme":
|
|
1819
2024
|
self.change_theme()
|
|
1820
2025
|
elif item == "Colors":
|
|
@@ -1885,6 +2090,60 @@ class TextEditor:
|
|
|
1885
2090
|
self.menu.open = False
|
|
1886
2091
|
self.menu_focus = False
|
|
1887
2092
|
|
|
2093
|
+
def _click_to_doc_pos(self, my, mx):
|
|
2094
|
+
"""Convert a mouse click at screen row my, col mx to (doc_y, doc_x).
|
|
2095
|
+
Works correctly in both normal and word-wrap mode."""
|
|
2096
|
+
height, width = self.stdscr.getmaxyx()
|
|
2097
|
+
line_num_width = 5 if self.show_line_numbers else 0
|
|
2098
|
+
|
|
2099
|
+
if not self.word_wrap:
|
|
2100
|
+
# Normal mode: simple 1-to-1 screen-row to doc-line mapping
|
|
2101
|
+
doc_y = min(max(0, self.top_line + my - 1), len(self.content) - 1)
|
|
2102
|
+
doc_x = min(max(0, mx - line_num_width + self.left_column),
|
|
2103
|
+
len(self.content[doc_y]))
|
|
2104
|
+
return doc_y, doc_x
|
|
2105
|
+
|
|
2106
|
+
# Wrap mode: walk visual rows from top_line to find which doc line and
|
|
2107
|
+
# which column within that line the click lands on.
|
|
2108
|
+
available_width = width - line_num_width - 1
|
|
2109
|
+
if available_width <= 0:
|
|
2110
|
+
return 0, 0
|
|
2111
|
+
|
|
2112
|
+
screen_y = 1 # visual rows start at row 1 (row 0 is the menu bar)
|
|
2113
|
+
content_height = height - 4
|
|
2114
|
+
|
|
2115
|
+
for line_idx in range(self.top_line, len(self.content)):
|
|
2116
|
+
raw_line = self.content[line_idx]
|
|
2117
|
+
chunks = self._wrap_line(raw_line, available_width)
|
|
2118
|
+
|
|
2119
|
+
for col_start, col_end in chunks:
|
|
2120
|
+
if screen_y == my:
|
|
2121
|
+
# The click is on this visual row
|
|
2122
|
+
col_offset = mx - line_num_width # chars from left edge
|
|
2123
|
+
doc_x = col_start + max(0, col_offset)
|
|
2124
|
+
doc_x = min(doc_x, col_end) # clamp to chunk end
|
|
2125
|
+
doc_x = min(doc_x, len(raw_line)) # clamp to line length
|
|
2126
|
+
return line_idx, doc_x
|
|
2127
|
+
screen_y += 1
|
|
2128
|
+
if screen_y > content_height:
|
|
2129
|
+
# Clicked below all content — return end of last line
|
|
2130
|
+
last = len(self.content) - 1
|
|
2131
|
+
return last, len(self.content[last])
|
|
2132
|
+
|
|
2133
|
+
# Fallback
|
|
2134
|
+
last = len(self.content) - 1
|
|
2135
|
+
return last, len(self.content[last])
|
|
2136
|
+
|
|
2137
|
+
"""Moves the cursor and ensures it's visible."""
|
|
2138
|
+
# Calculate new y position
|
|
2139
|
+
self.cursor_y = max(0, min(self.cursor_y + dy, len(self.content) - 1))
|
|
2140
|
+
|
|
2141
|
+
# Calculate new x position, clamping to the new line's length
|
|
2142
|
+
self.cursor_x = min(max(0, self.cursor_x + dx), len(self.content[self.cursor_y]))
|
|
2143
|
+
|
|
2144
|
+
# Ensure the view scrolls if necessary
|
|
2145
|
+
self._ensure_cursor_visible()
|
|
2146
|
+
|
|
1888
2147
|
def move_cursor(self, dy, dx):
|
|
1889
2148
|
"""Moves the cursor and ensures it's visible."""
|
|
1890
2149
|
# Calculate new y position
|
|
@@ -1970,22 +2229,23 @@ class TextEditor:
|
|
|
1970
2229
|
def save_file(self, save_as=False):
|
|
1971
2230
|
"""Save the current file with improved error handling and validation."""
|
|
1972
2231
|
if self._check_read_only(): return
|
|
1973
|
-
filename_to_save = None
|
|
1974
2232
|
|
|
1975
2233
|
if not save_as and self.current_file:
|
|
1976
|
-
|
|
2234
|
+
self._do_save(self.current_file, ask_overwrite=False)
|
|
1977
2235
|
else:
|
|
1978
|
-
|
|
1979
|
-
if
|
|
1980
|
-
|
|
2236
|
+
start_dir = os.getcwd()
|
|
2237
|
+
if self.current_file:
|
|
2238
|
+
start_dir = os.path.dirname(self.current_file)
|
|
2239
|
+
|
|
2240
|
+
self.file_browser = FileBrowser(self.stdscr, start_dir=start_dir, is_save_dialog=True)
|
|
2241
|
+
self.browser_mode = True
|
|
2242
|
+
self.message = "Choose location to save..."
|
|
2243
|
+
|
|
2244
|
+
def _do_save(self, filename_to_save, ask_overwrite=True):
|
|
2245
|
+
if ask_overwrite and os.path.exists(filename_to_save):
|
|
2246
|
+
if not self.get_user_confirmation(f"'{os.path.basename(filename_to_save)}' exists. Overwrite? (y/n)"):
|
|
2247
|
+
self.message = "Save cancelled."
|
|
1981
2248
|
return
|
|
1982
|
-
|
|
1983
|
-
filename_to_save = filename_input.strip()
|
|
1984
|
-
|
|
1985
|
-
if os.path.exists(filename_to_save):
|
|
1986
|
-
if not self.get_user_confirmation(f"'{os.path.basename(filename_to_save)}' exists. Overwrite? (y/n)"):
|
|
1987
|
-
self.message = "Save cancelled."
|
|
1988
|
-
return
|
|
1989
2249
|
|
|
1990
2250
|
if not filename_to_save:
|
|
1991
2251
|
self.message = "Save cancelled - invalid filename."
|
|
@@ -2028,7 +2288,8 @@ class TextEditor:
|
|
|
2028
2288
|
|
|
2029
2289
|
def get_user_confirmation(self, prompt):
|
|
2030
2290
|
"""Displays a prompt and waits for a 'y' or 'n' response. Returns True for 'y', False otherwise."""
|
|
2031
|
-
self.stdscr.nodelay(0)
|
|
2291
|
+
# self.stdscr.nodelay(0) - previous code that probably doesn't wait long enough
|
|
2292
|
+
self.stdscr.timeout(-1) # Force infinite wait for the keystroke
|
|
2032
2293
|
response = False
|
|
2033
2294
|
try:
|
|
2034
2295
|
# Re-draw the interface to ensure the prompt is on a clean screen
|
|
@@ -2054,7 +2315,8 @@ class TextEditor:
|
|
|
2054
2315
|
pass # Ignore errors during confirmation
|
|
2055
2316
|
finally:
|
|
2056
2317
|
# Restore non-blocking input mode
|
|
2057
|
-
self.stdscr.nodelay(1)
|
|
2318
|
+
# self.stdscr.nodelay(1) - previous code that probably doesn't wait long enough
|
|
2319
|
+
self.stdscr.timeout(50)
|
|
2058
2320
|
return response
|
|
2059
2321
|
|
|
2060
2322
|
def open_file(self):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|