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.7.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.2" # must be > 0.9.1
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.7.0 - Copyright 2024-26, All Rights Reserved.
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, curses.COLOR_BLACK)
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
- title = f" File Browser: {os.path.basename(self.current_dir) or self.current_dir} "
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
- instructions = "↑↓: Navigate | Enter: Open | Esc: Cancel | Backspace: Parent | T: Toggle file type"
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')): # ← ADD THIS
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 ClassNotFound:
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(style=self.color_theme, has_default_colors=has_default_colors)
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
- status = f"File: {self.current_file or 'Untitled'} | Ln {self.cursor_y + 1}, Col {self.cursor_x + 1}{h_info} | Theme: {self.color_theme}"
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
- # Calculate cursor screen position
1052
- screen_y = self.cursor_y - self.top_line + 1
1053
- screen_x = self.cursor_x - self.left_column + line_num_width
1054
-
1055
- # Ensure cursor is within valid screen boundaries
1056
- if (screen_y >= 1 and screen_y < height - 3 and
1057
- screen_x >= line_num_width and screen_x < width):
1058
- self.stdscr.move(screen_y, screen_x)
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
- # If cursor would be off-screen, place it at a safe position
1061
- safe_y = min(max(1, screen_y), height - 4)
1062
- safe_x = min(max(line_num_width, screen_x), width - 1)
1063
- self.stdscr.move(safe_y, safe_x)
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: self.stdscr.move(1, line_num_width)
1066
- except curses.error: pass
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
- # Determine the base text attribute from the canvas pair (respects user color choices)
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
- # Lex the entire visible block for multi-line context
1102
- lines_tokens =[]
1103
- visible_lines = self.content[self.top_line : self.top_line + content_height]
1218
+ available_width = width - line_num_width - 1
1219
+ if available_width <= 0:
1220
+ return
1104
1221
 
1105
- if self.formatter and visible_lines:
1106
- # Join visible lines and lex them as one continuous block
1107
- visible_text = "\n".join(visible_lines)
1108
- token_stream = lex(visible_text, self.lexer)
1109
- all_tokens = self.formatter.format(token_stream, None)
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
- for i in range(content_height):
1139
- line_idx = self.top_line + i
1140
- screen_y = i + 1
1141
-
1142
- if screen_y >= height - 3: break
1143
-
1144
- if line_idx < len(self.content):
1145
- try:
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
- self.stdscr.addstr(screen_y, 0, f"{line_idx + 1:4d} ", curses.A_DIM)
1148
-
1149
- available_width = width - line_num_width - 1
1150
- if available_width <= 0: continue
1151
-
1152
- # Fetch the pre-calculated tokens for this specific line
1153
- tokens = lines_tokens[i] if i < len(lines_tokens) else[]
1154
-
1155
- # 2. Unified drawing loop
1156
- screen_x_pos = line_num_width
1157
- line_x_pos = 0 # Tracks position in the actual document line
1158
-
1159
- for text, attr in tokens:
1160
- if screen_x_pos >= width - 1:
1161
- break # Screen is horizontally full, stop processing tokens
1162
-
1163
- for char_to_draw in text:
1164
- # Only draw if the character is within the visible horizontal window
1165
- if line_x_pos >= self.left_column:
1166
- if screen_x_pos < width - 1:
1167
- # Merge the token attr with any canvas fg override
1168
- # final_attr = attr | base_text_attr if base_text_attr else attr
1169
- # PAIR_NUMBER extracts bits 8-23 from the attr value (portable, no PAIR_NUMBER needed)
1170
- pair_num = (attr >> 8) & 0xFF if attr else 0
1171
- final_attr = base_text_attr if pair_num == 0 else attr
1172
- # Handle text selection highlighting
1173
- if self.is_selected(line_idx, line_x_pos):
1174
- final_attr |= curses.A_REVERSE
1175
-
1176
- self.stdscr.addstr(screen_y, screen_x_pos, char_to_draw, final_attr)
1177
- screen_x_pos += 1
1178
- else:
1179
- break # Reached the right edge
1180
-
1181
- line_x_pos += 1 # Always advance document index
1182
-
1183
- except curses.error:
1184
- continue
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, _, _ = curses.getmouse()
1397
- result = self.file_browser.handle_mouse(mx, my)
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
- self._load_file_content(result)
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 = min(max(0, self.top_line + my - 1), len(self.content) - 1)
1625
+ clicked_y, clicked_x = self._click_to_doc_pos(my, mx)
1433
1626
  self.cursor_y = clicked_y
1434
- if 0 <= clicked_y < len(self.content):
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 = min(max(0, self.top_line + my - 1), len(self.content) - 1)
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.move_cursor(0, -1)
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.move_cursor(0, 1)
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
- filename_to_save = self.current_file
2234
+ self._do_save(self.current_file, ask_overwrite=False)
1977
2235
  else:
1978
- filename_input = self.get_user_input("Save As: ")
1979
- if not filename_input or filename_input.strip() == "":
1980
- self.message = "Save cancelled - no filename provided."
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