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,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyRich
4
+ class Sidebar
5
+ attr_accessor :width, :height
6
+ attr_reader :plan, :tasks
7
+
8
+ def initialize(plan: "", tasks: [])
9
+ @plan = plan
10
+ @tasks = tasks
11
+ @width = 0
12
+ @height = 0
13
+ @focused = false
14
+ end
15
+
16
+ def focus
17
+ @focused = true
18
+ self
19
+ end
20
+
21
+ def blur
22
+ @focused = false
23
+ self
24
+ end
25
+
26
+ def update_plan(text)
27
+ @plan = text.to_s
28
+ self
29
+ end
30
+
31
+ def set_tasks(tasks)
32
+ @tasks = tasks
33
+ self
34
+ end
35
+
36
+ def add_task(label, status: :pending)
37
+ @tasks << { label: label, status: status }
38
+ self
39
+ end
40
+
41
+ def render
42
+ plan_height = [(@height * 0.48).floor, 3].max
43
+ tasks_height = [@height - plan_height, 3].max
44
+ [
45
+ *panel_lines("Plan", @plan, plan_height),
46
+ *panel_lines("Tasks", render_tasks, tasks_height)
47
+ ].first(@height)
48
+ end
49
+
50
+ private
51
+
52
+ def panel_lines(title, content, height)
53
+ panel = Panel.new(content, title: title, border_style: @focused ? :green : :blue, title_align: :left)
54
+ panel.width = @width
55
+ panel.height = height
56
+ panel.render
57
+ end
58
+
59
+ def render_tasks
60
+ return "No active tasks" if @tasks.empty?
61
+
62
+ @tasks.map do |task|
63
+ case task
64
+ when Hash
65
+ "#{status_marker(task[:status])} #{task[:label]} #{AnsiCode.color(:black, true)}#{task[:status]}#{AnsiCode.reset}"
66
+ else
67
+ "• #{task}"
68
+ end
69
+ end.join("\n")
70
+ end
71
+
72
+ def status_marker(status)
73
+ case status
74
+ when :done, :completed
75
+ "#{AnsiCode.color(:green, true)}✓#{AnsiCode.reset}"
76
+ when :running, :in_progress
77
+ "#{AnsiCode.color(:blue, true)}●#{AnsiCode.reset}"
78
+ when :failed, :error
79
+ "#{AnsiCode.color(:red, true)}!#{AnsiCode.reset}"
80
+ else
81
+ "#{AnsiCode.color(:black, true)}○#{AnsiCode.reset}"
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyRich
4
+ class SlashInput
5
+ attr_reader :value, :selected_index
6
+
7
+ DEFAULT_MENU_LIMIT = 8
8
+
9
+ def initialize(prompt: "> ", items: [], width: nil, menu_limit: DEFAULT_MENU_LIMIT, on_select: nil, on_submit: nil)
10
+ @prompt = prompt
11
+ @items = normalize_items(items)
12
+ @width = width
13
+ @menu_limit = menu_limit
14
+ @on_select = on_select
15
+ @on_submit = on_submit
16
+ @value = ""
17
+ @selected_index = 0
18
+ @menu_open = false
19
+ end
20
+
21
+ def attach(layout, priority: 100)
22
+ [:string, :backspace, :up, :down, :enter, :escape].each do |event_name|
23
+ layout.key(event_name, priority) do |event_data, live|
24
+ handle_key(event_data, live)
25
+ false
26
+ end
27
+ end
28
+ self
29
+ end
30
+
31
+ def handle_key(event_data, live = nil)
32
+ case event_data[:name]
33
+ when :string
34
+ append(event_data[:value].to_s)
35
+ when :backspace
36
+ backspace
37
+ when :up
38
+ move_selection(-1)
39
+ when :down
40
+ move_selection(1)
41
+ when :enter
42
+ enter(live)
43
+ when :escape
44
+ close_menu
45
+ end
46
+ end
47
+
48
+ def render
49
+ lines = [input_line]
50
+ return fit_lines(lines) unless menu_open?
51
+
52
+ matches = filtered_items.first(@menu_limit)
53
+ if matches.empty?
54
+ lines << "#{AnsiCode.color(:yellow)} No matches#{AnsiCode.reset}"
55
+ else
56
+ matches.each_with_index do |item, index|
57
+ lines << render_item(item, index == @selected_index)
58
+ end
59
+ end
60
+
61
+ fit_lines(lines)
62
+ end
63
+
64
+ def menu_open?
65
+ @menu_open
66
+ end
67
+
68
+ private
69
+
70
+ def append(text)
71
+ @value += text
72
+ @menu_open = true if text == "/" || @menu_open
73
+ clamp_selection
74
+ end
75
+
76
+ def backspace
77
+ @value = @value[0...-1].to_s
78
+ @menu_open = false unless @value.include?("/")
79
+ clamp_selection
80
+ end
81
+
82
+ def enter(live)
83
+ if @menu_open
84
+ select_current(live)
85
+ else
86
+ submitted_value = @value
87
+ @on_submit&.call(submitted_value, live)
88
+ reset_input
89
+ end
90
+ end
91
+
92
+ def select_current(live)
93
+ item = filtered_items[@selected_index]
94
+ return unless item
95
+
96
+ @value = replace_query_with(item[:value])
97
+ @menu_open = false
98
+ @selected_index = 0
99
+ @on_select&.call(item, live)
100
+ end
101
+
102
+ def move_selection(delta)
103
+ return unless @menu_open
104
+
105
+ count = [filtered_items.size, @menu_limit].min
106
+ return if count.zero?
107
+
108
+ @selected_index = (@selected_index + delta) % count
109
+ end
110
+
111
+ def close_menu
112
+ @menu_open = false
113
+ @selected_index = 0
114
+ end
115
+
116
+ def reset_input
117
+ @value = ""
118
+ close_menu
119
+ end
120
+
121
+ def filtered_items
122
+ query = current_query.downcase
123
+ return @items if query.empty?
124
+
125
+ @items.select do |item|
126
+ item[:label].downcase.include?(query) || item[:value].downcase.include?(query)
127
+ end
128
+ end
129
+
130
+ def current_query
131
+ slash_index = @value.rindex("/")
132
+ return "" unless slash_index
133
+
134
+ @value[(slash_index + 1)..].to_s
135
+ end
136
+
137
+ def replace_query_with(replacement)
138
+ slash_index = @value.rindex("/")
139
+ return @value unless slash_index
140
+
141
+ @value[0...slash_index].to_s + replacement
142
+ end
143
+
144
+ def clamp_selection
145
+ count = [filtered_items.size, @menu_limit].min
146
+ @selected_index = 0 if count.zero? || @selected_index >= count
147
+ end
148
+
149
+ def input_line
150
+ "#{@prompt}#{@value}"
151
+ end
152
+
153
+ def render_item(item, selected)
154
+ marker = selected ? ">" : " "
155
+ color = selected ? AnsiCode.inverse : AnsiCode.color(:white)
156
+ description = item[:description].to_s
157
+ suffix = description.empty? ? "" : " #{AnsiCode.color(:black, true)}#{description}#{AnsiCode.reset}"
158
+ " #{color}#{marker} #{item[:label]}#{AnsiCode.reset}#{suffix}"
159
+ end
160
+
161
+ def fit_lines(lines)
162
+ return lines unless @width
163
+
164
+ lines.map { |line| truncate_display(line, @width) }
165
+ end
166
+
167
+ def truncate_display(line, max_width)
168
+ return line if line.display_width <= max_width
169
+
170
+ result = ""
171
+ width = 0
172
+ line.each_char do |char|
173
+ char_width = char.display_width
174
+ break if width + char_width > max_width
175
+
176
+ result += char
177
+ width += char_width
178
+ end
179
+ result
180
+ end
181
+
182
+ def normalize_items(items)
183
+ items.map do |item|
184
+ case item
185
+ when Hash
186
+ {
187
+ label: item.fetch(:label, item.fetch("label", item[:value] || item["value"])).to_s,
188
+ value: item.fetch(:value, item.fetch("value", item[:label] || item["label"])).to_s,
189
+ description: item.fetch(:description, item.fetch("description", "")).to_s
190
+ }
191
+ else
192
+ { label: item.to_s, value: item.to_s, description: "" }
193
+ end
194
+ end
195
+ end
196
+ end
197
+ end
@@ -219,23 +219,23 @@ module RubyRich
219
219
  def render_multiline_row(row, column_widths)
220
220
  # Prepare each cell's lines
221
221
  row_lines = row.map.with_index do |cell, i|
222
- # 获取单元格的样式序列
222
+ # Get cell style sequence
223
223
  style_sequence = cell.render.match(/\e\[[0-9;]*m/)&.to_s || ""
224
224
  reset_sequence = style_sequence.empty? ? "" : "\e[0m"
225
-
226
- # 分割成多行并保持样式
225
+
226
+ # Split into multiple lines while preserving styles
227
227
  cell_content = cell.render.split("\n")
228
-
229
- # 为每一行添加样式
230
- cell_content.map! { |line|
231
- line = line.gsub(/\e\[[0-9;]*m/, '') # 移除可能存在的样式序列
232
- style_sequence + line + reset_sequence
228
+
229
+ # Add style to each line
230
+ cell_content.map! { |line|
231
+ line = line.gsub(/\e\[[0-9;]*m/, '') # Remove existing style sequences
232
+ style_sequence + line + reset_sequence
233
233
  }
234
-
235
- # 填充到指定的行高
234
+
235
+ # Pad to specified row height
236
236
  padded_content = cell_content + [" "] * [@row_height - cell_content.size, 0].max
237
-
238
- # 对每一行应用对齐,保持样式
237
+
238
+ # Apply alignment to each line while preserving styles
239
239
  padded_content.map { |line| align_cell(line, column_widths[i]) }
240
240
  end
241
241