textbringer 13 → 15

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2ebcccec1dcc4c9b2116dc02f0e4548d30f019c0da7fc1d0573f84cf08b8590d
4
- data.tar.gz: 1f2b135717e1fde5ca8f13fdb9a967d5a7cad5aea6af83c040f35bec85c3bf23
3
+ metadata.gz: 5ad5b7d9a5cc2022075f802db23f24781dcb3be366a4ba3ee27015a66ada834d
4
+ data.tar.gz: 1933c54c72c4e189b4f0a6ba81d6186d7618c9eaaa3eccbe5b61925d8959c108
5
5
  SHA512:
6
- metadata.gz: a9a9ea8e0234c2fda07af756eb12e8acc53a7f0c3e9cee57276adb742bda13a896875f4b21334794737a55d6e7c7c07168baea74304336e687834f3e5ba4b5d2
7
- data.tar.gz: 1c3a84fc349f0d68cd55498d6ae45d159f672b824c8307e89e30bd4eb68c70126de3c358a9255177c74230b747b6255dbdfd36182fe9d56b34927b8a88f60fbb
6
+ metadata.gz: 44736e20de4174e865ad160107e48461e26d46cd495e74000138c724753dafaae65b5e530902e8f1ad1f38659e301cfe2fbec71208913f85d01af5e23b124bc5
7
+ data.tar.gz: b07e7c3533f540b88157c268d498f7ec6bd077db6fce0e3adb154acc02fb9d960cee0bfb23cd91342a6dce12f0c8af1083dac1434fc67c9fddc361bd03623ded
data/CLAUDE.md ADDED
@@ -0,0 +1,197 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Overview
6
+
7
+ Textbringer is an Emacs-like text editor written in Ruby. It is extensible by Ruby instead of Lisp and runs in the terminal using ncurses.
8
+
9
+ **Ruby Version**: Requires Ruby >= 3.2
10
+
11
+ ## Development Commands
12
+
13
+ ### Setup
14
+ ```bash
15
+ bundle install
16
+ ```
17
+
18
+ For ncursesw support (required for multibyte characters):
19
+ ```bash
20
+ sudo apt-get install libncursesw5-dev
21
+ gem install curses
22
+ ```
23
+
24
+ ### Running the Editor
25
+ ```bash
26
+ # Run the main executable
27
+ ./exe/txtb
28
+
29
+ # Or after installation
30
+ txtb
31
+ ```
32
+
33
+ ### Testing
34
+ ```bash
35
+ # Run all tests
36
+ bundle exec rake test
37
+
38
+ # Or simply (default task)
39
+ bundle exec rake
40
+
41
+ # On Ubuntu/Linux (for CI)
42
+ xvfb-run bundle exec rake test
43
+
44
+ # Run a single test file
45
+ ruby -Ilib:test test/textbringer/test_buffer.rb
46
+ ```
47
+
48
+ ### Build and Release
49
+ ```bash
50
+ # Install gem locally
51
+ bundle exec rake install
52
+
53
+ # Bump version and create release
54
+ bundle exec rake bump
55
+ ```
56
+
57
+ ## Architecture
58
+
59
+ ### Core Components
60
+
61
+ **Buffer** (`lib/textbringer/buffer.rb`)
62
+ - The fundamental text container, similar to Emacs buffers
63
+ - Uses a gap buffer implementation (GAP_SIZE = 256) for efficient text editing
64
+ - Supports undo/redo with UNDO_LIMIT = 1000
65
+ - Handles encoding detection (UTF-8, EUC-JP, Windows-31J) and file format conversion
66
+ - Manages marks, point (cursor position), and the kill ring
67
+ - Class methods maintain global buffer list (@@list, @@current, @@minibuffer)
68
+
69
+ **Window** (`lib/textbringer/window.rb`)
70
+ - Display abstraction using curses for terminal UI
71
+ - Multiple windows can display different buffers or the same buffer
72
+ - Window.current tracks the active window
73
+ - Echo area (@@echo_area) for messages and minibuffer input
74
+ - Manages cursor position and window splitting/deletion
75
+
76
+ **Controller** (`lib/textbringer/controller.rb`)
77
+ - The main event loop and command dispatcher
78
+ - Reads key sequences and dispatches to commands
79
+ - Handles prefix arguments, keyboard macros, and recursive editing
80
+ - Maintains command execution state (this_command, last_command)
81
+ - Pre/post command hooks for extensibility
82
+
83
+ **Mode** (`lib/textbringer/mode.rb`)
84
+ - Buffer modes define context-specific behavior and syntax highlighting
85
+ - Modes inherit from Mode class (FundamentalMode, ProgrammingMode, RubyMode, CMode, etc.)
86
+ - Each mode has its own keymap and syntax table
87
+ - `define_local_command` creates mode-specific commands
88
+ - Modes are automatically selected based on file_name_pattern or interpreter_name_pattern
89
+
90
+ **Keymap** (`lib/textbringer/keymap.rb`)
91
+ - Tree structure for key bindings (supports multi-stroke sequences)
92
+ - Uses `kbd()` function to parse Emacs-style key notation
93
+ - Key sequences can bind to commands (symbols) or nested keymaps
94
+
95
+ **Commands** (`lib/textbringer/commands.rb` and `lib/textbringer/commands/*.rb`)
96
+ - Commands are defined using `define_command(name, doc:)`
97
+ - Available as module functions in the Commands module
98
+ - Command groups: buffers, windows, files, isearch, replace, rectangle, etc.
99
+ - All commands accessible via Alt+x or key bindings
100
+
101
+ ### Plugin System
102
+
103
+ Plugins are loaded from `~/.textbringer/plugins/` via `Plugin.load_plugins`. Examples:
104
+ - Mournmail (mail client)
105
+ - MedicineShield (Mastodon client)
106
+ - textbringer-presentation
107
+ - textbringer-ghost_text
108
+
109
+ ### Configuration
110
+
111
+ User configuration is loaded from:
112
+ 1. `~/.textbringer/init.rb` (loaded first)
113
+ 2. `~/.textbringer.rb` (loaded after plugins)
114
+
115
+ Global configuration hash: `CONFIG` in `lib/textbringer/config.rb`
116
+
117
+ Key settings:
118
+ - `east_asian_ambiguous_width`: Character width (1 or 2)
119
+ - `tab_width`, `indent_tabs_mode`: Indentation
120
+ - `syntax_highlight`, `highlight_buffer_size_limit`: Syntax highlighting
121
+ - `default_input_method`: Input method for non-ASCII text
122
+
123
+ ### Input Methods
124
+
125
+ Support for non-ASCII input:
126
+ - T-Code (`lib/textbringer/input_methods/t_code_input_method.rb`)
127
+ - Hiragana (`lib/textbringer/input_methods/hiragana_input_method.rb`)
128
+ - Hangul (`lib/textbringer/input_methods/hangul_input_method.rb`)
129
+
130
+ ### Testing Infrastructure
131
+
132
+ Tests use `Test::Unit` with custom `Textbringer::TestCase` base class in `test/test_helper.rb`.
133
+
134
+ Key test helpers:
135
+ - `FakeController`: Test controller with `test_key_buffer` for simulating input
136
+ - `FakeCursesWindow`: Mock curses window for headless testing
137
+ - `push_keys(keys)`: Simulate keyboard input
138
+ - `mkcdtmpdir`: Create temporary directory for file tests
139
+ - `Window.setup_for_test`: Initialize test environment
140
+
141
+ Tests are organized mirroring lib structure: `test/textbringer/**/*`.
142
+
143
+ ## Code Patterns
144
+
145
+ ### Defining Commands
146
+ ```ruby
147
+ define_command(:command_name, doc: "Description") do
148
+ # Command implementation
149
+ # Access current buffer: Buffer.current
150
+ # Get prefix arg: current_prefix_arg
151
+ end
152
+ ```
153
+
154
+ ### Mode-Specific Commands
155
+ ```ruby
156
+ class MyMode < Mode
157
+ define_local_command(:my_command) do
158
+ # Mode-specific implementation
159
+ end
160
+ end
161
+ ```
162
+
163
+ ### Key Bindings
164
+ ```ruby
165
+ GLOBAL_MAP.define_key("\C-x\C-f", :find_file)
166
+ MODE_MAP.define_key("C-c C-c", :compile)
167
+ ```
168
+
169
+ ### Buffer Operations
170
+ - Always use `Buffer.current` to get the active buffer
171
+ - `@buffer.point` is the cursor position
172
+ - `@buffer.mark` for region operations
173
+ - `@buffer.insert(text)`, `@buffer.delete_char(n)` for modifications
174
+ - `@buffer.save_point` and `@buffer.goto_char(pos)` for navigation
175
+
176
+ ### Window Management
177
+ - `Window.current` is the active window
178
+ - `Window.redisplay` updates the display
179
+ - `Window.echo_area` for messages
180
+ - `message(text)` to display in echo area
181
+
182
+ ## File Organization
183
+
184
+ - `lib/textbringer.rb`: Main entry point, requires all components
185
+ - `lib/textbringer/commands/*.rb`: Command implementations by category
186
+ - `lib/textbringer/modes/*.rb`: Major and minor modes
187
+ - `lib/textbringer/faces/*.rb`: Syntax highlighting face definitions
188
+ - `exe/txtb`: Main executable
189
+ - `exe/tbclient`: Client for server mode
190
+ - `exe/tbtags`: Tag file generator
191
+
192
+ ## Notes
193
+
194
+ - The editor is designed to mimic Emacs conventions and terminology
195
+ - Key sequences use Emacs notation: C- (Control), M- (Meta/Alt), S- (Shift)
196
+ - The codebase uses extensive metaprogramming for command registration and mode definition
197
+ - All user-facing text editing operations should go through Buffer methods to maintain undo/redo support
@@ -10,7 +10,8 @@ module Textbringer
10
10
 
11
11
  attr_accessor :mode, :keymap
12
12
  attr_reader :name, :file_name, :file_encoding, :file_format, :point, :marks
13
- attr_reader :current_line, :current_column, :visible_mark
13
+ attr_reader :current_line, :current_column, :visible_mark, :mark_active
14
+ attr_reader :last_match
14
15
  attr_reader :input_method
15
16
 
16
17
  GAP_SIZE = 256
@@ -253,8 +254,10 @@ module Textbringer
253
254
  @save_point_level = 0
254
255
  @match_offsets = []
255
256
  @visible_mark = nil
257
+ @mark_active = false
256
258
  @read_only = read_only
257
259
  @callbacks = {}
260
+ @last_match = nil
258
261
  @input_method = nil
259
262
  end
260
263
 
@@ -945,6 +948,20 @@ module Textbringer
945
948
  end
946
949
  end
947
950
 
951
+ def activate_mark
952
+ @mark_active = true
953
+ set_visible_mark(@mark.location) if @mark
954
+ end
955
+
956
+ def deactivate_mark
957
+ @mark_active = false
958
+ delete_visible_mark
959
+ end
960
+
961
+ def mark_active?
962
+ @mark_active
963
+ end
964
+
948
965
  def self.region_boundaries(s, e)
949
966
  if s > e
950
967
  [e, s]
@@ -1090,203 +1107,6 @@ module Textbringer
1090
1107
  insert_for_yank(KILL_RING.rotate(1))
1091
1108
  end
1092
1109
 
1093
- # Returns start_line, start_col, end_line, and end_col of the rectangle region
1094
- # Note that start_col and end_col are 0-origin and width-based (neither 1-origin nor codepoint-based)
1095
- def rectangle_boundaries(s = @point, e = mark)
1096
- s, e = Buffer.region_boundaries(s, e)
1097
- save_excursion do
1098
- goto_char(s)
1099
- start_line = @current_line
1100
- beginning_of_line
1101
- start_col = display_width(substring(@point, s))
1102
- goto_char(e)
1103
- end_line = @current_line
1104
- beginning_of_line
1105
- end_col = display_width(substring(@point, e))
1106
-
1107
- # Ensure start_col <= end_col
1108
- if start_col > end_col
1109
- start_col, end_col = end_col, start_col
1110
- end
1111
- [start_line, start_col, end_line, end_col]
1112
- end
1113
- end
1114
-
1115
- def apply_on_rectangle(s = @point, e = mark, reverse: false)
1116
- start_line, start_col, end_line, end_col = rectangle_boundaries(s, e)
1117
-
1118
- save_excursion do
1119
- composite_edit do
1120
- if reverse
1121
- goto_line(end_line)
1122
- else
1123
- goto_line(start_line)
1124
- end
1125
-
1126
- loop do
1127
- beginning_of_line
1128
- line_start = @point
1129
-
1130
- # Move to start column
1131
- col = 0
1132
- while col < start_col && !end_of_line?
1133
- forward_char
1134
- col = display_width(substring(line_start, @point))
1135
- end
1136
-
1137
- yield(start_col, end_col, col, line_start)
1138
-
1139
- # Move to next line for forward iteration
1140
- if reverse
1141
- break if @current_line <= start_line
1142
- backward_line
1143
- else
1144
- break if @current_line >= end_line
1145
- forward_line
1146
- end
1147
- end
1148
- end
1149
- end
1150
- end
1151
-
1152
- def extract_rectangle(s = @point, e = mark)
1153
- lines = []
1154
- apply_on_rectangle(s, e) do |start_col, end_col, col, line_start|
1155
- start_pos = @point
1156
- width = end_col - start_col
1157
-
1158
- # If we haven't reached start_col, the line is too short
1159
- if col < start_col
1160
- # Line is shorter than start column, extract all spaces
1161
- lines << " " * width
1162
- else
1163
- # Move to end column
1164
- while col < end_col && !end_of_line?
1165
- forward_char
1166
- col = display_width(substring(line_start, @point))
1167
- end
1168
- end_pos = @point
1169
-
1170
- # Extract the rectangle text for this line
1171
- if end_pos > start_pos
1172
- extracted = substring(start_pos, end_pos)
1173
- # Pad with spaces if the extracted text is shorter than rectangle width
1174
- extracted_width = display_width(extracted)
1175
- if extracted_width < width
1176
- extracted += " " * (width - extracted_width)
1177
- end
1178
- lines << extracted
1179
- else
1180
- lines << " " * width
1181
- end
1182
- end
1183
- end
1184
-
1185
- lines
1186
- end
1187
-
1188
- def copy_rectangle(s = @point, e = mark)
1189
- lines = extract_rectangle(s, e)
1190
- @@killed_rectangle = lines
1191
- end
1192
-
1193
- def kill_rectangle(s = @point, e = mark)
1194
- copy_rectangle(s, e)
1195
- delete_rectangle(s, e)
1196
- end
1197
-
1198
- def delete_rectangle(s = @point, e = mark)
1199
- check_read_only_flag
1200
-
1201
- apply_on_rectangle(s, e, reverse: true) do |start_col, end_col, col, line_start|
1202
- start_pos = @point
1203
-
1204
- # Only delete if we're within the line bounds
1205
- if col >= start_col
1206
- # Move to end column
1207
- while col < end_col && !end_of_line?
1208
- forward_char
1209
- col = display_width(substring(line_start, @point))
1210
- end
1211
- end_pos = @point
1212
-
1213
- # Delete the rectangle text for this line
1214
- if end_pos > start_pos
1215
- delete_region(start_pos, end_pos)
1216
- end
1217
- end
1218
- end
1219
- end
1220
-
1221
- def yank_rectangle
1222
- raise "No rectangle in kill ring" if @@killed_rectangle.nil?
1223
- lines = @@killed_rectangle
1224
- start_line = @current_line
1225
- start_point = @point
1226
- start_col = save_excursion {
1227
- beginning_of_line
1228
- display_width(substring(@point, start_point))
1229
- }
1230
- composite_edit do
1231
- lines.each_with_index do |line, i|
1232
- goto_line(start_line + i)
1233
- beginning_of_line
1234
- line_start = @point
1235
-
1236
- # Move to start column, extending line if necessary
1237
- col = 0
1238
- while col < start_col && !end_of_line?
1239
- forward_char
1240
- col = display_width(substring(line_start, @point))
1241
- end
1242
-
1243
- # If line is shorter than start_col, extend it with spaces
1244
- if col < start_col
1245
- insert(" " * (start_col - col))
1246
- end
1247
-
1248
- # Insert the rectangle line
1249
- insert(line)
1250
- end
1251
- end
1252
- end
1253
-
1254
- def open_rectangle(s = @point, e = mark)
1255
- check_read_only_flag
1256
- s, e = Buffer.region_boundaries(s, e)
1257
- composite_edit do
1258
- apply_on_rectangle(s, e) do |start_col, end_col, col, line_start|
1259
- # If line is shorter than start_col, extend it with spaces
1260
- if col < start_col
1261
- insert(" " * (start_col - col))
1262
- end
1263
-
1264
- # Insert spaces to create the rectangle
1265
- insert(" " * (end_col - start_col))
1266
- end
1267
- goto_char(s)
1268
- end
1269
- end
1270
-
1271
- def clear_rectangle(s = @point, e = mark)
1272
- check_read_only_flag
1273
- apply_on_rectangle(s, e, reverse: true) do |start_col, end_col, col, line_start|
1274
- start_pos = @point
1275
- if col < start_col
1276
- insert(" " * (end_col - start_col))
1277
- else
1278
- while col < end_col && !end_of_line?
1279
- forward_char
1280
- col = display_width(substring(line_start, @point))
1281
- end
1282
- end_pos = @point
1283
-
1284
- delete_region(start_pos, end_pos) if end_pos > start_pos
1285
- insert(" " * (end_col - start_col))
1286
- end
1287
- end
1288
- end
1289
-
1290
1110
  def undo
1291
1111
  undo_or_redo(:undo, @undo_stack, @redo_stack)
1292
1112
  end
@@ -1295,7 +1115,7 @@ module Textbringer
1295
1115
  undo_or_redo(:redo, @redo_stack, @undo_stack)
1296
1116
  end
1297
1117
 
1298
- def re_search_forward(s, raise_error: true, count: 1)
1118
+ def re_search_forward(s, raise_error: true, goto_beginning: false, count: 1)
1299
1119
  if count < 0
1300
1120
  return re_search_backward(s, raise_error: raise_error, count: -count)
1301
1121
  end
@@ -1310,7 +1130,7 @@ module Textbringer
1310
1130
  return nil
1311
1131
  end
1312
1132
  end
1313
- pos = match_end(0)
1133
+ pos = goto_beginning ? match_beginning(0) : match_end(0)
1314
1134
  end
1315
1135
  goto_char(pos)
1316
1136
  end
@@ -1356,6 +1176,7 @@ module Textbringer
1356
1176
 
1357
1177
  def byteindex(forward, re, pos)
1358
1178
  @match_offsets = []
1179
+ @last_match = nil
1359
1180
  method = forward ? :byteindex : :byterindex
1360
1181
  adjust_gap(0, 0)
1361
1182
  s = @contents.byteslice(@gap_end..-1)
@@ -1364,9 +1185,9 @@ module Textbringer
1364
1185
  end
1365
1186
  i = s.send(method, re, pos)
1366
1187
  if i
1367
- m = Regexp.last_match
1368
- (0 .. m.size - 1).each do |j|
1369
- @match_offsets.push(m.byteoffset(j))
1188
+ @last_match = Regexp.last_match
1189
+ (0 .. @last_match.size - 1).each do |j|
1190
+ @match_offsets.push(@last_match.byteoffset(j))
1370
1191
  end
1371
1192
  i
1372
1193
  else
@@ -1491,6 +1312,10 @@ module Textbringer
1491
1312
  end
1492
1313
  end
1493
1314
 
1315
+ def minor_mode_active?(mode_class)
1316
+ @minor_modes.any? { |mode| mode.instance_of?(mode_class) }
1317
+ end
1318
+
1494
1319
  def mode_names
1495
1320
  names = []
1496
1321
  names.push(mode&.name || 'None')
@@ -1948,7 +1773,6 @@ module Textbringer
1948
1773
  end
1949
1774
 
1950
1775
  KILL_RING = Ring.new
1951
- @@killed_rectangle = nil
1952
1776
 
1953
1777
  class UndoableAction
1954
1778
  attr_accessor :version
@@ -93,7 +93,12 @@ module Textbringer
93
93
 
94
94
  define_command(:exchange_point_and_mark,
95
95
  doc: "Exchange the positions of point and mark.") do
96
- Buffer.current.exchange_point_and_mark
96
+ buffer = Buffer.current
97
+ buffer.exchange_point_and_mark
98
+ # Activate mark if transient mark mode is enabled
99
+ if TransientMarkMode.enabled?
100
+ buffer.activate_mark
101
+ end
97
102
  end
98
103
 
99
104
  define_command(:copy_region,
@@ -129,36 +134,6 @@ module Textbringer
129
134
  Buffer.current.delete_region
130
135
  end
131
136
 
132
- define_command(:kill_rectangle,
133
- doc: "Kill the text of the region-rectangle, saving its contents as the last killed rectangle.") do
134
- Buffer.current.kill_rectangle
135
- end
136
-
137
- define_command(:copy_rectangle_as_kill,
138
- doc: "Save the text of the region-rectangle as the last killed rectangle.") do
139
- Buffer.current.copy_rectangle
140
- end
141
-
142
- define_command(:delete_rectangle,
143
- doc: "Delete the text of the region-rectangle.") do
144
- Buffer.current.delete_rectangle
145
- end
146
-
147
- define_command(:yank_rectangle,
148
- doc: "Yank the last killed rectangle with its upper left corner at point.") do
149
- Buffer.current.yank_rectangle
150
- end
151
-
152
- define_command(:open_rectangle,
153
- doc: "Insert blank space to fill the space of the region-rectangle. This pushes the previous contents of the region-rectangle to the right.") do
154
- Buffer.current.open_rectangle
155
- end
156
-
157
- define_command(:clear_rectangle,
158
- doc: "Clear the region-rectangle by replacing its contents with spaces.") do
159
- Buffer.current.clear_rectangle
160
- end
161
-
162
137
  define_command(:transpose_chars,
163
138
  doc: "Transpose characters.") do
164
139
  Buffer.current.transpose_chars
@@ -177,6 +152,10 @@ module Textbringer
177
152
  buffer.pop_to_mark
178
153
  else
179
154
  buffer.push_mark
155
+ # Activate mark if transient mark mode is enabled
156
+ if TransientMarkMode.enabled?
157
+ buffer.activate_mark
158
+ end
180
159
  message("Mark set")
181
160
  end
182
161
  end
@@ -77,7 +77,10 @@ module Textbringer
77
77
  end
78
78
 
79
79
  def isearch_done
80
- Buffer.current.delete_visible_mark
80
+ # Don't delete visible_mark if mark is active (transient mark mode)
81
+ unless Buffer.current.mark_active?
82
+ Buffer.current.delete_visible_mark
83
+ end
81
84
  Controller.current.overriding_map = nil
82
85
  remove_hook(:pre_command_hook, :isearch_pre_command_hook)
83
86
  ISEARCH_STATUS[:last_string] = ISEARCH_STATUS[:string]
@@ -154,8 +157,11 @@ module Textbringer
154
157
  if Buffer.current != Buffer.minibuffer
155
158
  message(isearch_prompt + ISEARCH_STATUS[:string], log: false)
156
159
  end
157
- Buffer.current.set_visible_mark(forward ? match_beginning(0) :
158
- match_end(0))
160
+ # Don't update visible_mark if mark is already active (transient mark mode)
161
+ unless Buffer.current.mark_active?
162
+ Buffer.current.set_visible_mark(forward ? match_beginning(0) :
163
+ match_end(0))
164
+ end
159
165
  goto_char(forward ? match_end(0) : match_beginning(0))
160
166
  else
161
167
  if Buffer.current != Buffer.minibuffer