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.
- checksums.yaml +4 -4
- data/README.md +38 -378
- data/lib/charming/application.rb +3 -3
- data/lib/charming/{application_model.rb → application_state.rb} +3 -3
- data/lib/charming/cli.rb +39 -3
- data/lib/charming/controller.rb +146 -24
- data/lib/charming/database_commands.rb +87 -0
- data/lib/charming/database_installer.rb +125 -0
- data/lib/charming/events/key_event.rb +15 -0
- data/lib/charming/events/mouse_event.rb +42 -0
- data/lib/charming/events/resize_event.rb +9 -0
- data/lib/charming/events/task_event.rb +19 -0
- data/lib/charming/events/timer_event.rb +9 -0
- data/lib/charming/generators/app_generator/app_spec_templates.rb +12 -8
- data/lib/charming/generators/app_generator/basic_templates.rb +14 -2
- data/lib/charming/generators/app_generator/component_templates.rb +1 -1
- data/lib/charming/generators/app_generator/controller_template.rb +3 -12
- data/lib/charming/generators/app_generator/database_templates.rb +45 -0
- data/lib/charming/generators/app_generator/layout_template.rb +51 -145
- data/lib/charming/generators/app_generator/screen_spec_templates.rb +7 -8
- data/lib/charming/generators/app_generator/{model_templates.rb → state_templates.rb} +5 -5
- data/lib/charming/generators/app_generator/view_template.rb +12 -18
- data/lib/charming/generators/app_generator.rb +37 -11
- data/lib/charming/generators/component_generator.rb +1 -1
- data/lib/charming/generators/controller_generator.rb +1 -4
- data/lib/charming/generators/model_generator.rb +119 -0
- data/lib/charming/generators/name.rb +0 -4
- data/lib/charming/generators/screen_generator.rb +14 -28
- data/lib/charming/generators/view_generator.rb +11 -14
- data/lib/charming/internal/renderer/differential.rb +2 -3
- data/lib/charming/internal/terminal/tty_backend.rb +25 -8
- data/lib/charming/presentation/component.rb +10 -0
- data/lib/charming/presentation/components/activity_indicator.rb +160 -0
- data/lib/charming/presentation/components/command_palette.rb +120 -0
- data/lib/charming/presentation/components/empty_state.rb +43 -0
- data/lib/charming/presentation/components/form/builder.rb +48 -0
- data/lib/charming/presentation/components/form/confirm.rb +56 -0
- data/lib/charming/presentation/components/form/field.rb +96 -0
- data/lib/charming/presentation/components/form/input.rb +57 -0
- data/lib/charming/presentation/components/form/note.rb +32 -0
- data/lib/charming/presentation/components/form/select.rb +89 -0
- data/lib/charming/presentation/components/form/textarea.rb +70 -0
- data/lib/charming/presentation/components/form.rb +127 -0
- data/lib/charming/presentation/components/keyboard_handler.rb +58 -0
- data/lib/charming/presentation/components/list.rb +104 -0
- data/lib/charming/presentation/components/markdown.rb +25 -0
- data/lib/charming/presentation/components/modal.rb +50 -0
- data/lib/charming/presentation/components/progressbar.rb +57 -0
- data/lib/charming/presentation/components/spinner.rb +39 -0
- data/lib/charming/presentation/components/table.rb +118 -0
- data/lib/charming/presentation/components/text_area.rb +219 -0
- data/lib/charming/presentation/components/text_input.rb +105 -0
- data/lib/charming/presentation/components/viewport.rb +220 -0
- data/lib/charming/presentation/layout.rb +43 -0
- data/lib/charming/presentation/markdown/renderer.rb +203 -0
- data/lib/charming/presentation/markdown/syntax_highlighter.rb +63 -0
- data/lib/charming/presentation/markdown.rb +8 -0
- data/lib/charming/presentation/template_view.rb +27 -0
- data/lib/charming/presentation/templates/erb_handler.rb +15 -0
- data/lib/charming/presentation/templates.rb +51 -0
- data/lib/charming/presentation/ui/border.rb +35 -0
- data/lib/charming/presentation/ui/style.rb +246 -0
- data/lib/charming/presentation/ui/theme.rb +180 -0
- data/lib/charming/{ui → presentation/ui}/themes/phosphor.json +2 -2
- data/lib/charming/presentation/ui/width.rb +26 -0
- data/lib/charming/presentation/ui.rb +232 -0
- data/lib/charming/presentation/view.rb +118 -0
- data/lib/charming/runtime.rb +7 -7
- data/lib/charming/screen.rb +5 -1
- data/lib/charming/tasks/inline_executor.rb +28 -0
- data/lib/charming/tasks/task.rb +9 -0
- data/lib/charming/{task_executor.rb → tasks/threaded_executor.rb} +4 -27
- data/lib/charming/version.rb +1 -1
- data/lib/charming.rb +4 -0
- metadata +114 -29
- data/lib/charming/component.rb +0 -8
- data/lib/charming/components/activity_indicator.rb +0 -158
- data/lib/charming/components/command_palette.rb +0 -118
- data/lib/charming/components/keyboard_handler.rb +0 -22
- data/lib/charming/components/list.rb +0 -105
- data/lib/charming/components/modal.rb +0 -48
- data/lib/charming/components/progressbar.rb +0 -55
- data/lib/charming/components/spinner.rb +0 -37
- data/lib/charming/components/table.rb +0 -115
- data/lib/charming/components/text_input.rb +0 -103
- data/lib/charming/components/viewport.rb +0 -191
- data/lib/charming/key_event.rb +0 -13
- data/lib/charming/mouse_event.rb +0 -40
- data/lib/charming/resize_event.rb +0 -7
- data/lib/charming/task.rb +0 -7
- data/lib/charming/task_event.rb +0 -17
- data/lib/charming/timer_event.rb +0 -7
- data/lib/charming/ui/border.rb +0 -33
- data/lib/charming/ui/style.rb +0 -244
- data/lib/charming/ui/theme.rb +0 -178
- data/lib/charming/ui/width.rb +0 -24
- data/lib/charming/ui.rb +0 -230
- data/lib/charming/view.rb +0 -116
- /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,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
|