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
@@ -1,191 +0,0 @@
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
@@ -1,13 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Charming
4
- # KeyEvent represents a terminal key press parsed by the backend. *key* is the normalized semantic
5
- # action name (e.g., `:up`, `:down`, `:q`), while *char*, *ctrl*, *alt*, and *shift* capture raw
6
- # input details for custom bindings.
7
- KeyEvent = Data.define(:key, :char, :ctrl, :alt, :shift) do
8
- # Constructs a key event with the required *key* symbol, plus optional *char* string and modifier booleans.
9
- def initialize(key:, char: nil, ctrl: false, alt: false, shift: false)
10
- super(key: key.to_sym, char: char, ctrl: ctrl, alt: alt, shift: shift)
11
- end
12
- end
13
- end
@@ -1,40 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Charming
4
- # MOUSE_BUTTON_MAP encodes terminal mouse button codes to semantic symbols. The constant is frozen and private.
5
- MOUSE_BUTTON_MAP = {
6
- 0 => :left, 1 => :middle, 2 => :right, 3 => :release,
7
- 64 => :scroll_up, 65 => :scroll_down,
8
- 66 => :scroll_up, 67 => :scroll_down
9
- }.freeze
10
- private_constant :MOUSE_BUTTON_MAP
11
-
12
- # MouseEvent represents a mouse input event. *button* encodes which button or action was triggered (left,
13
- # right, scroll), while *x* and *y* provide the cursor position. Modifier booleans (*ctrl*, *alt*, *shift*)
14
- # capture key state at the time of the event.
15
- MouseEvent = Data.define(:button, :x, :y, :ctrl, :alt, :shift) do
16
- def initialize(button:, x:, y:, ctrl: false, alt: false, shift: false)
17
- super
18
- end
19
-
20
- # Returns the semantic symbol for *button* — one of `left`, `right`, `scroll_up`, etc. or `:unknown`.
21
- def button_name
22
- MOUSE_BUTTON_MAP.fetch(button, :unknown)
23
- end
24
-
25
- # Returns `true` when the current event is a click (left, middle, or right button).
26
- def click?
27
- %i[left middle right].include?(button_name)
28
- end
29
-
30
- # Returns `true` when the button name maps to either direction of scroll.
31
- def scroll?
32
- %i[scroll_up scroll_down].include?(button_name)
33
- end
34
-
35
- # Returns `true` when the current event is a mouse release action.
36
- def release?
37
- button_name == :release
38
- end
39
- end
40
- end
@@ -1,7 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Charming
4
- # ResizeEvent represents a terminal window resize. *width* and *height* carry the new terminal dimensions
5
- # in screen cells, replacing the previous Screen dimensions for all subsequent rendering.
6
- ResizeEvent = Data.define(:width, :height)
7
- end
data/lib/charming/task.rb DELETED
@@ -1,7 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Charming
4
- Task = Data.define(:name, :block) do
5
- def call = block.call
6
- end
7
- end
@@ -1,17 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Charming
4
- # TaskEvent represents background task completion. *name* is the declared task identifier, *value* carries
5
- # the return result and *error* captures any exception raised during execution. The `error?` predicate
6
- # simplifies error handling in controller handlers.
7
- TaskEvent = Data.define(:name, :value, :error) do
8
- def initialize(name:, value: nil, error: nil)
9
- super
10
- end
11
-
12
- # Returns `true` when the task finished with a non-nil exception.
13
- def error?
14
- !error.nil?
15
- end
16
- end
17
- end
@@ -1,7 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Charming
4
- # TimerEvent represents a timed dispatch from the runtime loop. *name* is the declared timer identifier;
5
- # *now* is the monotonically rising clock value at emission for throttle comparisons.
6
- TimerEvent = Data.define(:name, :now)
7
- end
@@ -1,33 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Charming
4
- module UI
5
- class Border
6
- attr_reader :top_left, :top_right, :bottom_left, :bottom_right, :horizontal, :vertical
7
-
8
- def initialize(corners:, edges:)
9
- @top_left, @top_right, @bottom_left, @bottom_right = corners
10
- @horizontal, @vertical = edges
11
- end
12
-
13
- def self.fetch(name)
14
- STYLES.fetch(name.to_sym)
15
- end
16
- end
17
-
18
- Border::STYLES = {
19
- normal: Border.new(
20
- corners: ["+", "+", "+", "+"], edges: ["-", "|"]
21
- ),
22
- rounded: Border.new(
23
- corners: ["╭", "╮", "╰", "╯"], edges: ["─", "│"]
24
- ),
25
- thick: Border.new(
26
- corners: ["┏", "┓", "┗", "┛"], edges: ["━", "┃"]
27
- ),
28
- double: Border.new(
29
- corners: ["╔", "╗", "╚", "╝"], edges: ["═", "║"]
30
- )
31
- }.freeze
32
- end
33
- end
@@ -1,244 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Charming
4
- module UI
5
- class Style
6
- ATTRIBUTES = {
7
- bold: 1,
8
- faint: 2,
9
- italic: 3,
10
- underline: 4,
11
- reverse: 7,
12
- strikethrough: 9
13
- }.freeze
14
-
15
- COLORS = {
16
- black: 30,
17
- red: 31,
18
- green: 32,
19
- yellow: 33,
20
- blue: 34,
21
- magenta: 35,
22
- cyan: 36,
23
- white: 37,
24
- bright_black: 90,
25
- bright_red: 91,
26
- bright_green: 92,
27
- bright_yellow: 93,
28
- bright_blue: 94,
29
- bright_magenta: 95,
30
- bright_cyan: 96,
31
- bright_white: 97
32
- }.freeze
33
-
34
- def initialize(options = {})
35
- @options = {
36
- attributes: [],
37
- padding: [0, 0, 0, 0],
38
- align: :left
39
- }.merge(options)
40
- end
41
-
42
- def foreground(color)
43
- with(foreground: color)
44
- end
45
- alias_method :fg, :foreground
46
-
47
- def background(color)
48
- with(background: color)
49
- end
50
- alias_method :bg, :background
51
-
52
- ATTRIBUTES.each_key do |attribute|
53
- define_method(attribute) do
54
- with(attributes: (@options.fetch(:attributes) + [attribute]).uniq)
55
- end
56
- end
57
-
58
- def padding(*values)
59
- with(padding: expand_box_values(values))
60
- end
61
-
62
- def border(style = :normal, sides: nil, foreground: nil)
63
- with(border: style, border_sides: sides, border_foreground: foreground)
64
- end
65
-
66
- def width(value)
67
- with(width: value)
68
- end
69
-
70
- def height(value)
71
- with(height: value)
72
- end
73
-
74
- def align(value)
75
- with(align: value)
76
- end
77
-
78
- def render(value)
79
- lines = apply_dimensions(value.to_s.lines(chomp: true))
80
- lines = apply_padding(lines)
81
- lines = apply_border(lines)
82
- apply_ansi(lines.join("\n"))
83
- end
84
-
85
- private
86
-
87
- def with(changes)
88
- self.class.new(@options.merge(changes))
89
- end
90
-
91
- def apply_dimensions(lines)
92
- content_width = target_content_width(lines)
93
- dimensioned = lines.map { |line| align_line(fit_line(line, content_width), content_width) }
94
- apply_height(dimensioned, content_width)
95
- end
96
-
97
- def target_content_width(lines)
98
- explicit_width = @options[:width]
99
- natural_width = lines.map { |line| Width.measure(line) }.max || 0
100
- explicit_width || natural_width
101
- end
102
-
103
- def fit_line(line, width)
104
- return line if Width.measure(line) <= width
105
-
106
- UI.visible_slice(line, 0, width)
107
- end
108
-
109
- def apply_height(lines, width)
110
- height = @options[:height]
111
- return lines unless height
112
-
113
- visible = lines.first(height)
114
- visible + Array.new([height - visible.length, 0].max) { " " * width }
115
- end
116
-
117
- def apply_padding(lines)
118
- top, right, bottom, left = @options.fetch(:padding)
119
- inner_width = lines.map { |line| Width.measure(line) }.max || 0
120
- empty = " " * (left + inner_width + right)
121
- padded = lines.map do |line|
122
- pad_line(line, inner_width, left, right)
123
- end
124
-
125
- Array.new(top, empty) + padded + Array.new(bottom, empty)
126
- end
127
-
128
- def apply_border(lines)
129
- border_name = @options[:border]
130
- return lines unless border_name
131
-
132
- border = Border.fetch(border_name)
133
- sides = Array(@options[:border_sides] || %i[top right bottom left]).map(&:to_sym)
134
- width = lines.map { |line| Width.measure(line) }.max || 0
135
- horizontal = border.horizontal * width
136
- body = lines.map { |line| border_line(line, width, border, sides) }
137
-
138
- [top_border(border, horizontal, sides), *body, bottom_border(border, horizontal, sides)].compact
139
- end
140
-
141
- def pad_line(line, inner_width, left, right)
142
- (" " * left) + line + (" " * (inner_width - Width.measure(line) + right))
143
- end
144
-
145
- def border_line(line, width, border, sides)
146
- left = sides.include?(:left) ? render_border(border.vertical) : ""
147
- right = sides.include?(:right) ? render_border(border.vertical) : ""
148
-
149
- "#{left}#{line}#{" " * (width - Width.measure(line))}#{right}"
150
- end
151
-
152
- def top_border(border, horizontal, sides)
153
- return unless sides.include?(:top)
154
- return render_border(horizontal) unless full_horizontal_border?(sides)
155
-
156
- render_border("#{border.top_left}#{horizontal}#{border.top_right}")
157
- end
158
-
159
- def bottom_border(border, horizontal, sides)
160
- return unless sides.include?(:bottom)
161
- return render_border(horizontal) unless full_horizontal_border?(sides)
162
-
163
- render_border("#{border.bottom_left}#{horizontal}#{border.bottom_right}")
164
- end
165
-
166
- def full_horizontal_border?(sides)
167
- sides.include?(:left) && sides.include?(:right)
168
- end
169
-
170
- def render_border(value)
171
- border_foreground = @options[:border_foreground]
172
- return value unless border_foreground
173
-
174
- Style.new(foreground: border_foreground, background: @options[:background]).render(value)
175
- end
176
-
177
- def apply_ansi(value)
178
- codes = ansi_codes
179
- return value if codes.empty?
180
-
181
- start = "\e[#{codes.join(";")}m"
182
- value.split("\n", -1).map { |line| "#{start}#{line.gsub("\e[0m", "\e[0m#{start}")}\e[0m" }.join("\n")
183
- end
184
-
185
- def ansi_codes
186
- @options.fetch(:attributes).map { |attribute| ATTRIBUTES.fetch(attribute) } +
187
- color_codes(@options[:foreground], foreground: true) +
188
- color_codes(@options[:background], foreground: false)
189
- end
190
-
191
- def color_codes(color, foreground:)
192
- return [] unless color
193
- return indexed_color_code(color, foreground: foreground) if color.is_a?(Integer)
194
- return named_color_code(color, foreground: foreground) if COLORS.key?(color.to_sym)
195
- return truecolor_codes(color, foreground: foreground) if color.to_s.start_with?("#")
196
-
197
- raise ArgumentError, "unknown color: #{color.inspect}"
198
- end
199
-
200
- def named_color_code(color, foreground:)
201
- code = COLORS.fetch(color.to_sym)
202
- [foreground ? code : code + 10]
203
- end
204
-
205
- def indexed_color_code(color, foreground:)
206
- raise ArgumentError, "indexed color must be between 0 and 255" unless color.between?(0, 255)
207
-
208
- [foreground ? 38 : 48, 5, color]
209
- end
210
-
211
- def truecolor_codes(color, foreground:)
212
- hex = color.to_s.delete_prefix("#")
213
- raise ArgumentError, "truecolor must be #rrggbb" unless hex.match?(/\A[0-9a-fA-F]{6}\z/)
214
-
215
- [foreground ? 38 : 48, 2, hex[0..1].to_i(16), hex[2..3].to_i(16), hex[4..5].to_i(16)]
216
- end
217
-
218
- def align_line(line, width)
219
- remaining = width - Width.measure(line)
220
- return line if remaining <= 0
221
-
222
- case @options.fetch(:align)
223
- when :right
224
- (" " * remaining) + line
225
- when :center
226
- left = remaining / 2
227
- (" " * left) + line + (" " * (remaining - left))
228
- else
229
- line + (" " * remaining)
230
- end
231
- end
232
-
233
- def expand_box_values(values)
234
- case values.length
235
- when 1 then [values[0], values[0], values[0], values[0]]
236
- when 2 then [values[0], values[1], values[0], values[1]]
237
- when 4 then values
238
- else
239
- raise ArgumentError, "padding expects 1, 2, or 4 values"
240
- end
241
- end
242
- end
243
- end
244
- end