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.
@@ -0,0 +1,325 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyRich
4
+ class LineEditor
5
+ WORD_PATTERN = /[^\s]+/
6
+
7
+ attr_reader :history, :cursor
8
+ attr_accessor :multiline, :history_path, :max_history
9
+
10
+ def initialize(multiline: false, history: [], history_path: nil, max_history: 200)
11
+ @multiline = multiline
12
+ @history_path = history_path
13
+ @max_history = max_history
14
+ @history = []
15
+ @history_index = nil
16
+ @chars = []
17
+ @cursor = 0
18
+ load_history
19
+ history.each { |item| add_history(item, persist: false) }
20
+ end
21
+
22
+ def value
23
+ @chars.join
24
+ end
25
+
26
+ def value=(text)
27
+ @chars = text.to_s.chars
28
+ @cursor = @chars.length
29
+ @history_index = nil
30
+ self
31
+ end
32
+
33
+ def empty?
34
+ @chars.empty?
35
+ end
36
+
37
+ def clear
38
+ @chars.clear
39
+ @cursor = 0
40
+ @history_index = nil
41
+ self
42
+ end
43
+
44
+ def insert(text)
45
+ incoming = normalize_insert_text(text)
46
+ return self if incoming.empty?
47
+
48
+ new_chars = incoming.chars
49
+ @chars.insert(@cursor, *new_chars)
50
+ @cursor += new_chars.length
51
+ @history_index = nil
52
+ self
53
+ end
54
+
55
+ def newline
56
+ insert("\n") if @multiline
57
+ self
58
+ end
59
+
60
+ def backspace
61
+ return false if @cursor.zero?
62
+
63
+ @chars.delete_at(@cursor - 1)
64
+ @cursor -= 1
65
+ true
66
+ end
67
+
68
+ def delete
69
+ return false if @cursor >= @chars.length
70
+
71
+ @chars.delete_at(@cursor)
72
+ true
73
+ end
74
+
75
+ def move_left
76
+ @cursor = [@cursor - 1, 0].max
77
+ self
78
+ end
79
+
80
+ def move_right
81
+ @cursor = [@cursor + 1, @chars.length].min
82
+ self
83
+ end
84
+
85
+ def home
86
+ @cursor = current_line_start
87
+ self
88
+ end
89
+
90
+ def end
91
+ @cursor = current_line_end
92
+ self
93
+ end
94
+
95
+ def buffer_start
96
+ @cursor = 0
97
+ self
98
+ end
99
+
100
+ def buffer_end
101
+ @cursor = @chars.length
102
+ self
103
+ end
104
+
105
+ def move_up
106
+ return history_previous if !@multiline || single_empty_line? || @history_index
107
+
108
+ move_vertical(-1)
109
+ end
110
+
111
+ def move_down
112
+ return history_next if !@multiline || single_empty_line? || @history_index
113
+
114
+ move_vertical(1)
115
+ end
116
+
117
+ def kill_to_end
118
+ return false if @cursor >= @chars.length
119
+
120
+ @chars.slice!(@cursor...current_line_end)
121
+ true
122
+ end
123
+
124
+ def kill_to_start
125
+ start = current_line_start
126
+ return false if @cursor <= start
127
+
128
+ @chars.slice!(start...@cursor)
129
+ @cursor = start
130
+ true
131
+ end
132
+
133
+ def kill_word_back
134
+ return false if @cursor.zero?
135
+
136
+ start = previous_word_start
137
+ @chars.slice!(start...@cursor)
138
+ @cursor = start
139
+ true
140
+ end
141
+
142
+ def submit_value
143
+ submitted = value
144
+ add_history(submitted) unless submitted.strip.empty?
145
+ clear
146
+ submitted
147
+ end
148
+
149
+ def add_history(text, persist: true)
150
+ item = text.to_s
151
+ return self if item.strip.empty?
152
+
153
+ @history.delete(item)
154
+ @history << item
155
+ @history.shift while @history.length > @max_history
156
+ persist_history if persist && @history_path
157
+ self
158
+ end
159
+
160
+ def lines
161
+ text = value
162
+ text.empty? ? [""] : text.split("\n", -1)
163
+ end
164
+
165
+ def cursor_line_col
166
+ before = @chars[0...@cursor].join
167
+ parts = before.empty? ? [""] : before.split("\n", -1)
168
+ [parts.length - 1, parts.last.to_s.chars.length]
169
+ end
170
+
171
+ def render_lines(width:, placeholder: nil, focused: true)
172
+ content = value
173
+ return [placeholder.to_s] if content.empty? && placeholder
174
+
175
+ rendered = []
176
+ line_index, col = cursor_line_col
177
+ lines.each_with_index do |line, index|
178
+ marker_col = focused && index == line_index ? col : nil
179
+ rendered.concat(wrap_line_with_cursor(line, width, marker_col))
180
+ end
181
+ rendered.empty? ? [""] : rendered
182
+ end
183
+
184
+ private
185
+
186
+ def normalize_insert_text(text)
187
+ incoming = text.to_s.gsub(/\r\n?/, "\n")
188
+ return incoming if @multiline
189
+
190
+ incoming.gsub(/\n/, " ")
191
+ end
192
+
193
+ def current_line_start
194
+ index = @cursor - 1
195
+ while index >= 0
196
+ return index + 1 if @chars[index] == "\n"
197
+
198
+ index -= 1
199
+ end
200
+ 0
201
+ end
202
+
203
+ def current_line_end
204
+ index = @cursor
205
+ while index < @chars.length
206
+ return index if @chars[index] == "\n"
207
+
208
+ index += 1
209
+ end
210
+ @chars.length
211
+ end
212
+
213
+ def move_vertical(delta)
214
+ current_line, current_col = cursor_line_col
215
+ target_line = current_line + delta
216
+ return self if target_line.negative? || target_line >= lines.length
217
+
218
+ @cursor = index_for_line_col(target_line, current_col)
219
+ self
220
+ end
221
+
222
+ def index_for_line_col(line_index, col)
223
+ index = 0
224
+ lines.each_with_index do |line, current|
225
+ line_length = line.chars.length
226
+ return index + [col, line_length].min if current == line_index
227
+
228
+ index += line_length + 1
229
+ end
230
+ @chars.length
231
+ end
232
+
233
+ def previous_word_start
234
+ index = @cursor
235
+ index -= 1 while index.positive? && whitespace?(@chars[index - 1])
236
+ index -= 1 while index.positive? && !whitespace?(@chars[index - 1])
237
+ index
238
+ end
239
+
240
+ def whitespace?(char)
241
+ char.to_s.match?(/\s/)
242
+ end
243
+
244
+ def single_empty_line?
245
+ value.empty?
246
+ end
247
+
248
+ def history_previous
249
+ return self if @history.empty?
250
+
251
+ @history_index = @history_index ? [@history_index - 1, 0].max : @history.length - 1
252
+ self.value = @history[@history_index]
253
+ @history_index = @history.index(value)
254
+ self
255
+ end
256
+
257
+ def history_next
258
+ return self unless @history_index
259
+
260
+ @history_index += 1
261
+ if @history_index >= @history.length
262
+ clear
263
+ else
264
+ self.value = @history[@history_index]
265
+ @history_index = @history.index(value)
266
+ end
267
+ self
268
+ end
269
+
270
+ def load_history
271
+ return unless @history_path && File.file?(@history_path)
272
+
273
+ File.readlines(@history_path, chomp: true).each { |line| add_history(line, persist: false) }
274
+ rescue IOError, SystemCallError
275
+ nil
276
+ end
277
+
278
+ def persist_history
279
+ File.write(@history_path, @history.join("\n") + "\n")
280
+ rescue IOError, SystemCallError
281
+ nil
282
+ end
283
+
284
+ def wrap_line_with_cursor(line, width, marker_col)
285
+ width = [width, 1].max
286
+ segments = []
287
+ current = +""
288
+ current_width = 0
289
+ chars = line.chars
290
+ chars.each_with_index do |char, index|
291
+ if marker_col == index
292
+ cursor = cursor_marker
293
+ if current_width + 1 > width
294
+ segments << current
295
+ current = +""
296
+ current_width = 0
297
+ end
298
+ current << cursor
299
+ current_width += 1
300
+ end
301
+
302
+ char_width = display_width(char)
303
+ if current_width + char_width > width
304
+ segments << current
305
+ current = +""
306
+ current_width = 0
307
+ end
308
+ current << char
309
+ current_width += char_width
310
+ end
311
+
312
+ current << cursor_marker if marker_col == chars.length
313
+ segments << current
314
+ segments
315
+ end
316
+
317
+ def cursor_marker
318
+ "#{AnsiCode.color(:blue, true)}_#{AnsiCode.reset}"
319
+ end
320
+
321
+ def display_width(char)
322
+ Unicode::DisplayWidth.of(char)
323
+ end
324
+ end
325
+ end
@@ -1,6 +1,9 @@
1
1
  require 'io/console'
2
+ require 'fileutils'
2
3
  require "tty-screen"
3
4
  require "tty-cursor"
5
+ require "thread"
6
+ require_relative "terminal"
4
7
 
5
8
  module RubyRich
6
9
 
@@ -10,15 +13,14 @@ module RubyRich
10
13
  end
11
14
 
12
15
  def print_with_pos(x,y,char)
13
- print "\e[?25l" # 隐藏光标
14
- print "\e[#{y};#{x}H" # 移动光标到左上角
16
+ print "\e[#{y};#{x}H" # Move cursor to top-left
15
17
  print char
16
18
  end
17
19
 
18
20
  def draw(buffer)
19
21
  unless @cache
20
- system("clear")
21
- print_with_pos(0,0,buffer.map { |line| line.compact.join("") }.join("\n"))
22
+ RubyRich::Terminal.clear
23
+ draw_full(buffer)
22
24
  @cache = buffer
23
25
  else
24
26
  buffer.each_with_index do |arr, y|
@@ -31,35 +33,48 @@ module RubyRich
31
33
  end
32
34
  end
33
35
  end
36
+
37
+ private
38
+
39
+ def draw_full(buffer)
40
+ buffer.each_with_index do |line, y|
41
+ print_with_pos(1, y + 1, line.compact.join(""))
42
+ end
43
+ $stdout.flush
44
+ end
34
45
  end
35
46
 
36
47
  class Live
37
48
  attr_accessor :params, :app, :listening, :layout
38
49
  class << self
39
- def start(layout, refresh_rate: 30, &proc)
40
- setup_terminal
50
+ def start(layout, refresh_rate: 30, mouse: false, alt_screen: false, autowrap: false, &proc)
51
+ setup_terminal(mouse: mouse, alt_screen: alt_screen, autowrap: autowrap)
41
52
  live = new(layout, refresh_rate)
53
+ live.mouse = mouse
42
54
  proc.call(live) if proc
43
55
  live.run(proc)
56
+ rescue Interrupt
57
+ live&.stop
44
58
  rescue => e
45
59
  puts e.message
46
60
  ensure
47
- restore_terminal
61
+ live&.shutdown
62
+ restore_terminal(mouse: mouse, alt_screen: alt_screen)
48
63
  end
49
64
 
50
65
  private
51
66
 
52
- def setup_terminal
53
- @original_state = `stty -g`
54
- system("stty -echo")
67
+ def setup_terminal(mouse: false, alt_screen: false, autowrap: false)
68
+ RubyRich::Terminal.setup(mouse: mouse, alt_screen: alt_screen, autowrap: autowrap)
55
69
  end
56
70
 
57
- def restore_terminal
58
- system("stty #{@original_state}")
59
- print TTY::Cursor.show
71
+ def restore_terminal(mouse: false, alt_screen: false)
72
+ RubyRich::Terminal.restore(mouse: mouse, alt_screen: alt_screen, autowrap: true)
60
73
  end
61
74
  end
62
75
 
76
+ attr_accessor :mouse
77
+
63
78
  def initialize(layout, refresh_rate)
64
79
  @layout = layout
65
80
  @layout.live = self
@@ -69,23 +84,49 @@ module RubyRich
69
84
  @cursor = TTY::Cursor
70
85
  @render = CacheRender.new
71
86
  @console = RubyRich::Console.new
87
+ @event_queue = Queue.new
88
+ @action_queue = Queue.new
89
+ @event_thread = nil
72
90
  @params = {}
91
+ if (log_path = ENV["RUBY_RICH_LOG"]).to_s.strip != ""
92
+ FileUtils.mkdir_p(File.dirname(log_path))
93
+ RubyRich.logger = Logger.new(log_path)
94
+ end
73
95
  end
74
96
 
75
97
  def run(proc = nil)
98
+ start_event_thread if @listening
76
99
  while @running
100
+ drain_action_queue
101
+ break unless @running
102
+
77
103
  render_frame
78
- if @listening
79
- event_data = @console.get_key()
80
- @layout.notify_listeners(event_data)
81
- end
104
+ drain_event_queue if @listening
82
105
  sleep 1.0 / @refresh_rate
83
106
  end
107
+ rescue Interrupt
108
+ @running = false
109
+ end
110
+
111
+ def post(&block)
112
+ return false unless block
113
+ return false unless @running
114
+
115
+ @action_queue << block
116
+ true
84
117
  end
85
118
 
86
119
  def stop
87
120
  @running = false
88
- system("clear")
121
+ shutdown
122
+ RubyRich::Terminal.clear
123
+ end
124
+
125
+ def shutdown
126
+ if @event_thread&.alive?
127
+ @event_thread.kill
128
+ @event_thread = nil
129
+ end
89
130
  end
90
131
 
91
132
  def move_cursor(x,y)
@@ -102,6 +143,46 @@ module RubyRich
102
143
 
103
144
  private
104
145
 
146
+ def start_event_thread
147
+ return if @event_thread&.alive?
148
+
149
+ @event_thread = Thread.new do
150
+ while @running
151
+ begin
152
+ event_data = @console.get_event
153
+ @event_queue << event_data if event_data
154
+ rescue IOError, SystemCallError
155
+ break
156
+ rescue Interrupt
157
+ @running = false
158
+ break
159
+ rescue => e
160
+ RubyRich.logger.error("Input event failed: #{e.class}: #{e.message}")
161
+ end
162
+ end
163
+ end
164
+ end
165
+
166
+ def drain_event_queue
167
+ until @event_queue.empty?
168
+ event_data = @event_queue.pop(true)
169
+ @layout.notify_listeners(event_data)
170
+ end
171
+ rescue ThreadError
172
+ nil
173
+ end
174
+
175
+ def drain_action_queue
176
+ until @action_queue.empty?
177
+ action = @action_queue.pop(true)
178
+ action.call(self)
179
+ end
180
+ rescue ThreadError
181
+ nil
182
+ rescue => e
183
+ RubyRich.logger.error("UI action failed: #{e.class}: #{e.message}")
184
+ end
185
+
105
186
  def render_frame
106
187
  @layout.calculate_dimensions(terminal_width, terminal_height)
107
188
  @render.draw(@layout.render_to_buffer)
@@ -115,4 +196,4 @@ module RubyRich
115
196
  TTY::Screen.height
116
197
  end
117
198
  end
118
- end
199
+ end