ruby_rich 0.4.0 → 0.4.2

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.
@@ -1,4 +1,6 @@
1
1
  require 'io/console'
2
+ require_relative 'event'
3
+ require_relative 'terminal'
2
4
 
3
5
  module RubyRich
4
6
  class Console
@@ -18,7 +20,25 @@ module RubyRich
18
20
  # 其他
19
21
  '[5~' => :page_up, '[6~' => :page_down,
20
22
  '[H' => :home, '[F' => :end,
21
- '[2~' => :insert, '[3~' => :delete
23
+ '[2~' => :insert, '[3~' => :delete,
24
+ '[Z' => :shift_tab,
25
+ '[13;2u' => :shift_enter,
26
+ '[13;3u' => :alt_enter,
27
+ '[13;2~' => :shift_enter,
28
+ '[13;3~' => :alt_enter
29
+ }.freeze
30
+
31
+ WINDOWS_SPECIAL_KEYS = {
32
+ 72 => :up,
33
+ 80 => :down,
34
+ 75 => :left,
35
+ 77 => :right,
36
+ 71 => :home,
37
+ 79 => :end,
38
+ 73 => :page_up,
39
+ 81 => :page_down,
40
+ 82 => :insert,
41
+ 83 => :delete
22
42
  }.freeze
23
43
 
24
44
  def initialize
@@ -86,45 +106,104 @@ module RubyRich
86
106
  system('clear')
87
107
  end
88
108
 
109
+ def get_event(input: $stdin)
110
+ if RubyRich::Terminal.windows_mouse_reporting?
111
+ event = RubyRich::Terminal.read_windows_input_event
112
+ return event if event
113
+ end
114
+
115
+ get_key(input: input)
116
+ end
117
+
89
118
  def get_key(input: $stdin)
90
119
  input.raw(intr: true) do |io|
120
+ RubyRich::Terminal.prepare_input
91
121
  char = io.getch
92
- # 优先处理回车键(ASCII 13 = \r,ASCII 10 = \n)
122
+ bytes = char.b.bytes
123
+ byte = bytes.first
124
+
125
+ return Event.key(:ctrl_c) if byte == 3
126
+
127
+ # Handle Enter key first (ASCII 13 = \r, ASCII 10 = \n)
93
128
  if char == "\r" || char == "\n"
94
- # 检查是否有后续输入(粘贴内容会有多个字符)
129
+ # Check for subsequent input (pasted content has multiple characters)
95
130
  has_more = IO.select([io], nil, nil, 0)
96
- return has_more ? {:name => :string, :value => char} : {:name=>:enter}
131
+
132
+ return has_more ? Event.key(:paste, value: collect_pending_input(io, char)) : Event.key(:enter)
97
133
  end
98
- # 单独处理 Tab 键(ASCII 9
134
+ # Handle Tab key separately (ASCII 9)
99
135
  if char == "\t"
100
- return {:name=>:tab}
101
- elsif char.ord == 0x07F
102
- return {:name=>:backspace}
103
- elsif char == "\e" # 检测到转义序列
104
- sequence = ''
105
- begin
106
- while (c = io.read_nonblock(1))
107
- sequence << c
108
- end
109
- rescue IO::WaitReadable
110
- retry if IO.select([io], nil, nil, 0.01)
111
- rescue EOFError
136
+ return Event.key(:tab)
137
+ elsif byte == 8 || byte == 0x7F
138
+ return Event.key(:backspace)
139
+ # Windows special keys can be delivered as:
140
+ # - "\xE0" + a second getch byte
141
+ # - "\xE0H" in one read
142
+ elsif byte == 0 || byte == 224
143
+ code = bytes[1]
144
+ code ||= io.getch.b.bytes.first
145
+ return Event.key(WINDOWS_SPECIAL_KEYS[code] || :"windows_special_#{code}")
146
+ elsif char == "\e" # Detect escape sequence
147
+ sequence = char.b.bytes.drop(1).pack('C*')
148
+
149
+ sleep 0.01
150
+ while IO.select([io], nil, nil, 0)
151
+ next_char = io.getch
152
+ break unless next_char
153
+
154
+ sequence << next_char
155
+ return read_bracketed_paste(io) if sequence == "[200~"
156
+ break if complete_escape_sequence?(sequence)
112
157
  end
158
+
113
159
  if sequence.empty?
114
- return {:name => :escape}
160
+ return Event.key(:escape)
161
+ elsif sequence == "\r" || sequence == "\n"
162
+ return Event.key(:alt_enter)
163
+ elsif (mouse_event = parse_sgr_mouse(sequence))
164
+ return mouse_event
115
165
  else
116
- return {:name => ESCAPE_SEQUENCES[sequence]} || {:name => :escape}
166
+ return Event.key(ESCAPE_SEQUENCES[sequence] || :"ansi_#{sequence.inspect}")
117
167
  end
118
- # 处理 Ctrl 组合键(排除 Tab 和回车)
119
- elsif char.ord.between?(1, 8) || char.ord.between?(10, 26)
120
- ctrl_char = (char.ord + 64).chr.downcase
121
- return {:name =>"ctrl_#{ctrl_char}".to_sym}
168
+ # Handle Ctrl combinations (excluding Tab and Enter)
169
+ elsif byte.between?(1, 8) || byte.between?(10, 26)
170
+ ctrl_char = (byte + 64).chr.downcase
171
+ return Event.key("ctrl_#{ctrl_char}".to_sym)
122
172
  else
123
- {:name => :string, :value => char}
173
+ if IO.select([io], nil, nil, 0)
174
+ Event.key(:paste, value: collect_pending_input(io, char))
175
+ else
176
+ Event.key(:string, value: char)
177
+ end
124
178
  end
125
179
  end
126
180
  end
127
181
 
182
+ def parse_sgr_mouse(sequence)
183
+ match = sequence.match(/\A\[<(\d+);(\d+);(\d+)([Mm])\z/)
184
+ return nil unless match
185
+
186
+ code = match[1].to_i
187
+ raw_x = match[2].to_i
188
+ raw_y = match[3].to_i
189
+ terminator = match[4]
190
+ button = mouse_button(code)
191
+ name = mouse_event_name(code, terminator)
192
+ direction = mouse_wheel_direction(code)
193
+
194
+ Event.mouse(
195
+ name,
196
+ button: button,
197
+ x: raw_x - 1,
198
+ y: raw_y - 1,
199
+ raw_x: raw_x,
200
+ raw_y: raw_y,
201
+ code: code,
202
+ modifiers: mouse_modifiers(code),
203
+ direction: direction
204
+ )
205
+ end
206
+
128
207
  def add_line(text)
129
208
  @lines << text
130
209
  end
@@ -151,6 +230,76 @@ module RubyRich
151
230
 
152
231
  private
153
232
 
233
+ def read_bracketed_paste(io)
234
+ terminator = "\e[201~"
235
+ buffer = +""
236
+ loop do
237
+ char = io.getch
238
+ break unless char
239
+
240
+ buffer << char
241
+ break if buffer.end_with?(terminator)
242
+ end
243
+ buffer = normalize_paste_text(buffer.delete_suffix(terminator))
244
+ Event.key(:paste, value: buffer)
245
+ end
246
+
247
+ def collect_pending_input(io, first_char)
248
+ buffer = +first_char
249
+ while IO.select([io], nil, nil, 0)
250
+ char = io.getch
251
+ break unless char
252
+
253
+ buffer << char
254
+ end
255
+ normalize_paste_text(buffer)
256
+ end
257
+
258
+ def normalize_paste_text(text)
259
+ text.to_s.gsub(/\r\n?/, "\n")
260
+ end
261
+
262
+ def complete_escape_sequence?(sequence)
263
+ if sequence.start_with?('[<')
264
+ sequence.end_with?('M') || sequence.end_with?('m') || sequence.length >= 32
265
+ else
266
+ ESCAPE_SEQUENCES.key?(sequence) || sequence.length >= 8
267
+ end
268
+ end
269
+
270
+ def mouse_event_name(code, terminator)
271
+ return :mouse_up if terminator == 'm'
272
+ return :mouse_wheel if (code & 64) == 64
273
+ return :mouse_drag if (code & 32) == 32
274
+
275
+ :mouse_down
276
+ end
277
+
278
+ def mouse_button(code)
279
+ return :wheel if (code & 64) == 64
280
+
281
+ case code & 3
282
+ when 0 then :left
283
+ when 1 then :middle
284
+ when 2 then :right
285
+ else :unknown
286
+ end
287
+ end
288
+
289
+ def mouse_wheel_direction(code)
290
+ return nil unless (code & 64) == 64
291
+
292
+ (code & 1) == 1 ? :down : :up
293
+ end
294
+
295
+ def mouse_modifiers(code)
296
+ modifiers = []
297
+ modifiers << :shift if (code & 4) == 4
298
+ modifiers << :alt if (code & 8) == 8
299
+ modifiers << :ctrl if (code & 16) == 16
300
+ modifiers
301
+ end
302
+
154
303
  def format_line(line)
155
304
  content = line.is_a?(RichText) ? line.render : line
156
305
  case @layout[:align]
@@ -163,4 +312,4 @@ module RubyRich
163
312
  end
164
313
  end
165
314
  end
166
- end
315
+ end
@@ -4,6 +4,7 @@ module RubyRich
4
4
  attr_accessor :width, :height
5
5
 
6
6
  def initialize(title: "", content: "", width: 48, height: 8, buttons: [:ok])
7
+ @title = title
7
8
  @width = width
8
9
  @height = height
9
10
  terminal_width = `tput cols`.to_i
@@ -63,4 +64,4 @@ module RubyRich
63
64
  end
64
65
  end
65
66
  end
66
- end
67
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyRich
4
+ module Event
5
+ module_function
6
+
7
+ def key(name, value: nil)
8
+ event = { name: name, type: :key }
9
+ event[:value] = value unless value.nil?
10
+ event
11
+ end
12
+
13
+ def mouse(name, button:, x:, y:, raw_x:, raw_y:, code:, modifiers: [], direction: nil)
14
+ event = {
15
+ name: name,
16
+ type: :mouse,
17
+ button: button,
18
+ x: x,
19
+ y: y,
20
+ raw_x: raw_x,
21
+ raw_y: raw_y,
22
+ code: code,
23
+ modifiers: modifiers
24
+ }
25
+ event[:direction] = direction if direction
26
+ event
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyRich
4
+ class FocusManager
5
+ attr_reader :focused_name
6
+
7
+ def initialize
8
+ @entries = []
9
+ @focused_name = nil
10
+ end
11
+
12
+ def register(name, layout, target)
13
+ @entries << { name: name, layout: layout, target: target }
14
+ focus(name) unless @focused_name
15
+ self
16
+ end
17
+
18
+ def attach(root_layout, priority: 500)
19
+ root_layout.key(:tab, priority) do |_event_data, _live|
20
+ current = @entries.find { |entry| entry[:name] == @focused_name }
21
+ if current && current[:target].respond_to?(:wants_tab?) && current[:target].wants_tab?
22
+ false
23
+ else
24
+ next_entry = focus_next
25
+ target = next_entry && next_entry[:target]
26
+ target.ignore_next_tab if target.respond_to?(:ignore_next_tab)
27
+
28
+ false
29
+ end
30
+ end
31
+
32
+ root_layout.key(:mouse_down, priority) do |event_data, _live|
33
+ entry = entry_at(event_data[:x], event_data[:y])
34
+ if entry
35
+ focus(entry[:name])
36
+ false
37
+ else
38
+ false
39
+ end
40
+ end
41
+
42
+ self
43
+ end
44
+
45
+ def focus(name)
46
+ entry = @entries.find { |item| item[:name] == name }
47
+ return nil unless entry
48
+
49
+ blur_current
50
+ @focused_name = entry[:name]
51
+ entry[:target].focus if entry[:target].respond_to?(:focus)
52
+ entry
53
+ end
54
+
55
+ def focus_next
56
+ return nil if @entries.empty?
57
+
58
+ index = @entries.index { |entry| entry[:name] == @focused_name } || -1
59
+ focus(@entries[(index + 1) % @entries.length][:name])
60
+ end
61
+
62
+ def focused?(name)
63
+ @focused_name == name
64
+ end
65
+
66
+ private
67
+
68
+ def blur_current
69
+ current = @entries.find { |entry| entry[:name] == @focused_name }
70
+ current[:target].blur if current && current[:target].respond_to?(:blur)
71
+ end
72
+
73
+ def entry_at(x, y)
74
+ @entries.reverse.find { |entry| entry[:layout].contains?(x, y) }
75
+ end
76
+ end
77
+ end
@@ -19,6 +19,7 @@ module RubyRich
19
19
  @show = true
20
20
  @event_listeners = {}
21
21
  @event_intercepted = false
22
+ @mouse_capture = nil
22
23
  end
23
24
 
24
25
  def key(event_name, priority = 0, &block)
@@ -35,6 +36,8 @@ module RubyRich
35
36
  end
36
37
 
37
38
  def notify_listeners(event_data)
39
+ return route_mouse_event(event_data) if event_data[:type] == :mouse
40
+
38
41
  return if @event_intercepted
39
42
  if @dialog
40
43
  @dialog.notify_listeners(event_data)
@@ -62,6 +65,27 @@ module RubyRich
62
65
  @parent ? @parent.root : self
63
66
  end
64
67
 
68
+ def contains?(x, y)
69
+ return false unless @show
70
+ return false unless @width && @height
71
+
72
+ x >= @x_offset &&
73
+ x < @x_offset + @width &&
74
+ y >= @y_offset &&
75
+ y < @y_offset + @height
76
+ end
77
+
78
+ def hit_test(x, y)
79
+ return nil unless contains?(x, y)
80
+
81
+ @children.reverse_each do |child|
82
+ hit = child.hit_test(x, y)
83
+ return hit if hit
84
+ end
85
+
86
+ self
87
+ end
88
+
65
89
  def split_row(*layouts)
66
90
  @split_direction = :row
67
91
  layouts.each { |l| l.parent = self }
@@ -75,6 +99,7 @@ module RubyRich
75
99
  end
76
100
 
77
101
  def add_child(layout)
102
+ layout.parent = self
78
103
  @children << layout
79
104
  end
80
105
 
@@ -111,15 +136,15 @@ module RubyRich
111
136
  end
112
137
 
113
138
  def render
114
- # 将缓冲区转换为字符串(每行用换行符连接)
139
+ # Convert buffer to string (join lines with newlines)
115
140
  buffer = render_to_buffer
116
141
  buffer.map { |line| line.compact.join("") }.join("\n")
117
142
  end
118
143
 
119
144
  def render_to_buffer
120
- # 初始化缓冲区(二维数组,每个元素代表一个字符)
145
+ # Initialize buffer (2D array, each element represents a character)
121
146
  buffer = Array.new(@height) { Array.new(@width, " ") }
122
- # 递归填充内容到缓冲区
147
+ # Recursively fill content into buffer
123
148
  render_into(buffer)
124
149
  render_dialog_into(buffer) if @dialog
125
150
  return buffer
@@ -150,7 +175,7 @@ module RubyRich
150
175
  content_lines = if content.is_a?(String)
151
176
  content.split("\n")[0...height]
152
177
  else
153
- content.render[0...height]
178
+ normalize_rendered_lines(content.render)[0...height]
154
179
  end
155
180
 
156
181
  content_lines.each_with_index do |line, line_index|
@@ -159,9 +184,9 @@ module RubyRich
159
184
 
160
185
  in_escape = false
161
186
  escape_char = ""
162
- char_width = 0 # 初始宽度调整为0,方便位置计算
187
+ char_width = 0 # Initialize width to 0 for position calculation
163
188
  line.each_char do |char|
164
- # 处理ANSI转义码
189
+ # Handle ANSI escape codes
165
190
  if in_escape
166
191
  escape_char += char
167
192
  in_escape = false if char == 'm'
@@ -169,49 +194,47 @@ module RubyRich
169
194
  escape_char = ""
170
195
  end
171
196
  next
172
- elsif char.ord == 27 # 检测到转义开始符\e
197
+ elsif char.ord == 27 # Detect escape sequence start \e
173
198
  in_escape = true
174
199
  escape_char += char
175
200
  next
176
201
  end
177
-
178
- # 计算字符宽度
202
+
203
+ # Calculate character width
179
204
  char_w = case char.ord
180
- when 0x0000..0x007F then 1 # 英文字符
181
- when 0x4E00..0x9FFF then 2 # 中文字符
205
+ when 0x0000..0x007F then 1 # English characters
206
+ when 0x4E00..0x9FFF then 2 # Chinese characters
182
207
  else Unicode::DisplayWidth.of(char)
183
208
  end
184
- # 计算字符的起始位置
209
+ # Calculate character start position
185
210
  x_start = x_offset + char_width
186
-
187
- # 超出右边界则跳过
211
+
212
+ # Skip if beyond right boundary
188
213
  next if x_start >= buffer[y_pos].size
189
-
190
- # 处理字符渲染(中文字符可能占用多个位置)
214
+
215
+ # Handle character rendering (Chinese characters may occupy multiple positions)
191
216
  char_w.times do |i|
192
217
  x_pos = x_start + i
193
- break if x_pos >= buffer[y_pos].size # 超出右边界停止
218
+ break if x_pos >= buffer[y_pos].size # Stop at right boundary
194
219
  unless escape_char.empty?
195
- char = escape_char + char + "\e[0m" # 每次都记录字符的实际颜色
220
+ char = escape_char + char + "\e[0m" # Record character's actual color each time
196
221
  end
197
- buffer[y_pos][x_pos] = char unless i > 0 # 中文字符仅在第一个位置写入,避免覆盖
222
+ buffer[y_pos][x_pos] = char unless i > 0 # Write Chinese character only at first position to avoid overwriting
198
223
  buffer[y_pos][x_pos+1] = nil if char_w == 2
199
224
  end
200
- char_width += char_w # 更新累计宽度
225
+ char_width += char_w # Update cumulative width
201
226
  end
202
227
  end
203
228
  end
204
229
 
205
230
  def calculate_node_dimensions(available_width, available_height)
206
- # 只在未设置宽度时计算宽度
207
- @width ||= if @size
208
- [@size, available_width].min
209
- else
210
- available_width
211
- end
231
+ @width = if @size && @parent&.split_direction == :row
232
+ [@size, available_width].min
233
+ else
234
+ available_width
235
+ end
212
236
 
213
- # 只在未设置高度时计算高度
214
- @height ||= if @size
237
+ @height = if @size && @parent&.split_direction == :column
215
238
  [@size, available_height].min
216
239
  else
217
240
  available_height
@@ -220,10 +243,20 @@ module RubyRich
220
243
  if @content.class == RubyRich::Panel
221
244
  @content.width = @width
222
245
  @content.height = @height
246
+ else
247
+ @content.width = @width if @content.respond_to?(:width=)
248
+ @content.height = @height if @content.respond_to?(:height=)
223
249
  end
224
250
 
225
251
  return if @children.empty?
226
252
 
253
+ @children.each do |child|
254
+ next unless child.content.respond_to?(:desired_height)
255
+ next unless @split_direction == :column
256
+
257
+ child.size = [[child.content.desired_height, 1].max, available_height - 2].min
258
+ end
259
+
227
260
  case @split_direction
228
261
  when :row
229
262
  remaining_width = @width
@@ -290,5 +323,60 @@ module RubyRich
290
323
  end
291
324
  end
292
325
  end
326
+
327
+ private
328
+
329
+ def route_mouse_event(event_data)
330
+ if @dialog
331
+ @dialog.notify_listeners(event_data)
332
+ return
333
+ end
334
+
335
+ capture = root.instance_variable_get(:@mouse_capture)
336
+ target = capture && [:mouse_drag, :mouse_up].include?(event_data[:name]) ? capture : (hit_test(event_data[:x], event_data[:y]) || self)
337
+ handled = target.bubble_mouse_event(event_data)
338
+
339
+ root.instance_variable_set(:@mouse_capture, target) if event_data[:name] == :mouse_down && handled
340
+ root.instance_variable_set(:@mouse_capture, nil) if event_data[:name] == :mouse_up
341
+ handled
342
+ end
343
+
344
+ protected
345
+
346
+ def bubble_mouse_event(event_data)
347
+ current = self
348
+ while current
349
+ return true if current.dispatch_event_listeners(event_data)
350
+
351
+ current = current.parent
352
+ end
353
+
354
+ false
355
+ end
356
+
357
+ def dispatch_event_listeners(event_data)
358
+ listeners = @event_listeners[event_data[:name]]
359
+ return false unless listeners
360
+
361
+ listeners.each do |listener|
362
+ result = listener[:block].call(event_data, root.live)
363
+ return true if result == true
364
+ end
365
+
366
+ false
367
+ end
368
+
369
+ private
370
+
371
+ def normalize_rendered_lines(rendered)
372
+ case rendered
373
+ when String
374
+ rendered.split("\n")
375
+ when Array
376
+ rendered
377
+ else
378
+ rendered.to_s.split("\n")
379
+ end
380
+ end
293
381
  end
294
- end
382
+ end