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,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "unicode/display_width"
4
+
5
+ module Charming
6
+ module Presentation
7
+ module Components
8
+ class Viewport < Component
9
+ include KeyboardHandler
10
+
11
+ ANSI_PATTERN = /\e\[[0-9;]*m/
12
+ KEY_ACTIONS = {
13
+ up: :scroll_up,
14
+ down: :scroll_down,
15
+ page_up: :page_up,
16
+ page_down: :page_down,
17
+ home: :scroll_home,
18
+ end: :scroll_end,
19
+ left: :scroll_left,
20
+ right: :scroll_right
21
+ }.freeze
22
+
23
+ attr_reader :offset, :column
24
+
25
+ def initialize(content:, width: nil, height: nil, offset: 0, column: 0, wrap: false, keymap: :vim)
26
+ super()
27
+ @content = content
28
+ @width = width
29
+ @height = height
30
+ @offset = offset
31
+ @column = column
32
+ @wrap = wrap
33
+ @keymap = keymap
34
+ clamp_position
35
+ end
36
+
37
+ def render
38
+ visible_lines.map { |line| render_line(line) }.join("\n")
39
+ end
40
+
41
+ def handle_mouse(event)
42
+ return nil unless height
43
+
44
+ if event.scroll?
45
+ scroll_delta = (event.button_name == :scroll_up) ? -1 : 1
46
+ @offset += scroll_delta
47
+ clamp_position
48
+ return :handled
49
+ end
50
+
51
+ return nil unless event.click?
52
+
53
+ clicked_row = event.y
54
+ return nil if clicked_row < offset || clicked_row >= offset + viewport_height
55
+
56
+ @offset = clicked_row
57
+ clamp_position
58
+ :handled
59
+ end
60
+
61
+ private
62
+
63
+ attr_reader :content, :width, :height
64
+
65
+ def scroll_up
66
+ @offset -= 1
67
+ clamp_position
68
+ end
69
+
70
+ def scroll_down
71
+ @offset += 1
72
+ clamp_position
73
+ end
74
+
75
+ def page_up
76
+ @offset -= page_size
77
+ clamp_position
78
+ end
79
+
80
+ def page_down
81
+ @offset += page_size
82
+ clamp_position
83
+ end
84
+
85
+ def scroll_home
86
+ @offset = 0
87
+ @column = 0
88
+ end
89
+
90
+ def scroll_end
91
+ @offset = max_offset
92
+ @column = max_column
93
+ end
94
+
95
+ def scroll_left
96
+ @column -= 1
97
+ clamp_position
98
+ end
99
+
100
+ def scroll_right
101
+ @column += 1
102
+ clamp_position
103
+ end
104
+
105
+ def clamp_position
106
+ @offset = offset.clamp(0, max_offset)
107
+ @column = column.clamp(0, max_column)
108
+ end
109
+
110
+ def visible_lines
111
+ lines = content_lines.slice(offset, viewport_height) || []
112
+ return lines unless height
113
+
114
+ lines + Array.new([height - lines.length, 0].max, "")
115
+ end
116
+
117
+ def render_line(line)
118
+ return line unless width
119
+ return pad_line(line, width) if wrap?
120
+
121
+ pad_line(clip_line(line), width)
122
+ end
123
+
124
+ def clip_line(line)
125
+ clipped = clip_tokens(line.to_s)
126
+ needs_reset?(clipped) ? "#{clipped}\e[0m" : clipped
127
+ end
128
+
129
+ def clip_tokens(line)
130
+ state = {cursor: 0, output: +""}
131
+ line.scan(/#{ANSI_PATTERN}|./mo) do |token|
132
+ ansi?(token) ? append_ansi(state, token) : append_character(state, token)
133
+ end
134
+ state.fetch(:output)
135
+ end
136
+
137
+ def append_ansi(state, token)
138
+ state.fetch(:output) << token
139
+ end
140
+
141
+ def append_character(state, char)
142
+ char_width = Unicode::DisplayWidth.of(char)
143
+ cursor = state.fetch(:cursor)
144
+ state.fetch(:output) << char if visible?(cursor, char_width)
145
+ state[:cursor] = cursor + char_width
146
+ end
147
+
148
+ def visible?(cursor, char_width)
149
+ cursor >= column && cursor + char_width <= column + width
150
+ end
151
+
152
+ def needs_reset?(value)
153
+ value.match?(ANSI_PATTERN) && !value.end_with?("\e[0m")
154
+ end
155
+
156
+ def pad_line(line, target_width)
157
+ line + (" " * [target_width - UI::Width.measure(line), 0].max)
158
+ end
159
+
160
+ def content_lines
161
+ return wrapped_content_lines if wrap?
162
+
163
+ rendered_content.lines(chomp: true)
164
+ end
165
+
166
+ def wrapped_content_lines
167
+ rendered_content.lines(chomp: true).flat_map { |line| wrap_line(line) }
168
+ end
169
+
170
+ def wrap_line(line)
171
+ line_width = UI::Width.measure(line)
172
+ return [""] if line_width.zero?
173
+
174
+ start_column = 0
175
+ out = []
176
+ while start_column < line_width
177
+ out << UI.visible_slice(line, start_column, width)
178
+ start_column += width
179
+ end
180
+ out
181
+ end
182
+
183
+ def rendered_content
184
+ content.respond_to?(:render) ? content.render.to_s : content.to_s
185
+ end
186
+
187
+ def viewport_height
188
+ height || content_lines.length
189
+ end
190
+
191
+ def page_size
192
+ [viewport_height, 1].max
193
+ end
194
+
195
+ def max_offset
196
+ [content_lines.length - viewport_height, 0].max
197
+ end
198
+
199
+ def max_column
200
+ return 0 if wrap?
201
+ return 0 unless width
202
+
203
+ [content_width - width, 0].max
204
+ end
205
+
206
+ def content_width
207
+ content_lines.map { |line| UI::Width.measure(line) }.max || 0
208
+ end
209
+
210
+ def ansi?(token)
211
+ token.match?(ANSI_PATTERN)
212
+ end
213
+
214
+ def wrap?
215
+ @wrap && width&.positive?
216
+ end
217
+ end
218
+ end
219
+ end
220
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Presentation
5
+ # Layout contains generic screen-size math and composition helpers. It is
6
+ # intentionally unaware of application shells such as sidebars or nav panes.
7
+ module Layout
8
+ module_function
9
+
10
+ def clamp_size(value, min: nil, max: nil)
11
+ size = value.to_i
12
+ size = [size, min].max if min
13
+ size = [size, max].min if max
14
+ size
15
+ end
16
+
17
+ def available_width(screen, reserved: 0, min: nil, max: nil)
18
+ clamp_size(screen.width - reserved, min: min, max: max)
19
+ end
20
+
21
+ def available_height(screen, reserved: 0, min: nil, max: nil)
22
+ clamp_size(screen.height - reserved, min: min, max: max)
23
+ end
24
+
25
+ def stack_or_row(*blocks, narrow:, gap: 0)
26
+ if narrow
27
+ UI.join_vertical(*blocks, gap: gap)
28
+ else
29
+ UI.join_horizontal(*blocks, gap: gap)
30
+ end
31
+ end
32
+
33
+ def selected_window_start(selected_index:, item_count:, window_size:)
34
+ count = item_count.to_i
35
+ size = [window_size.to_i, 1].max
36
+ selected = selected_index.to_i.clamp(0, [count - 1, 0].max)
37
+ max_start = [count - size, 0].max
38
+
39
+ (selected - size + 1).clamp(0, max_start)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "kramdown"
4
+
5
+ module Charming
6
+ module Presentation
7
+ module Markdown
8
+ class Renderer
9
+ DEFAULT_RULE_WIDTH = 40
10
+
11
+ def initialize(content:, width: nil, theme: UI::Theme.default, syntax_highlighting: true)
12
+ @content = content
13
+ @width = width
14
+ @theme = theme || UI::Theme.default
15
+ @syntax_highlighting = syntax_highlighting
16
+ end
17
+
18
+ def render
19
+ document = Kramdown::Document.new(content.to_s)
20
+ render_blocks(document.root.children)
21
+ end
22
+
23
+ private
24
+
25
+ attr_reader :content, :width, :theme
26
+
27
+ def render_blocks(elements, list_depth: 0, width: @width)
28
+ elements.filter_map do |element|
29
+ rendered = render_block(element, list_depth: list_depth, width: width)
30
+ rendered unless rendered.to_s.empty?
31
+ end.join("\n\n")
32
+ end
33
+
34
+ def render_block(element, list_depth: 0, width: @width)
35
+ case element.type
36
+ when :p
37
+ wrap(render_inlines(element.children), width: width)
38
+ when :header
39
+ render_header(element, width: width)
40
+ when :blockquote
41
+ render_blockquote(element, list_depth: list_depth, width: width)
42
+ when :ul
43
+ render_list(element, ordered: false, list_depth: list_depth, width: width)
44
+ when :ol
45
+ render_list(element, ordered: true, list_depth: list_depth, width: width)
46
+ when :li
47
+ render_blocks(element.children, list_depth: list_depth, width: width)
48
+ when :codeblock
49
+ render_codeblock(element)
50
+ when :hr
51
+ render_rule(width: width)
52
+ when :blank
53
+ nil
54
+ else
55
+ render_unknown(element, list_depth: list_depth, width: width)
56
+ end
57
+ end
58
+
59
+ def render_unknown(element, list_depth:, width:)
60
+ return wrap(element.value.to_s, width: width) if element.children.empty?
61
+
62
+ render_blocks(element.children, list_depth: list_depth, width: width)
63
+ end
64
+
65
+ def render_header(element, width:)
66
+ rendered = wrap(render_inlines(element.children), width: width)
67
+ style = if element.options[:level].to_i == 1
68
+ style_for(:markdown_heading, fallback: theme_style(:title))
69
+ else
70
+ style_for(:markdown_subheading, fallback: theme_style(:title))
71
+ end
72
+ style.render(rendered)
73
+ end
74
+
75
+ def render_blockquote(element, list_depth:, width:)
76
+ quote_width = width ? [width - 2, 1].max : nil
77
+ rendered = render_blocks(element.children, list_depth: list_depth, width: quote_width)
78
+ border = style_for(:markdown_quote_border, fallback: theme_style(:border)).render("|")
79
+ quote_style = style_for(:markdown_quote, fallback: theme_style(:muted))
80
+
81
+ rendered.lines(chomp: true).map do |line|
82
+ "#{border} #{quote_style.render(line)}"
83
+ end.join("\n")
84
+ end
85
+
86
+ def render_list(element, ordered:, list_depth:, width:)
87
+ element.children.each_with_index.map do |item, index|
88
+ marker = ordered ? "#{ordered_start(element) + index}." : "-"
89
+ render_list_item(item, marker: marker, list_depth: list_depth, width: width)
90
+ end.join("\n")
91
+ end
92
+
93
+ def render_list_item(element, marker:, list_depth:, width:)
94
+ indent = " " * list_depth
95
+ first_prefix = "#{indent}#{marker} "
96
+ rest_prefix = "#{indent}#{" " * (marker.length + 1)}"
97
+ item_width = width ? [width - UI::Width.measure(first_prefix), 1].max : nil
98
+ body = render_blocks(element.children, list_depth: list_depth + 1, width: item_width)
99
+
100
+ body.lines(chomp: true).each_with_index.map do |line, index|
101
+ "#{index.zero? ? first_prefix : rest_prefix}#{line}"
102
+ end.join("\n")
103
+ end
104
+
105
+ def ordered_start(element)
106
+ element.options.fetch(:start, 1).to_i
107
+ end
108
+
109
+ def render_codeblock(element)
110
+ code = element.value.to_s
111
+ rendered = if @syntax_highlighting
112
+ SyntaxHighlighter.new(theme: theme).render(code, language: code_language(element))
113
+ else
114
+ style_for(:markdown_code, fallback: theme_style(:warn)).render(code)
115
+ end
116
+
117
+ rendered.lines(chomp: true).map { |line| " #{line}" }.join("\n")
118
+ end
119
+
120
+ def render_rule(width:)
121
+ style_for(:markdown_rule, fallback: theme_style(:border)).render("-" * (width || DEFAULT_RULE_WIDTH))
122
+ end
123
+
124
+ def render_inlines(elements)
125
+ elements.map { |element| render_inline(element) }.join
126
+ end
127
+
128
+ def render_inline(element)
129
+ case element.type
130
+ when :text
131
+ element.value.to_s
132
+ when :strong
133
+ style_for(:markdown_strong, fallback: theme_style(:text).bold).render(render_inlines(element.children))
134
+ when :em
135
+ style_for(:markdown_emphasis, fallback: theme_style(:text).italic).render(render_inlines(element.children))
136
+ when :codespan
137
+ style_for(:markdown_inline_code, fallback: theme_style(:warn)).render(element.value.to_s)
138
+ when :a
139
+ render_link(element)
140
+ when :br
141
+ "\n"
142
+ when :entity
143
+ element.value.respond_to?(:char) ? element.value.char : element.value.to_s
144
+ else
145
+ element.children.empty? ? element.value.to_s : render_inlines(element.children)
146
+ end
147
+ end
148
+
149
+ def render_link(element)
150
+ label = render_inlines(element.children)
151
+ href = element.attr["href"].to_s
152
+ rendered = href.empty? ? label : "#{label} <#{href}>"
153
+ style_for(:markdown_link, fallback: theme_style(:info).underline).render(rendered)
154
+ end
155
+
156
+ def code_language(element)
157
+ return element.options[:lang] if element.options[:lang]
158
+
159
+ element.attr["class"].to_s[/language-([^\s]+)/, 1]
160
+ end
161
+
162
+ def wrap(value, width:)
163
+ return value unless width
164
+
165
+ value.to_s.lines(chomp: true).map { |line| wrap_line(line, width) }.join("\n")
166
+ end
167
+
168
+ def wrap_line(line, width)
169
+ return line if UI::Width.measure(line) <= width
170
+
171
+ lines = []
172
+ current = +""
173
+
174
+ line.split(/\s+/).each do |word|
175
+ candidate = current.empty? ? word : "#{current} #{word}"
176
+
177
+ if !current.empty? && UI::Width.measure(candidate) > width
178
+ lines << current.rstrip
179
+ current = word
180
+ else
181
+ current = candidate
182
+ end
183
+ end
184
+
185
+ lines << current.rstrip unless current.empty?
186
+ lines.join("\n")
187
+ end
188
+
189
+ def style_for(name, fallback:)
190
+ return theme.public_send(name) if theme.respond_to?(name)
191
+
192
+ fallback
193
+ end
194
+
195
+ def theme_style(name)
196
+ return theme.public_send(name) if theme.respond_to?(name)
197
+
198
+ UI::Theme::DEFAULT_TOKENS.fetch(name).then { |token| UI::Theme.new(name => token).public_send(name) }
199
+ end
200
+ end
201
+ end
202
+ end
203
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rouge"
4
+
5
+ module Charming
6
+ module Presentation
7
+ module Markdown
8
+ class SyntaxHighlighter
9
+ def initialize(theme: UI::Theme.default)
10
+ @theme = theme || UI::Theme.default
11
+ end
12
+
13
+ def render(code, language: nil)
14
+ lexer = lexer_for(language, code)
15
+ lexer.lex(code.to_s).map do |token, value|
16
+ style_for(token).render(value)
17
+ end.join
18
+ end
19
+
20
+ private
21
+
22
+ attr_reader :theme
23
+
24
+ def lexer_for(language, code)
25
+ Rouge::Lexer.find_fancy(language, code) || Rouge::Lexers::PlainText
26
+ end
27
+
28
+ def style_for(token)
29
+ name = token_name(token)
30
+
31
+ case name
32
+ when /Comment/
33
+ theme_style(:markdown_code_comment, fallback: theme_style(:muted).italic)
34
+ when /Keyword/
35
+ theme_style(:markdown_code_keyword, fallback: theme_style(:title))
36
+ when /String/
37
+ theme_style(:markdown_code_string, fallback: theme_style(:warn))
38
+ when /Number|Literal/
39
+ theme_style(:markdown_code_literal, fallback: theme_style(:info))
40
+ when /Name\.(Class|Constant|Function|Namespace)/
41
+ theme_style(:markdown_code_constant, fallback: theme_style(:info))
42
+ when /Error/
43
+ theme_style(:markdown_code_error, fallback: theme_style(:warn).bold)
44
+ else
45
+ theme_style(:markdown_code, fallback: theme_style(:text))
46
+ end
47
+ end
48
+
49
+ def token_name(token)
50
+ return token.qualname if token.respond_to?(:qualname)
51
+
52
+ token.to_s
53
+ end
54
+
55
+ def theme_style(name, fallback: nil)
56
+ return theme.public_send(name) if theme.respond_to?(name)
57
+
58
+ fallback || UI.style
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Presentation
5
+ module Markdown
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Presentation
5
+ class TemplateView < View
6
+ def initialize(template:, namespace: nil, **assigns)
7
+ super(**assigns)
8
+ @template = template
9
+ @namespace = namespace
10
+ end
11
+
12
+ def render
13
+ template.render(self).to_s
14
+ end
15
+
16
+ def template_binding
17
+ return binding unless namespace
18
+
19
+ namespace.module_eval("->(view) { view.instance_eval { binding } }", __FILE__, __LINE__).call(self)
20
+ end
21
+
22
+ private
23
+
24
+ attr_reader :template, :namespace
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+
5
+ module Charming
6
+ module Presentation
7
+ module Templates
8
+ class ErbHandler
9
+ def self.render(path, view)
10
+ ERB.new(File.read(path), trim_mode: "-").result(view.template_binding)
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Presentation
5
+ module Templates
6
+ ResolvedTemplate = Data.define(:path, :handler) do
7
+ def render(view)
8
+ handler.render(path, view)
9
+ end
10
+ end
11
+
12
+ MissingTemplateError = Class.new(Error)
13
+
14
+ class << self
15
+ def register(extension, handler)
16
+ handlers[extension] = handler
17
+ end
18
+
19
+ def resolve(name, root: nil)
20
+ views_root = File.join(root || Dir.pwd, "app", "views")
21
+ searched_paths = candidate_paths(views_root, name.to_s)
22
+
23
+ searched_paths.each do |path|
24
+ next unless File.file?(path)
25
+
26
+ return ResolvedTemplate.new(path: path, handler: handler_for(path))
27
+ end
28
+
29
+ raise MissingTemplateError, "Missing template #{name.inspect}. Searched: #{searched_paths.join(", ")}"
30
+ end
31
+
32
+ def handlers
33
+ @handlers ||= {}
34
+ end
35
+
36
+ private
37
+
38
+ def candidate_paths(views_root, name)
39
+ path = File.expand_path(name, views_root)
40
+ return [path] if handler_for(path)
41
+
42
+ handlers.keys.map { |extension| "#{path}#{extension}" }
43
+ end
44
+
45
+ def handler_for(path)
46
+ handlers.find { |extension, _handler| path.end_with?(extension) }&.last
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Presentation
5
+ module UI
6
+ class Border
7
+ attr_reader :top_left, :top_right, :bottom_left, :bottom_right, :horizontal, :vertical
8
+
9
+ def initialize(corners:, edges:)
10
+ @top_left, @top_right, @bottom_left, @bottom_right = corners
11
+ @horizontal, @vertical = edges
12
+ end
13
+
14
+ def self.fetch(name)
15
+ STYLES.fetch(name.to_sym)
16
+ end
17
+ end
18
+
19
+ Border::STYLES = {
20
+ normal: Border.new(
21
+ corners: ["+", "+", "+", "+"], edges: ["-", "|"]
22
+ ),
23
+ rounded: Border.new(
24
+ corners: ["╭", "╮", "╰", "╯"], edges: ["─", "│"]
25
+ ),
26
+ thick: Border.new(
27
+ corners: ["┏", "┓", "┗", "┛"], edges: ["━", "┃"]
28
+ ),
29
+ double: Border.new(
30
+ corners: ["╔", "╗", "╚", "╝"], edges: ["═", "║"]
31
+ )
32
+ }.freeze
33
+ end
34
+ end
35
+ end