ruby_rich 0.1.0 → 0.3.0

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: af37dc09091efdc06215f083a176d91ce1b9ecf240ba22f4471b739b9eaeb890
4
- data.tar.gz: 57e7a9e0378b20353b94d9f815d9c96e1865f96128257857921c80e5f7a31fb9
3
+ metadata.gz: '009a5bcd619dc3fa975c6ab3b01d1494e002ccc760b6e8ac335026f63860671e'
4
+ data.tar.gz: 205f8d0f1cdab77854a564c43761c30113f2989216ba2ed91b7ac66e77f2387f
5
5
  SHA512:
6
- metadata.gz: 440203e48f904f11270482a9e80f9edb28787e64302d6d6dcf8527eaee2ec57028303e8d8a331b6bfe2ce45c445aba122ceaa6cdd975a524ce62f8c55fe2638f
7
- data.tar.gz: 4f043b626479294f8d785e6e181614e1b2b0ca867f5714a8e6323ccb6b96433ad0ec7ef432b173ecc4cfbe9e6ecc215799174fffcb5953a3d0ce41bad8964f74
6
+ metadata.gz: bedd0da9ee879bcca97fc9faf3c597cf857a05b6b8cca59004e9fbaefdbbee13b885b89902d4909a2b5718d7877201780f44418be7e2f3044f65c9840e40fe58
7
+ data.tar.gz: ac828f4bb8e7552b6a1b7310a0ba7be97d256710ce7f4aca3c523e0493b41e458fb722b33afafe01b90ab568338f14f6d56a3d546b29d191131986b07690acab
@@ -0,0 +1,195 @@
1
+ module RubyRich
2
+ class AnsiCode
3
+ ANSI_CODES = {
4
+ reset: "\e[0m",
5
+ bold: "1",
6
+ faint: "2",
7
+ italic: "3",
8
+ underline: "4",
9
+ curly_underline: "4:3",
10
+ dotted_underline: "4:4",
11
+ dashed_underline: "4:5",
12
+ double_underline: "21",
13
+ blink: "5",
14
+ rapid_blink: "6",
15
+ inverse: "7",
16
+ invisible: "8",
17
+ strikethrough: "9",
18
+ fraktur: "20",
19
+ no_blink: "25",
20
+ no_inverse: "27",
21
+ overline: "53",
22
+ color: {
23
+ black: "30",
24
+ red: "31",
25
+ green: "32",
26
+ yellow: "33",
27
+ blue: "34",
28
+ magenta: "35",
29
+ cyan: "36",
30
+ white: "37"
31
+ },
32
+ bright_color:{
33
+ black: "90",
34
+ red: "91",
35
+ green: "92",
36
+ yellow: "93",
37
+ blue: "94",
38
+ magenta: "95",
39
+ cyan: "96",
40
+ white: "97"
41
+ },
42
+ background: {
43
+ black: "40",
44
+ red: "41",
45
+ green: "42",
46
+ yellow: "43",
47
+ blue: "44",
48
+ magenta: "45",
49
+ cyan: "46",
50
+ white: "47"
51
+ },
52
+ bright_background: {
53
+ black: "100",
54
+ red: "101",
55
+ green: "102",
56
+ yellow: "103",
57
+ blue: "104",
58
+ magenta: "105",
59
+ cyan: "106",
60
+ white: "107"
61
+ }
62
+ }
63
+
64
+ def self.reset
65
+ ANSI_CODES[:reset]
66
+ end
67
+
68
+ def self.color(color, bright=false)
69
+ if bright
70
+ "\e[#{ANSI_CODES[:bright_color][color]}m"
71
+ else
72
+ "\e[#{ANSI_CODES[:color][color]}m"
73
+ end
74
+ end
75
+
76
+ def self.background(color, bright=false)
77
+ if bright
78
+ "\e[#{ANSI_CODES[:bright_background][color]}m"
79
+ else
80
+ "\e[#{ANSI_CODES[:background][color]}m"
81
+ end
82
+ end
83
+
84
+ def self.bold
85
+ "\e[#{ANSI_CODES[:bold]}m"
86
+ end
87
+
88
+ def self.italic
89
+ "\e[#{ANSI_CODES[:italic]}m"
90
+ end
91
+
92
+ def self.underline(style=nil)
93
+ case style
94
+ when nil
95
+ return "\e[#{ANSI_CODES[:underline]}m"
96
+ when :double
97
+ return "\e[#{ANSI_CODES[:double_underline]}m"
98
+ when :curly
99
+ return "\e[#{ANSI_CODES[:curly_underline]}m"
100
+ when :dotted
101
+ return "\e[#{ANSI_CODES[:dotted_underline]}m"
102
+ when :dashed
103
+ return "\e[#{ANSI_CODES[:dashed_underline]}m"
104
+ end
105
+ end
106
+
107
+ def self.blink
108
+ "\e[#{ANSI_CODES[:blink]}m"
109
+ end
110
+
111
+ def self.rapid_blink
112
+ "\e[#{ANSI_CODES[:rapid_blink]}m"
113
+ end
114
+
115
+ def self.inverse
116
+ "\e[#{ANSI_CODES[:inverse]}m"
117
+ end
118
+
119
+ def self.fraktur
120
+ "\e[#{ANSI_CODES[:fraktur]}m"
121
+ end
122
+
123
+ def self.invisible
124
+ "\e[#{ANSI_CODES[:invisible]}m"
125
+ end
126
+
127
+ def self.strikethrough
128
+ "\e[#{ANSI_CODES[:strikethrough]}m"
129
+ end
130
+
131
+ def self.overline
132
+ "\e[#{ANSI_CODES[:overline]}m"
133
+ end
134
+
135
+ def self.no_blink
136
+ "\e[#{ANSI_CODES[:no_blink]}m"
137
+ end
138
+
139
+ def self.no_inverse
140
+ "\e[#{ANSI_CODES[:no_inverse]}m"
141
+ end
142
+
143
+ def self.font(font_color,
144
+ font_bright: false,
145
+ background: nil,
146
+ background_bright: false,
147
+ bold: false,
148
+ italic: false,
149
+ underline: false,
150
+ underline_style: nil,
151
+ strikethrough: false,
152
+ overline: false
153
+ )
154
+ code = if font_bright
155
+ "\e[#{ANSI_CODES[:bright_color][font_color]}"
156
+ else
157
+ "\e[#{ANSI_CODES[:color][font_color]}"
158
+ end
159
+ if background
160
+ code += ";" + if background_bright
161
+ "#{ANSI_CODES[:bright_background][background]}"
162
+ else
163
+ "#{ANSI_CODES[:background][background]}"
164
+ end
165
+ end
166
+ if bold
167
+ code += ";" + ANSI_CODES[:bold]
168
+ end
169
+ if italic
170
+ code += ";" + ANSI_CODES[:italic]
171
+ end
172
+ if underline
173
+ case underline_style
174
+ when nil
175
+ code += ";" + ANSI_CODES[:underline]
176
+ when :double
177
+ code += ";" + ANSI_CODES[:double_underline]
178
+ when :curly
179
+ code += ";" + ANSI_CODES[:curly_underline]
180
+ when :dotted
181
+ code += ";" + ANSI_CODES[:dotted_underline]
182
+ when :dashed
183
+ code += ";" + ANSI_CODES[:dashed_underline]
184
+ end
185
+ end
186
+ if strikethrough
187
+ code += ";" + ANSI_CODES[:strikethrough]
188
+ end
189
+ if overline
190
+ code += ";" + ANSI_CODES[:overline]
191
+ end
192
+ return code+"m"
193
+ end
194
+ end
195
+ end
@@ -1,9 +1,31 @@
1
+ require 'io/console'
2
+
1
3
  module RubyRich
2
4
  class Console
5
+ ESCAPE_SEQUENCES = {
6
+ # 方向键
7
+ '[A' => :up, '[B' => :down,
8
+ '[C' => :right, '[D' => :left,
9
+ # 功能键
10
+ 'OP' => :f1, 'OQ' => :f2, 'OR' => :f3, 'OS' => :f4,
11
+ '[15~' => :f5, '[17~' => :f6, '[18~' => :f7,
12
+ '[19~' => :f8, '[20~' => :f9, '[21~' => :f10,
13
+ '[23~' => :f11, '[24~' => :f12,
14
+ # 添加媒体键示例
15
+ '[1~' => :home, '[4~' => :end,
16
+ # 添加 macOS 功能键
17
+ '[25~' => :audio_mute,
18
+ # 其他
19
+ '[5~' => :page_up, '[6~' => :page_down,
20
+ '[H' => :home, '[F' => :end,
21
+ '[2~' => :insert, '[3~' => :delete
22
+ }.freeze
23
+
3
24
  def initialize
4
25
  @lines = []
5
26
  @buffer = []
6
27
  @layout = { spacing: 1, align: :left }
28
+ @styles = {}
7
29
  end
8
30
 
9
31
  def set_layout(spacing: 1, align: :left)
@@ -11,12 +33,89 @@ module RubyRich
11
33
  @layout[:align] = align
12
34
  end
13
35
 
36
+ def style(name, **attributes)
37
+ @styles[name] = attributes
38
+ end
39
+
40
+ def print(*objects, sep: ' ', end_char: "\n")
41
+ line_text = objects.map(&:to_s).join(sep)
42
+ add_line(line_text)
43
+ render
44
+ end
45
+
46
+ def log(message, *objects, sep: ' ', end_char: "\n")
47
+ timestamp = Time.now.strftime("%Y-%m-%d %H:%M:%S")
48
+ log_message = "[#{timestamp}] LOG: #{message} #{objects.map(&:to_s).join(sep)}"
49
+ add_line(log_message)
50
+ render
51
+ end
52
+
53
+ def rule(title: nil, characters: '#', style: 'bold')
54
+ rule_line = characters * 80
55
+ if title
56
+ formatted_title = " #{title} ".center(80, characters)
57
+ add_line(formatted_title)
58
+ else
59
+ add_line(rule_line)
60
+ end
61
+ render
62
+ end
63
+
64
+ def self.raw
65
+ old_state = `stty -g`
66
+ system('stty raw -echo -icanon isig') rescue nil
67
+ yield
68
+ ensure
69
+ system("stty #{old_state}") rescue nil
70
+ end
71
+
72
+ def self.clear
73
+ system('clear')
74
+ end
75
+
76
+ def get_key(input: $stdin)
77
+ input.raw(intr: true) do |io|
78
+ char = io.getch
79
+ # 优先处理回车键(ASCII 13 = \r,ASCII 10 = \n)
80
+ if char == "\r" || char == "\n"
81
+ return {:name=>:enter}
82
+ end
83
+ # 单独处理 Tab 键(ASCII 9)
84
+ if char == "\t"
85
+ return {:name=>:tab}
86
+ elsif char.ord == 0x07F
87
+ return {:name=>:backspace}
88
+ elsif char == "\e" # 检测到转义序列
89
+ sequence = ''
90
+ begin
91
+ while (c = io.read_nonblock(1))
92
+ sequence << c
93
+ end
94
+ rescue IO::WaitReadable
95
+ retry if IO.select([io], nil, nil, 0.01)
96
+ rescue EOFError
97
+ end
98
+ if sequence.empty?
99
+ return {:name => :escape}
100
+ else
101
+ return {:name => ESCAPE_SEQUENCES[sequence]} || {:name => :escape}
102
+ end
103
+ # 处理 Ctrl 组合键(排除 Tab 和回车)
104
+ elsif char.ord.between?(1, 8) || char.ord.between?(10, 26)
105
+ ctrl_char = (char.ord + 64).chr.downcase
106
+ return {:name =>"ctrl_#{ctrl_char}".to_sym}
107
+ else
108
+ {:name => :string, :value => char}
109
+ end
110
+ end
111
+ end
112
+
14
113
  def add_line(text)
15
114
  @lines << text
16
115
  end
17
116
 
18
117
  def clear
19
- print "\e[H\e[2J"
118
+ Kernel.print "\e[H\e[2J"
20
119
  end
21
120
 
22
121
  def render
@@ -24,14 +123,13 @@ module RubyRich
24
123
  @lines.each_with_index do |line, index|
25
124
  formatted_line = format_line(line)
26
125
  @buffer << formatted_line
27
- puts formatted_line
28
- puts "\n" * @layout[:spacing] if index < @lines.size - 1
126
+ Kernel.puts formatted_line
127
+ Kernel.puts "\n" * @layout[:spacing] if index < @lines.size - 1
29
128
  end
30
129
  end
31
130
 
32
131
  def update_line(index, text)
33
132
  return unless index.between?(0, @lines.size - 1)
34
-
35
133
  @lines[index] = text
36
134
  render
37
135
  end
@@ -50,4 +148,4 @@ module RubyRich
50
148
  end
51
149
  end
52
150
  end
53
- end
151
+ end
@@ -0,0 +1,66 @@
1
+ module RubyRich
2
+ class Dialog
3
+ attr_accessor :title, :content, :buttons, :live
4
+ attr_accessor :width, :height
5
+
6
+ def initialize(title: "", content: "", width: 48, height: 8, buttons: [:ok])
7
+ @width = width
8
+ @height = height
9
+ terminal_width = `tput cols`.to_i
10
+ terminal_height = `tput lines`.to_i
11
+ @event_listeners = {}
12
+ @layout = RubyRich::Layout.new(name: :title, width: width, height: height)
13
+ @panel = RubyRich::Panel.new("", title: title, border_style: :white)
14
+ @layout.update_content(@panel)
15
+ @layout.calculate_dimensions(terminal_width, terminal_height)
16
+ @button_str = build_button(buttons)
17
+ @panel.content = " \n \n#{content}"+AnsiCode.reset+"\n \n \n" + " "*((@panel.inner_width - @button_str.display_width)/2) + @button_str
18
+ end
19
+
20
+ def content=(content)
21
+ @panel.content = " \n \n#{content}"+AnsiCode.reset+"\n \n \n" + " "*((@panel.inner_width - @button_str.display_width)/2) + @button_str
22
+ end
23
+
24
+ def build_button(buttons)
25
+ str = ""
26
+ buttons.each do |btn|
27
+ case btn
28
+ when :ok
29
+ str += " "+AnsiCode.color(:blue) + "OK(enter)"+ AnsiCode.reset
30
+ when :cancel
31
+ str += " "+AnsiCode.color(:red) + "Cancel(esc)"+ AnsiCode.reset
32
+ end
33
+ end
34
+ str.strip
35
+ end
36
+
37
+ def render_to_buffer
38
+ @layout.render_to_buffer
39
+ end
40
+
41
+ def key(event_name, priority = 0, &block)
42
+ unless @event_listeners[event_name]
43
+ @event_listeners[event_name] = []
44
+ end
45
+ @event_listeners[event_name] << { priority: priority, block: block }
46
+ @event_listeners[event_name].sort_by! { |l| -l[:priority] } # Higher priority first
47
+ end
48
+
49
+ def on(event_name, &block)
50
+ if event_name==:close
51
+ @event_listeners[event_name] = [{block: block}]
52
+ end
53
+ end
54
+
55
+ def notify_listeners(event_data)
56
+ event_name = event_data[:name]
57
+ result = nil
58
+ if @event_listeners[event_name]
59
+ @event_listeners[event_name].each do |listener|
60
+ result = listener[:block].call(event_data, @live)
61
+ end
62
+ return result
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,294 @@
1
+ module RubyRich
2
+ class Layout
3
+ attr_accessor :name, :ratio, :size, :children, :content, :parent, :live, :dialog
4
+ attr_accessor :x_offset, :y_offset, :width, :height, :show
5
+ attr_reader :split_direction
6
+
7
+ def initialize(name: nil, ratio: 1, size: nil, width: nil, height: nil)
8
+ @name = name
9
+ @ratio = ratio
10
+ @size = size
11
+ @children = []
12
+ @content = nil
13
+ @parent = nil
14
+ @x_offset = 0
15
+ @y_offset = 0
16
+ @width = width if width
17
+ @height = height if height
18
+ @split_direction = nil
19
+ @show = true
20
+ @event_listeners = {}
21
+ @event_intercepted = false
22
+ end
23
+
24
+ def key(event_name, priority = 0, &block)
25
+ unless @event_listeners[event_name]
26
+ @event_listeners[event_name] = []
27
+ end
28
+ @event_listeners[event_name] << { priority: priority, block: block }
29
+ @event_listeners[event_name].sort_by! { |l| -l[:priority] } # Higher priority first
30
+ end
31
+
32
+ def show=(flag)
33
+ @show = flag
34
+ @event_intercepted = !flag
35
+ end
36
+
37
+ def notify_listeners(event_data)
38
+ return if @event_intercepted
39
+ if @dialog
40
+ @dialog.notify_listeners(event_data)
41
+ else
42
+ event_name = event_data[:name]
43
+ if @event_listeners[event_name]
44
+ @event_listeners[event_name].each do |listener|
45
+ next if @event_intercepted
46
+ result = listener[:block].call(event_data, self.root.live)
47
+ if result == true
48
+ @event_intercepted = true
49
+ end
50
+ end
51
+ end
52
+
53
+ unless @event_intercepted
54
+ @children.each do |child|
55
+ child.notify_listeners(event_data)
56
+ end
57
+ end
58
+ end
59
+ end
60
+
61
+ def root
62
+ @parent ? @parent.root : self
63
+ end
64
+
65
+ def split_row(*layouts)
66
+ @split_direction = :row
67
+ layouts.each { |l| l.parent = self }
68
+ @children.concat(layouts)
69
+ end
70
+
71
+ def split_column(*layouts)
72
+ @split_direction = :column
73
+ layouts.each { |l| l.parent = self }
74
+ @children.concat(layouts)
75
+ end
76
+
77
+ def add_child(layout)
78
+ @children << layout
79
+ end
80
+
81
+ def update_content(content)
82
+ @content = content
83
+ end
84
+
85
+ def calculate_dimensions(terminal_width, terminal_height)
86
+ @x_offset = 0
87
+ @y_offset = 0
88
+ calculate_node_dimensions(terminal_width, terminal_height)
89
+ end
90
+
91
+ def [](name)
92
+ find_by_name(name)
93
+ end
94
+
95
+ def find_by_name(name)
96
+ return self if @name == name
97
+ @children.each do |child|
98
+ result = child.find_by_name(name)
99
+ return result if result
100
+ end
101
+ nil
102
+ end
103
+
104
+ def show_dialog(dialog)
105
+ @dialog = dialog
106
+ end
107
+
108
+ def hide_dialog
109
+ @dialog.notify_listeners({:name=>:close})
110
+ @dialog = nil
111
+ end
112
+
113
+ def render
114
+ # 将缓冲区转换为字符串(每行用换行符连接)
115
+ buffer = render_to_buffer
116
+ buffer.map { |line| line.compact.join("") }.join("\n")
117
+ end
118
+
119
+ def render_to_buffer
120
+ # 初始化缓冲区(二维数组,每个元素代表一个字符)
121
+ buffer = Array.new(@height) { Array.new(@width, " ") }
122
+ # 递归填充内容到缓冲区
123
+ render_into(buffer)
124
+ render_dialog_into(buffer) if @dialog
125
+ return buffer
126
+ end
127
+
128
+ def draw
129
+ puts render
130
+ end
131
+
132
+ def render_dialog_into(buffer)
133
+ start_x = (@width - 2 - @dialog.width) / 2 + 1
134
+ start_y = (@height - 2 - @dialog.height) / 2 + 1
135
+ dialog_buffer = @dialog.render_to_buffer
136
+ buffer.each_with_index do |arr, y|
137
+ arr.each_with_index do |char, x|
138
+ if x >= start_x && y >= start_y
139
+ if y-start_y <= dialog_buffer.size-1 && x-start_x <= dialog_buffer[y-start_y].size-1
140
+ buffer[y][x] = dialog_buffer[y-start_y][x-start_x]
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
146
+
147
+ def render_into(buffer)
148
+ children.each { |child| child.render_into(buffer) } if children
149
+ return unless content
150
+ content_lines = if content.is_a?(String)
151
+ content.split("\n")[0...height]
152
+ else
153
+ content.render[0...height]
154
+ end
155
+
156
+ content_lines.each_with_index do |line, line_index|
157
+ y_pos = y_offset + line_index
158
+ next if y_pos >= buffer.size
159
+
160
+ in_escape = false
161
+ escape_char = ""
162
+ char_width = 0 # 初始宽度调整为0,方便位置计算
163
+ line.each_char do |char|
164
+ # 处理ANSI转义码
165
+ if in_escape
166
+ escape_char += char
167
+ in_escape = false if char == 'm'
168
+ if escape_char=="\e[0m"
169
+ escape_char = ""
170
+ end
171
+ next
172
+ elsif char.ord == 27 # 检测到转义开始符\e
173
+ in_escape = true
174
+ escape_char += char
175
+ next
176
+ end
177
+
178
+ # 计算字符宽度
179
+ char_w = case char.ord
180
+ when 0x0000..0x007F then 1 # 英文字符
181
+ when 0x4E00..0x9FFF then 2 # 中文字符
182
+ else Unicode::DisplayWidth.of(char)
183
+ end
184
+ # 计算字符的起始位置
185
+ x_start = x_offset + char_width
186
+
187
+ # 超出右边界则跳过
188
+ next if x_start >= buffer[y_pos].size
189
+
190
+ # 处理字符渲染(中文字符可能占用多个位置)
191
+ char_w.times do |i|
192
+ x_pos = x_start + i
193
+ break if x_pos >= buffer[y_pos].size # 超出右边界停止
194
+ unless escape_char.empty?
195
+ char = escape_char + char + "\e[0m" # 每次都记录字符的实际颜色
196
+ end
197
+ buffer[y_pos][x_pos] = char unless i > 0 # 中文字符仅在第一个位置写入,避免覆盖
198
+ buffer[y_pos][x_pos+1] = nil if char_w == 2
199
+ end
200
+ char_width += char_w # 更新累计宽度
201
+ end
202
+ end
203
+ end
204
+
205
+ 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
212
+
213
+ # 只在未设置高度时计算高度
214
+ @height ||= if @size
215
+ [@size, available_height].min
216
+ else
217
+ available_height
218
+ end
219
+
220
+ if @content.class == RubyRich::Panel
221
+ @content.width = @width
222
+ @content.height = @height
223
+ end
224
+
225
+ return if @children.empty?
226
+
227
+ case @split_direction
228
+ when :row
229
+ remaining_width = @width
230
+ fixed_children, flexible_children = @children.partition { |c| c.size }
231
+
232
+ fixed_children.each do |child|
233
+ child_width = [child.size, remaining_width].min
234
+ child.width = child_width
235
+ remaining_width -= child_width
236
+ end
237
+
238
+ total_ratio = flexible_children.sum(&:ratio)
239
+ if total_ratio > 0
240
+ ratio_width = remaining_width.to_f / total_ratio
241
+ flexible_children.each do |child|
242
+ child_width = (child.ratio * ratio_width).floor
243
+ child.width = child_width
244
+ remaining_width -= child_width
245
+ end
246
+
247
+ flexible_children.last.width += remaining_width if remaining_width > 0
248
+ end
249
+
250
+ @children.each { |child| child.height = @height }
251
+
252
+ current_x = @x_offset
253
+ @children.each do |child|
254
+ child.x_offset = current_x
255
+ child.y_offset = @y_offset
256
+ current_x += child.width
257
+ child.calculate_node_dimensions(child.width, child.height)
258
+ end
259
+
260
+ when :column
261
+ remaining_height = @height
262
+ fixed_children, flexible_children = @children.partition { |c| c.size }
263
+
264
+ fixed_children.each do |child|
265
+ child_height = [child.size, remaining_height].min
266
+ child.height = child_height
267
+ remaining_height -= child_height
268
+ end
269
+
270
+ total_ratio = flexible_children.sum(&:ratio)
271
+ if total_ratio > 0
272
+ ratio_height = remaining_height.to_f / total_ratio
273
+ flexible_children.each do |child|
274
+ child_height = (child.ratio * ratio_height).floor
275
+ child.height = child_height
276
+ remaining_height -= child_height
277
+ end
278
+
279
+ flexible_children.last.height += remaining_height if remaining_height > 0
280
+ end
281
+
282
+ @children.each { |child| child.width = @width }
283
+
284
+ current_y = @y_offset
285
+ @children.each do |child|
286
+ child.x_offset = @x_offset
287
+ child.y_offset = current_y
288
+ current_y += child.height
289
+ child.calculate_node_dimensions(child.width, child.height)
290
+ end
291
+ end
292
+ end
293
+ end
294
+ end
@@ -0,0 +1,118 @@
1
+ require 'io/console'
2
+ require "tty-screen"
3
+ require "tty-cursor"
4
+
5
+ module RubyRich
6
+
7
+ class CacheRender
8
+ def initialize
9
+ @cache = nil
10
+ end
11
+
12
+ def print_with_pos(x,y,char)
13
+ print "\e[?25l" # 隐藏光标
14
+ print "\e[#{y};#{x}H" # 移动光标到左上角
15
+ print char
16
+ end
17
+
18
+ def draw(buffer)
19
+ unless @cache
20
+ system("clear")
21
+ print_with_pos(0,0,buffer.map { |line| line.compact.join("") }.join("\n"))
22
+ @cache = buffer
23
+ else
24
+ buffer.each_with_index do |arr, y|
25
+ arr.each_with_index do |char, x|
26
+ if @cache[y][x] != char
27
+ print_with_pos(x + 1 , y + 1 , char)
28
+ @cache[y][x] = char
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+
36
+ class Live
37
+ attr_accessor :params, :app, :listening, :layout
38
+ class << self
39
+ def start(layout, refresh_rate: 30, &proc)
40
+ setup_terminal
41
+ live = new(layout, refresh_rate)
42
+ proc.call(live) if proc
43
+ live.run(proc)
44
+ rescue => e
45
+ puts e.message
46
+ ensure
47
+ restore_terminal
48
+ end
49
+
50
+ private
51
+
52
+ def setup_terminal
53
+ @original_state = `stty -g`
54
+ system("stty -echo")
55
+ end
56
+
57
+ def restore_terminal
58
+ system("stty #{@original_state}")
59
+ print TTY::Cursor.show
60
+ end
61
+ end
62
+
63
+ def initialize(layout, refresh_rate)
64
+ @layout = layout
65
+ @layout.live = self
66
+ @refresh_rate = refresh_rate
67
+ @running = true
68
+ @last_frame = Time.now
69
+ @cursor = TTY::Cursor
70
+ @render = CacheRender.new
71
+ @console = RubyRich::Console.new
72
+ @params = {}
73
+ end
74
+
75
+ def run(proc = nil)
76
+ while @running
77
+ render_frame
78
+ if @listening
79
+ event_data = @console.get_key()
80
+ @layout.notify_listeners(event_data)
81
+ end
82
+ sleep 1.0 / @refresh_rate
83
+ end
84
+ end
85
+
86
+ def stop
87
+ @running = false
88
+ system("clear")
89
+ end
90
+
91
+ def move_cursor(x,y)
92
+ print @cursor.move_to(x, y)
93
+ end
94
+
95
+ def find_layout(name)
96
+ @layout[name]
97
+ end
98
+
99
+ def find_panel(name)
100
+ @layout[name].content
101
+ end
102
+
103
+ private
104
+
105
+ def render_frame
106
+ @layout.calculate_dimensions(terminal_width, terminal_height)
107
+ @render.draw(@layout.render_to_buffer)
108
+ end
109
+
110
+ def terminal_width
111
+ TTY::Screen.width
112
+ end
113
+
114
+ def terminal_height
115
+ TTY::Screen.height
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,183 @@
1
+ module RubyRich
2
+ class Panel
3
+ attr_accessor :width, :height, :content, :line_pos, :border_style, :title
4
+ attr_accessor :title_align, :content_changed
5
+
6
+ def initialize(content = "", title: nil, border_style: :white, title_align: :center)
7
+ @content = content
8
+ @title = title
9
+ @border_style = border_style
10
+ @width = 0
11
+ @height = 0
12
+ @line_pos = 0
13
+ @title_align = title_align
14
+ end
15
+
16
+ def inner_width
17
+ @width - 2 # Account for border characters
18
+ end
19
+
20
+ def page_up
21
+ @line_pos -= ( @height - 4 )
22
+ if @line_pos < 0
23
+ @line_pos = 0
24
+ end
25
+ @content_changed = false
26
+ end
27
+
28
+ def page_down
29
+ unless @content.empty?
30
+ content_lines = wrap_content(@content)
31
+ @line_pos += ( @height - 4 )
32
+ if @line_pos + ( @height - 4 ) > content_lines.size
33
+ @line_pos = content_lines.size - @height + 2
34
+ end
35
+ @content_changed = false
36
+ end
37
+ end
38
+
39
+ def home
40
+ @line_pos = 0
41
+ @content_changed = false
42
+ end
43
+
44
+ def end
45
+ unless @content.empty?
46
+ content_lines = wrap_content(@content)
47
+ @line_pos = content_lines.size - @height + 2
48
+ @content_changed = false
49
+ end
50
+ end
51
+
52
+ def render
53
+ lines = []
54
+ color_code = AnsiCode.color(@border_style) || AnsiCode.color(:white)
55
+ reset_code = AnsiCode.reset
56
+
57
+ # Top border
58
+ top_border = color_code + "┌"
59
+ if @title
60
+ title_text = "[ #{@title} ]"
61
+ bar_width = @width - @title.display_width-6
62
+ case @title_align
63
+ when :left
64
+ top_border += title_text + '─' * bar_width
65
+ when :center
66
+ top_border += '─' * (bar_width/2) + title_text + '─' * (bar_width - bar_width/2)
67
+ when :right
68
+ top_border += '─' * bar_width + title_text
69
+ end
70
+ else
71
+ top_border += '─' * (@width - 2)
72
+ end
73
+ top_border += "┐" + reset_code
74
+ lines << top_border
75
+
76
+ # Content area
77
+ content_lines = wrap_content(@content)
78
+ if @line_pos==0
79
+ if @content_changed == false
80
+ if content_lines.size > @height - 2
81
+ content_lines=content_lines[0..@height - 3]
82
+ end
83
+ else
84
+ if content_lines.size > @height - 2
85
+ @line_pos = content_lines.size - @height + 2
86
+ content_lines=content_lines[@line_pos..-1]
87
+ @content_changed = false
88
+ end
89
+ end
90
+ else
91
+ if @line_pos+@height-2 >= content_lines.size
92
+ content_lines=content_lines[@line_pos..-1]
93
+ else
94
+ content_lines=content_lines[@line_pos..@line_pos+@height-3]
95
+ end
96
+ end
97
+
98
+ content_lines.each do |line|
99
+ lines << color_code + "│" + reset_code +
100
+ line + " "*(@width - line.display_width - 2) +
101
+ color_code + "│" + reset_code
102
+ end
103
+
104
+ # Fill remaining vertical space
105
+ remaining_lines = @height - 2 - content_lines.size
106
+ remaining_lines.times do
107
+ lines << color_code + "│" + reset_code +
108
+ " " * (@width - 2) +
109
+ color_code + "│" + reset_code
110
+ end
111
+
112
+ # Bottom border
113
+ lines << color_code + "└" + "─" * (@width - 2) + "┘" + reset_code
114
+
115
+ lines
116
+ end
117
+
118
+ def content=(new_content)
119
+ @content = new_content
120
+ content_lines = wrap_content(@content)
121
+ if content_lines.size > @height - 2
122
+ @line_pos = content_lines.size - @height + 2
123
+ end
124
+ @content_changed = true
125
+ end
126
+
127
+ private
128
+
129
+ def split_text_by_width(text)
130
+ result = []
131
+ current_line = ""
132
+ current_width = 0
133
+
134
+ # Split text into tokens of ANSI codes and regular text
135
+ tokens = text.scan(/(\e\[[0-9;]*m)|(.)/)
136
+ .map { |m| m.compact.first }
137
+
138
+ tokens.each do |token|
139
+ # Calculate width for regular text, ANSI codes have 0 width
140
+ if token.start_with?("\e[")
141
+ token_width = 0
142
+ else
143
+ token_width = token.chars.sum { |c| Unicode::DisplayWidth.of(c) }
144
+ end
145
+
146
+ if current_width + token_width <= @width - 4
147
+ current_line += token
148
+ current_width += token_width
149
+ else
150
+ result << current_line
151
+ current_line = token
152
+ current_width = token_width
153
+ end
154
+ end
155
+
156
+ # Add remaining line
157
+ result << current_line unless current_line.empty?
158
+
159
+ result
160
+ end
161
+
162
+ def wrap_content(text)
163
+ text.split("\n").flat_map do |line|
164
+ split_text_by_width(line)
165
+ end
166
+ end
167
+ end
168
+ end
169
+
170
+ # Extend String to remove ANSI codes for alignment
171
+ class String
172
+ def uncolorize
173
+ gsub(/\e\[[0-9;]*m/, '')
174
+ end
175
+
176
+ def display_width
177
+ width = 0
178
+ self.uncolorize.each_char do |char|
179
+ width += Unicode::DisplayWidth.of(char)
180
+ end
181
+ width
182
+ end
183
+ end
@@ -33,7 +33,7 @@ module RubyRich
33
33
 
34
34
  def apply_style(content, style)
35
35
  style_methods = style.downcase.split
36
- rich_text = RichRuby::RichText.new(content)
36
+ rich_text = RubyRich::RichText.new(content)
37
37
  style.downcase.split.each do |method|
38
38
  case method
39
39
  when 'bold'
@@ -1,5 +1,8 @@
1
1
  module RubyRich
2
2
  class ProgressBar
3
+
4
+ attr_reader :progress
5
+
3
6
  def initialize(total, width: 50, style: :default)
4
7
  @total = total
5
8
  @progress = 0
@@ -34,7 +34,7 @@ module RubyRich
34
34
  private
35
35
 
36
36
  def format_cell(cell)
37
- cell.is_a?(RichRuby::RichText) ? cell : RichRuby::RichText.new(cell.to_s)
37
+ cell.is_a?(RubyRich::RichText) ? cell : RubyRich::RichText.new(cell.to_s)
38
38
  end
39
39
 
40
40
  def calculate_column_widths
@@ -0,0 +1,56 @@
1
+ module RubyRich
2
+ class RichText
3
+ # 默认主题
4
+ @@theme = {
5
+ error: { color: :red, bold: true },
6
+ success: { color: :green, bold: true },
7
+ info: { color: :cyan },
8
+ warning: { color: :yellow, bold: true }
9
+ }
10
+
11
+ def self.set_theme(new_theme)
12
+ @@theme.merge!(new_theme)
13
+ end
14
+
15
+ def initialize(text, style: nil)
16
+ @text = text
17
+ @styles = []
18
+ apply_theme(style) if style
19
+ end
20
+
21
+ def style(color: :white,
22
+ font_bright: false,
23
+ background: nil,
24
+ background_bright: false,
25
+ bold: false,
26
+ italic: false,
27
+ underline: false,
28
+ underline_style: nil,
29
+ strikethrough: false,
30
+ overline: false
31
+ )
32
+ @styles << AnsiCode.font(color, font_bright, background, background_bright, bold, italic, underline, underline_style, strikethrough, overline)
33
+ self
34
+ end
35
+
36
+ def render
37
+ "#{@styles.join}#{@text}#{AnsiCode.reset}"
38
+ end
39
+
40
+ private
41
+
42
+ def add_style(code, error_message)
43
+ if code
44
+ @styles << code
45
+ else
46
+ raise ArgumentError, error_message
47
+ end
48
+ end
49
+
50
+ def apply_theme(style)
51
+ theme_styles = @@theme[style]
52
+ raise ArgumentError, "Undefined theme style: #{style}" unless theme_styles
53
+ style(**theme_styles)
54
+ end
55
+ end
56
+ end
@@ -1,3 +1,3 @@
1
1
  module RubyRich
2
- VERSION = "0.1.0"
3
- end
2
+ VERSION = "0.3.0"
3
+ end
data/lib/ruby_rich.rb CHANGED
@@ -10,9 +10,13 @@ require 'redcarpet'
10
10
  require_relative 'ruby_rich/console'
11
11
  require_relative 'ruby_rich/table'
12
12
  require_relative 'ruby_rich/progress_bar'
13
- require_relative 'ruby_rich/rich_text'
14
- require_relative 'ruby_rich/rich_print'
15
- require_relative 'ruby_rich/rich_panel'
13
+ require_relative 'ruby_rich/layout'
14
+ require_relative 'ruby_rich/live'
15
+ require_relative 'ruby_rich/text'
16
+ require_relative 'ruby_rich/print'
17
+ require_relative 'ruby_rich/panel'
18
+ require_relative 'ruby_rich/dialog'
19
+ require_relative 'ruby_rich/ansi_code'
16
20
  require_relative 'ruby_rich/version'
17
21
 
18
22
  # 定义主模块
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_rich
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - zhuang biaowei
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-02-02 00:00:00.000000000 Z
10
+ date: 2025-03-10 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: rake
@@ -45,12 +45,16 @@ extensions: []
45
45
  extra_rdoc_files: []
46
46
  files:
47
47
  - lib/ruby_rich.rb
48
+ - lib/ruby_rich/ansi_code.rb
48
49
  - lib/ruby_rich/console.rb
50
+ - lib/ruby_rich/dialog.rb
51
+ - lib/ruby_rich/layout.rb
52
+ - lib/ruby_rich/live.rb
53
+ - lib/ruby_rich/panel.rb
54
+ - lib/ruby_rich/print.rb
49
55
  - lib/ruby_rich/progress_bar.rb
50
- - lib/ruby_rich/rich_panel.rb
51
- - lib/ruby_rich/rich_print.rb
52
- - lib/ruby_rich/rich_text.rb
53
56
  - lib/ruby_rich/table.rb
57
+ - lib/ruby_rich/text.rb
54
58
  - lib/ruby_rich/version.rb
55
59
  homepage: https://github.com/zhuangbiaowei/ruby_rich
56
60
  licenses:
@@ -70,7 +74,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
70
74
  - !ruby/object:Gem::Version
71
75
  version: '0'
72
76
  requirements: []
73
- rubygems_version: 3.6.2
77
+ rubygems_version: 3.6.4
74
78
  specification_version: 4
75
79
  summary: Rich text formatting and console output for Ruby
76
80
  test_files: []
@@ -1,94 +0,0 @@
1
- module RubyRich
2
- class RichPanel
3
- # ANSI escape codes for styling
4
- ANSI_CODES = {
5
- reset: "\e[0m",
6
- bold: "\e[1m",
7
- underline: "\e[4m",
8
- color: {
9
- black: "\e[30m",
10
- red: "\e[31m",
11
- green: "\e[32m",
12
- yellow: "\e[33m",
13
- blue: "\e[34m",
14
- magenta: "\e[35m",
15
- cyan: "\e[36m",
16
- white: "\e[37m"
17
- },
18
- background: {
19
- black: "\e[40m",
20
- red: "\e[41m",
21
- green: "\e[42m",
22
- yellow: "\e[43m",
23
- blue: "\e[44m",
24
- magenta: "\e[45m",
25
- cyan: "\e[46m",
26
- white: "\e[47m"
27
- }
28
- }
29
-
30
- attr_accessor :title, :content, :border_color, :title_color, :footer
31
-
32
- def initialize(content, title: nil, footer: nil, border_color: :white, title_color: :white)
33
- @content = content.is_a?(String) ? content.split("\n") : content
34
- @title = title
35
- @footer = footer
36
- @border_color = border_color
37
- @title_color = title_color
38
- end
39
-
40
- def render
41
- content_lines = format_content
42
- panel_width = calculate_panel_width(content_lines)
43
-
44
- lines = []
45
- lines << top_border(panel_width)
46
- lines += content_lines.map { |line| format_line(line, panel_width) }
47
- lines << bottom_border(panel_width)
48
-
49
- lines.join("\n")
50
- end
51
-
52
- private
53
-
54
- def top_border(width)
55
- title_text = @title ? colorize(" #{@title} ", @title_color) : ""
56
- padding = (width - title_text.uncolorize.length - 2) / 2
57
- "#{colorize("╭", @border_color)}#{colorize("─" * padding, @border_color)}#{title_text}#{colorize("─" * (width - title_text.uncolorize.length - padding - 2), @border_color)}#{colorize("╮", @border_color)}"
58
- end
59
-
60
- def bottom_border(width)
61
- footer_text = @footer ? colorize(" #{@footer} ", @title_color) : ""
62
- padding = (width - footer_text.uncolorize.length - 2) / 2
63
- "#{colorize("╰", @border_color)}#{colorize("─" * padding, @border_color)}#{footer_text}#{colorize("─" * (width - footer_text.uncolorize.length - padding - 2), @border_color)}#{colorize("╯", @border_color)}"
64
- end
65
-
66
- def format_line(line, width)
67
- "#{colorize("│", @border_color)} #{line.ljust(width - 4)} #{colorize("│", @border_color)}"
68
- end
69
-
70
- def format_content
71
- @content.map(&:strip)
72
- end
73
-
74
- def calculate_panel_width(content_lines)
75
- [
76
- @title ? @title.uncolorize.length + 4 : 0,
77
- @footer ? @footer.uncolorize.length + 4 : 0,
78
- content_lines.map(&:length).max + 4
79
- ].max
80
- end
81
-
82
- def colorize(text, color)
83
- code = ANSI_CODES[:color][color] || ""
84
- "#{code}#{text}#{ANSI_CODES[:reset]}"
85
- end
86
- end
87
- end
88
-
89
- # Extend String to remove ANSI codes for alignment
90
- class String
91
- def uncolorize
92
- gsub(/\e\[[0-9;]*m/, '')
93
- end
94
- end
@@ -1,80 +0,0 @@
1
- module RubyRich
2
- class RichText
3
- # 默认主题
4
- @@theme = {
5
- error: { color: :red, bold: true },
6
- success: { color: :green, bold: true },
7
- info: { color: :cyan },
8
- warning: { color: :yellow, bold: true }
9
- }
10
-
11
- # ANSI 转义码常量
12
- ANSI_CODES = {
13
- reset: "\e[0m",
14
- bold: "\e[1m",
15
- italic: "\e[3m",
16
- underline: "\e[4m",
17
- blink: "\e[5m",
18
- color: {
19
- black: "\e[30m",
20
- red: "\e[31m",
21
- green: "\e[32m",
22
- yellow: "\e[33m",
23
- blue: "\e[34m",
24
- magenta: "\e[35m",
25
- cyan: "\e[36m",
26
- white: "\e[37m"
27
- },
28
- background: {
29
- black: "\e[40m",
30
- red: "\e[41m",
31
- green: "\e[42m",
32
- yellow: "\e[43m",
33
- blue: "\e[44m",
34
- magenta: "\e[45m",
35
- cyan: "\e[46m",
36
- white: "\e[47m"
37
- }
38
- }
39
-
40
- def self.set_theme(new_theme)
41
- @@theme.merge!(new_theme)
42
- end
43
-
44
- def initialize(text, style: nil)
45
- @text = text
46
- @styles = []
47
- apply_theme(style) if style
48
- end
49
-
50
- def style(color: nil, background: nil, bold: false, italic: false, underline: false, blink: false)
51
- @styles << ANSI_CODES[:color][color] if color
52
- @styles << ANSI_CODES[:background][background] if background
53
- @styles << ANSI_CODES[:bold] if bold
54
- @styles << ANSI_CODES[:italic] if italic
55
- @styles << ANSI_CODES[:underline] if underline
56
- @styles << ANSI_CODES[:blink] if blink
57
- self
58
- end
59
-
60
- def render
61
- "#{@styles.join}#{@text}#{ANSI_CODES[:reset]}"
62
- end
63
-
64
- private
65
-
66
- def add_style(code, error_message)
67
- if code
68
- @styles << code
69
- else
70
- raise ArgumentError, error_message
71
- end
72
- end
73
-
74
- def apply_theme(style)
75
- theme_styles = @@theme[style]
76
- raise ArgumentError, "Undefined theme style: #{style}" unless theme_styles
77
- style(**theme_styles)
78
- end
79
- end
80
- end