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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9aa0a319cdc7f8e965d906b1059a76083730240463a3ed33a4cb79214d2762b6
4
- data.tar.gz: 3993baf2bf25459b7ec201b112d98c05aa96eaac68e77510b013cfd2d3cf69c8
3
+ metadata.gz: d7426dda13275f37439bdcc360a55971b34cc4b62916eeaee734b127ab9a8a88
4
+ data.tar.gz: fcc6391ac0697659a8b85005766186502731e8473928a5e5165d7c0a97d533f1
5
5
  SHA512:
6
- metadata.gz: cb57afbe902f32238deab1589433564632fcea16b43351faebf530262deafb50d36719180a120e9496206ed99eaebc56f7c7506c190cb75abfaf104b00aecea1
7
- data.tar.gz: 633f52c2fc1b4012b0e02ad06fba07d1cde388f1949ad06189a2b1354ab4952d9ae2c68898f8e4de899f58b9b800579f77212a3db6569340371d700fa3a3d12d
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
@@ -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
- @attributes = 0
43
- @attributes |= Curses.color_pair(@color_pair)
44
- @attributes |= Curses::A_BOLD if bold
45
- @attributes |= Curses::A_UNDERLINE if underline
46
- @attributes |= Curses::A_REVERSE if reverse
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,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
@@ -142,24 +142,13 @@ module Textbringer
142
142
  @buffer.save_point do |point|
143
143
  @window.erase
144
144
 
145
- # Get face attributes if face is specified
146
- face_attrs = 0
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 attributes if specified
153
- current_line_attrs = 0
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.attrset(face_attrs)
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
- line_attrs = if @current_line_face && line_num == point_line
199
- current_line_attrs
200
- else
201
- face_attrs
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 line_attrs != 0 && col < @columns
246
- @window.attron(line_attrs)
247
- @window.addstr(" " * (@columns - col))
248
- @window.attroff(line_attrs)
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 face_attrs != 0
242
+ if face
267
243
  while line_num < @lines
268
244
  @window.setpos(line_num, 0)
269
- @window.attron(face_attrs)
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
@@ -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