textbringer 14 → 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: 8b974ebb2c6338de9890aee4aa219529d5d81f9b838b21bb6e738ddf205015d5
4
- data.tar.gz: fe3ed3ec2825a26e9e0c6eaa777a6e5c53549ac604ffe38eceb653ca2e6ef6b2
3
+ metadata.gz: 5ad5b7d9a5cc2022075f802db23f24781dcb3be366a4ba3ee27015a66ada834d
4
+ data.tar.gz: 1933c54c72c4e189b4f0a6ba81d6186d7618c9eaaa3eccbe5b61925d8959c108
5
5
  SHA512:
6
- metadata.gz: ddcdd02dc9fd8d7397ef7994d000d470268aff0052dcab907a2a6cb073a9f39847180c1dfc40984e36d19e4bd64459cdeed8ab6ba493cdf4d1df542d9f4ac3cb
7
- data.tar.gz: cf0095973d9e53072a6243568ffe70aaf3a33b5091767c00ff0064ed9dcfc7f0f9c053b7d146abf69ec0cacb88f4495f16a283e05383246a4a84a001413b8cfd
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]
@@ -1159,6 +1176,7 @@ module Textbringer
1159
1176
 
1160
1177
  def byteindex(forward, re, pos)
1161
1178
  @match_offsets = []
1179
+ @last_match = nil
1162
1180
  method = forward ? :byteindex : :byterindex
1163
1181
  adjust_gap(0, 0)
1164
1182
  s = @contents.byteslice(@gap_end..-1)
@@ -1167,9 +1185,9 @@ module Textbringer
1167
1185
  end
1168
1186
  i = s.send(method, re, pos)
1169
1187
  if i
1170
- m = Regexp.last_match
1171
- (0 .. m.size - 1).each do |j|
1172
- @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))
1173
1191
  end
1174
1192
  i
1175
1193
  else
@@ -1294,6 +1312,10 @@ module Textbringer
1294
1312
  end
1295
1313
  end
1296
1314
 
1315
+ def minor_mode_active?(mode_class)
1316
+ @minor_modes.any? { |mode| mode.instance_of?(mode_class) }
1317
+ end
1318
+
1297
1319
  def mode_names
1298
1320
  names = []
1299
1321
  names.push(mode&.name || 'None')
@@ -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,
@@ -147,6 +152,10 @@ module Textbringer
147
152
  buffer.pop_to_mark
148
153
  else
149
154
  buffer.push_mark
155
+ # Activate mark if transient mark mode is enabled
156
+ if TransientMarkMode.enabled?
157
+ buffer.activate_mark
158
+ end
150
159
  message("Mark set")
151
160
  end
152
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
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "open3"
4
+ require "uri"
4
5
 
5
6
  module Textbringer
6
7
  module Commands
@@ -8,13 +9,17 @@ module Textbringer
8
9
  def initialize
9
10
  @personal_dictionary_modified = false
10
11
  @stdin, @stdout, @stderr, @wait_thr =
11
- Open3.popen3("aspell -a")
12
+ Open3.popen3(CONFIG[:ispell_command])
12
13
  @stdout.gets # consume the banner
13
14
  end
14
15
 
15
16
  def check_word(word)
16
17
  send_command("^" + word)
17
18
  result = @stdout.gets
19
+ if result.nil? || result == "\n"
20
+ # aspell can't handle word, which may contain multibyte characters
21
+ return [word, nil]
22
+ end
18
23
  @stdout.gets
19
24
  case result
20
25
  when /\A&\s+([^\s]+)\s+\d+\s+\d+:\s+(.*)/
@@ -70,66 +75,54 @@ module Textbringer
70
75
  ISPELL_MODE_MAP.define_key(?i, :ispell_insert)
71
76
  ISPELL_MODE_MAP.define_key(" ", :ispell_skip)
72
77
  ISPELL_MODE_MAP.define_key(?q, :ispell_quit)
78
+ ISPELL_MODE_MAP.define_key("\C-g", :ispell_quit)
73
79
 
74
80
  ISPELL_STATUS = {}
75
81
 
76
- ISPELL_WORD_REGEXP = /[A-Za-z]+/
77
-
78
- define_command(:ispell_word) do
79
- buffer = Buffer.current
80
- word = buffer.save_excursion {
81
- while !buffer.beginning_of_buffer? && buffer.char_after =~ /[A-Za-z]/
82
- buffer.backward_char
83
- end
84
- buffer.re_search_forward(/[A-Za-z]+/, raise_error: false) &&
85
- buffer.match_string(0)
86
- }
87
- if word.nil?
88
- message("No word at point.")
89
- return
90
- end
91
- start_pos = buffer.match_beginning(0)
92
- end_pos = buffer.match_end(0)
93
- ispell = Ispell.new
94
- begin
95
- _original, suggestions = ispell.check_word(word)
96
- if suggestions.nil? || suggestions.empty?
97
- message("#{word.inspect} is spelled correctly.")
98
- else
99
- s = read_from_minibuffer("Correct #{word} with: ",
100
- completion_proc: ->(s) {
101
- suggestions.grep(/^#{Regexp.quote(s)}/)
102
- })
103
- if s
104
- buffer.composite_edit do
105
- buffer.delete_region(start_pos, end_pos)
106
- buffer.insert(s)
107
- end
108
- end
109
- end
110
- ensure
111
- ispell.close
112
- end
113
- end
82
+ URI_REGEXP = URI::RFC2396_PARSER.make_regexp(["http", "https", "ftp", "mailto"])
83
+ EMAIL_REGEXP = /
84
+ # local-part
85
+ ( # dot-atom
86
+ (?<atom>[0-9a-z!\#$%&'*+\-\/=?^_`{|}~]+)
87
+ (\.\g<atom>)*
88
+ | # quoted-string
89
+ \"([\x20\x21\x23-\x5b\x5d-\x7e]
90
+ |\\[\x20-\x7e])*\"
91
+ )@
92
+ # domain
93
+ (?<sub_domain>[0-9a-z]([0-9a-z-]*[0-9a-z])?)
94
+ (\.\g<sub_domain>)*
95
+ /ix
96
+ ISPELL_WORD_REGEXP = /
97
+ (?<uri>#{URI_REGEXP})
98
+ | (?<email>#{EMAIL_REGEXP})
99
+ | (?<word>[[:alpha:]]+(?:'[[:alpha:]]+)*)
100
+ /x
114
101
 
115
102
  define_command(:ispell_buffer) do |recursive_edit: false|
103
+ ISPELL_STATUS[:recursive_edit] = false
116
104
  Buffer.current.beginning_of_buffer
117
105
  ispell_mode
118
- ispell_forward
119
- ISPELL_STATUS[:recursive_edit] = recursive_edit
120
- if recursive_edit
121
- recursive_edit()
106
+ if !ispell_forward
107
+ ISPELL_STATUS[:recursive_edit] = recursive_edit
108
+ if recursive_edit
109
+ recursive_edit()
110
+ end
122
111
  end
123
112
  end
124
113
 
125
114
  def ispell_done
126
- Buffer.current.delete_visible_mark
115
+ # Don't delete visible_mark if mark is active (transient mark mode)
116
+ unless Buffer.current.mark_active?
117
+ Buffer.current.delete_visible_mark
118
+ end
127
119
  Controller.current.overriding_map = nil
128
120
  ISPELL_STATUS[:ispell]&.close
129
121
  ISPELL_STATUS[:ispell] = nil
130
122
  if ISPELL_STATUS[:recursive_edit]
131
123
  exit_recursive_edit
132
124
  end
125
+ ISPELL_STATUS[:recursive_edit] = false
133
126
  end
134
127
 
135
128
  def ispell_mode
@@ -141,8 +134,15 @@ module Textbringer
141
134
  buffer = Buffer.current
142
135
  while buffer.re_search_forward(ISPELL_WORD_REGEXP, raise_error: false,
143
136
  goto_beginning: true)
137
+ if buffer.last_match[:word].nil?
138
+ buffer.goto_char(buffer.match_end(0))
139
+ next
140
+ end
144
141
  ispell_beginning = buffer.point
145
- buffer.set_visible_mark
142
+ # Don't update visible_mark if mark is already active (transient mark mode)
143
+ unless buffer.mark_active?
144
+ buffer.set_visible_mark
145
+ end
146
146
  buffer.goto_char(buffer.match_end(0))
147
147
  word = buffer.match_string(0)
148
148
  _original, suggestions = ISPELL_STATUS[:ispell].check_word(word)
@@ -150,9 +150,9 @@ module Textbringer
150
150
  ISPELL_STATUS[:beginning] = ispell_beginning
151
151
  ISPELL_STATUS[:word] = word
152
152
  ISPELL_STATUS[:suggestions] = suggestions
153
- message("Mispelled: #{word} [r]eplace, [a]ccept, [i]nsert, [SPC] to skip, [q]uit")
153
+ message_misspelled
154
154
  recenter
155
- return
155
+ return false
156
156
  end
157
157
  end
158
158
  Controller.current.overriding_map = nil
@@ -162,17 +162,25 @@ module Textbringer
162
162
  end
163
163
  message("Finished spelling check.")
164
164
  ispell_done
165
+ true
165
166
  end
166
167
 
167
168
  define_command(:ispell_replace) do
169
+ ensure_ispell_active
168
170
  word = ISPELL_STATUS[:word]
169
171
  suggestions = ISPELL_STATUS[:suggestions]
170
172
  Controller.current.overriding_map = nil
171
- s = read_from_minibuffer("Correct #{word} with: ",
172
- completion_proc: ->(s) {
173
- suggestions.grep(/^#{Regexp.quote(s)}/)
174
- })
175
- Controller.current.overriding_map = ISPELL_MODE_MAP
173
+ begin
174
+ s = read_from_minibuffer("Correct #{word} with: ",
175
+ completion_proc: ->(s) {
176
+ suggestions.grep(/^#{Regexp.quote(s)}/)
177
+ })
178
+ rescue Quit
179
+ message_misspelled
180
+ return
181
+ ensure
182
+ Controller.current.overriding_map = ISPELL_MODE_MAP
183
+ end
176
184
  if !s.empty?
177
185
  buffer = Buffer.current
178
186
  pos = buffer.point
@@ -186,28 +194,43 @@ module Textbringer
186
194
  end
187
195
 
188
196
  define_command(:ispell_accept) do
197
+ ensure_ispell_active
189
198
  ISPELL_STATUS[:ispell].add_to_session_dictionary(ISPELL_STATUS[:word])
190
199
  ispell_forward
191
200
  end
192
201
 
193
202
  define_command(:ispell_insert) do
203
+ ensure_ispell_active
194
204
  ISPELL_STATUS[:ispell].add_to_personal_dictionary(ISPELL_STATUS[:word])
195
205
  ispell_forward
196
206
  end
197
207
 
198
208
  define_command(:ispell_skip) do
209
+ ensure_ispell_active
199
210
  ispell_forward
200
211
  end
201
212
 
202
213
  define_command(:ispell_quit) do
214
+ ensure_ispell_active
203
215
  message("Quitting spell check.")
204
216
  ispell_done
205
217
  end
206
218
 
207
219
  define_command(:ispell_unknown_command) do
208
- word = ISPELL_STATUS[:word]
209
- message("Mispelled: #{word} [r]eplace, [a]ccept, [i]nsert, [SPC] to skip, [q]uit")
220
+ ensure_ispell_active
221
+ message_misspelled
210
222
  Window.beep
211
223
  end
224
+
225
+ def message_misspelled
226
+ word = ISPELL_STATUS[:word]
227
+ message("Misspelled: #{word} [r]eplace, [a]ccept, [i]nsert, [SPC] to skip, [q]uit")
228
+ end
229
+
230
+ def ensure_ispell_active
231
+ if ISPELL_STATUS[:ispell].nil?
232
+ raise EditorError, "ispell is not active"
233
+ end
234
+ end
212
235
  end
213
236
  end
@@ -82,10 +82,9 @@ module Textbringer
82
82
 
83
83
  def update_completions(xs)
84
84
  if xs.size > 1
85
- if COMPLETION[:original_buffer].nil?
85
+ if COMPLETION[:completions_window].nil?
86
+ Window.list.last.split
86
87
  COMPLETION[:completions_window] = Window.list.last
87
- COMPLETION[:original_buffer] =
88
- COMPLETION[:completions_window].buffer
89
88
  end
90
89
  completions = Buffer.find_or_new("*Completions*", undo_limit: 0)
91
90
  if !completions.mode.is_a?(CompletionListMode)
@@ -97,15 +96,13 @@ module Textbringer
97
96
  xs.each do |x|
98
97
  completions.insert(x + "\n")
99
98
  end
99
+ completions.beginning_of_buffer
100
100
  COMPLETION[:completions_window].buffer = completions
101
101
  ensure
102
102
  completions.read_only = true
103
103
  end
104
104
  else
105
- if COMPLETION[:original_buffer]
106
- COMPLETION[:completions_window].buffer =
107
- COMPLETION[:original_buffer]
108
- end
105
+ delete_completions_window
109
106
  end
110
107
  end
111
108
  private :update_completions
@@ -42,7 +42,10 @@ module Textbringer
42
42
  loop do
43
43
  re_search_forward(regexp)
44
44
  Window.current.recenter_if_needed
45
- Buffer.current.set_visible_mark(match_beginning(0))
45
+ # Don't update visible_mark if mark is already active (transient mark mode)
46
+ unless Buffer.current.mark_active?
47
+ Buffer.current.set_visible_mark(match_beginning(0))
48
+ end
46
49
  begin
47
50
  Window.redisplay
48
51
  c = read_single_char("Replace?", [?y, ?n, ?!, ?q, ?.])
@@ -66,7 +69,10 @@ module Textbringer
66
69
  break
67
70
  end
68
71
  ensure
69
- Buffer.current.delete_visible_mark
72
+ # Don't delete visible_mark if mark is active (transient mark mode)
73
+ unless Buffer.current.mark_active?
74
+ Buffer.current.delete_visible_mark
75
+ end
70
76
  end
71
77
  end
72
78
  rescue SearchError
@@ -15,6 +15,7 @@ module Textbringer
15
15
  shell_file_name: ENV["SHELL"],
16
16
  shell_command_switch: "-c",
17
17
  grep_command: "grep -nH -e",
18
+ ispell_command: "aspell -a",
18
19
  fill_column: 70,
19
20
  read_file_name_completion_ignore_case: RUBY_PLATFORM.match?(/darwin/),
20
21
  default_input_method: "t_code"
@@ -0,0 +1,58 @@
1
+ module Textbringer
2
+ # Base class for global minor modes that affect all buffers.
3
+ # Unlike buffer-local MinorMode, global minor modes have a single on/off state.
4
+ class GlobalMinorMode
5
+ extend Commands
6
+ include Commands
7
+
8
+ class << self
9
+ attr_accessor :mode_name
10
+ attr_accessor :command_name
11
+
12
+ def enabled=(val)
13
+ @enabled = val
14
+ end
15
+
16
+ def enabled? = @enabled
17
+ end
18
+
19
+ def self.inherited(child)
20
+ # Initialize enabled to false immediately
21
+ child.instance_variable_set(:@enabled, false)
22
+
23
+ class_name = child.name
24
+ if class_name.nil? || class_name.empty?
25
+ raise ArgumentError, "GlobalMinorMode subclasses must be named classes (anonymous classes are not supported)"
26
+ end
27
+
28
+ base_name = class_name.slice(/[^:]*\z/)
29
+ child.mode_name = base_name.sub(/Mode\z/, "")
30
+ command_name = base_name.sub(/\A[A-Z]/) { |s| s.downcase }.
31
+ gsub(/(?<=[a-z])([A-Z])/) {
32
+ "_" + $1.downcase
33
+ }
34
+ command = command_name.intern
35
+ child.command_name = command
36
+
37
+ # Define the toggle command
38
+ define_command(command) do
39
+ if child.enabled?
40
+ child.disable
41
+ child.enabled = false
42
+ else
43
+ child.enable
44
+ child.enabled = true
45
+ end
46
+ end
47
+ end
48
+
49
+ # Override these in subclasses
50
+ def self.enable
51
+ raise EditorError, "Subclass must implement enable"
52
+ end
53
+
54
+ def self.disable
55
+ raise EditorError, "Subclass must implement disable"
56
+ end
57
+ end
58
+ end
@@ -24,10 +24,7 @@ module Textbringer
24
24
  if s.size > 0
25
25
  Window.current = Window.echo_area
26
26
  complete_minibuffer_with_string(s)
27
- if COMPLETION[:original_buffer]
28
- COMPLETION[:completions_window].buffer =
29
- COMPLETION[:original_buffer]
30
- end
27
+ delete_completions_window
31
28
  end
32
29
  end
33
30
  end
@@ -0,0 +1,101 @@
1
+ require "set"
2
+
3
+ module Textbringer
4
+ # Transient Mark Mode is a global minor mode that highlights
5
+ # the region between mark and point when the mark is active.
6
+ class TransientMarkMode < GlobalMinorMode
7
+ # Commands that should NOT deactivate the mark
8
+ MARK_PRESERVING_COMMANDS = [
9
+ :set_mark_command,
10
+ :exchange_point_and_mark,
11
+ :transient_mark_mode,
12
+ # Navigation commands that should preserve mark
13
+ :beginning_of_line,
14
+ :end_of_line,
15
+ :next_line,
16
+ :previous_line,
17
+ :forward_char,
18
+ :backward_char,
19
+ :forward_word,
20
+ :backward_word,
21
+ :scroll_up_command,
22
+ :scroll_down_command,
23
+ :beginning_of_buffer,
24
+ :end_of_buffer,
25
+ # Search commands
26
+ :isearch_forward,
27
+ :isearch_backward,
28
+ :isearch_repeat_forward,
29
+ :isearch_repeat_backward,
30
+ :isearch_printing_char,
31
+ # Undo/redo
32
+ :undo,
33
+ :redo_command,
34
+ # Other navigation
35
+ :goto_line,
36
+ :goto_char,
37
+ :recenter,
38
+ :move_to_beginning_of_line,
39
+ :move_to_end_of_line
40
+ ].to_set.freeze
41
+
42
+ # Hook to deactivate mark before most commands
43
+ PRE_COMMAND_HOOK = -> {
44
+ buffer = Buffer.current
45
+ controller = Controller.current
46
+
47
+ return unless buffer.mark_active?
48
+
49
+ # Check if this command preserves the mark
50
+ command = controller.this_command
51
+ unless MARK_PRESERVING_COMMANDS.include?(command)
52
+ buffer.deactivate_mark
53
+ end
54
+ }
55
+
56
+ # Hook to update visible mark after commands
57
+ POST_COMMAND_HOOK = -> {
58
+ buffer = Buffer.current
59
+
60
+ # Skip if in isearch or ispell mode (they manage their own highlighting)
61
+ controller = Controller.current
62
+ if controller.overriding_map
63
+ return if Commands.const_defined?(:ISEARCH_MODE_MAP) &&
64
+ controller.overriding_map == Commands::ISEARCH_MODE_MAP
65
+ return if Commands.const_defined?(:ISPELL_MODE_MAP) &&
66
+ controller.overriding_map == Commands::ISPELL_MODE_MAP
67
+ end
68
+
69
+ # Update visible_mark to reflect mark_active state
70
+ if buffer.mark_active?
71
+ begin
72
+ mark = buffer.mark
73
+ buffer.set_visible_mark(mark.location) if mark
74
+ rescue EditorError
75
+ # Mark is not set, do nothing
76
+ end
77
+ elsif !buffer.mark_active? && buffer.visible_mark
78
+ buffer.delete_visible_mark
79
+ end
80
+ }
81
+
82
+ def self.enable
83
+ # Add global hooks (not buffer-local)
84
+ add_hook(:pre_command_hook, PRE_COMMAND_HOOK, local: false)
85
+ add_hook(:post_command_hook, POST_COMMAND_HOOK, local: false)
86
+ message("Transient Mark mode enabled")
87
+ end
88
+
89
+ def self.disable
90
+ # Remove global hooks
91
+ remove_hook(:pre_command_hook, PRE_COMMAND_HOOK, local: false)
92
+ remove_hook(:post_command_hook, POST_COMMAND_HOOK, local: false)
93
+
94
+ # Deactivate mark in all buffers
95
+ Buffer.list.each do |buffer|
96
+ buffer.deactivate_mark
97
+ end
98
+ message("Transient Mark mode disabled")
99
+ end
100
+ end
101
+ end
@@ -134,7 +134,6 @@ module Textbringer
134
134
  end
135
135
 
136
136
  COMPLETION = {
137
- original_buffer: nil,
138
137
  completions_window: nil
139
138
  }
140
139
 
@@ -186,11 +185,14 @@ module Textbringer
186
185
  Buffer.minibuffer.keymap = old_minibuffer_map
187
186
  Buffer.minibuffer.disable_input_method
188
187
  Controller.current.current_prefix_arg = old_current_prefix_arg
189
- if COMPLETION[:original_buffer]
190
- COMPLETION[:completions_window].buffer = COMPLETION[:original_buffer]
191
- COMPLETION[:completions_window] = nil
192
- COMPLETION[:original_buffer] = nil
193
- end
188
+ delete_completions_window
189
+ end
190
+ end
191
+
192
+ def delete_completions_window
193
+ if COMPLETION[:completions_window]
194
+ Window.delete_window(COMPLETION[:completions_window])
195
+ COMPLETION[:completions_window] = nil
194
196
  end
195
197
  end
196
198
 
@@ -1,3 +1,3 @@
1
1
  module Textbringer
2
- VERSION = "14"
2
+ VERSION = "15"
3
3
  end
@@ -405,7 +405,7 @@ module Textbringer
405
405
  @window.erase
406
406
  @window.setpos(0, 0)
407
407
  @window.attrset(0)
408
- if @buffer.visible_mark &&
408
+ if current? && @buffer.visible_mark &&
409
409
  @buffer.point_after_mark?(@buffer.visible_mark)
410
410
  @window.attron(Curses::A_REVERSE)
411
411
  end
@@ -450,7 +450,7 @@ module Textbringer
450
450
  break if newx == columns && cury == lines - 2
451
451
  @buffer.forward_char
452
452
  end
453
- if @buffer.visible_mark
453
+ if current? && @buffer.visible_mark
454
454
  @window.attroff(Curses::A_REVERSE)
455
455
  end
456
456
  @buffer.mark_to_point(@bottom_of_window)
@@ -679,7 +679,7 @@ module Textbringer
679
679
  if @buffer.point_at_mark?(point)
680
680
  @cursor.y = cury
681
681
  @cursor.x = curx
682
- if @buffer.visible_mark
682
+ if current? && @buffer.visible_mark
683
683
  if @buffer.point_after_mark?(@buffer.visible_mark)
684
684
  @window.attroff(Curses::A_REVERSE)
685
685
  elsif @buffer.point_before_mark?(@buffer.visible_mark)
@@ -687,7 +687,7 @@ module Textbringer
687
687
  end
688
688
  end
689
689
  end
690
- if @buffer.visible_mark &&
690
+ if current? && @buffer.visible_mark &&
691
691
  @buffer.point_at_mark?(@buffer.visible_mark)
692
692
  if @buffer.point_after_mark?(point)
693
693
  @window.attroff(Curses::A_REVERSE)
data/lib/textbringer.rb CHANGED
@@ -37,7 +37,9 @@ require_relative "textbringer/modes/completion_list_mode"
37
37
  require_relative "textbringer/modes/buffer_list_mode"
38
38
  require_relative "textbringer/modes/help_mode"
39
39
  require_relative "textbringer/minor_mode"
40
+ require_relative "textbringer/global_minor_mode"
40
41
  require_relative "textbringer/modes/overwrite_mode"
42
+ require_relative "textbringer/modes/transient_mark_mode"
41
43
  require_relative "textbringer/input_method"
42
44
  require_relative "textbringer/input_methods/t_code_input_method"
43
45
  require_relative "textbringer/input_methods/hiragana_input_method"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: textbringer
3
3
  version: !ruby/object:Gem::Version
4
- version: '14'
4
+ version: '15'
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shugo Maeda
@@ -329,6 +329,7 @@ extensions: []
329
329
  extra_rdoc_files: []
330
330
  files:
331
331
  - CHANGES.md
332
+ - CLAUDE.md
332
333
  - Gemfile
333
334
  - LICENSE.txt
334
335
  - README.ja.md
@@ -368,6 +369,7 @@ files:
368
369
  - lib/textbringer/face.rb
369
370
  - lib/textbringer/faces/basic.rb
370
371
  - lib/textbringer/faces/programming.rb
372
+ - lib/textbringer/global_minor_mode.rb
371
373
  - lib/textbringer/input_method.rb
372
374
  - lib/textbringer/input_methods/hangul_input_method.rb
373
375
  - lib/textbringer/input_methods/hiragana_input_method.rb
@@ -385,6 +387,7 @@ files:
385
387
  - lib/textbringer/modes/overwrite_mode.rb
386
388
  - lib/textbringer/modes/programming_mode.rb
387
389
  - lib/textbringer/modes/ruby_mode.rb
390
+ - lib/textbringer/modes/transient_mark_mode.rb
388
391
  - lib/textbringer/plugin.rb
389
392
  - lib/textbringer/ring.rb
390
393
  - lib/textbringer/utils.rb