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.
Files changed (64) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +421 -0
  4. data/exe/charming +6 -0
  5. data/lib/charming/application.rb +90 -0
  6. data/lib/charming/application_model.rb +13 -0
  7. data/lib/charming/cli.rb +60 -0
  8. data/lib/charming/component.rb +8 -0
  9. data/lib/charming/components/activity_indicator.rb +158 -0
  10. data/lib/charming/components/command_palette.rb +118 -0
  11. data/lib/charming/components/keyboard_handler.rb +22 -0
  12. data/lib/charming/components/list.rb +105 -0
  13. data/lib/charming/components/modal.rb +48 -0
  14. data/lib/charming/components/progressbar.rb +55 -0
  15. data/lib/charming/components/spinner.rb +37 -0
  16. data/lib/charming/components/table.rb +115 -0
  17. data/lib/charming/components/text_input.rb +103 -0
  18. data/lib/charming/components/viewport.rb +191 -0
  19. data/lib/charming/controller.rb +523 -0
  20. data/lib/charming/focus.rb +65 -0
  21. data/lib/charming/generators/app_file_generator.rb +28 -0
  22. data/lib/charming/generators/app_generator/app_spec_templates.rb +86 -0
  23. data/lib/charming/generators/app_generator/basic_templates.rb +69 -0
  24. data/lib/charming/generators/app_generator/component_templates.rb +36 -0
  25. data/lib/charming/generators/app_generator/controller_template.rb +69 -0
  26. data/lib/charming/generators/app_generator/layout_template.rb +160 -0
  27. data/lib/charming/generators/app_generator/model_templates.rb +30 -0
  28. data/lib/charming/generators/app_generator/screen_spec_templates.rb +70 -0
  29. data/lib/charming/generators/app_generator/view_template.rb +90 -0
  30. data/lib/charming/generators/app_generator.rb +76 -0
  31. data/lib/charming/generators/base.rb +29 -0
  32. data/lib/charming/generators/component_generator.rb +30 -0
  33. data/lib/charming/generators/controller_generator.rb +50 -0
  34. data/lib/charming/generators/name.rb +32 -0
  35. data/lib/charming/generators/screen_generator.rb +154 -0
  36. data/lib/charming/generators/view_generator.rb +34 -0
  37. data/lib/charming/generators.rb +7 -0
  38. data/lib/charming/internal/renderer/differential.rb +53 -0
  39. data/lib/charming/internal/renderer/full_repaint.rb +19 -0
  40. data/lib/charming/internal/terminal/adapter.rb +52 -0
  41. data/lib/charming/internal/terminal/memory_backend.rb +91 -0
  42. data/lib/charming/internal/terminal/tty_backend.rb +250 -0
  43. data/lib/charming/key_event.rb +13 -0
  44. data/lib/charming/mouse_event.rb +40 -0
  45. data/lib/charming/resize_event.rb +7 -0
  46. data/lib/charming/response.rb +33 -0
  47. data/lib/charming/router.rb +137 -0
  48. data/lib/charming/runtime.rb +192 -0
  49. data/lib/charming/screen.rb +8 -0
  50. data/lib/charming/task.rb +7 -0
  51. data/lib/charming/task_event.rb +17 -0
  52. data/lib/charming/task_executor.rb +62 -0
  53. data/lib/charming/timer_event.rb +7 -0
  54. data/lib/charming/ui/border.rb +33 -0
  55. data/lib/charming/ui/style.rb +244 -0
  56. data/lib/charming/ui/theme.rb +178 -0
  57. data/lib/charming/ui/themes/phosphor.json +100 -0
  58. data/lib/charming/ui/width.rb +24 -0
  59. data/lib/charming/ui.rb +230 -0
  60. data/lib/charming/version.rb +5 -0
  61. data/lib/charming/view.rb +116 -0
  62. data/lib/charming.rb +24 -0
  63. data/sig/charming.rbs +3 -0
  64. 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