charming 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/LICENSE.txt +21 -0
- data/README.md +421 -0
- data/exe/charming +6 -0
- data/lib/charming/application.rb +90 -0
- data/lib/charming/application_model.rb +13 -0
- data/lib/charming/cli.rb +60 -0
- data/lib/charming/component.rb +8 -0
- data/lib/charming/components/activity_indicator.rb +158 -0
- data/lib/charming/components/command_palette.rb +118 -0
- data/lib/charming/components/keyboard_handler.rb +22 -0
- data/lib/charming/components/list.rb +105 -0
- data/lib/charming/components/modal.rb +48 -0
- data/lib/charming/components/progressbar.rb +55 -0
- data/lib/charming/components/spinner.rb +37 -0
- data/lib/charming/components/table.rb +115 -0
- data/lib/charming/components/text_input.rb +103 -0
- data/lib/charming/components/viewport.rb +191 -0
- data/lib/charming/controller.rb +523 -0
- data/lib/charming/focus.rb +65 -0
- data/lib/charming/generators/app_file_generator.rb +28 -0
- data/lib/charming/generators/app_generator/app_spec_templates.rb +86 -0
- data/lib/charming/generators/app_generator/basic_templates.rb +69 -0
- data/lib/charming/generators/app_generator/component_templates.rb +36 -0
- data/lib/charming/generators/app_generator/controller_template.rb +69 -0
- data/lib/charming/generators/app_generator/layout_template.rb +160 -0
- data/lib/charming/generators/app_generator/model_templates.rb +30 -0
- data/lib/charming/generators/app_generator/screen_spec_templates.rb +70 -0
- data/lib/charming/generators/app_generator/view_template.rb +90 -0
- data/lib/charming/generators/app_generator.rb +76 -0
- data/lib/charming/generators/base.rb +29 -0
- data/lib/charming/generators/component_generator.rb +30 -0
- data/lib/charming/generators/controller_generator.rb +50 -0
- data/lib/charming/generators/name.rb +32 -0
- data/lib/charming/generators/screen_generator.rb +154 -0
- data/lib/charming/generators/view_generator.rb +34 -0
- data/lib/charming/generators.rb +7 -0
- data/lib/charming/internal/renderer/differential.rb +53 -0
- data/lib/charming/internal/renderer/full_repaint.rb +19 -0
- data/lib/charming/internal/terminal/adapter.rb +52 -0
- data/lib/charming/internal/terminal/memory_backend.rb +91 -0
- data/lib/charming/internal/terminal/tty_backend.rb +250 -0
- data/lib/charming/key_event.rb +13 -0
- data/lib/charming/mouse_event.rb +40 -0
- data/lib/charming/resize_event.rb +7 -0
- data/lib/charming/response.rb +33 -0
- data/lib/charming/router.rb +137 -0
- data/lib/charming/runtime.rb +192 -0
- data/lib/charming/screen.rb +8 -0
- data/lib/charming/task.rb +7 -0
- data/lib/charming/task_event.rb +17 -0
- data/lib/charming/task_executor.rb +62 -0
- data/lib/charming/timer_event.rb +7 -0
- data/lib/charming/ui/border.rb +33 -0
- data/lib/charming/ui/style.rb +244 -0
- data/lib/charming/ui/theme.rb +178 -0
- data/lib/charming/ui/themes/phosphor.json +100 -0
- data/lib/charming/ui/width.rb +24 -0
- data/lib/charming/ui.rb +230 -0
- data/lib/charming/version.rb +5 -0
- data/lib/charming/view.rb +116 -0
- data/lib/charming.rb +24 -0
- data/sig/charming.rbs +3 -0
- metadata +225 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Components
|
|
5
|
+
class TextInput < Component
|
|
6
|
+
include KeyboardHandler
|
|
7
|
+
|
|
8
|
+
# Maps editing keys (left/right/home/end/backspace/delete) to the instance
|
|
9
|
+
# methods they dispatch via KeyboardHandler. Each symbol key (e.g., :left)
|
|
10
|
+
# maps to a method (e.g., :move_left) that adjusts cursor position or text content.
|
|
11
|
+
KEY_ACTIONS = {
|
|
12
|
+
left: :move_left,
|
|
13
|
+
right: :move_right,
|
|
14
|
+
home: :move_home,
|
|
15
|
+
end: :move_end,
|
|
16
|
+
backspace: :delete_before_cursor,
|
|
17
|
+
delete: :delete_at_cursor
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
attr_reader :value, :cursor
|
|
21
|
+
|
|
22
|
+
def initialize(value: "", placeholder: "", width: nil, cursor: nil)
|
|
23
|
+
super()
|
|
24
|
+
@value = value.dup
|
|
25
|
+
@placeholder = placeholder
|
|
26
|
+
@width = width
|
|
27
|
+
@cursor = cursor || @value.length
|
|
28
|
+
clamp_position
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def handle_key(event)
|
|
32
|
+
return :handled if character_event?(event) && insert(event.char)
|
|
33
|
+
|
|
34
|
+
super
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def render
|
|
38
|
+
rendered = render_value
|
|
39
|
+
@width ? style.width(@width).render(rendered) : rendered
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
attr_reader :placeholder
|
|
45
|
+
|
|
46
|
+
def character_event?(event)
|
|
47
|
+
event.respond_to?(:char) && event.char && event.char.length == 1 && printable?(event.char)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def printable?(char)
|
|
51
|
+
!char.match?(/[[:cntrl:]]/)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def insert(char)
|
|
55
|
+
@value = value[0...cursor] + char + value[cursor..]
|
|
56
|
+
@cursor += char.length
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def move_left
|
|
60
|
+
@cursor -= 1 if cursor.positive?
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def move_right
|
|
64
|
+
@cursor += 1 if cursor < value.length
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def move_home
|
|
68
|
+
@cursor = 0
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def move_end
|
|
72
|
+
@cursor = value.length
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def delete_before_cursor
|
|
76
|
+
return if cursor.zero?
|
|
77
|
+
|
|
78
|
+
@value = value[0...(cursor - 1)] + value[cursor..]
|
|
79
|
+
@cursor -= 1
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def delete_at_cursor
|
|
83
|
+
return if cursor >= value.length
|
|
84
|
+
|
|
85
|
+
@value = value[0...cursor] + value[(cursor + 1)..]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def render_value
|
|
89
|
+
return cursor_marker + placeholder if value.empty?
|
|
90
|
+
|
|
91
|
+
value[0...cursor] + cursor_marker + value[cursor..]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def cursor_marker
|
|
95
|
+
"|"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def clamp_position
|
|
99
|
+
@cursor = cursor.clamp(0, value.length)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "unicode/display_width"
|
|
4
|
+
|
|
5
|
+
module Charming
|
|
6
|
+
module Components
|
|
7
|
+
class Viewport < Component
|
|
8
|
+
include KeyboardHandler
|
|
9
|
+
|
|
10
|
+
ANSI_PATTERN = /\e\[[0-9;]*m/
|
|
11
|
+
KEY_ACTIONS = {
|
|
12
|
+
up: :scroll_up,
|
|
13
|
+
down: :scroll_down,
|
|
14
|
+
page_up: :page_up,
|
|
15
|
+
page_down: :page_down,
|
|
16
|
+
home: :scroll_home,
|
|
17
|
+
end: :scroll_end,
|
|
18
|
+
left: :scroll_left,
|
|
19
|
+
right: :scroll_right
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
attr_reader :offset, :column
|
|
23
|
+
|
|
24
|
+
def initialize(content:, width: nil, height: nil, offset: 0, column: 0)
|
|
25
|
+
super()
|
|
26
|
+
@content = content
|
|
27
|
+
@width = width
|
|
28
|
+
@height = height
|
|
29
|
+
@offset = offset
|
|
30
|
+
@column = column
|
|
31
|
+
clamp_position
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def render
|
|
35
|
+
visible_lines.map { |line| render_line(line) }.join("\n")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def handle_mouse(event)
|
|
39
|
+
return nil unless height
|
|
40
|
+
|
|
41
|
+
if event.scroll?
|
|
42
|
+
scroll_delta = (event.button_name == :scroll_up) ? -1 : 1
|
|
43
|
+
@offset += scroll_delta
|
|
44
|
+
clamp_position
|
|
45
|
+
return :handled
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
return nil unless event.click?
|
|
49
|
+
|
|
50
|
+
clicked_row = event.y
|
|
51
|
+
return nil if clicked_row < offset || clicked_row >= offset + viewport_height
|
|
52
|
+
|
|
53
|
+
@offset = clicked_row
|
|
54
|
+
clamp_position
|
|
55
|
+
:handled
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
attr_reader :content, :width, :height
|
|
61
|
+
|
|
62
|
+
def scroll_up
|
|
63
|
+
@offset -= 1
|
|
64
|
+
clamp_position
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def scroll_down
|
|
68
|
+
@offset += 1
|
|
69
|
+
clamp_position
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def page_up
|
|
73
|
+
@offset -= page_size
|
|
74
|
+
clamp_position
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def page_down
|
|
78
|
+
@offset += page_size
|
|
79
|
+
clamp_position
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def scroll_home
|
|
83
|
+
@offset = 0
|
|
84
|
+
@column = 0
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def scroll_end
|
|
88
|
+
@offset = max_offset
|
|
89
|
+
@column = max_column
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def scroll_left
|
|
93
|
+
@column -= 1
|
|
94
|
+
clamp_position
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def scroll_right
|
|
98
|
+
@column += 1
|
|
99
|
+
clamp_position
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def clamp_position
|
|
103
|
+
@offset = offset.clamp(0, max_offset)
|
|
104
|
+
@column = column.clamp(0, max_column)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def visible_lines
|
|
108
|
+
lines = content_lines.slice(offset, viewport_height) || []
|
|
109
|
+
return lines unless height
|
|
110
|
+
|
|
111
|
+
lines + Array.new([height - lines.length, 0].max, "")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def render_line(line)
|
|
115
|
+
return line unless width
|
|
116
|
+
|
|
117
|
+
pad_line(clip_line(line), width)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def clip_line(line)
|
|
121
|
+
clipped = clip_tokens(line.to_s)
|
|
122
|
+
needs_reset?(clipped) ? "#{clipped}\e[0m" : clipped
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def clip_tokens(line)
|
|
126
|
+
state = {cursor: 0, output: +""}
|
|
127
|
+
line.scan(/#{ANSI_PATTERN}|./mo) do |token|
|
|
128
|
+
ansi?(token) ? append_ansi(state, token) : append_character(state, token)
|
|
129
|
+
end
|
|
130
|
+
state.fetch(:output)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def append_ansi(state, token)
|
|
134
|
+
state.fetch(:output) << token
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def append_character(state, char)
|
|
138
|
+
char_width = Unicode::DisplayWidth.of(char)
|
|
139
|
+
cursor = state.fetch(:cursor)
|
|
140
|
+
state.fetch(:output) << char if visible?(cursor, char_width)
|
|
141
|
+
state[:cursor] = cursor + char_width
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def visible?(cursor, char_width)
|
|
145
|
+
cursor >= column && cursor + char_width <= column + width
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def needs_reset?(value)
|
|
149
|
+
value.match?(ANSI_PATTERN) && !value.end_with?("\e[0m")
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def pad_line(line, target_width)
|
|
153
|
+
line + (" " * [target_width - UI::Width.measure(line), 0].max)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def content_lines
|
|
157
|
+
rendered_content.lines(chomp: true)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def rendered_content
|
|
161
|
+
content.respond_to?(:render) ? content.render.to_s : content.to_s
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def viewport_height
|
|
165
|
+
height || content_lines.length
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def page_size
|
|
169
|
+
[viewport_height, 1].max
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def max_offset
|
|
173
|
+
[content_lines.length - viewport_height, 0].max
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def max_column
|
|
177
|
+
return 0 unless width
|
|
178
|
+
|
|
179
|
+
[content_width - width, 0].max
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def content_width
|
|
183
|
+
content_lines.map { |line| UI::Width.measure(line) }.max || 0
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def ansi?(token)
|
|
187
|
+
token.match?(ANSI_PATTERN)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|