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.
@@ -0,0 +1,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "uri"
5
+
6
+ module Textbringer
7
+ module Commands
8
+ class Ispell
9
+ def initialize
10
+ @personal_dictionary_modified = false
11
+ @stdin, @stdout, @stderr, @wait_thr =
12
+ Open3.popen3(CONFIG[:ispell_command])
13
+ @stdout.gets # consume the banner
14
+ end
15
+
16
+ def check_word(word)
17
+ send_command("^" + word)
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
23
+ @stdout.gets
24
+ case result
25
+ when /\A&\s+([^\s]+)\s+\d+\s+\d+:\s+(.*)/
26
+ [$1, $2.split(/, /)]
27
+ when /\A\*/, /\A\+/, /\A\-/, /\A\%/
28
+ [word, []]
29
+ when /\A#/
30
+ [word, nil]
31
+ else
32
+ raise "unexpected output from aspell: #{result}"
33
+ end
34
+ end
35
+
36
+ def add_to_session_dictionary(word)
37
+ send_command("@" + word)
38
+ end
39
+
40
+ def add_to_personal_dictionary(word)
41
+ send_command("*" + word)
42
+ @personal_dictionary_modified = true
43
+ end
44
+
45
+ def personal_dictionary_modified?
46
+ @personal_dictionary_modified
47
+ end
48
+
49
+ def save_personal_dictionary
50
+ send_command("#")
51
+ end
52
+
53
+ def send_command(line)
54
+ @stdin.puts(line)
55
+ @stdin.flush
56
+ end
57
+
58
+ def close
59
+ @stdin.close
60
+ @stdout.close
61
+ @stderr.close
62
+ end
63
+ end
64
+
65
+ define_keymap :ISPELL_MODE_MAP
66
+ (?\x20..?\x7e).each do |c|
67
+ ISPELL_MODE_MAP.define_key(c, :ispell_unknown_command)
68
+ end
69
+ ISPELL_MODE_MAP.define_key(?\t, :ispell_unknown_command)
70
+ ISPELL_MODE_MAP.handle_undefined_key do |key|
71
+ ispell_unknown_command
72
+ end
73
+ ISPELL_MODE_MAP.define_key(?r, :ispell_replace)
74
+ ISPELL_MODE_MAP.define_key(?a, :ispell_accept)
75
+ ISPELL_MODE_MAP.define_key(?i, :ispell_insert)
76
+ ISPELL_MODE_MAP.define_key(" ", :ispell_skip)
77
+ ISPELL_MODE_MAP.define_key(?q, :ispell_quit)
78
+ ISPELL_MODE_MAP.define_key("\C-g", :ispell_quit)
79
+
80
+ ISPELL_STATUS = {}
81
+
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
101
+
102
+ define_command(:ispell_buffer) do |recursive_edit: false|
103
+ ISPELL_STATUS[:recursive_edit] = false
104
+ Buffer.current.beginning_of_buffer
105
+ ispell_mode
106
+ if !ispell_forward
107
+ ISPELL_STATUS[:recursive_edit] = recursive_edit
108
+ if recursive_edit
109
+ recursive_edit()
110
+ end
111
+ end
112
+ end
113
+
114
+ def ispell_done
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
119
+ Controller.current.overriding_map = nil
120
+ ISPELL_STATUS[:ispell]&.close
121
+ ISPELL_STATUS[:ispell] = nil
122
+ if ISPELL_STATUS[:recursive_edit]
123
+ exit_recursive_edit
124
+ end
125
+ ISPELL_STATUS[:recursive_edit] = false
126
+ end
127
+
128
+ def ispell_mode
129
+ ISPELL_STATUS[:ispell] = Ispell.new
130
+ Controller.current.overriding_map = ISPELL_MODE_MAP
131
+ end
132
+
133
+ def ispell_forward
134
+ buffer = Buffer.current
135
+ while buffer.re_search_forward(ISPELL_WORD_REGEXP, raise_error: false,
136
+ goto_beginning: true)
137
+ if buffer.last_match[:word].nil?
138
+ buffer.goto_char(buffer.match_end(0))
139
+ next
140
+ end
141
+ ispell_beginning = buffer.point
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
+ buffer.goto_char(buffer.match_end(0))
147
+ word = buffer.match_string(0)
148
+ _original, suggestions = ISPELL_STATUS[:ispell].check_word(word)
149
+ if !suggestions.nil? && !suggestions.empty?
150
+ ISPELL_STATUS[:beginning] = ispell_beginning
151
+ ISPELL_STATUS[:word] = word
152
+ ISPELL_STATUS[:suggestions] = suggestions
153
+ message_misspelled
154
+ recenter
155
+ return false
156
+ end
157
+ end
158
+ Controller.current.overriding_map = nil
159
+ if ISPELL_STATUS[:ispell]&.personal_dictionary_modified? &&
160
+ y_or_n?("Personal dictionary modified. Save?")
161
+ ISPELL_STATUS[:ispell].save_personal_dictionary
162
+ end
163
+ message("Finished spelling check.")
164
+ ispell_done
165
+ true
166
+ end
167
+
168
+ define_command(:ispell_replace) do
169
+ ensure_ispell_active
170
+ word = ISPELL_STATUS[:word]
171
+ suggestions = ISPELL_STATUS[:suggestions]
172
+ Controller.current.overriding_map = nil
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
184
+ if !s.empty?
185
+ buffer = Buffer.current
186
+ pos = buffer.point
187
+ buffer.goto_char(ISPELL_STATUS[:beginning])
188
+ buffer.composite_edit do
189
+ buffer.delete_region(buffer.point, pos)
190
+ buffer.insert(s)
191
+ end
192
+ end
193
+ ispell_forward
194
+ end
195
+
196
+ define_command(:ispell_accept) do
197
+ ensure_ispell_active
198
+ ISPELL_STATUS[:ispell].add_to_session_dictionary(ISPELL_STATUS[:word])
199
+ ispell_forward
200
+ end
201
+
202
+ define_command(:ispell_insert) do
203
+ ensure_ispell_active
204
+ ISPELL_STATUS[:ispell].add_to_personal_dictionary(ISPELL_STATUS[:word])
205
+ ispell_forward
206
+ end
207
+
208
+ define_command(:ispell_skip) do
209
+ ensure_ispell_active
210
+ ispell_forward
211
+ end
212
+
213
+ define_command(:ispell_quit) do
214
+ ensure_ispell_active
215
+ message("Quitting spell check.")
216
+ ispell_done
217
+ end
218
+
219
+ define_command(:ispell_unknown_command) do
220
+ ensure_ispell_active
221
+ message_misspelled
222
+ Window.beep
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
235
+ end
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
@@ -0,0 +1,290 @@
1
+ module Textbringer::Buffer::RectangleMethods
2
+ SHARED_VALUES = {}
3
+
4
+ refine Textbringer::Buffer do
5
+ # Returns start_line, start_col, end_line, and end_col of the rectangle region
6
+ # Note that start_col and end_col are 0-origin and width-based (neither 1-origin nor codepoint-based)
7
+ def rectangle_boundaries(s = @point, e = mark)
8
+ s, e = Buffer.region_boundaries(s, e)
9
+ save_excursion do
10
+ goto_char(s)
11
+ start_line = @current_line
12
+ beginning_of_line
13
+ start_col = display_width(substring(@point, s))
14
+ goto_char(e)
15
+ end_line = @current_line
16
+ beginning_of_line
17
+ end_col = display_width(substring(@point, e))
18
+
19
+ # Ensure start_col <= end_col
20
+ if start_col > end_col
21
+ start_col, end_col = end_col, start_col
22
+ end
23
+ [start_line, start_col, end_line, end_col]
24
+ end
25
+ end
26
+
27
+ def apply_on_rectangle(s = @point, e = mark)
28
+ start_line, start_col, end_line, end_col = rectangle_boundaries(s, e)
29
+
30
+ save_excursion do
31
+ composite_edit do
32
+ goto_line(start_line)
33
+
34
+ loop do
35
+ beginning_of_line
36
+ line_start = @point
37
+
38
+ # Move to start column
39
+ col = 0
40
+ while col < start_col && !end_of_line?
41
+ forward_char
42
+ col = display_width(substring(line_start, @point))
43
+ end
44
+
45
+ yield(start_col, end_col, col, line_start, start_line, end_line)
46
+
47
+ # Move to next line for forward iteration
48
+ break if @current_line >= end_line
49
+ forward_line
50
+ end
51
+ end
52
+ end
53
+ end
54
+
55
+ def extract_rectangle(s = @point, e = mark)
56
+ lines = []
57
+ apply_on_rectangle(s, e) do |start_col, end_col, col, line_start|
58
+ start_pos = @point
59
+ width = end_col - start_col
60
+
61
+ # If we haven't reached start_col, the line is too short
62
+ if col < start_col
63
+ # Line is shorter than start column, extract all spaces
64
+ lines << " " * width
65
+ else
66
+ # Move to end column
67
+ while col < end_col && !end_of_line?
68
+ forward_char
69
+ col = display_width(substring(line_start, @point))
70
+ end
71
+ end_pos = @point
72
+
73
+ # Extract the rectangle text for this line
74
+ if end_pos > start_pos
75
+ extracted = substring(start_pos, end_pos)
76
+ # Pad with spaces if the extracted text is shorter than rectangle width
77
+ extracted_width = display_width(extracted)
78
+ if extracted_width < width
79
+ extracted += " " * (width - extracted_width)
80
+ end
81
+ lines << extracted
82
+ else
83
+ lines << " " * width
84
+ end
85
+ end
86
+ end
87
+
88
+ lines
89
+ end
90
+
91
+ def copy_rectangle(s = @point, e = mark)
92
+ lines = extract_rectangle(s, e)
93
+ SHARED_VALUES[:killed_rectangle] = lines
94
+ end
95
+
96
+ def kill_rectangle(s = @point, e = mark)
97
+ copy_rectangle(s, e)
98
+ delete_rectangle(s, e)
99
+ end
100
+
101
+ def delete_rectangle(s = @point, e = mark)
102
+ check_read_only_flag
103
+
104
+ apply_on_rectangle(s, e) do |start_col, end_col, col, line_start|
105
+ start_pos = @point
106
+
107
+ # Only delete if we're within the line bounds
108
+ if col >= start_col
109
+ # Move to end column
110
+ while col < end_col && !end_of_line?
111
+ forward_char
112
+ col = display_width(substring(line_start, @point))
113
+ end
114
+ end_pos = @point
115
+
116
+ # Delete the rectangle text for this line
117
+ if end_pos > start_pos
118
+ delete_region(start_pos, end_pos)
119
+ end
120
+ end
121
+ end
122
+ end
123
+
124
+ def yank_rectangle
125
+ raise "No rectangle in kill ring" if SHARED_VALUES[:killed_rectangle].nil?
126
+ lines = SHARED_VALUES[:killed_rectangle]
127
+ start_line = @current_line
128
+ start_point = @point
129
+ start_col = save_excursion {
130
+ beginning_of_line
131
+ display_width(substring(@point, start_point))
132
+ }
133
+ composite_edit do
134
+ lines.each_with_index do |line, i|
135
+ goto_line(start_line + i)
136
+ beginning_of_line
137
+ line_start = @point
138
+
139
+ # Move to start column, extending line if necessary
140
+ col = 0
141
+ while col < start_col && !end_of_line?
142
+ forward_char
143
+ col = display_width(substring(line_start, @point))
144
+ end
145
+
146
+ # If line is shorter than start_col, extend it with spaces
147
+ if col < start_col
148
+ insert(" " * (start_col - col))
149
+ end
150
+
151
+ # Insert the rectangle line
152
+ insert(line)
153
+ end
154
+ end
155
+ end
156
+
157
+ def open_rectangle(s = @point, e = mark)
158
+ check_read_only_flag
159
+ s, e = Buffer.region_boundaries(s, e)
160
+ composite_edit do
161
+ apply_on_rectangle(s, e) do |start_col, end_col, col, line_start|
162
+ # If line is shorter than start_col, extend it with spaces
163
+ if col < start_col
164
+ insert(" " * (start_col - col))
165
+ end
166
+
167
+ # Insert spaces to create the rectangle
168
+ insert(" " * (end_col - start_col))
169
+ end
170
+ goto_char(s)
171
+ end
172
+ end
173
+
174
+ def clear_rectangle(s = @point, e = mark)
175
+ check_read_only_flag
176
+ apply_on_rectangle(s, e) do |start_col, end_col, col, line_start|
177
+ start_pos = @point
178
+ if col < start_col
179
+ insert(" " * (end_col - start_col))
180
+ else
181
+ while col < end_col && !end_of_line?
182
+ forward_char
183
+ col = display_width(substring(line_start, @point))
184
+ end
185
+ end_pos = @point
186
+
187
+ delete_region(start_pos, end_pos) if end_pos > start_pos
188
+ insert(" " * (end_col - start_col))
189
+ end
190
+ end
191
+ end
192
+
193
+ def string_rectangle(str, s = @point, e = mark)
194
+ check_read_only_flag
195
+ apply_on_rectangle(s, e) do |start_col, end_col, col, line_start|
196
+ start_pos = @point
197
+ if col < start_col
198
+ insert(" " * (start_col - col))
199
+ insert(str)
200
+ else
201
+ while col < end_col && !end_of_line?
202
+ forward_char
203
+ col = display_width(substring(line_start, @point))
204
+ end
205
+ end_pos = @point
206
+
207
+ delete_region(start_pos, end_pos) if end_pos > start_pos
208
+ insert(str)
209
+ end
210
+ end
211
+ end
212
+
213
+ def string_insert_rectangle(str, s = @point, e = mark)
214
+ check_read_only_flag
215
+ apply_on_rectangle(s, e) do |start_col, end_col, col, line_start|
216
+ if col < start_col
217
+ insert(" " * (start_col - col))
218
+ end
219
+ insert(str)
220
+ end
221
+ end
222
+
223
+ def rectangle_number_lines(s = @point, e = mark)
224
+ check_read_only_flag
225
+ n = 1
226
+ number_width = nil
227
+ apply_on_rectangle(s, e) do |start_col, end_col, col, line_start, start_line, end_line|
228
+ number_width ||= (1 + (end_line - start_line)).to_s.size
229
+ if col < start_col
230
+ insert(" " * (start_col - col))
231
+ end
232
+ insert(n.to_s.rjust(number_width) + " ")
233
+ n += 1
234
+ end
235
+ end
236
+ end
237
+ end
238
+
239
+ using Textbringer::Buffer::RectangleMethods
240
+
241
+ module Textbringer
242
+ module Commands
243
+ define_command(:kill_rectangle,
244
+ doc: "Kill the text of the region-rectangle, saving its contents as the last killed rectangle.") do
245
+ Buffer.current.kill_rectangle
246
+ end
247
+
248
+ define_command(:copy_rectangle_as_kill,
249
+ doc: "Save the text of the region-rectangle as the last killed rectangle.") do
250
+ Buffer.current.copy_rectangle
251
+ end
252
+
253
+ define_command(:delete_rectangle,
254
+ doc: "Delete the text of the region-rectangle.") do
255
+ Buffer.current.delete_rectangle
256
+ end
257
+
258
+ define_command(:yank_rectangle,
259
+ doc: "Yank the last killed rectangle with its upper left corner at point.") do
260
+ Buffer.current.yank_rectangle
261
+ end
262
+
263
+ define_command(:open_rectangle,
264
+ 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
265
+ Buffer.current.open_rectangle
266
+ end
267
+
268
+ define_command(:clear_rectangle,
269
+ doc: "Clear the region-rectangle by replacing its contents with spaces.") do
270
+ Buffer.current.clear_rectangle
271
+ end
272
+
273
+ define_command(:string_rectangle,
274
+ doc: "Replace rectangle contents with the specified string on each line.") do
275
+ |str = read_from_minibuffer("String rectangle: ")|
276
+ Buffer.current.string_rectangle(str)
277
+ end
278
+
279
+ define_command(:string_insert_rectangle,
280
+ doc: "Insert the specified string on each line of the rectangle.") do
281
+ |str = read_from_minibuffer("String insert rectangle: ")|
282
+ Buffer.current.string_insert_rectangle(str)
283
+ end
284
+
285
+ define_command(:rectangle_number_lines,
286
+ doc: "Insert numbers in front of the region-rectangle.") do
287
+ Buffer.current.rectangle_number_lines
288
+ end
289
+ end
290
+ end
@@ -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
@@ -209,6 +209,8 @@ module Textbringer
209
209
  GLOBAL_MAP.define_key("\C-xry", :yank_rectangle)
210
210
  GLOBAL_MAP.define_key("\C-xro", :open_rectangle)
211
211
  GLOBAL_MAP.define_key("\C-xrc", :clear_rectangle)
212
+ GLOBAL_MAP.define_key("\C-xrt", :string_rectangle)
213
+ GLOBAL_MAP.define_key("\C-xrN", :rectangle_number_lines)
212
214
  GLOBAL_MAP.define_key("\C-x(", :start_keyboard_macro)
213
215
  GLOBAL_MAP.define_key(:f3, :start_keyboard_macro)
214
216
  GLOBAL_MAP.define_key("\C-x)", :end_keyboard_macro)
@@ -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