textbringer 22 → 24

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: 75bc23a6bf83f87be572ca81acfb29c2171d99d07d0fc48c5a50fac711b12ecb
4
- data.tar.gz: 5811dd657e36666831c26b9212494138c815629fe24997bb939c375c6d4a7944
3
+ metadata.gz: d7426dda13275f37439bdcc360a55971b34cc4b62916eeaee734b127ab9a8a88
4
+ data.tar.gz: fcc6391ac0697659a8b85005766186502731e8473928a5e5165d7c0a97d533f1
5
5
  SHA512:
6
- metadata.gz: a3b91ec8b00442665b7060840b1e7a43be4193f887b0d9bc6925e1eb528b8b7e6dd173e2ba2c453b11ab8d56a248a4ef875ba760427a06d27de5dea5c58b19e8
7
- data.tar.gz: 0abd782f34b8ebc44391a86bc6074e8369773de886738681d025bfafe22f01d357889c54dd8fb8b20a1d321f4789dd590793d32c9d1ddb82d28228c29b8c4d33
6
+ metadata.gz: be219b93c87935b98780c5d41430dd3d0760b839e20977dc843fa88aa8253614fb0e68dc7f90e37c08de6a23722daec928294f9702114908707011c33c766dc6
7
+ data.tar.gz: 6f61d4ed9ce65308bf150f24c886e05a1743af8bd38913c80f750db77c64a90b543ae95f60774b9d8ae52953cfa91e5f36e62665e0830b9aab6f6d97925ab289
data/CLAUDE.md CHANGED
@@ -35,12 +35,6 @@ txtb
35
35
  # Run all tests
36
36
  bundle exec rake test
37
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
38
  # Run a single test file
45
39
  ruby -Ilib:test test/textbringer/test_buffer.rb
46
40
  ```
@@ -112,11 +112,13 @@ module Textbringer
112
112
  end
113
113
 
114
114
  def self.current=(buffer)
115
+ @@current&.input_method&.on_deactivate
115
116
  if buffer && buffer.name && @@table.key?(buffer.name)
116
117
  @@list.delete(buffer)
117
118
  @@list.unshift(buffer)
118
119
  end
119
120
  @@current = buffer
121
+ @@current&.input_method&.on_activate
120
122
  end
121
123
 
122
124
  def self.minibuffer
@@ -0,0 +1,31 @@
1
+ require "fileutils"
2
+
3
+ module Textbringer
4
+ module Commands
5
+ define_command(:dired, doc: "Open a directory browser.") do
6
+ |dir = read_file_name("Dired: ",
7
+ default: (Buffer.current.file_name ?
8
+ File.dirname(Buffer.current.file_name) : Dir.pwd) + "/")|
9
+ dir = File.expand_path(dir)
10
+ raise EditorError, "#{dir} is not a directory" unless File.directory?(dir)
11
+ buf_name = "*Dired: #{dir}*"
12
+ buffer = Buffer.find_or_new(buf_name, undo_limit: 0, read_only: true)
13
+ buffer[:dired_directory] = dir
14
+ buffer.apply_mode(DiredMode) unless buffer.mode.is_a?(DiredMode)
15
+ if buffer.bytesize == 0
16
+ buffer.read_only_edit do
17
+ buffer.insert(DiredMode.generate_listing(dir))
18
+ buffer.beginning_of_buffer
19
+ buffer.forward_line
20
+ until buffer.end_of_buffer?
21
+ buffer.beginning_of_line
22
+ break unless buffer.looking_at?(/^[D ] \S+\s+\d+\s+[\d-]+\s+[\d:]+\s+\.\.?\/$/)
23
+ buffer.forward_line
24
+ end
25
+ end
26
+ end
27
+ switch_to_buffer(buffer)
28
+ dired_move_to_filename_command
29
+ end
30
+ end
31
+ end
@@ -5,6 +5,10 @@ module Textbringer
5
5
  module Commands
6
6
  define_command(:find_file, doc: "Open or create a file.") do
7
7
  |file_name = read_file_name("Find file: ", default: (Buffer.current.file_name ? File.dirname(Buffer.current.file_name) : Dir.pwd) + "/")|
8
+ if File.directory?(file_name)
9
+ dired(file_name)
10
+ next
11
+ end
8
12
  config = EditorConfig.load_file(file_name)
9
13
  buffer = Buffer.find_file(file_name)
10
14
  if buffer.new_file?
@@ -0,0 +1,25 @@
1
+ module Textbringer
2
+ module Commands
3
+ define_command(:gamegrid_show_scores,
4
+ doc: "Display high scores for a game.") do
5
+ |game_name = read_from_minibuffer("Game name: ")|
6
+ scores = Gamegrid.load_scores(game_name)
7
+ buffer = Buffer.find_or_new("*Scores*", undo_limit: 0)
8
+ buffer.read_only_edit do
9
+ buffer.clear
10
+ buffer.insert("High Scores for #{game_name}\n")
11
+ buffer.insert("=" * 40 + "\n\n")
12
+ if scores.empty?
13
+ buffer.insert("No scores recorded.\n")
14
+ else
15
+ scores.each_with_index do |entry, i|
16
+ buffer.insert(
17
+ "#{i + 1}. #{entry[:score]} #{entry[:player]} #{entry[:time]}\n"
18
+ )
19
+ end
20
+ end
21
+ end
22
+ switch_to_buffer(buffer)
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,10 @@
1
+ module Textbringer
2
+ module Commands
3
+ define_command(:tetris, doc: "Play Tetris.") do
4
+ buffer = Buffer.find_or_new("*Tetris*", undo_limit: 0)
5
+ buffer.apply_mode(TetrisMode) unless buffer.mode.is_a?(TetrisMode)
6
+ switch_to_buffer(buffer)
7
+ buffer.mode.tetris_new_game
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,6 @@
1
+ module Textbringer
2
+ Face.define :dired_directory, foreground: "magenta"
3
+ Face.define :dired_symlink, foreground: "cyan"
4
+ Face.define :dired_executable, foreground: "green"
5
+ Face.define :dired_flagged, foreground: "red"
6
+ end
@@ -0,0 +1,23 @@
1
+ module Textbringer
2
+ # Foreground color faces for gamegrid
3
+ Face.define :gamegrid_red, foreground: "red"
4
+ Face.define :gamegrid_green, foreground: "green"
5
+ Face.define :gamegrid_blue, foreground: "blue"
6
+ Face.define :gamegrid_yellow, foreground: "yellow"
7
+ Face.define :gamegrid_cyan, foreground: "cyan"
8
+ Face.define :gamegrid_magenta, foreground: "magenta"
9
+ Face.define :gamegrid_white, foreground: "white"
10
+
11
+ # Block faces (solid background) for Tetris-style solid blocks
12
+ Face.define :gamegrid_block_red, background: "red", foreground: "red"
13
+ Face.define :gamegrid_block_green, background: "green", foreground: "green"
14
+ Face.define :gamegrid_block_blue, background: "blue", foreground: "blue"
15
+ Face.define :gamegrid_block_yellow, background: "yellow", foreground: "yellow"
16
+ Face.define :gamegrid_block_cyan, background: "cyan", foreground: "cyan"
17
+ Face.define :gamegrid_block_magenta, background: "magenta", foreground: "magenta"
18
+ Face.define :gamegrid_block_white, background: "white", foreground: "white"
19
+
20
+ # Utility faces
21
+ Face.define :gamegrid_border, foreground: "white", bold: true
22
+ Face.define :gamegrid_score, foreground: "yellow", bold: true
23
+ end
@@ -0,0 +1,164 @@
1
+ require "fileutils"
2
+ require "time"
3
+
4
+ module Textbringer
5
+ class Gamegrid
6
+ attr_reader :width, :height
7
+ attr_accessor :score
8
+
9
+ def initialize(width, height, margin_left: 0)
10
+ @width = width
11
+ @height = height
12
+ @margin_left = margin_left
13
+ @grid = Array.new(height) { Array.new(width, 0) }
14
+ @faces = Array.new(height) { Array.new(width, nil) }
15
+ @display_options = {}
16
+ @score = 0
17
+ @timer_thread = nil
18
+ end
19
+
20
+ # Cell API
21
+
22
+ def set_cell(x, y, value)
23
+ check_bounds(x, y)
24
+ @grid[y][x] = value
25
+ end
26
+
27
+ def get_cell(x, y)
28
+ check_bounds(x, y)
29
+ @grid[y][x]
30
+ end
31
+
32
+ def set_face(x, y, face_name)
33
+ check_bounds(x, y)
34
+ @faces[y][x] = face_name
35
+ end
36
+
37
+ def get_face(x, y)
38
+ check_bounds(x, y)
39
+ @faces[y][x]
40
+ end
41
+
42
+ def set_display_option(value, char:, face: nil)
43
+ @display_options[value] = { char: char, face: face }
44
+ end
45
+
46
+ def fill(value)
47
+ @height.times do |y|
48
+ @width.times do |x|
49
+ @grid[y][x] = value
50
+ @faces[y][x] = nil
51
+ end
52
+ end
53
+ end
54
+
55
+ # Rendering
56
+
57
+ def render
58
+ margin = " " * @margin_left
59
+ @height.times.map { |y|
60
+ margin + @width.times.map { |x|
61
+ cell_char(@grid[y][x])
62
+ }.join
63
+ }.join("\n")
64
+ end
65
+
66
+ def face_map
67
+ highlight_on = {}
68
+ highlight_off = {}
69
+ offset = 0
70
+ @height.times do |y|
71
+ offset += @margin_left
72
+ @width.times do |x|
73
+ value = @grid[y][x]
74
+ # Priority: explicit set_face > display_option face > nil
75
+ face_name = @faces[y][x]
76
+ if face_name.nil?
77
+ opt = @display_options[value]
78
+ face_name = opt[:face] if opt
79
+ end
80
+ if face_name
81
+ face = Face[face_name]
82
+ if face
83
+ highlight_on[offset] = face
84
+ char_len = cell_char(value).bytesize
85
+ highlight_off[offset + char_len] = true
86
+ end
87
+ end
88
+ offset += cell_char(value).bytesize
89
+ end
90
+ offset += 1 # newline
91
+ end
92
+ [highlight_on, highlight_off]
93
+ end
94
+
95
+ # Timer
96
+
97
+ def start_timer(interval, &callback)
98
+ stop_timer
99
+ @timer_thread = Thread.new do
100
+ loop do
101
+ sleep(interval)
102
+ Controller.current.next_tick(&callback)
103
+ rescue ThreadError
104
+ break
105
+ end
106
+ end
107
+ end
108
+
109
+ def stop_timer
110
+ if @timer_thread
111
+ @timer_thread.kill
112
+ @timer_thread = nil
113
+ end
114
+ end
115
+
116
+ def timer_active?
117
+ !@timer_thread.nil? && @timer_thread.alive?
118
+ end
119
+
120
+ # Score persistence
121
+
122
+ def self.score_file_path(game_name)
123
+ safe_name = File.basename(game_name).gsub(/[^A-Za-z0-9_\-]/, "_")
124
+ File.expand_path("~/.textbringer/scores/#{safe_name}.scores")
125
+ end
126
+
127
+ def self.add_score(game_name, score, player_name: "anonymous")
128
+ path = score_file_path(game_name)
129
+ FileUtils.mkdir_p(File.dirname(path))
130
+ File.open(path, "a") do |f|
131
+ f.puts("#{score}\t#{player_name}\t#{Time.now.iso8601}")
132
+ end
133
+ end
134
+
135
+ def self.load_scores(game_name, limit: 10)
136
+ path = score_file_path(game_name)
137
+ return [] unless File.exist?(path)
138
+ lines = File.readlines(path, chomp: true)
139
+ lines.map { |line|
140
+ parts = line.split("\t")
141
+ { score: parts[0].to_i, player: parts[1], time: parts[2] }
142
+ }.sort_by { |h| -h[:score] }.first(limit)
143
+ end
144
+
145
+ private
146
+
147
+ def check_bounds(x, y)
148
+ if x < 0 || x >= @width || y < 0 || y >= @height
149
+ raise ArgumentError, "coordinates (#{x}, #{y}) out of bounds"
150
+ end
151
+ end
152
+
153
+ def cell_char(value)
154
+ opt = @display_options[value]
155
+ if opt
156
+ opt[:char]
157
+ elsif value.is_a?(String)
158
+ value
159
+ else
160
+ " "
161
+ end
162
+ end
163
+ end
164
+ end
@@ -36,6 +36,12 @@ module Textbringer
36
36
  @enabled = false
37
37
  end
38
38
 
39
+ def on_activate
40
+ end
41
+
42
+ def on_deactivate
43
+ end
44
+
39
45
  def enabled?
40
46
  @enabled
41
47
  end
@@ -1,8 +1,12 @@
1
1
  require "open-uri"
2
2
  require "fileutils"
3
+ require "socket"
4
+ require "timeout"
3
5
 
4
6
  module Textbringer
5
7
  CONFIG[:skk_dictionary_path] = File.expand_path("~/.textbringer/skk/SKK-JISYO.L")
8
+ CONFIG[:skk_server_host] = nil # nil = disabled (default)
9
+ CONFIG[:skk_server_port] = 1178
6
10
 
7
11
  class SkkInputMethod < InputMethod
8
12
  HIRAGANA_TABLE = {
@@ -46,7 +50,11 @@ module Textbringer
46
50
  "xtu" => "っ", "xtsu" => "っ",
47
51
  "xya" => "ゃ", "xyu" => "ゅ", "xyo" => "ょ",
48
52
  "xa" => "ぁ", "xi" => "ぃ", "xu" => "ぅ", "xe" => "ぇ", "xo" => "ぉ",
49
- "," => "、", "." => "。",
53
+ "-" => "ー", "," => "、", "." => "。", "[" => "「", "]" => "」",
54
+ "z-" => "~", "z." => "…", "z/" => "・", "z," => "‥",
55
+ "z(" => "(", "z)" => ")", "z[" => "『", "z]" => "』",
56
+ "zh" => "←", "zj" => "↓", "zk" => "↑", "zl" => "→", "zL" => "⇒",
57
+ "z " => " ",
50
58
  }
51
59
 
52
60
  HIRAGANA_PREFIXES = HIRAGANA_TABLE.keys.flat_map { |s|
@@ -99,6 +107,14 @@ module Textbringer
99
107
  (s.size - 1).times.map { |i| s[0, i + 1] }
100
108
  }.uniq
101
109
 
110
+ STATUS_NAMES = {
111
+ hiragana: "かな",
112
+ katakana: "カナ",
113
+ hankaku_katakana: "半カナ",
114
+ zenkaku_ascii: "全英",
115
+ ascii: "SKK:"
116
+ }
117
+
102
118
  DEFAULT_CURSOR_COLORS = {
103
119
  hiragana: "pink",
104
120
  katakana: "green",
@@ -120,6 +136,7 @@ module Textbringer
120
136
  @marker_pos = nil
121
137
  @okuriiari = nil
122
138
  @okurinasi = nil
139
+ @skk_server_socket = nil
123
140
  end
124
141
 
125
142
  def toggle
@@ -128,22 +145,26 @@ module Textbringer
128
145
  update_cursor_color
129
146
  else
130
147
  reset_cursor_color
148
+ close_skk_server
131
149
  end
132
150
  end
133
151
 
134
152
  def disable
135
153
  super
136
154
  reset_cursor_color
155
+ close_skk_server
156
+ end
157
+
158
+ def on_activate
159
+ update_cursor_color if @enabled
160
+ end
161
+
162
+ def on_deactivate
163
+ reset_cursor_color if @enabled
137
164
  end
138
165
 
139
166
  def status
140
- case @phase
141
- when :converting then "▽"
142
- when :selecting then "▼"
143
- else
144
- { hiragana: "あ", katakana: "ア", hankaku_katakana: "ア",
145
- zenkaku_ascii: "A", ascii: "A" }[@mode]
146
- end
167
+ STATUS_NAMES[@mode]
147
168
  end
148
169
 
149
170
  def handle_event(event)
@@ -201,7 +222,7 @@ module Textbringer
201
222
  process_romaji(event)
202
223
  end
203
224
  when "l"
204
- if [:hiragana, :katakana, :hankaku_katakana].include?(@mode)
225
+ if @roman_buffer != "z" && [:hiragana, :katakana, :hankaku_katakana].include?(@mode)
205
226
  @roman_buffer = +""
206
227
  @mode = :ascii
207
228
  Window.redisplay
@@ -211,7 +232,7 @@ module Textbringer
211
232
  process_romaji(event)
212
233
  end
213
234
  when "L"
214
- if [:hiragana, :katakana, :hankaku_katakana].include?(@mode)
235
+ if @roman_buffer != "z" && [:hiragana, :katakana, :hankaku_katakana].include?(@mode)
215
236
  @roman_buffer = +""
216
237
  @mode = :zenkaku_ascii
217
238
  Window.redisplay
@@ -389,8 +410,8 @@ module Textbringer
389
410
  if kana
390
411
  @roman_buffer = +""
391
412
  if @okuri_roman
392
- # Completing okurigana
393
- @okuri_kana = kana
413
+ # Completing okurigana (accumulate in case a vowel kana was already prepended)
414
+ @okuri_kana = (@okuri_kana || "") + kana
394
415
  with_target_buffer do |buffer|
395
416
  buffer.insert(kana)
396
417
  end
@@ -429,7 +450,14 @@ module Textbringer
429
450
  first_char = @roman_buffer[0]
430
451
  last_char = @roman_buffer[-1]
431
452
  @roman_buffer = +""
432
- append_yomi_kana(first_char)
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
433
461
  process_converting_romaji(last_char)
434
462
  end
435
463
 
@@ -457,9 +485,20 @@ module Textbringer
457
485
  nil
458
486
  end
459
487
 
460
- def start_okurigana(consonant)
461
- @okuri_roman = consonant.dup
462
- @roman_buffer = consonant.dup
488
+ def start_okurigana(c)
489
+ @okuri_roman = c.dup
490
+ kana = HIRAGANA_TABLE[c]
491
+ if kana
492
+ # Vowel okurigana: insert the kana immediately and record it in @okuri_kana.
493
+ # (A vowel is never a prefix of a longer romaji sequence, so it's always complete.)
494
+ @okuri_kana = kana
495
+ @roman_buffer = +""
496
+ with_target_buffer { |b| b.insert(kana) }
497
+ Window.redisplay
498
+ start_selecting
499
+ else
500
+ @roman_buffer = c.dup
501
+ end
463
502
  end
464
503
 
465
504
  def cancel_converting
@@ -492,16 +531,19 @@ module Textbringer
492
531
  end
493
532
 
494
533
  def start_selecting
495
- ensure_dictionary_loaded
496
-
497
534
  lookup_key = if @okuri_roman
498
535
  @yomi + @okuri_roman
499
536
  else
500
537
  @yomi
501
538
  end
502
539
 
503
- dict = @okuri_roman ? @okuriiari : @okurinasi
504
- candidates = dict[lookup_key]
540
+ candidates = if CONFIG[:skk_server_host]
541
+ skk_server_lookup(lookup_key)
542
+ else
543
+ ensure_dictionary_loaded
544
+ dict = @okuri_roman ? @okuriiari : @okurinasi
545
+ dict[lookup_key]
546
+ end
505
547
 
506
548
  if candidates.nil? || candidates.empty?
507
549
  message("No conversion: #{@yomi}")
@@ -750,6 +792,49 @@ module Textbringer
750
792
  STDOUT.write("\e]112\a")
751
793
  STDOUT.flush
752
794
  end
795
+
796
+ SKK_SERVER_TIMEOUT = 3 # seconds
797
+
798
+ def close_skk_server
799
+ return unless @skk_server_socket
800
+ begin
801
+ @skk_server_socket.write("0\n")
802
+ rescue IOError, Errno::EPIPE
803
+ end
804
+ @skk_server_socket.close rescue nil
805
+ @skk_server_socket = nil
806
+ end
807
+
808
+ def skk_server_connect
809
+ return true if @skk_server_socket && !@skk_server_socket.closed?
810
+ host = CONFIG[:skk_server_host]
811
+ return false unless host
812
+ port = CONFIG[:skk_server_port] || 1178
813
+ Timeout.timeout(SKK_SERVER_TIMEOUT) do
814
+ @skk_server_socket = TCPSocket.new(host, port)
815
+ end
816
+ true
817
+ end
818
+
819
+ def skk_server_lookup(lookup_key)
820
+ skk_server_connect
821
+ begin
822
+ Timeout.timeout(SKK_SERVER_TIMEOUT) do
823
+ @skk_server_socket.write("1#{lookup_key} \n".encode("EUC-JP"))
824
+ response = @skk_server_socket.gets("\n")
825
+ return nil unless response
826
+ response = response.encode("UTF-8", "EUC-JP", invalid: :replace, undef: :replace).chomp
827
+ return nil unless response.start_with?("1/")
828
+ body = response[2..]
829
+ candidates = body.split("/").map { |c| c.split(";").first&.strip }.compact.reject(&:empty?)
830
+ candidates.empty? ? nil : candidates
831
+ end
832
+ rescue Timeout::Error, IOError, Errno::EPIPE, Errno::ECONNRESET
833
+ @skk_server_socket.close rescue nil
834
+ @skk_server_socket = nil
835
+ nil
836
+ end
837
+ end
753
838
  end
754
839
 
755
840
  SKK_DICTIONARY_URL = "https://github.com/skk-dev/dict/raw/090619ac57ef230a0506c191b569fc8c82b1025b/SKK-JISYO.L"
@@ -177,6 +177,7 @@ module Textbringer
177
177
  GLOBAL_MAP.define_key("\C-z", :suspend_textbringer)
178
178
  GLOBAL_MAP.define_key("\C-x\C-f", :find_file)
179
179
  GLOBAL_MAP.define_key("\C-x\C-r", :find_file_read_only)
180
+ GLOBAL_MAP.define_key("\C-xd", :dired)
180
181
  GLOBAL_MAP.define_key("\C-x\C-v", :find_alternate_file)
181
182
  GLOBAL_MAP.define_key("\C-xb", :switch_to_buffer)
182
183
  GLOBAL_MAP.define_key("\C-x\C-b", :list_buffers)