textbringer 19 → 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 +4 -4
- data/CLAUDE.md +0 -6
- data/lib/textbringer/buffer.rb +2 -0
- data/lib/textbringer/commands/dired.rb +31 -0
- data/lib/textbringer/commands/files.rb +4 -0
- data/lib/textbringer/commands/gamegrid.rb +25 -0
- data/lib/textbringer/commands/tetris.rb +10 -0
- data/lib/textbringer/face.rb +6 -6
- data/lib/textbringer/faces/dired.rb +6 -0
- data/lib/textbringer/faces/gamegrid.rb +23 -0
- data/lib/textbringer/floating_window.rb +15 -40
- data/lib/textbringer/gamegrid.rb +164 -0
- data/lib/textbringer/input_method.rb +6 -0
- data/lib/textbringer/input_methods/skk_input_method.rb +132 -22
- data/lib/textbringer/keymap.rb +1 -0
- data/lib/textbringer/modes/dired_mode.rb +322 -0
- data/lib/textbringer/modes/gamegrid_mode.rb +46 -0
- data/lib/textbringer/modes/tetris_mode.rb +316 -0
- data/lib/textbringer/modes/transient_mark_mode.rb +2 -2
- data/lib/textbringer/version.rb +1 -1
- data/lib/textbringer/window.rb +71 -88
- data/lib/textbringer.rb +7 -0
- metadata +11 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d7426dda13275f37439bdcc360a55971b34cc4b62916eeaee734b127ab9a8a88
|
|
4
|
+
data.tar.gz: fcc6391ac0697659a8b85005766186502731e8473928a5e5165d7c0a97d533f1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
```
|
data/lib/textbringer/buffer.rb
CHANGED
|
@@ -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
|
data/lib/textbringer/face.rb
CHANGED
|
@@ -2,7 +2,7 @@ require "curses"
|
|
|
2
2
|
|
|
3
3
|
module Textbringer
|
|
4
4
|
class Face
|
|
5
|
-
attr_reader :name, :attributes
|
|
5
|
+
attr_reader :name, :attributes, :color_pair, :text_attrs
|
|
6
6
|
|
|
7
7
|
@@face_table = {}
|
|
8
8
|
@@next_color_pair = 1
|
|
@@ -39,11 +39,11 @@ module Textbringer
|
|
|
39
39
|
@reverse = reverse
|
|
40
40
|
Curses.init_pair(@color_pair,
|
|
41
41
|
Color[foreground], Color[background])
|
|
42
|
-
@
|
|
43
|
-
@
|
|
44
|
-
@
|
|
45
|
-
@
|
|
46
|
-
@attributes
|
|
42
|
+
@text_attrs = 0
|
|
43
|
+
@text_attrs |= Curses::A_BOLD if bold
|
|
44
|
+
@text_attrs |= Curses::A_UNDERLINE if underline
|
|
45
|
+
@text_attrs |= Curses::A_REVERSE if reverse
|
|
46
|
+
@attributes = Curses.color_pair(@color_pair) | @text_attrs
|
|
47
47
|
self
|
|
48
48
|
end
|
|
49
49
|
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
|
|
@@ -142,24 +142,13 @@ module Textbringer
|
|
|
142
142
|
@buffer.save_point do |point|
|
|
143
143
|
@window.erase
|
|
144
144
|
|
|
145
|
-
# Get face
|
|
146
|
-
|
|
147
|
-
if @face && Window.has_colors?
|
|
148
|
-
face = Face[@face]
|
|
149
|
-
face_attrs = face.attributes if face
|
|
150
|
-
end
|
|
145
|
+
# Get face if face is specified
|
|
146
|
+
face = @face && Window.has_colors? ? Face[@face] : nil
|
|
151
147
|
|
|
152
|
-
# Get current line face
|
|
153
|
-
|
|
154
|
-
if @current_line_face && Window.has_colors?
|
|
155
|
-
current_line_face = Face[@current_line_face]
|
|
156
|
-
current_line_attrs = current_line_face.attributes if current_line_face
|
|
157
|
-
end
|
|
148
|
+
# Get current line face if specified
|
|
149
|
+
current_line_face = @current_line_face && Window.has_colors? ? Face[@current_line_face] : nil
|
|
158
150
|
|
|
159
|
-
@window
|
|
160
|
-
@in_region = false
|
|
161
|
-
@in_isearch = false
|
|
162
|
-
@current_highlight_attrs = face_attrs
|
|
151
|
+
apply_face_attrs(@window, face)
|
|
163
152
|
|
|
164
153
|
# First pass: find which line contains point
|
|
165
154
|
point_line = nil
|
|
@@ -195,11 +184,10 @@ module Textbringer
|
|
|
195
184
|
@window.setpos(line_num, 0)
|
|
196
185
|
|
|
197
186
|
# Determine which face to use for this line
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
end
|
|
187
|
+
line_face = @current_line_face && line_num == point_line ? current_line_face : face
|
|
188
|
+
|
|
189
|
+
# Set attributes for the entire line
|
|
190
|
+
apply_face_attrs(@window, line_face)
|
|
203
191
|
|
|
204
192
|
# Render characters on this line
|
|
205
193
|
col = 0
|
|
@@ -228,29 +216,17 @@ module Textbringer
|
|
|
228
216
|
break
|
|
229
217
|
end
|
|
230
218
|
|
|
231
|
-
# Apply face attributes to all characters
|
|
232
|
-
if line_attrs != 0
|
|
233
|
-
@window.attron(line_attrs)
|
|
234
|
-
end
|
|
235
219
|
@window.addstr(s)
|
|
236
|
-
if line_attrs != 0
|
|
237
|
-
@window.attroff(line_attrs)
|
|
238
|
-
end
|
|
239
220
|
|
|
240
221
|
col += char_width
|
|
241
222
|
@buffer.forward_char
|
|
242
223
|
end
|
|
243
224
|
|
|
244
225
|
# Fill remaining space on the line with the face background
|
|
245
|
-
if
|
|
246
|
-
|
|
247
|
-
@window
|
|
248
|
-
@window.
|
|
249
|
-
elsif line_attrs == 0 && face_attrs != 0 && col < @columns
|
|
250
|
-
# Use default face for padding if no line-specific attrs
|
|
251
|
-
@window.attron(face_attrs)
|
|
252
|
-
@window.addstr(" " * (@columns - col))
|
|
253
|
-
@window.attroff(face_attrs)
|
|
226
|
+
if col < @columns
|
|
227
|
+
fill_face = line_face || face
|
|
228
|
+
apply_face_attrs(@window, fill_face)
|
|
229
|
+
@window.addstr(" " * (@columns - col)) if fill_face
|
|
254
230
|
end
|
|
255
231
|
|
|
256
232
|
# Track cursor position
|
|
@@ -263,12 +239,11 @@ module Textbringer
|
|
|
263
239
|
end
|
|
264
240
|
|
|
265
241
|
# Fill remaining lines with the face background
|
|
266
|
-
if
|
|
242
|
+
if face
|
|
267
243
|
while line_num < @lines
|
|
268
244
|
@window.setpos(line_num, 0)
|
|
269
|
-
@window
|
|
245
|
+
apply_face_attrs(@window, face)
|
|
270
246
|
@window.addstr(" " * @columns)
|
|
271
|
-
@window.attroff(face_attrs)
|
|
272
247
|
line_num += 1
|
|
273
248
|
end
|
|
274
249
|
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
|