chamomile-petals 0.1.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 +7 -0
- data/lib/petals/command_palette.rb +114 -0
- data/lib/petals/cursor.rb +94 -0
- data/lib/petals/file_picker/key_map.rb +17 -0
- data/lib/petals/file_picker.rb +276 -0
- data/lib/petals/help.rb +89 -0
- data/lib/petals/key_binding.rb +31 -0
- data/lib/petals/list/key_map.rb +18 -0
- data/lib/petals/list.rb +432 -0
- data/lib/petals/log_view.rb +141 -0
- data/lib/petals/paginator/key_map.rb +10 -0
- data/lib/petals/paginator.rb +97 -0
- data/lib/petals/progress.rb +199 -0
- data/lib/petals/render_cache.rb +15 -0
- data/lib/petals/spinner/types.rb +20 -0
- data/lib/petals/spinner.rb +73 -0
- data/lib/petals/stopwatch.rb +101 -0
- data/lib/petals/table/key_map.rb +14 -0
- data/lib/petals/table.rb +165 -0
- data/lib/petals/text_area/key_map.rb +27 -0
- data/lib/petals/text_area.rb +449 -0
- data/lib/petals/text_input/key_map.rb +20 -0
- data/lib/petals/text_input.rb +291 -0
- data/lib/petals/timer.rb +122 -0
- data/lib/petals/version.rb +5 -0
- data/lib/petals/viewport/key_map.rb +18 -0
- data/lib/petals/viewport.rb +257 -0
- data/lib/petals.rb +29 -0
- metadata +115 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Petals
|
|
4
|
+
ProgressFrameMsg = Data.define(:id, :tag)
|
|
5
|
+
|
|
6
|
+
# Animated progress bar with spring-based animation and optional gradient.
|
|
7
|
+
class Progress
|
|
8
|
+
DEFAULT_WIDTH = 40
|
|
9
|
+
FULL_CHAR = "\u2588"
|
|
10
|
+
EMPTY_CHAR = "\u2591"
|
|
11
|
+
|
|
12
|
+
# Spring constants
|
|
13
|
+
FREQUENCY = 14.0
|
|
14
|
+
DAMPING = 2.2
|
|
15
|
+
EPSILON = 0.001
|
|
16
|
+
FPS = 60
|
|
17
|
+
|
|
18
|
+
@next_id = 0
|
|
19
|
+
@id_mutex = Mutex.new
|
|
20
|
+
@id_pid = Process.pid
|
|
21
|
+
|
|
22
|
+
def self.next_id
|
|
23
|
+
@id_mutex.synchronize do
|
|
24
|
+
if Process.pid != @id_pid
|
|
25
|
+
@id_pid = Process.pid
|
|
26
|
+
@next_id = 0
|
|
27
|
+
@id_mutex = Mutex.new
|
|
28
|
+
end
|
|
29
|
+
@next_id += 1
|
|
30
|
+
"#{@id_pid}-pg-#{@next_id}"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
attr_reader :id, :percent, :frequency, :damping
|
|
35
|
+
attr_accessor :width, :full_char, :empty_char, :show_percentage,
|
|
36
|
+
:percentage_format, :full_color, :empty_color, :gradient
|
|
37
|
+
|
|
38
|
+
def initialize(width: DEFAULT_WIDTH, show_percentage: true, percentage_format: " %3.0f%%",
|
|
39
|
+
full_char: FULL_CHAR, empty_char: EMPTY_CHAR,
|
|
40
|
+
full_color: nil, empty_color: nil, gradient: nil,
|
|
41
|
+
frequency: FREQUENCY, damping: DAMPING)
|
|
42
|
+
@id = self.class.next_id
|
|
43
|
+
@width = width
|
|
44
|
+
@full_char = full_char
|
|
45
|
+
@empty_char = empty_char
|
|
46
|
+
@show_percentage = show_percentage
|
|
47
|
+
@percentage_format = percentage_format
|
|
48
|
+
@full_color = full_color
|
|
49
|
+
@empty_color = empty_color
|
|
50
|
+
@gradient = gradient
|
|
51
|
+
@frequency = frequency.to_f
|
|
52
|
+
@damping = damping.to_f
|
|
53
|
+
@percent = 0.0
|
|
54
|
+
@target = 0.0
|
|
55
|
+
@velocity = 0.0
|
|
56
|
+
@tag = 0
|
|
57
|
+
@animating = false
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def set_percent(p)
|
|
61
|
+
@target = p.to_f.clamp(0.0, 1.0)
|
|
62
|
+
start_animation
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def incr_percent(v)
|
|
66
|
+
set_percent(@target + v)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def decr_percent(v)
|
|
70
|
+
set_percent(@target - v)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def set_spring_options(frequency, damping)
|
|
74
|
+
@frequency = frequency.to_f
|
|
75
|
+
@damping = damping.to_f
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def animating?
|
|
79
|
+
@animating
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def update(msg)
|
|
83
|
+
return unless msg.is_a?(ProgressFrameMsg)
|
|
84
|
+
return unless msg.id == @id && msg.tag == @tag
|
|
85
|
+
|
|
86
|
+
dt = 1.0 / FPS
|
|
87
|
+
force = (@target - @percent) * @frequency
|
|
88
|
+
@velocity = (@velocity + (force * dt)) * (1.0 / (1.0 + (@damping * dt)))
|
|
89
|
+
@percent += @velocity * dt
|
|
90
|
+
@percent = @percent.clamp(0.0, 1.0)
|
|
91
|
+
|
|
92
|
+
if (@percent - @target).abs < EPSILON && @velocity.abs < EPSILON
|
|
93
|
+
@percent = @target
|
|
94
|
+
@velocity = 0.0
|
|
95
|
+
@animating = false
|
|
96
|
+
@tag += 1
|
|
97
|
+
nil
|
|
98
|
+
else
|
|
99
|
+
@tag += 1
|
|
100
|
+
frame_cmd
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def view
|
|
105
|
+
render_bar(@percent)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def view_as(percent)
|
|
109
|
+
render_bar(percent.to_f.clamp(0.0, 1.0))
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def start_animation
|
|
115
|
+
@tag += 1
|
|
116
|
+
if @animating
|
|
117
|
+
@velocity = 0.0
|
|
118
|
+
return frame_cmd
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
@animating = true
|
|
122
|
+
frame_cmd
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def frame_cmd
|
|
126
|
+
captured_id = @id
|
|
127
|
+
captured_tag = @tag
|
|
128
|
+
-> {
|
|
129
|
+
sleep(1.0 / FPS)
|
|
130
|
+
ProgressFrameMsg.new(id: captured_id, tag: captured_tag)
|
|
131
|
+
}
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def render_bar(pct)
|
|
135
|
+
pct_text = @show_percentage ? format(@percentage_format, pct * 100) : ""
|
|
136
|
+
bar_width = [@width - pct_text.length, 0].max
|
|
137
|
+
|
|
138
|
+
filled_width = (pct * bar_width).round
|
|
139
|
+
empty_width = bar_width - filled_width
|
|
140
|
+
|
|
141
|
+
bar = if @gradient && @gradient.length >= 2
|
|
142
|
+
render_gradient_bar(pct, filled_width, empty_width)
|
|
143
|
+
elsif @full_color || @empty_color
|
|
144
|
+
render_colored_bar(filled_width, empty_width)
|
|
145
|
+
else
|
|
146
|
+
(@full_char * filled_width) + (@empty_char * empty_width)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
bar + pct_text
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def render_colored_bar(filled_width, empty_width)
|
|
153
|
+
result = ""
|
|
154
|
+
if @full_color && filled_width.positive?
|
|
155
|
+
r, g, b = @full_color
|
|
156
|
+
result += "\e[38;2;#{r};#{g};#{b}m#{@full_char * filled_width}\e[0m"
|
|
157
|
+
else
|
|
158
|
+
result += @full_char * filled_width
|
|
159
|
+
end
|
|
160
|
+
if @empty_color && empty_width.positive?
|
|
161
|
+
r, g, b = @empty_color
|
|
162
|
+
result += "\e[38;2;#{r};#{g};#{b}m#{@empty_char * empty_width}\e[0m"
|
|
163
|
+
else
|
|
164
|
+
result += @empty_char * empty_width
|
|
165
|
+
end
|
|
166
|
+
result
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def render_gradient_bar(_pct, filled_width, empty_width)
|
|
170
|
+
return @empty_char * @width if filled_width.zero?
|
|
171
|
+
|
|
172
|
+
chars = filled_width.times.map do |i|
|
|
173
|
+
t = filled_width > 1 ? i.to_f / (filled_width - 1) : 0.0
|
|
174
|
+
r, g, b = lerp_color(t)
|
|
175
|
+
"\e[38;2;#{r};#{g};#{b}m#{@full_char}\e[0m"
|
|
176
|
+
end
|
|
177
|
+
chars.join + (@empty_char * empty_width)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def lerp_color(t)
|
|
181
|
+
colors = @gradient
|
|
182
|
+
return colors[0] if t <= 0.0
|
|
183
|
+
return colors[-1] if t >= 1.0
|
|
184
|
+
|
|
185
|
+
scaled = t * (colors.length - 1)
|
|
186
|
+
idx = scaled.floor
|
|
187
|
+
idx = [idx, colors.length - 2].min
|
|
188
|
+
frac = scaled - idx
|
|
189
|
+
|
|
190
|
+
c1 = colors[idx]
|
|
191
|
+
c2 = colors[idx + 1]
|
|
192
|
+
[
|
|
193
|
+
(c1[0] + ((c2[0] - c1[0]) * frac)).round,
|
|
194
|
+
(c1[1] + ((c2[1] - c1[1]) * frac)).round,
|
|
195
|
+
(c1[2] + ((c2[2] - c1[2]) * frac)).round,
|
|
196
|
+
]
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Petals
|
|
4
|
+
# Mixin for render result caching keyed on content hash.
|
|
5
|
+
# Include in components whose render output depends only on their data and dimensions.
|
|
6
|
+
module RenderCache
|
|
7
|
+
def cached_render(width:, height:, cache_key:)
|
|
8
|
+
key = [width, height, cache_key]
|
|
9
|
+
return @render_cache if @render_cache_key == key
|
|
10
|
+
|
|
11
|
+
@render_cache_key = key
|
|
12
|
+
@render_cache = yield
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Petals
|
|
4
|
+
SpinnerType = Data.define(:frames, :fps)
|
|
5
|
+
|
|
6
|
+
module Spinners
|
|
7
|
+
LINE = SpinnerType.new(frames: %w[| / - \\], fps: 10)
|
|
8
|
+
DOT = SpinnerType.new(frames: %w[⣾ ⣽ ⣻ ⢿ ⡿ ⣟ ⣯ ⣷], fps: 10)
|
|
9
|
+
MINI_DOT = SpinnerType.new(frames: %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏], fps: 12)
|
|
10
|
+
JUMP = SpinnerType.new(frames: %w[⢄ ⢂ ⢁ ⡁ ⡈ ⡐ ⡠], fps: 10)
|
|
11
|
+
PULSE = SpinnerType.new(frames: %w[█ ▓ ▒ ░], fps: 8)
|
|
12
|
+
POINTS = SpinnerType.new(frames: ["∙∙∙", "●∙∙", "∙●∙", "∙∙●"], fps: 7)
|
|
13
|
+
GLOBE = SpinnerType.new(frames: %w[🌍 🌎 🌏], fps: 4)
|
|
14
|
+
MOON = SpinnerType.new(frames: %w[🌑 🌒 🌓 🌔 🌕 🌖 🌗 🌘], fps: 8)
|
|
15
|
+
MONKEY = SpinnerType.new(frames: %w[🙈 🙉 🙊], fps: 3)
|
|
16
|
+
METER = SpinnerType.new(frames: ["▱▱▱", "▰▱▱", "▰▰▱", "▰▰▰", "▰▰▱", "▰▱▱", "▱▱▱"], fps: 7)
|
|
17
|
+
HAMBURGER = SpinnerType.new(frames: %w[☱ ☲ ☴ ☲], fps: 3)
|
|
18
|
+
ELLIPSIS = SpinnerType.new(frames: ["", ".", "..", "..."], fps: 3)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Petals
|
|
4
|
+
SpinnerTickMsg = Data.define(:id, :tag, :time)
|
|
5
|
+
|
|
6
|
+
# Animated spinner with configurable frame types and tick-based updates.
|
|
7
|
+
class Spinner
|
|
8
|
+
@next_id = 0
|
|
9
|
+
@id_mutex = Mutex.new
|
|
10
|
+
@id_pid = Process.pid
|
|
11
|
+
|
|
12
|
+
def self.next_id
|
|
13
|
+
@id_mutex.synchronize do
|
|
14
|
+
if Process.pid != @id_pid
|
|
15
|
+
@id_pid = Process.pid
|
|
16
|
+
@next_id = 0
|
|
17
|
+
@id_mutex = Mutex.new
|
|
18
|
+
end
|
|
19
|
+
@next_id += 1
|
|
20
|
+
"#{@id_pid}-#{@next_id}"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
attr_reader :id, :spinner_type
|
|
25
|
+
|
|
26
|
+
def initialize(type: Spinners::LINE)
|
|
27
|
+
@id = self.class.next_id
|
|
28
|
+
@spinner_type = type
|
|
29
|
+
@frame = 0
|
|
30
|
+
@tag = 0
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Returns a command that sleeps for the frame interval then produces a SpinnerTickMsg.
|
|
34
|
+
def tick_cmd
|
|
35
|
+
captured_id = @id
|
|
36
|
+
captured_tag = @tag
|
|
37
|
+
fps = @spinner_type.fps
|
|
38
|
+
-> {
|
|
39
|
+
sleep(1.0 / fps)
|
|
40
|
+
SpinnerTickMsg.new(id: captured_id, tag: captured_tag, time: Time.now)
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Advance frame if msg is a matching SpinnerTickMsg; return cmd or nil.
|
|
45
|
+
def update(msg)
|
|
46
|
+
return unless msg.is_a?(SpinnerTickMsg)
|
|
47
|
+
return unless msg.id == @id && msg.tag == @tag
|
|
48
|
+
|
|
49
|
+
@frame = (@frame + 1) % @spinner_type.frames.size
|
|
50
|
+
@tag += 1
|
|
51
|
+
tick_cmd
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Current frame string.
|
|
55
|
+
def view
|
|
56
|
+
@spinner_type.frames[@frame]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Reset to first frame, invalidate in-flight ticks.
|
|
60
|
+
def reset
|
|
61
|
+
@frame = 0
|
|
62
|
+
@tag += 1
|
|
63
|
+
self
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Change spinner type, reset frame, invalidate in-flight ticks.
|
|
67
|
+
def spinner_type=(type)
|
|
68
|
+
@spinner_type = type
|
|
69
|
+
@frame = 0
|
|
70
|
+
@tag += 1
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Petals
|
|
4
|
+
StopwatchTickMsg = Data.define(:id, :tag, :time)
|
|
5
|
+
|
|
6
|
+
# Count-up stopwatch with start/stop/reset and tick-based updates.
|
|
7
|
+
class Stopwatch
|
|
8
|
+
@next_id = 0
|
|
9
|
+
@id_mutex = Mutex.new
|
|
10
|
+
@id_pid = Process.pid
|
|
11
|
+
|
|
12
|
+
def self.next_id
|
|
13
|
+
@id_mutex.synchronize do
|
|
14
|
+
if Process.pid != @id_pid
|
|
15
|
+
@id_pid = Process.pid
|
|
16
|
+
@next_id = 0
|
|
17
|
+
@id_mutex = Mutex.new
|
|
18
|
+
end
|
|
19
|
+
@next_id += 1
|
|
20
|
+
"#{@id_pid}-sw-#{@next_id}"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
attr_reader :id, :interval, :elapsed
|
|
25
|
+
|
|
26
|
+
def initialize(interval: 1.0)
|
|
27
|
+
@id = self.class.next_id
|
|
28
|
+
@interval = interval
|
|
29
|
+
@elapsed = 0.0
|
|
30
|
+
@tag = 0
|
|
31
|
+
@running = false
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def start_cmd
|
|
35
|
+
return nil if @running
|
|
36
|
+
|
|
37
|
+
@running = true
|
|
38
|
+
tick_cmd
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def stop
|
|
42
|
+
@running = false
|
|
43
|
+
@tag += 1
|
|
44
|
+
self
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def toggle
|
|
48
|
+
if @running
|
|
49
|
+
stop
|
|
50
|
+
nil
|
|
51
|
+
else
|
|
52
|
+
start_cmd
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def reset
|
|
57
|
+
@elapsed = 0.0
|
|
58
|
+
@running = false
|
|
59
|
+
@tag += 1
|
|
60
|
+
self
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def running?
|
|
64
|
+
@running
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def update(msg)
|
|
68
|
+
return unless msg.is_a?(StopwatchTickMsg)
|
|
69
|
+
return unless msg.id == @id && msg.tag == @tag
|
|
70
|
+
|
|
71
|
+
@elapsed += @interval
|
|
72
|
+
@tag += 1
|
|
73
|
+
tick_cmd
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def view
|
|
77
|
+
total = @elapsed.ceil.to_i
|
|
78
|
+
hours = total / 3600
|
|
79
|
+
minutes = (total % 3600) / 60
|
|
80
|
+
seconds = total % 60
|
|
81
|
+
|
|
82
|
+
if hours.positive?
|
|
83
|
+
format("%d:%02d:%02d", hours, minutes, seconds)
|
|
84
|
+
else
|
|
85
|
+
format("%02d:%02d", minutes, seconds)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def tick_cmd
|
|
92
|
+
captured_id = @id
|
|
93
|
+
captured_tag = @tag
|
|
94
|
+
interval = @interval
|
|
95
|
+
-> {
|
|
96
|
+
sleep(interval)
|
|
97
|
+
StopwatchTickMsg.new(id: captured_id, tag: captured_tag, time: Time.now)
|
|
98
|
+
}
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Petals
|
|
4
|
+
class Table
|
|
5
|
+
DEFAULT_KEY_MAP = KeyBinding.normalize({
|
|
6
|
+
up: [[:up, []], ["k", []]],
|
|
7
|
+
down: [[:down, []], ["j", []]],
|
|
8
|
+
page_up: [[:page_up, []]],
|
|
9
|
+
page_down: [[:page_down, []]],
|
|
10
|
+
goto_top: [["g", []]],
|
|
11
|
+
goto_bottom: [["G", [:shift]]],
|
|
12
|
+
})
|
|
13
|
+
end
|
|
14
|
+
end
|
data/lib/petals/table.rb
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Petals
|
|
4
|
+
# Tabular data display with selection, scrolling, and focus gating.
|
|
5
|
+
class Table
|
|
6
|
+
Column = Data.define(:title, :width)
|
|
7
|
+
|
|
8
|
+
attr_accessor :columns, :key_map, :height
|
|
9
|
+
attr_reader :cursor, :rows
|
|
10
|
+
|
|
11
|
+
def initialize(columns: [], rows: [], height: 10, key_map: DEFAULT_KEY_MAP)
|
|
12
|
+
@columns = columns
|
|
13
|
+
@rows = rows
|
|
14
|
+
@height = height
|
|
15
|
+
@key_map = key_map
|
|
16
|
+
@cursor = 0
|
|
17
|
+
@offset = 0
|
|
18
|
+
@focused = false
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def rows=(rows)
|
|
22
|
+
@rows = rows
|
|
23
|
+
@cursor = @cursor.clamp(0, [row_count - 1, 0].max)
|
|
24
|
+
clamp_offset
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def selected_row
|
|
28
|
+
return nil if @rows.empty?
|
|
29
|
+
|
|
30
|
+
@rows[@cursor]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def move_up(n = 1)
|
|
34
|
+
@cursor = (@cursor - n).clamp(0, [row_count - 1, 0].max)
|
|
35
|
+
clamp_offset
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def move_down(n = 1)
|
|
39
|
+
@cursor = (@cursor + n).clamp(0, [row_count - 1, 0].max)
|
|
40
|
+
clamp_offset
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def goto_top
|
|
44
|
+
@cursor = 0
|
|
45
|
+
clamp_offset
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def goto_bottom
|
|
49
|
+
@cursor = [row_count - 1, 0].max
|
|
50
|
+
clamp_offset
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def cursor=(n)
|
|
54
|
+
@cursor = n.clamp(0, [row_count - 1, 0].max)
|
|
55
|
+
clamp_offset
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def focus
|
|
59
|
+
@focused = true
|
|
60
|
+
self
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def blur
|
|
64
|
+
@focused = false
|
|
65
|
+
self
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def focused?
|
|
69
|
+
@focused
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def update(msg)
|
|
73
|
+
return unless @focused
|
|
74
|
+
|
|
75
|
+
case msg
|
|
76
|
+
when Chamomile::KeyMsg
|
|
77
|
+
handle_key(msg)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def view
|
|
84
|
+
header = render_header
|
|
85
|
+
separator = render_separator
|
|
86
|
+
body = render_body
|
|
87
|
+
|
|
88
|
+
[header, separator, body].compact.join("\n")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def row_count
|
|
94
|
+
@rows.length
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def handle_key(msg)
|
|
98
|
+
kb = KeyBinding
|
|
99
|
+
if kb.key_matches?(msg, @key_map, :up)
|
|
100
|
+
move_up
|
|
101
|
+
elsif kb.key_matches?(msg, @key_map, :down)
|
|
102
|
+
move_down
|
|
103
|
+
elsif kb.key_matches?(msg, @key_map, :page_up)
|
|
104
|
+
move_up(@height)
|
|
105
|
+
elsif kb.key_matches?(msg, @key_map, :page_down)
|
|
106
|
+
move_down(@height)
|
|
107
|
+
elsif kb.key_matches?(msg, @key_map, :goto_top)
|
|
108
|
+
goto_top
|
|
109
|
+
elsif kb.key_matches?(msg, @key_map, :goto_bottom)
|
|
110
|
+
goto_bottom
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def render_header
|
|
115
|
+
return "" if @columns.empty?
|
|
116
|
+
|
|
117
|
+
@columns.map { |col| truncate_pad(col.title, col.width) }.join(" ")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def render_separator
|
|
121
|
+
return "" if @columns.empty?
|
|
122
|
+
|
|
123
|
+
@columns.map { |col| "\u2500" * col.width }.join(" ")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def render_body
|
|
127
|
+
return "" if @rows.empty?
|
|
128
|
+
|
|
129
|
+
visible = @rows[@offset, @height] || []
|
|
130
|
+
lines = visible.each_with_index.map do |row, i|
|
|
131
|
+
idx = @offset + i
|
|
132
|
+
line = render_row(row)
|
|
133
|
+
if idx == @cursor
|
|
134
|
+
"\e[7m#{line}\e[0m"
|
|
135
|
+
else
|
|
136
|
+
line
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
lines.join("\n")
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def render_row(row)
|
|
143
|
+
@columns.each_with_index.map do |col, i|
|
|
144
|
+
cell = row[i].to_s
|
|
145
|
+
truncate_pad(cell, col.width)
|
|
146
|
+
end.join(" ")
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def truncate_pad(text, width)
|
|
150
|
+
if text.length > width
|
|
151
|
+
width > 1 ? "#{text[0, width - 1]}\u2026" : text[0, width]
|
|
152
|
+
else
|
|
153
|
+
text.ljust(width)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def clamp_offset
|
|
158
|
+
return if @rows.empty?
|
|
159
|
+
|
|
160
|
+
@offset = @cursor if @cursor < @offset
|
|
161
|
+
@offset = @cursor - @height + 1 if @cursor >= @offset + @height
|
|
162
|
+
@offset = [0, @offset].max
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Petals
|
|
4
|
+
class TextArea
|
|
5
|
+
DEFAULT_KEY_MAP = KeyBinding.normalize({
|
|
6
|
+
character_forward: [[:right, []], ["f", [:ctrl]]],
|
|
7
|
+
character_backward: [[:left, []], ["b", [:ctrl]]],
|
|
8
|
+
word_forward: [[:right, [:alt]], [:right, [:ctrl]], ["f", [:alt]]],
|
|
9
|
+
word_backward: [[:left, [:alt]], [:left, [:ctrl]], ["b", [:alt]]],
|
|
10
|
+
delete_word_backward: [[:backspace, [:alt]], ["w", [:ctrl]]],
|
|
11
|
+
delete_word_forward: [[:delete, [:alt]], ["d", [:alt]]],
|
|
12
|
+
delete_after_cursor: [["k", [:ctrl]]],
|
|
13
|
+
delete_before_cursor: [["u", [:ctrl]]],
|
|
14
|
+
delete_char_backward: [[:backspace, []], ["h", [:ctrl]]],
|
|
15
|
+
delete_char_forward: [[:delete, []], ["d", [:ctrl]]],
|
|
16
|
+
line_start: [[:home, []], ["a", [:ctrl]]],
|
|
17
|
+
line_end: [[:end_key, []], ["e", [:ctrl]]],
|
|
18
|
+
line_up: [[:up, []]],
|
|
19
|
+
line_down: [[:down, []]],
|
|
20
|
+
new_line: [[:enter, []]],
|
|
21
|
+
input_begin: [[:home, [:ctrl]]],
|
|
22
|
+
input_end: [[:end_key, [:ctrl]]],
|
|
23
|
+
page_up: [[:page_up, []]],
|
|
24
|
+
page_down: [[:page_down, []]],
|
|
25
|
+
})
|
|
26
|
+
end
|
|
27
|
+
end
|