charming 0.1.0 → 0.1.1

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 (99) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +38 -378
  3. data/lib/charming/application.rb +3 -3
  4. data/lib/charming/{application_model.rb → application_state.rb} +3 -3
  5. data/lib/charming/cli.rb +39 -3
  6. data/lib/charming/controller.rb +146 -24
  7. data/lib/charming/database_commands.rb +87 -0
  8. data/lib/charming/database_installer.rb +125 -0
  9. data/lib/charming/events/key_event.rb +15 -0
  10. data/lib/charming/events/mouse_event.rb +42 -0
  11. data/lib/charming/events/resize_event.rb +9 -0
  12. data/lib/charming/events/task_event.rb +19 -0
  13. data/lib/charming/events/timer_event.rb +9 -0
  14. data/lib/charming/generators/app_generator/app_spec_templates.rb +12 -8
  15. data/lib/charming/generators/app_generator/basic_templates.rb +14 -2
  16. data/lib/charming/generators/app_generator/component_templates.rb +1 -1
  17. data/lib/charming/generators/app_generator/controller_template.rb +3 -12
  18. data/lib/charming/generators/app_generator/database_templates.rb +45 -0
  19. data/lib/charming/generators/app_generator/layout_template.rb +51 -145
  20. data/lib/charming/generators/app_generator/screen_spec_templates.rb +7 -8
  21. data/lib/charming/generators/app_generator/{model_templates.rb → state_templates.rb} +5 -5
  22. data/lib/charming/generators/app_generator/view_template.rb +12 -18
  23. data/lib/charming/generators/app_generator.rb +37 -11
  24. data/lib/charming/generators/component_generator.rb +1 -1
  25. data/lib/charming/generators/controller_generator.rb +1 -4
  26. data/lib/charming/generators/model_generator.rb +119 -0
  27. data/lib/charming/generators/name.rb +0 -4
  28. data/lib/charming/generators/screen_generator.rb +14 -28
  29. data/lib/charming/generators/view_generator.rb +11 -14
  30. data/lib/charming/internal/renderer/differential.rb +2 -3
  31. data/lib/charming/internal/terminal/tty_backend.rb +25 -8
  32. data/lib/charming/presentation/component.rb +10 -0
  33. data/lib/charming/presentation/components/activity_indicator.rb +160 -0
  34. data/lib/charming/presentation/components/command_palette.rb +120 -0
  35. data/lib/charming/presentation/components/empty_state.rb +43 -0
  36. data/lib/charming/presentation/components/form/builder.rb +48 -0
  37. data/lib/charming/presentation/components/form/confirm.rb +56 -0
  38. data/lib/charming/presentation/components/form/field.rb +96 -0
  39. data/lib/charming/presentation/components/form/input.rb +57 -0
  40. data/lib/charming/presentation/components/form/note.rb +32 -0
  41. data/lib/charming/presentation/components/form/select.rb +89 -0
  42. data/lib/charming/presentation/components/form/textarea.rb +70 -0
  43. data/lib/charming/presentation/components/form.rb +127 -0
  44. data/lib/charming/presentation/components/keyboard_handler.rb +58 -0
  45. data/lib/charming/presentation/components/list.rb +104 -0
  46. data/lib/charming/presentation/components/markdown.rb +25 -0
  47. data/lib/charming/presentation/components/modal.rb +50 -0
  48. data/lib/charming/presentation/components/progressbar.rb +57 -0
  49. data/lib/charming/presentation/components/spinner.rb +39 -0
  50. data/lib/charming/presentation/components/table.rb +118 -0
  51. data/lib/charming/presentation/components/text_area.rb +219 -0
  52. data/lib/charming/presentation/components/text_input.rb +105 -0
  53. data/lib/charming/presentation/components/viewport.rb +220 -0
  54. data/lib/charming/presentation/layout.rb +43 -0
  55. data/lib/charming/presentation/markdown/renderer.rb +203 -0
  56. data/lib/charming/presentation/markdown/syntax_highlighter.rb +63 -0
  57. data/lib/charming/presentation/markdown.rb +8 -0
  58. data/lib/charming/presentation/template_view.rb +27 -0
  59. data/lib/charming/presentation/templates/erb_handler.rb +15 -0
  60. data/lib/charming/presentation/templates.rb +51 -0
  61. data/lib/charming/presentation/ui/border.rb +35 -0
  62. data/lib/charming/presentation/ui/style.rb +246 -0
  63. data/lib/charming/presentation/ui/theme.rb +180 -0
  64. data/lib/charming/{ui → presentation/ui}/themes/phosphor.json +2 -2
  65. data/lib/charming/presentation/ui/width.rb +26 -0
  66. data/lib/charming/presentation/ui.rb +232 -0
  67. data/lib/charming/presentation/view.rb +118 -0
  68. data/lib/charming/runtime.rb +7 -7
  69. data/lib/charming/screen.rb +5 -1
  70. data/lib/charming/tasks/inline_executor.rb +28 -0
  71. data/lib/charming/tasks/task.rb +9 -0
  72. data/lib/charming/{task_executor.rb → tasks/threaded_executor.rb} +4 -27
  73. data/lib/charming/version.rb +1 -1
  74. data/lib/charming.rb +4 -0
  75. metadata +114 -29
  76. data/lib/charming/component.rb +0 -8
  77. data/lib/charming/components/activity_indicator.rb +0 -158
  78. data/lib/charming/components/command_palette.rb +0 -118
  79. data/lib/charming/components/keyboard_handler.rb +0 -22
  80. data/lib/charming/components/list.rb +0 -105
  81. data/lib/charming/components/modal.rb +0 -48
  82. data/lib/charming/components/progressbar.rb +0 -55
  83. data/lib/charming/components/spinner.rb +0 -37
  84. data/lib/charming/components/table.rb +0 -115
  85. data/lib/charming/components/text_input.rb +0 -103
  86. data/lib/charming/components/viewport.rb +0 -191
  87. data/lib/charming/key_event.rb +0 -13
  88. data/lib/charming/mouse_event.rb +0 -40
  89. data/lib/charming/resize_event.rb +0 -7
  90. data/lib/charming/task.rb +0 -7
  91. data/lib/charming/task_event.rb +0 -17
  92. data/lib/charming/timer_event.rb +0 -7
  93. data/lib/charming/ui/border.rb +0 -33
  94. data/lib/charming/ui/style.rb +0 -244
  95. data/lib/charming/ui/theme.rb +0 -178
  96. data/lib/charming/ui/width.rb +0 -24
  97. data/lib/charming/ui.rb +0 -230
  98. data/lib/charming/view.rb +0 -116
  99. /data/lib/charming/{generators.rb → generators/error.rb} +0 -0
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Presentation
5
+ module Components
6
+ class Progressbar < Component
7
+ attr_accessor :total, :current, :label, :complete, :incomplete, :bar_format
8
+
9
+ def initialize(total:, complete: "=", incomplete: " ", bar_format: :classic, label: nil)
10
+ super()
11
+ @total = [total.to_i, 0].max
12
+ @complete = complete.to_s
13
+ @incomplete = incomplete.to_s
14
+ @bar_format = bar_format.to_sym
15
+ @label = label
16
+ @current = 0
17
+ end
18
+
19
+ def tick(count = 1)
20
+ update(@current + count)
21
+ self
22
+ end
23
+
24
+ def update(value)
25
+ @current = value.to_i.clamp(0, @total)
26
+ self
27
+ end
28
+
29
+ def complete!
30
+ @current = @total
31
+ self
32
+ end
33
+
34
+ def render
35
+ width = [@total, 1].max
36
+ completed = completed_width(width)
37
+ incomplete = width - completed
38
+ incomplete -= 1 if @current.zero?
39
+ bar = (@complete * completed) + (@incomplete * incomplete)
40
+ result = "[" + bar + "]"
41
+
42
+ return result unless @label
43
+
44
+ "#{result} #{@label}"
45
+ end
46
+
47
+ private
48
+
49
+ def completed_width(width)
50
+ return 0 unless @total.positive?
51
+
52
+ ((width * @current) / @total.to_f).round
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Presentation
5
+ module Components
6
+ class Spinner < Component
7
+ DEFAULT_FRAMES = ["-", "\\", "|", "/"].freeze
8
+
9
+ attr_reader :frames, :index, :label
10
+
11
+ def initialize(frames: DEFAULT_FRAMES, index: 0, label: nil)
12
+ super()
13
+ raise ArgumentError, "frames cannot be empty" if frames.empty?
14
+
15
+ @frames = frames
16
+ @index = index
17
+ @label = label
18
+ end
19
+
20
+ def tick
21
+ @index = (index + 1) % frames.length
22
+ self
23
+ end
24
+
25
+ def render
26
+ return frame unless label
27
+
28
+ "#{frame} #{label}"
29
+ end
30
+
31
+ private
32
+
33
+ def frame
34
+ frames.fetch(index % frames.length)
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty-table"
4
+
5
+ module Charming
6
+ module Presentation
7
+ module Components
8
+ class Table < Component
9
+ include KeyboardHandler
10
+
11
+ KEY_ACTIONS = {
12
+ up: :move_up,
13
+ down: :move_down,
14
+ home: :move_home,
15
+ end: :move_end
16
+ }.freeze
17
+
18
+ HEADER_HEIGHT = 2
19
+
20
+ attr_reader :header, :rows, :selected_index
21
+
22
+ def initialize(header:, rows: [], selected_index: 0, keymap: :vim)
23
+ super()
24
+ @header = Array(header).map(&:to_s)
25
+ @rows = Array(rows)
26
+ @selected_index = clamp_index(selected_index)
27
+ @keymap = keymap
28
+ end
29
+
30
+ def handle_key(event)
31
+ return nil if rows.empty?
32
+
33
+ case Charming.key_of(event)
34
+ when :enter then [:selected, selected_row]
35
+ else super
36
+ end
37
+ end
38
+
39
+ def handle_mouse(event)
40
+ return nil if rows.empty?
41
+ return nil unless event.respond_to?(:click?) && event.click?
42
+
43
+ clicked = event.y - HEADER_HEIGHT
44
+ return nil if clicked.negative? || clicked >= rows.length
45
+
46
+ @selected_index = clicked
47
+ :handled
48
+ end
49
+
50
+ def selected_row
51
+ rows[selected_index]
52
+ end
53
+
54
+ def render
55
+ return "(empty table)" if header.empty? && rows.empty?
56
+
57
+ normalized = rows.map { |row| normalize_row(row) }
58
+ lines = TTY::Table.new(header: header, rows: normalized)
59
+ .render(:unicode)
60
+ .lines(chomp: true)
61
+
62
+ compact_layout(lines)
63
+ end
64
+
65
+ private
66
+
67
+ def normalize_row(row)
68
+ cells = case row
69
+ when Hash then row.values
70
+ when String then [row]
71
+ else Array(row)
72
+ end
73
+ return cells if header.length <= 1 || cells.length <= header.length
74
+
75
+ kept = cells.first(header.length - 1)
76
+ merged = cells[(header.length - 1)..].join(" ")
77
+ kept + [merged]
78
+ end
79
+
80
+ def compact_layout(lines)
81
+ return lines.join("\n") if lines.length < 4
82
+
83
+ top, header_line, _separator, *rest = lines
84
+ body = rest.first(rows.length)
85
+ bottom = rest[rows.length]
86
+
87
+ highlighted = body.each_with_index.map do |line, index|
88
+ (index == selected_index) ? "\e[7m#{line}\e[m" : line
89
+ end
90
+
91
+ [top, header_line, *highlighted, bottom].compact.join("\n")
92
+ end
93
+
94
+ def move_up
95
+ @selected_index -= 1 if selected_index.positive?
96
+ end
97
+
98
+ def move_down
99
+ @selected_index += 1 if selected_index < rows.length - 1
100
+ end
101
+
102
+ def move_home
103
+ @selected_index = 0
104
+ end
105
+
106
+ def move_end
107
+ @selected_index = rows.length - 1
108
+ end
109
+
110
+ def clamp_index(value)
111
+ return 0 if rows.empty?
112
+
113
+ value.to_i.clamp(0, rows.length - 1)
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,219 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Presentation
5
+ module Components
6
+ class TextArea < Component
7
+ attr_reader :value, :cursor, :offset, :preferred_column
8
+
9
+ def initialize(value: "", placeholder: "", width: nil, height: nil, cursor: nil, offset: 0, preferred_column: nil)
10
+ super()
11
+ @value = value.dup
12
+ @placeholder = placeholder
13
+ @width = width
14
+ @height = height
15
+ @cursor = cursor || @value.length
16
+ @offset = offset
17
+ @preferred_column = preferred_column
18
+ clamp_position
19
+ ensure_cursor_visible
20
+ end
21
+
22
+ def handle_key(event)
23
+ key = Charming.key_of(event)
24
+ return :handled if newline_event?(event) && insert("\n")
25
+ return :handled if character_event?(event) && insert(event.char)
26
+
27
+ case key
28
+ when :left then move_left
29
+ when :right then move_right
30
+ when :up then move_up
31
+ when :down then move_down
32
+ when :home then move_home
33
+ when :end then move_end
34
+ when :backspace then delete_before_cursor
35
+ when :delete then delete_at_cursor
36
+ when :page_up then page_up
37
+ when :page_down then page_down
38
+ else return nil
39
+ end
40
+
41
+ :handled
42
+ end
43
+
44
+ def render
45
+ visible_lines.map { |line| render_line(line) }.join("\n")
46
+ end
47
+
48
+ private
49
+
50
+ attr_reader :placeholder, :width, :height
51
+
52
+ def newline_event?(event)
53
+ key = Charming.key_of(event)
54
+ return true if key == :enter && event.respond_to?(:shift) && event.shift
55
+ return true if key == :j && event.respond_to?(:ctrl) && event.ctrl
56
+
57
+ false
58
+ end
59
+
60
+ def character_event?(event)
61
+ event.respond_to?(:char) && event.char && event.char.length == 1 && printable?(event.char)
62
+ end
63
+
64
+ def printable?(char)
65
+ !char.match?(/[[:cntrl:]]/)
66
+ end
67
+
68
+ def insert(text)
69
+ @value = value[0...cursor].to_s + text + value[cursor..].to_s
70
+ @cursor += text.length
71
+ reset_preferred_column
72
+ ensure_cursor_visible
73
+ end
74
+
75
+ def move_left
76
+ @cursor -= 1 if cursor.positive?
77
+ reset_preferred_column
78
+ ensure_cursor_visible
79
+ end
80
+
81
+ def move_right
82
+ @cursor += 1 if cursor < value.length
83
+ reset_preferred_column
84
+ ensure_cursor_visible
85
+ end
86
+
87
+ def move_up
88
+ move_vertical(-1)
89
+ end
90
+
91
+ def move_down
92
+ move_vertical(+1)
93
+ end
94
+
95
+ def move_home
96
+ row, = cursor_position
97
+ @cursor = line_start(row)
98
+ reset_preferred_column
99
+ ensure_cursor_visible
100
+ end
101
+
102
+ def move_end
103
+ row, = cursor_position
104
+ @cursor = line_start(row) + line_length(row)
105
+ reset_preferred_column
106
+ ensure_cursor_visible
107
+ end
108
+
109
+ def delete_before_cursor
110
+ return if cursor.zero?
111
+
112
+ @value = value[0...(cursor - 1)].to_s + value[cursor..].to_s
113
+ @cursor -= 1
114
+ reset_preferred_column
115
+ ensure_cursor_visible
116
+ end
117
+
118
+ def delete_at_cursor
119
+ return if cursor >= value.length
120
+
121
+ @value = value[0...cursor].to_s + value[(cursor + 1)..].to_s
122
+ reset_preferred_column
123
+ ensure_cursor_visible
124
+ end
125
+
126
+ def page_up
127
+ @offset -= viewport_height
128
+ clamp_offset
129
+ end
130
+
131
+ def page_down
132
+ @offset += viewport_height
133
+ clamp_offset
134
+ end
135
+
136
+ def move_vertical(delta)
137
+ row, column = cursor_position
138
+ target_row = (row + delta).clamp(0, lines.length - 1)
139
+ @preferred_column ||= column
140
+ @cursor = line_start(target_row) + [@preferred_column, line_length(target_row)].min
141
+ ensure_cursor_visible
142
+ end
143
+
144
+ def reset_preferred_column
145
+ @preferred_column = cursor_position.last
146
+ end
147
+
148
+ def cursor_position
149
+ before = value[0...cursor].to_s
150
+ row = before.count("\n")
151
+ last_newline = before.rindex("\n")
152
+ column = last_newline ? before.length - last_newline - 1 : before.length
153
+ [row, column]
154
+ end
155
+
156
+ def line_start(row)
157
+ lines.first(row).sum(&:length) + row
158
+ end
159
+
160
+ def line_length(row)
161
+ lines.fetch(row, "").length
162
+ end
163
+
164
+ def lines
165
+ value.empty? ? [""] : value.split("\n", -1)
166
+ end
167
+
168
+ def rendered_lines
169
+ return [cursor_marker + placeholder] if value.empty?
170
+
171
+ (value[0...cursor].to_s + cursor_marker + value[cursor..].to_s).split("\n", -1)
172
+ end
173
+
174
+ def visible_lines
175
+ ensure_cursor_visible
176
+ rendered = rendered_lines.slice(offset, viewport_height) || []
177
+ return rendered unless height
178
+
179
+ rendered + Array.new([height - rendered.length, 0].max, "")
180
+ end
181
+
182
+ def render_line(line)
183
+ return line unless width
184
+
185
+ clipped = UI.visible_slice(line, 0, width)
186
+ clipped + (" " * [width - UI::Width.measure(clipped), 0].max)
187
+ end
188
+
189
+ def ensure_cursor_visible
190
+ row, = cursor_position
191
+ @offset = row if row < offset
192
+ @offset = row - viewport_height + 1 if row >= offset + viewport_height
193
+ clamp_offset
194
+ end
195
+
196
+ def clamp_position
197
+ @cursor = cursor.clamp(0, value.length)
198
+ clamp_offset
199
+ end
200
+
201
+ def clamp_offset
202
+ @offset = offset.clamp(0, max_offset)
203
+ end
204
+
205
+ def max_offset
206
+ [lines.length - viewport_height, 0].max
207
+ end
208
+
209
+ def viewport_height
210
+ height || lines.length
211
+ end
212
+
213
+ def cursor_marker
214
+ "|"
215
+ end
216
+ end
217
+ end
218
+ end
219
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Presentation
5
+ module Components
6
+ class TextInput < Component
7
+ include KeyboardHandler
8
+
9
+ # Maps editing keys (left/right/home/end/backspace/delete) to the instance
10
+ # methods they dispatch via KeyboardHandler. Each symbol key (e.g., :left)
11
+ # maps to a method (e.g., :move_left) that adjusts cursor position or text content.
12
+ KEY_ACTIONS = {
13
+ left: :move_left,
14
+ right: :move_right,
15
+ home: :move_home,
16
+ end: :move_end,
17
+ backspace: :delete_before_cursor,
18
+ delete: :delete_at_cursor
19
+ }.freeze
20
+
21
+ attr_reader :value, :cursor
22
+
23
+ def initialize(value: "", placeholder: "", width: nil, cursor: nil)
24
+ super()
25
+ @value = value.dup
26
+ @placeholder = placeholder
27
+ @width = width
28
+ @cursor = cursor || @value.length
29
+ clamp_position
30
+ end
31
+
32
+ def handle_key(event)
33
+ return :handled if character_event?(event) && insert(event.char)
34
+
35
+ super
36
+ end
37
+
38
+ def render
39
+ rendered = render_value
40
+ @width ? style.width(@width).render(rendered) : rendered
41
+ end
42
+
43
+ private
44
+
45
+ attr_reader :placeholder
46
+
47
+ def character_event?(event)
48
+ event.respond_to?(:char) && event.char && event.char.length == 1 && printable?(event.char)
49
+ end
50
+
51
+ def printable?(char)
52
+ !char.match?(/[[:cntrl:]]/)
53
+ end
54
+
55
+ def insert(char)
56
+ @value = value[0...cursor] + char + value[cursor..]
57
+ @cursor += char.length
58
+ end
59
+
60
+ def move_left
61
+ @cursor -= 1 if cursor.positive?
62
+ end
63
+
64
+ def move_right
65
+ @cursor += 1 if cursor < value.length
66
+ end
67
+
68
+ def move_home
69
+ @cursor = 0
70
+ end
71
+
72
+ def move_end
73
+ @cursor = value.length
74
+ end
75
+
76
+ def delete_before_cursor
77
+ return if cursor.zero?
78
+
79
+ @value = value[0...(cursor - 1)] + value[cursor..]
80
+ @cursor -= 1
81
+ end
82
+
83
+ def delete_at_cursor
84
+ return if cursor >= value.length
85
+
86
+ @value = value[0...cursor] + value[(cursor + 1)..]
87
+ end
88
+
89
+ def render_value
90
+ return cursor_marker + placeholder if value.empty?
91
+
92
+ value[0...cursor] + cursor_marker + value[cursor..]
93
+ end
94
+
95
+ def cursor_marker
96
+ "|"
97
+ end
98
+
99
+ def clamp_position
100
+ @cursor = cursor.clamp(0, value.length)
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end