textbringer 27 → 28

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.
@@ -5,6 +5,7 @@ require "timeout"
5
5
 
6
6
  module Textbringer
7
7
  CONFIG[:skk_dictionary_path] = File.expand_path("~/.textbringer/skk/SKK-JISYO.L")
8
+ CONFIG[:skk_user_dictionary_path] = File.expand_path("~/.textbringer/skk/skk-jisyo.utf8")
8
9
  CONFIG[:skk_server_host] = nil # nil = disabled (default)
9
10
  CONFIG[:skk_server_port] = 1178
10
11
 
@@ -123,20 +124,60 @@ module Textbringer
123
124
  ascii: nil, # nil = reset to terminal default
124
125
  }
125
126
 
127
+ # Mirrors ddskk's skk-kakutei-history: the okuri-nasi yomi confirmed in
128
+ # this session, most recent first, shared by all buffers, and used to
129
+ # complete an empty yomi (skk-comp-by-history).
130
+ KAKUTEI_HISTORY_LIMIT = 100 # skk-kakutei-history-limit
131
+ @@kakutei_history = []
132
+
133
+ def self.kakutei_history
134
+ @@kakutei_history
135
+ end
136
+
137
+ # Mirrors ddskk's skk-get-jisyo-buffer, which reads a dictionary file
138
+ # into a single buffer shared by every buffer using SKK: the user
139
+ # dictionary is loaded once per path and shared by all instances, so
140
+ # that saving from one buffer does not drop what another one learned.
141
+ @@user_dictionaries = {}
142
+
143
+ # A user dictionary candidate like (skk-ignore-dic-word "留守電"),
144
+ # which ddskk evaluates to hide the quoted words of the system
145
+ # dictionary (see purge_candidate).
146
+ IGNORE_DIC_WORD_RE = /\A\(skk-ignore-dic-word((?: +"[^"]*")+)\)\z/
147
+
126
148
  def initialize
127
149
  super
128
150
  @mode = :hiragana # :hiragana | :katakana | :hankaku_katakana | :zenkaku_ascii | :ascii
129
151
  @phase = :normal # :normal | :converting | :selecting
130
152
  @roman_buffer = +""
153
+ @okuri_roman = nil # the consonant that started okurigana (dictionary fallback)
154
+ @okuri_start_pos = nil # buffer position of the "*" marker; nil = no okurigana
155
+ # @yomi/@okuri_kana are snapshots taken when entering the selecting
156
+ # phase (see start_selecting); during converting they are derived
157
+ # from the buffer via current_yomi/current_okuri_kana instead.
131
158
  @yomi = +""
132
- @okuri_roman = nil
133
159
  @okuri_kana = nil
134
160
  @candidates = []
135
161
  @candidate_index = 0
162
+ @base_candidates = [] # the system dictionary's candidates for @lookup_key
136
163
  @marker_pos = nil
164
+ @lookup_key = nil
165
+ # Yomi completion state: the yomi it started from, the matching
166
+ # user-dictionary keys, and the index of the one currently shown.
167
+ @comp_prefix = nil
168
+ @comp_candidates = nil
169
+ @comp_index = nil
170
+ @comp_message_shown = false
171
+ # Whether the previous key was TAB/Shift-TAB, standing in for ddskk's
172
+ # (eq last-command 'skk-comp-do).
173
+ @last_key_completed = false
137
174
  @okuriiari = nil
138
175
  @okurinasi = nil
176
+ @user_okuriiari = nil
177
+ @user_okurinasi = nil
139
178
  @skk_server_socket = nil
179
+ @prefix_window = nil
180
+ @prefix_buffer = nil
140
181
  end
141
182
 
142
183
  def toggle
@@ -146,6 +187,7 @@ module Textbringer
146
187
  else
147
188
  reset_cursor_color
148
189
  close_skk_server
190
+ close_prefix_window
149
191
  end
150
192
  end
151
193
 
@@ -153,6 +195,7 @@ module Textbringer
153
195
  super
154
196
  reset_cursor_color
155
197
  close_skk_server
198
+ close_prefix_window
156
199
  end
157
200
 
158
201
  def on_activate
@@ -161,6 +204,7 @@ module Textbringer
161
204
 
162
205
  def on_deactivate
163
206
  reset_cursor_color if @enabled
207
+ close_prefix_window
164
208
  end
165
209
 
166
210
  def status
@@ -168,16 +212,114 @@ module Textbringer
168
212
  end
169
213
 
170
214
  def handle_event(event)
171
- case @phase
172
- when :normal then handle_normal(event)
173
- when :converting then handle_converting(event)
174
- when :selecting then handle_selecting(event)
175
- end
215
+ # Hidden first, not to be drawn over what the event inserts: cut in
216
+ # two by it, a wide character would not be shown properly.
217
+ @prefix_window.hide if @prefix_window&.visible?
218
+ result =
219
+ case @phase
220
+ when :normal then handle_normal(event)
221
+ when :converting then handle_converting(event)
222
+ when :selecting then handle_selecting(event)
223
+ end
224
+ update_prefix_window
225
+ result
176
226
  end
177
227
 
178
228
  private
179
229
 
230
+ # Mirrors ddskk's skk-insert-prefix, which shows the unconfirmed
231
+ # romaji at point with an overlay when skk-echo is non-nil (the
232
+ # default). Textbringer has no overlays, so it is shown in a
233
+ # floating window over point instead, which leaves the buffer and
234
+ # its undo history untouched. During conversion the romaji is
235
+ # already in the buffer (see insert_roman_preview).
236
+ def update_prefix_window
237
+ if @enabled && @phase == :normal && !@roman_buffer.empty? &&
238
+ !isearch_mode?
239
+ show_prefix_window
240
+ else
241
+ close_prefix_window
242
+ end
243
+ end
244
+
245
+ def show_prefix_window
246
+ window = Window.current
247
+ width = prefix_window_width(window.buffer)
248
+ y, x = prefix_window_position(window, width)
249
+ @prefix_buffer ||= Buffer.new(name: " *skk-prefix*", undo_limit: 0)
250
+ @prefix_buffer.replace(
251
+ @roman_buffer + " " * (width - Buffer.display_width(@roman_buffer))
252
+ )
253
+ if @prefix_window.nil? || @prefix_window.deleted?
254
+ # Like ddskk's skk-prefix-*-face; defined here, after the colors
255
+ # are set up, unless the user has defined it.
256
+ Face.define(:skk_prefix, underline: true) unless Face[:skk_prefix]
257
+ @prefix_window = FloatingWindow.new(1, width, y, x,
258
+ buffer: @prefix_buffer,
259
+ face: :skk_prefix)
260
+ else
261
+ @prefix_window.resize(1, width) if @prefix_window.columns != width
262
+ @prefix_window.move_to(y: y, x: x)
263
+ end
264
+ @prefix_window.show
265
+ Window.redisplay
266
+ # Put the cursor after the romaji, as after characters typed; the
267
+ # next redisplay puts it back at point.
268
+ cursor_x = [x - window.x + Buffer.display_width(@roman_buffer),
269
+ window.columns - 1].min
270
+ window.window.setpos(y - window.y, cursor_x)
271
+ window.window.noutrefresh
272
+ Window.update
273
+ end
274
+
275
+ # Wide enough to cover the characters after point as a whole: a wide
276
+ # character cut in two by the window would not be shown properly
277
+ # again when the window is gone.
278
+ def prefix_window_width(buffer)
279
+ width = Buffer.display_width(@roman_buffer)
280
+ covered = 0
281
+ buffer.save_excursion do
282
+ while covered < width && !buffer.end_of_buffer? &&
283
+ buffer.char_after != "\n"
284
+ covered += Buffer.display_width(buffer.char_after)
285
+ buffer.forward_char
286
+ end
287
+ end
288
+ [width, covered].max
289
+ end
290
+
291
+ # A window sticking out of the screen is not drawn at all, so romaji
292
+ # that does not fit in the rest of the line goes to the beginning of
293
+ # the next line, where the kana will go. The echo area has no next
294
+ # line, so it is moved to the left there instead.
295
+ def prefix_window_position(window, width)
296
+ y = window.cursor.y
297
+ x = window.cursor.x
298
+ if x + width > window.columns
299
+ if window.echo_area?
300
+ x = [window.columns - width, 0].max
301
+ else
302
+ y += 1
303
+ x = 0
304
+ end
305
+ end
306
+ [window.y + y, window.x + x]
307
+ end
308
+
309
+ def close_prefix_window
310
+ return if @prefix_window.nil?
311
+ @prefix_window.close
312
+ @prefix_window = nil
313
+ end
314
+
180
315
  def handle_normal(event)
316
+ # Like ddskk's skk-delete-backward-char, backspace erases the
317
+ # unconfirmed romaji as a whole, leaving the character before it.
318
+ if backspace_event?(event) && !@roman_buffer.empty?
319
+ @roman_buffer = +""
320
+ return nil
321
+ end
322
+
181
323
  unless event.is_a?(String)
182
324
  @roman_buffer = +""
183
325
  return event
@@ -257,7 +399,39 @@ module Textbringer
257
399
  end
258
400
 
259
401
  def handle_converting(event)
402
+ # Like ddskk, any key clears the message a completion left behind.
403
+ if @comp_message_shown
404
+ Window.echo_area.clear_message
405
+ @comp_message_shown = false
406
+ end
407
+
408
+ # Like ddskk's skk-comp-do setting this-command, only TAB/Shift-TAB
409
+ # keep the flag up for the next key; any other key drops it.
410
+ after_completion = @last_key_completed
411
+ @last_key_completed = false
412
+
413
+ if backspace_event?(event)
414
+ backspace_converting
415
+ return nil
416
+ end
417
+
418
+ # Like skk-insert, "." and "," (skk-next-completion-char and
419
+ # skk-previous-completion-char) move through the candidates only
420
+ # right after a completion, and are typed as "。" and "、" otherwise.
421
+ if event == "\t" || (after_completion && event == ".")
422
+ complete_yomi(after_completion)
423
+ @last_key_completed = true
424
+ return nil
425
+ end
426
+
427
+ if event == :btab || (after_completion && event == ",")
428
+ complete_yomi_backward(after_completion)
429
+ @last_key_completed = true
430
+ return nil
431
+ end
432
+
260
433
  unless event.is_a?(String)
434
+ discard_roman_preview
261
435
  commit_converting
262
436
  return event
263
437
  end
@@ -265,22 +439,34 @@ module Textbringer
265
439
  # Control characters not handled below: commit conversion and pass through
266
440
  if event.bytesize == 1 && (event.ord < 0x20 || event.ord == 0x7f) &&
267
441
  event != "\C-g" && event != "\C-j"
442
+ discard_roman_preview
268
443
  commit_converting
269
444
  return event
270
445
  end
271
446
 
272
447
  case event
273
448
  when "\C-g"
274
- cancel_converting
449
+ discard_roman_preview
450
+ if after_completion && @comp_prefix
451
+ cancel_completion
452
+ else
453
+ cancel_converting
454
+ end
275
455
  nil
276
456
  when "\C-j"
457
+ discard_roman_preview
277
458
  commit_converting
278
459
  nil
279
460
  when " "
461
+ discard_roman_preview
280
462
  start_selecting
281
463
  nil
464
+ when "q"
465
+ toggle_yomi_katakana
466
+ nil
282
467
  when /\A[A-Z]\z/
283
- if @okuri_roman.nil?
468
+ if @okuri_start_pos.nil?
469
+ discard_roman_preview
284
470
  start_okurigana(event.downcase)
285
471
  else
286
472
  process_converting_romaji(event.downcase)
@@ -307,6 +493,9 @@ module Textbringer
307
493
  when "x"
308
494
  prev_candidate
309
495
  nil
496
+ when "X"
497
+ purge_candidate
498
+ nil
310
499
  when "\r", "\n"
311
500
  confirm_selecting
312
501
  nil
@@ -394,36 +583,39 @@ module Textbringer
394
583
  nil
395
584
  end
396
585
 
586
+ # Mirrors ddskk: the headword and okurigana being composed are just
587
+ # ordinary buffer text between markers ("▽"/"*"), so a confirmed kana
588
+ # is always simply inserted here -- there is no need to decide whether
589
+ # it belongs to the yomi or the okurigana, unlike a design that tracks
590
+ # them in separate string variables.
397
591
  def process_converting_romaji(event)
592
+ # Remove the preview of the previously buffered, unconfirmed romaji
593
+ delete_roman_preview
594
+
398
595
  # Special "n" handling: flush "ん" before appending if next char won't extend "n"
399
596
  if @roman_buffer == "n" && !%w[n y a i u e o].include?(event)
400
597
  @roman_buffer = +""
401
- append_yomi_kana("ん")
598
+ insert_kana(converting_kana_for_mode("ん"))
402
599
  end
403
600
 
404
601
  @roman_buffer << event
405
602
 
406
- table = hiragana_table_for_converting
407
- prefixes = hiragana_prefixes_for_converting
603
+ # ddskk's romaji rules pair each kana with its katakana form and pick
604
+ # one by the current mode (skk-katakana), so a katakana-mode headword
605
+ # is katakana from the first kana onward, not converted afterward.
606
+ table = converting_table
607
+ prefixes = HIRAGANA_PREFIXES
408
608
 
409
609
  kana = table[@roman_buffer]
410
610
  if kana
411
611
  @roman_buffer = +""
412
- if @okuri_roman
413
- # Completing okurigana (accumulate in case a vowel kana was already prepended)
414
- @okuri_kana = (@okuri_kana || "") + kana
415
- with_target_buffer do |buffer|
416
- buffer.insert(kana)
417
- end
418
- Window.redisplay
419
- start_selecting
420
- else
421
- append_yomi_kana(kana)
422
- end
612
+ insert_kana(kana)
613
+ start_selecting if @okuri_start_pos
423
614
  return
424
615
  end
425
616
 
426
617
  if prefixes.include?(@roman_buffer)
618
+ insert_roman_preview(@roman_buffer)
427
619
  return
428
620
  end
429
621
 
@@ -432,8 +624,9 @@ module Textbringer
432
624
  first = @roman_buffer[0]
433
625
  rest = @roman_buffer[1..]
434
626
  if first == rest[0] && first =~ /[bcdfghjklmnpqrstvwxyz]/
435
- append_yomi_kana("っ")
627
+ insert_kana(converting_kana_for_mode("っ"))
436
628
  @roman_buffer = +rest # Keep the second consonant buffered
629
+ insert_roman_preview(@roman_buffer)
437
630
  return
438
631
  end
439
632
  end
@@ -442,7 +635,7 @@ module Textbringer
442
635
  if @roman_buffer.size == 1
443
636
  char = @roman_buffer
444
637
  @roman_buffer = +""
445
- append_yomi_kana(char)
638
+ insert_kana(char)
446
639
  return
447
640
  end
448
641
 
@@ -450,31 +643,17 @@ module Textbringer
450
643
  first_char = @roman_buffer[0]
451
644
  last_char = @roman_buffer[-1]
452
645
  @roman_buffer = +""
453
- if @okuri_roman && (kana = HIRAGANA_TABLE[first_char])
454
- # Vowel starts okurigana: accumulate kana and continue buffering the rest
455
- @okuri_kana = (@okuri_kana || "") + kana
456
- with_target_buffer { |b| b.insert(kana) }
457
- Window.redisplay
458
- else
459
- append_yomi_kana(first_char)
460
- end
646
+ insert_kana(first_char)
461
647
  process_converting_romaji(last_char)
462
648
  end
463
649
 
464
- def append_yomi_kana(kana)
465
- @yomi << kana
466
- with_target_buffer do |buffer|
467
- buffer.insert(kana)
468
- end
469
- Window.redisplay
470
- end
471
-
472
650
  def start_converting(first_char)
473
651
  @phase = :converting
474
- @yomi = +""
475
652
  @okuri_roman = nil
476
- @okuri_kana = nil
653
+ @okuri_start_pos = nil
477
654
  @roman_buffer = +""
655
+ reset_completion
656
+ @last_key_completed = false
478
657
  with_target_buffer do |buffer|
479
658
  @marker_pos = buffer.point
480
659
  buffer.insert("▽")
@@ -487,36 +666,128 @@ module Textbringer
487
666
 
488
667
  def start_okurigana(c)
489
668
  @okuri_roman = c.dup
490
- kana = HIRAGANA_TABLE[c]
669
+ with_target_buffer do |buffer|
670
+ @okuri_start_pos = buffer.point
671
+ buffer.insert("*")
672
+ end
673
+ Window.redisplay
674
+ kana = converting_table[c]
491
675
  if kana
492
- # Vowel okurigana: insert the kana immediately and record it in @okuri_kana.
676
+ # Vowel okurigana: insert the kana immediately.
493
677
  # (A vowel is never a prefix of a longer romaji sequence, so it's always complete.)
494
- @okuri_kana = kana
495
678
  @roman_buffer = +""
496
- with_target_buffer { |b| b.insert(kana) }
497
- Window.redisplay
679
+ insert_kana(kana)
498
680
  start_selecting
499
681
  else
500
682
  @roman_buffer = c.dup
683
+ insert_roman_preview(@roman_buffer)
684
+ end
685
+ end
686
+
687
+ def insert_roman_preview(s)
688
+ with_target_buffer do |buffer|
689
+ buffer.insert(s)
690
+ end
691
+ Window.redisplay
692
+ end
693
+
694
+ def delete_roman_preview
695
+ return if @roman_buffer.empty?
696
+ with_target_buffer do |buffer|
697
+ buffer.delete_region(buffer.point - @roman_buffer.bytesize, buffer.point)
501
698
  end
502
699
  end
503
700
 
701
+ def discard_roman_preview
702
+ delete_roman_preview
703
+ @roman_buffer = +""
704
+ end
705
+
706
+ def backspace_event?(event)
707
+ event == :backspace ||
708
+ (event.is_a?(String) && (event == "\C-h" || event.ord == 0x7f))
709
+ end
710
+
711
+ # Mirrors ddskk, where the headword being composed is ordinary
712
+ # buffer text, so backspace just deletes its last character one
713
+ # step at a time: unconfirmed romaji first, then okurigana, then
714
+ # the confirmed yomi, cancelling the conversion once everything is
715
+ # gone.
716
+ def backspace_converting
717
+ unless @roman_buffer.empty?
718
+ delete_roman_preview
719
+ @roman_buffer = @roman_buffer[0..-2]
720
+ if @roman_buffer.empty?
721
+ # Only undo entering okurigana entirely (removing the "*" marker)
722
+ # if no okurigana kana has been confirmed yet -- e.g. after a
723
+ # geminate consonant already confirmed "っ", erasing the preview
724
+ # of the following consonant must not discard that "っ" along
725
+ # with the okurigana marker.
726
+ if @okuri_start_pos &&
727
+ with_target_buffer(&:point) == @okuri_start_pos + "*".bytesize
728
+ undo_okurigana_start
729
+ end
730
+ Window.redisplay
731
+ else
732
+ insert_roman_preview(@roman_buffer)
733
+ end
734
+ return
735
+ end
736
+
737
+ point = with_target_buffer(&:point)
738
+ boundary = @okuri_start_pos ? @okuri_start_pos + "*".bytesize :
739
+ @marker_pos + "▽".bytesize
740
+
741
+ if point == boundary
742
+ if @okuri_start_pos
743
+ undo_okurigana_start
744
+ Window.redisplay
745
+ else
746
+ cancel_converting
747
+ end
748
+ return
749
+ end
750
+
751
+ with_target_buffer do |buffer|
752
+ last = buffer.char_before(buffer.point)
753
+ buffer.delete_region(buffer.point - last.bytesize, buffer.point)
754
+ end
755
+ Window.redisplay
756
+ end
757
+
758
+ # Undoes entering okurigana (removes the "*" marker), as if the
759
+ # uppercase key that started it had never been pressed.
760
+ def undo_okurigana_start
761
+ with_target_buffer do |buffer|
762
+ buffer.delete_region(@okuri_start_pos, @okuri_start_pos + "*".bytesize)
763
+ end
764
+ @okuri_roman = nil
765
+ @okuri_start_pos = nil
766
+ end
767
+
504
768
  def cancel_converting
505
769
  with_target_buffer do |buffer|
506
770
  buffer.delete_region(@marker_pos, buffer.point)
507
771
  end
508
772
  @phase = :normal
509
- @yomi = +""
510
773
  @roman_buffer = +""
511
774
  @okuri_roman = nil
512
- @okuri_kana = nil
775
+ @okuri_start_pos = nil
513
776
  @marker_pos = nil
777
+ Window.echo_area.clear_message
514
778
  Window.redisplay
515
779
  update_cursor_color
516
780
  end
517
781
 
518
782
  def commit_converting
783
+ # skk-kakutei records a yomi confirmed as-is in the ▽ mode.
784
+ if @okuri_start_pos.nil? && starts_with_hiragana?(current_yomi)
785
+ record_kakutei_history(current_yomi)
786
+ end
519
787
  with_target_buffer do |buffer|
788
+ if @okuri_start_pos
789
+ buffer.delete_region(@okuri_start_pos, @okuri_start_pos + "*".bytesize)
790
+ end
520
791
  # Remove the ▽ marker (3 bytes for ▽ in UTF-8)
521
792
  marker_end = @marker_pos + "▽".bytesize
522
793
  buffer.delete_region(@marker_pos, marker_end)
@@ -524,29 +795,203 @@ module Textbringer
524
795
  @phase = :normal
525
796
  @roman_buffer = +""
526
797
  @okuri_roman = nil
527
- @okuri_kana = nil
798
+ @okuri_start_pos = nil
528
799
  @marker_pos = nil
529
800
  Window.redisplay
530
801
  update_cursor_color
531
802
  end
532
803
 
533
- def start_selecting
534
- lookup_key = if @okuri_roman
535
- @yomi + @okuri_roman
804
+ # Reads the yomi/okurigana straight out of the buffer, the way ddskk's
805
+ # skk-set-okurigana slices skk-henkan-key out of the region between its
806
+ # markers, instead of accumulating them in separate variables while
807
+ # converting.
808
+ def current_yomi
809
+ with_target_buffer do |buffer|
810
+ buffer.substring(@marker_pos + "▽".bytesize, @okuri_start_pos || buffer.point)
811
+ end
812
+ end
813
+
814
+ def current_okuri_kana
815
+ return nil unless @okuri_start_pos
816
+ with_target_buffer do |buffer|
817
+ buffer.substring(@okuri_start_pos + "*".bytesize, buffer.point)
818
+ end
819
+ end
820
+
821
+ # Mirrors ddskk's skk-toggle-characters in the "on" (▽) branch: it
822
+ # converts the yomi to the opposite kana and commits, refusing (as
823
+ # ddskk's skk-error does) while unconfirmed romaji remains. Since
824
+ # okurigana always leaves unconfirmed romaji buffered until its first
825
+ # kana is confirmed (at which point converting phase is left via
826
+ # start_selecting), this naturally also covers -- by refusing -- the
827
+ # only okurigana case reachable here.
828
+ def toggle_yomi_katakana
829
+ unless @roman_buffer.empty?
830
+ message("There remains a kana prefix")
831
+ Window.redisplay
832
+ return
833
+ end
834
+
835
+ yomi = current_yomi
836
+ return if yomi.empty?
837
+
838
+ # skk-henkan-skk-region-by-func records the yomi before toggling it
839
+ # (and commit_converting records it after, like its skk-kakutei).
840
+ record_kakutei_history(yomi) if starts_with_hiragana?(yomi)
841
+ converted = katakana?(yomi[0]) ? katakana_to_hiragana(yomi) : hiragana_to_katakana(yomi)
842
+ with_target_buffer do |buffer|
843
+ buffer.delete_region(@marker_pos + "▽".bytesize, buffer.point)
844
+ buffer.insert(converted)
845
+ end
846
+ commit_converting
847
+ end
848
+
849
+ # Mirrors ddskk's skk-comp (SKK manual section 6.4): completes the yomi
850
+ # from the user dictionary's okuri-nasi keys only, most recently
851
+ # confirmed first. Like skk-insert's
852
+ # (skk-comp (not (eq last-command 'skk-comp-do))), TAB right after
853
+ # TAB/Shift-TAB advances the completion, and otherwise starts a new one.
854
+ def complete_yomi(after_completion)
855
+ return unless @okuri_start_pos.nil?
856
+
857
+ # Like skk-comp-do's (skk-kana-cleanup 'force): unconfirmed romaji
858
+ # that is a kana by itself ("n") is confirmed, and anything else is
859
+ # dropped. Typing the romaji already broke any run of completions.
860
+ unless @roman_buffer.empty?
861
+ pending = @roman_buffer
862
+ discard_roman_preview
863
+ insert_kana(converting_kana_for_mode("ん")) if pending == "n"
864
+ end
865
+
866
+ if after_completion && @comp_candidates
867
+ advance_completion(1, first: false)
536
868
  else
537
- @yomi
869
+ @comp_prefix = current_yomi
870
+ ensure_user_dictionary_loaded
871
+ # Dictionary keys are always hiragana; like skk-comp-do, search with
872
+ # the yomi in hiragana and show the candidates back in katakana.
873
+ key = @mode == :katakana ? katakana_to_hiragana(@comp_prefix) : @comp_prefix
874
+ candidates =
875
+ if key.empty?
876
+ # skk-comp-from-jisyo gives nothing for an empty yomi; only
877
+ # skk-comp-by-history completes it, from the kakutei history.
878
+ @@kakutei_history.uniq
879
+ else
880
+ # Like skk-comp-search-current-buffer, skip the yomi's own
881
+ # entry: completing to what is already there would change
882
+ # nothing.
883
+ @user_okurinasi.keys.select { |k| k.start_with?(key) && k != key }
884
+ end
885
+ @comp_candidates =
886
+ @mode == :katakana ? candidates.map { |k| hiragana_to_katakana(k) } : candidates
887
+ @comp_index = -1
888
+ advance_completion(1, first: true)
538
889
  end
890
+ end
539
891
 
540
- candidates = if CONFIG[:skk_server_host]
541
- skk_server_lookup(lookup_key)
892
+ def complete_yomi_backward(after_completion)
893
+ if after_completion && @comp_candidates
894
+ advance_completion(-1, first: false)
895
+ else
896
+ # Drop any completion from before the yomi was edited, so a TAB
897
+ # right after this starts afresh instead of resuming it.
898
+ reset_completion
899
+ Window.beep
900
+ show_completion_message(
901
+ "No more previous completions for \"#{current_yomi}\""
902
+ )
903
+ end
904
+ end
905
+
906
+ # Mirrors ddskk's skk-henkan-off-by-quit taking the skk-comp-do branch:
907
+ # C-g right after a completion, even a failed one, only restores the
908
+ # yomi it started from.
909
+ def cancel_completion
910
+ with_target_buffer do |buffer|
911
+ buffer.delete_region(@marker_pos + "▽".bytesize, buffer.point)
912
+ buffer.insert(@comp_prefix)
913
+ end
914
+ reset_completion
915
+ Window.redisplay
916
+ end
917
+
918
+ def show_completion_message(text)
919
+ message(text)
920
+ @comp_message_shown = true
921
+ Window.redisplay
922
+ end
923
+
924
+ # Like ddskk's skk-comp-circulate (nil by default), stops at either end
925
+ # instead of wrapping around. FIRST is skk-comp-do's argument of the
926
+ # same name, which picks "No completions" over "No more completions".
927
+ def advance_completion(direction, first:)
928
+ new_index = @comp_index + direction
929
+ if new_index < 0 || new_index >= @comp_candidates.size
930
+ Window.beep
931
+ show_completion_message(
932
+ if direction < 0
933
+ "No more previous completions for \"#{@comp_prefix}\""
934
+ elsif @comp_prefix.empty?
935
+ "No more words in history"
936
+ elsif first
937
+ "No completions for \"#{@comp_prefix}\""
938
+ else
939
+ "No more completions for \"#{@comp_prefix}\""
940
+ end
941
+ )
942
+ return
943
+ end
944
+ @comp_index = new_index
945
+ with_target_buffer do |buffer|
946
+ buffer.delete_region(@marker_pos + "▽".bytesize, buffer.point)
947
+ buffer.insert(@comp_candidates[@comp_index])
948
+ end
949
+ Window.redisplay
950
+ end
951
+
952
+ def reset_completion
953
+ @comp_prefix = nil
954
+ @comp_candidates = nil
955
+ @comp_index = nil
956
+ end
957
+
958
+ def start_selecting
959
+ reset_completion
960
+
961
+ # Snapshot the yomi/okurigana now: once selecting replaces the
962
+ # buffer text with a "▼" candidate, the markers no longer delimit them.
963
+ @yomi = current_yomi
964
+ @okuri_kana = current_okuri_kana
965
+
966
+ # Dictionary keys are always hiragana, even when composing in
967
+ # katakana mode (mirrors ddskk's skk-katakana-to-hiragana call in
968
+ # skk-set-okurigana).
969
+ yomi_key = @mode == :katakana ? katakana_to_hiragana(@yomi) : @yomi
970
+ @lookup_key = @okuri_roman ? (yomi_key + @okuri_roman) : yomi_key
971
+
972
+ ensure_user_dictionary_loaded
973
+ user_dict = @okuri_roman ? @user_okuriiari : @user_okurinasi
974
+ user_candidates = user_dict[@lookup_key] || []
975
+
976
+ base_candidates = if CONFIG[:skk_server_host]
977
+ skk_server_lookup(@lookup_key) || []
542
978
  else
543
979
  ensure_dictionary_loaded
544
980
  dict = @okuri_roman ? @okuriiari : @okurinasi
545
- dict[lookup_key]
981
+ dict[@lookup_key] || []
546
982
  end
547
983
 
548
- if candidates.nil? || candidates.empty?
549
- message("No conversion: #{@yomi}")
984
+ @base_candidates = base_candidates
985
+ # User dictionary candidates are ordered by recency (see
986
+ # learn_candidate) and take priority, mirroring ddskk's
987
+ # skk-search-prog-list ordering the private dictionary before the
988
+ # system one. Words an skk-ignore-dic-word candidate lists are left
989
+ # out of the system dictionary's candidates.
990
+ user_words = user_candidates.grep_v(IGNORE_DIC_WORD_RE)
991
+ candidates = (user_words + (base_candidates - ignored_words(user_candidates))).uniq
992
+
993
+ if candidates.empty?
994
+ register_new_word
550
995
  return
551
996
  end
552
997
 
@@ -562,13 +1007,26 @@ module Textbringer
562
1007
  update_cursor_color
563
1008
  end
564
1009
 
1010
+ # Like ddskk, SPC past the last candidate does not wrap around:
1011
+ # skk-henkan-1 finds no more candidates, so skk-henkan goes on to
1012
+ # skk-henkan-in-minibuff and asks for a new word.
565
1013
  def next_candidate
566
- @candidate_index = (@candidate_index + 1) % @candidates.size
1014
+ if @candidate_index + 1 >= @candidates.size
1015
+ register_new_word
1016
+ return
1017
+ end
1018
+ @candidate_index += 1
567
1019
  replace_candidate
568
1020
  end
569
1021
 
1022
+ # Like skk-previous-candidate with skk-henkan-count 0, "x" on the first
1023
+ # candidate goes back to the yomi instead of wrapping around.
570
1024
  def prev_candidate
571
- @candidate_index = (@candidate_index - 1 + @candidates.size) % @candidates.size
1025
+ if @candidate_index == 0
1026
+ cancel_selecting
1027
+ return
1028
+ end
1029
+ @candidate_index -= 1
572
1030
  replace_candidate
573
1031
  end
574
1032
 
@@ -581,15 +1039,50 @@ module Textbringer
581
1039
  end
582
1040
 
583
1041
  def confirm_selecting
584
- candidate = @candidates[@candidate_index]
1042
+ confirm_word(@candidates[@candidate_index])
1043
+ end
1044
+
1045
+ # Shared by confirm_selecting and register_new_word: insert the final
1046
+ # word, learn it into the user dictionary, and leave converting/
1047
+ # selecting entirely.
1048
+ def confirm_word(word)
1049
+ with_target_buffer do |buffer|
1050
+ buffer.delete_region(@marker_pos, buffer.point)
1051
+ buffer.insert(word + (@okuri_kana || ""))
1052
+ end
1053
+ learn_candidate(word)
1054
+ leave_conversion
1055
+ end
1056
+
1057
+ # Mirrors ddskk's skk-purge-from-jisyo: once confirmed with yes-or-no-p,
1058
+ # remove the current candidate from the user dictionary and delete the
1059
+ # ▼ text from the buffer without inserting anything.
1060
+ def purge_candidate
1061
+ word = @candidates[@candidate_index]
1062
+ purge =
1063
+ begin
1064
+ yes_or_no?("Really purge \"#{@lookup_key} /#{word}/ \"?")
1065
+ rescue Quit
1066
+ false
1067
+ end
1068
+ unless purge
1069
+ Window.redisplay
1070
+ return
1071
+ end
1072
+
1073
+ purge_from_user_dictionary(word)
585
1074
  with_target_buffer do |buffer|
586
1075
  buffer.delete_region(@marker_pos, buffer.point)
587
- buffer.insert(candidate + (@okuri_kana || ""))
588
1076
  end
1077
+ leave_conversion
1078
+ end
1079
+
1080
+ def leave_conversion
589
1081
  @phase = :normal
590
1082
  @yomi = +""
591
1083
  @roman_buffer = +""
592
1084
  @okuri_roman = nil
1085
+ @okuri_start_pos = nil
593
1086
  @okuri_kana = nil
594
1087
  @candidates = []
595
1088
  @candidate_index = 0
@@ -598,6 +1091,44 @@ module Textbringer
598
1091
  update_cursor_color
599
1092
  end
600
1093
 
1094
+ # Mirrors ddskk's skk-henkan-in-minibuff: when no candidate is found,
1095
+ # or SPC runs past the last one, read a new word for skk-henkan-key from the minibuffer and register
1096
+ # it. Unlike ddskk, the minibuffer here is not switched into SKK
1097
+ # itself, so entering kanji requires switching input method by hand
1098
+ # (e.g. C-\); nested registration (ddskk lets an unresolved word typed
1099
+ # inside the registration prompt itself trigger another registration)
1100
+ # is left for a follow-up.
1101
+ def register_new_word
1102
+ # EchoArea#redisplay only ever draws @prompt when @message is nil --
1103
+ # otherwise it draws @message instead. A "No conversion: ..." message
1104
+ # left over from a previous registration attempt would otherwise
1105
+ # blank out this new prompt on screen even though @prompt itself
1106
+ # gets set correctly underneath it.
1107
+ Window.echo_area.clear_message
1108
+ prompt = "SKK register #{@yomi}#{@okuri_roman ? "*#{@okuri_roman}" : ""}: "
1109
+ new_word =
1110
+ begin
1111
+ read_from_minibuffer(prompt)
1112
+ rescue Quit
1113
+ +""
1114
+ end
1115
+
1116
+ if new_word.empty?
1117
+ # Registering after the candidates ran out leaves the last one
1118
+ # shown, as skk-henkan-in-minibuff puts it back when given an empty
1119
+ # string; with no candidates at all there is nothing to go back to.
1120
+ message("No conversion: #{@yomi}") unless @phase == :selecting
1121
+ Window.redisplay
1122
+ return
1123
+ end
1124
+
1125
+ confirm_word(new_word)
1126
+ end
1127
+
1128
+ # Mirrors ddskk's skk-previous-candidate: cancelling out of the
1129
+ # candidate list drops the okurigana distinction entirely (the "*"
1130
+ # marker is not restored), so the okurigana text rejoins the yomi as
1131
+ # plain, un-marked headword text.
601
1132
  def cancel_selecting
602
1133
  with_target_buffer do |buffer|
603
1134
  buffer.delete_region(@marker_pos, buffer.point)
@@ -605,6 +1136,8 @@ module Textbringer
605
1136
  end
606
1137
  @phase = :converting
607
1138
  @roman_buffer = +""
1139
+ @okuri_roman = nil
1140
+ @okuri_start_pos = nil
608
1141
  @candidates = []
609
1142
  @candidate_index = 0
610
1143
  Window.redisplay
@@ -614,12 +1147,27 @@ module Textbringer
614
1147
  def ensure_dictionary_loaded
615
1148
  return if @okuriiari
616
1149
 
617
- path = CONFIG[:skk_dictionary_path]
618
- @okuriiari = {}
619
- @okurinasi = {}
1150
+ @okuriiari, @okurinasi =
1151
+ parse_skk_dictionary(CONFIG[:skk_dictionary_path], encoding: "EUC-JP:UTF-8")
1152
+ end
1153
+
1154
+ def ensure_user_dictionary_loaded
1155
+ path = CONFIG[:skk_user_dictionary_path]
1156
+ @user_okuriiari, @user_okurinasi =
1157
+ @@user_dictionaries[path] ||=
1158
+ if path && File.exist?(path)
1159
+ parse_skk_dictionary(path, encoding: "UTF-8")
1160
+ else
1161
+ [{}, {}]
1162
+ end
1163
+ end
1164
+
1165
+ def parse_skk_dictionary(path, encoding:)
1166
+ okuriiari = {}
1167
+ okurinasi = {}
620
1168
  section = :okuriiari
621
1169
 
622
- File.foreach(path, encoding: "EUC-JP:UTF-8") do |line|
1170
+ File.foreach(path, encoding: encoding) do |line|
623
1171
  line.chomp!
624
1172
  if line == ";; okuri-nasi entries."
625
1173
  section = :okurinasi
@@ -634,9 +1182,82 @@ module Textbringer
634
1182
  next if candidates.empty?
635
1183
 
636
1184
  if section == :okuriiari
637
- @okuriiari[key] = candidates
1185
+ okuriiari[key] = candidates
638
1186
  else
639
- @okurinasi[key] = candidates
1187
+ okurinasi[key] = candidates
1188
+ end
1189
+ end
1190
+
1191
+ [okuriiari, okurinasi]
1192
+ end
1193
+
1194
+ # Mirrors ddskk's skk-update-jisyo-1: move the confirmed candidate to
1195
+ # the front of its entry, and the entry itself to the front of the
1196
+ # dictionary (completion walks the keys in that order), then persist
1197
+ # immediately, since this SKK implementation has no explicit save
1198
+ # command or autosave-count setting yet.
1199
+ def learn_candidate(candidate)
1200
+ dict = @okuri_roman ? @user_okuriiari : @user_okurinasi
1201
+ entries = dict.delete(@lookup_key) || []
1202
+ candidates = [candidate] + entries.reject { |c| c == candidate }
1203
+ dict.replace({ @lookup_key => candidates }.merge(dict))
1204
+ save_user_dictionary
1205
+ # skk-update-jisyo records only okuri-nasi conversions in the history.
1206
+ record_kakutei_history(@lookup_key) unless @okuri_roman
1207
+ end
1208
+
1209
+ # Mirrors the purge branch of ddskk's skk-update-jisyo-1: a word the
1210
+ # system dictionary (or SKK server) also has is quoted with
1211
+ # skk-ignore-dic-word, folded into one candidate as
1212
+ # skk-compose-ignore-word does, so that later conversions hide it;
1213
+ # any other word is just deleted. Unlike ddskk's skk-update-jisyo,
1214
+ # the yomi is not recorded in the kakutei history.
1215
+ def purge_from_user_dictionary(word)
1216
+ dict = @okuri_roman ? @user_okuriiari : @user_okurinasi
1217
+ entries = (dict.delete(@lookup_key) || []) - [word]
1218
+ if @base_candidates.include?(word)
1219
+ ignored = ignored_words(entries) + [word]
1220
+ quoted = ignored.map { |w| "\"#{w}\"" }.join(" ")
1221
+ entries = ["(skk-ignore-dic-word #{quoted})"] + entries.grep_v(IGNORE_DIC_WORD_RE)
1222
+ end
1223
+ # skk-update-jisyo-1 writes nothing back for an entry left empty.
1224
+ dict.replace({ @lookup_key => entries }.merge(dict)) unless entries.empty?
1225
+ save_user_dictionary
1226
+ end
1227
+
1228
+ def ignored_words(candidates)
1229
+ candidates.flat_map { |c|
1230
+ m = IGNORE_DIC_WORD_RE.match(c)
1231
+ m ? m[1].scan(/"([^"]*)"/).flatten : []
1232
+ }
1233
+ end
1234
+
1235
+ # Like skk-update-kakutei-history, keeping only the newest
1236
+ # KAKUTEI_HISTORY_LIMIT entries.
1237
+ def record_kakutei_history(yomi)
1238
+ @@kakutei_history.unshift(yomi)
1239
+ @@kakutei_history.slice!(KAKUTEI_HISTORY_LIMIT..)
1240
+ end
1241
+
1242
+ # The check skk-kakutei and skk-henkan-skk-region-by-func make with
1243
+ # skk-what-char-type at the ▽ point before recording a yomi.
1244
+ def starts_with_hiragana?(yomi)
1245
+ yomi.match?(/\A[ぁ-ん]/)
1246
+ end
1247
+
1248
+ def save_user_dictionary
1249
+ path = CONFIG[:skk_user_dictionary_path]
1250
+ return unless path
1251
+
1252
+ FileUtils.mkdir_p(File.dirname(path))
1253
+ File.open(path, "w:UTF-8") do |f|
1254
+ f.puts ";; okuri-ari entries."
1255
+ @user_okuriiari.each do |key, candidates|
1256
+ f.puts "#{key} /#{candidates.join('/')}/"
1257
+ end
1258
+ f.puts ";; okuri-nasi entries."
1259
+ @user_okurinasi.each do |key, candidates|
1260
+ f.puts "#{key} /#{candidates.join('/')}/"
640
1261
  end
641
1262
  end
642
1263
  end
@@ -660,6 +1281,16 @@ module Textbringer
660
1281
  }.join
661
1282
  end
662
1283
 
1284
+ def katakana_to_hiragana(kana)
1285
+ kana.chars.map { |c|
1286
+ c.ord.between?(0x30A1, 0x30F6) ? (c.ord - 0x60).chr("UTF-8") : c
1287
+ }.join
1288
+ end
1289
+
1290
+ def katakana?(char)
1291
+ char.ord.between?(0x30A1, 0x30F6)
1292
+ end
1293
+
663
1294
  def hiragana_to_hankaku_katakana(kana)
664
1295
  kana.chars.map { |c|
665
1296
  case c.ord
@@ -766,13 +1397,18 @@ module Textbringer
766
1397
  end
767
1398
  end
768
1399
 
769
- # During converting phase, always use hiragana for yomi tracking
770
- def hiragana_table_for_converting
771
- HIRAGANA_TABLE
1400
+ # Unlike current_table/current_prefixes (used by process_romaji for
1401
+ # ordinary input), converting only switches to katakana, not hankaku
1402
+ # katakana: half-width katakana isn't hiragana-convertible for the
1403
+ # dictionary lookup key the way full-width katakana is (see
1404
+ # start_selecting), and ddskk's half-width kana input is a separate
1405
+ # mode with its own conversion path, not part of skk-katakana.
1406
+ def converting_table
1407
+ @mode == :katakana ? KATAKANA_TABLE : HIRAGANA_TABLE
772
1408
  end
773
1409
 
774
- def hiragana_prefixes_for_converting
775
- HIRAGANA_PREFIXES
1410
+ def converting_kana_for_mode(hiragana_kana)
1411
+ @mode == :katakana ? hiragana_to_katakana(hiragana_kana) : hiragana_kana
776
1412
  end
777
1413
 
778
1414
  def update_cursor_color