charming 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +1 -14
- data/lib/charming/application.rb +19 -2
- data/lib/charming/cli.rb +3 -3
- data/lib/charming/controller/component_dispatching.rb +47 -3
- data/lib/charming/controller/focus.rb +123 -0
- data/lib/charming/controller/focus_management.rb +1 -1
- data/lib/charming/controller/rendering.rb +4 -15
- data/lib/charming/controller/session_state.rb +11 -0
- data/lib/charming/controller.rb +11 -2
- data/lib/charming/database/commands.rb +106 -0
- data/lib/charming/generators/database_installer.rb +154 -0
- data/lib/charming/generators/model_generator.rb +2 -10
- data/lib/charming/generators/name.rb +1 -1
- data/lib/charming/generators/view_generator.rb +1 -1
- data/lib/charming/presentation/components/form/field.rb +1 -1
- data/lib/charming/presentation/components/markdown.rb +7 -7
- data/lib/charming/presentation/layout/builder.rb +8 -2
- data/lib/charming/presentation/layout/pane.rb +40 -108
- data/lib/charming/presentation/layout/pane_behavior.rb +39 -0
- data/lib/charming/presentation/layout/pane_geometry.rb +71 -0
- data/lib/charming/presentation/layout/pane_style.rb +39 -0
- data/lib/charming/presentation/layout/rect.rb +5 -0
- data/lib/charming/presentation/layout/screen_layout.rb +7 -0
- data/lib/charming/presentation/layout/split.rb +7 -0
- data/lib/charming/presentation/markdown/render_context.rb +28 -10
- data/lib/charming/presentation/markdown/renderer.rb +264 -39
- data/lib/charming/presentation/markdown/style_config.rb +215 -0
- data/lib/charming/presentation/markdown/syntax_highlighter.rb +3 -2
- data/lib/charming/presentation/markdown.rb +2 -2
- data/lib/charming/presentation/view.rb +7 -0
- data/lib/charming/router.rb +3 -8
- data/lib/charming/runtime.rb +2 -0
- data/lib/charming/version.rb +1 -1
- data/lib/charming.rb +2 -2
- metadata +45 -9
- data/lib/charming/database_commands.rb +0 -103
- data/lib/charming/database_installer.rb +0 -152
- data/lib/charming/focus.rb +0 -121
- data/lib/charming/presentation/markdown/block_renderers.rb +0 -118
- data/lib/charming/presentation/markdown/inline_renderers.rb +0 -66
|
@@ -1,72 +1,58 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
3
|
+
require "commonmarker"
|
|
4
|
+
require "uri"
|
|
4
5
|
|
|
5
6
|
module Charming
|
|
6
7
|
module Markdown
|
|
7
|
-
# Renderer
|
|
8
|
-
# Kramdown, then walks the document's block and inline trees to produce styled
|
|
9
|
-
# terminal output. Code blocks are highlighted via Rouge when `syntax_highlighting`
|
|
10
|
-
# is enabled.
|
|
8
|
+
# Renderer parses CommonMark/GFM with Commonmarker and renders it as ANSI text.
|
|
11
9
|
class Renderer
|
|
12
|
-
# Wrap width used by `render_rule` when no width is otherwise specified.
|
|
13
10
|
DEFAULT_RULE_WIDTH = 40
|
|
14
11
|
|
|
15
|
-
|
|
16
|
-
attr_reader :content, :width, :theme, :syntax_highlighting
|
|
12
|
+
attr_reader :content, :width, :theme, :syntax_highlighting, :style, :base_url
|
|
17
13
|
|
|
18
|
-
|
|
19
|
-
# many display columns. *theme* is the Charming theme used to style blocks/inlines.
|
|
20
|
-
# *syntax_highlighting* enables Rouge-backed code block highlighting (default true).
|
|
21
|
-
def initialize(content:, width: nil, theme: UI::Theme.default, syntax_highlighting: true)
|
|
14
|
+
def initialize(content:, width: nil, theme: UI::Theme.default, syntax_highlighting: true, style: :dark, base_url: nil)
|
|
22
15
|
@content = content
|
|
23
16
|
@width = width
|
|
24
17
|
@theme = theme || UI::Theme.default
|
|
25
18
|
@syntax_highlighting = syntax_highlighting
|
|
19
|
+
@style = StyleConfig.from(style)
|
|
20
|
+
@base_url = base_url
|
|
26
21
|
end
|
|
27
22
|
|
|
28
|
-
# Parses the content and returns the fully-rendered Markdown as a single string.
|
|
29
23
|
def render
|
|
30
|
-
|
|
31
|
-
|
|
24
|
+
context = RenderContext.from(
|
|
25
|
+
width: width,
|
|
26
|
+
style: style,
|
|
27
|
+
base_url: base_url,
|
|
28
|
+
source_lines: content.to_s.lines(chomp: true)
|
|
29
|
+
)
|
|
30
|
+
render_document(parse_document, context: context)
|
|
32
31
|
end
|
|
33
32
|
|
|
34
|
-
|
|
35
|
-
# *list_depth* is forwarded to the render context for list indentation. *width*
|
|
36
|
-
# defaults to the renderer's configured width.
|
|
37
|
-
def render_blocks(elements, list_depth: 0, width: @width)
|
|
38
|
-
context = RenderContext.from(width: width, list_depth: list_depth)
|
|
33
|
+
def render_blocks(elements, context:)
|
|
39
34
|
elements.filter_map do |element|
|
|
40
|
-
rendered =
|
|
35
|
+
rendered = render_block(element, context: context)
|
|
41
36
|
rendered unless rendered.to_s.empty?
|
|
42
37
|
end.join("\n\n")
|
|
43
38
|
end
|
|
44
39
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def render_inlines(elements, width: @width)
|
|
48
|
-
context = RenderContext.from(width: width)
|
|
49
|
-
elements.map { |element| inline_renderer.render(element, context: context) }.join
|
|
40
|
+
def render_inlines(elements, context:)
|
|
41
|
+
elements.map { |element| render_inline(element, context: context) }.join
|
|
50
42
|
end
|
|
51
43
|
|
|
52
|
-
# Word-wraps *value* to *width* display columns (when *width* is given), preserving
|
|
53
|
-
# any ANSI styling on each line. Returns *value* unchanged when *width* is nil.
|
|
54
44
|
def wrap(value, width:)
|
|
55
45
|
return value unless width
|
|
56
46
|
|
|
57
47
|
value.to_s.lines(chomp: true).map { |line| wrap_line(line, width) }.join("\n")
|
|
58
48
|
end
|
|
59
49
|
|
|
60
|
-
# Returns the theme's style for *name* if the theme defines it, otherwise returns
|
|
61
|
-
# *fallback*. Lets views override markdown-specific theme tokens.
|
|
62
50
|
def style_for(name, fallback:)
|
|
63
51
|
return theme.public_send(name) if theme.respond_to?(name)
|
|
64
52
|
|
|
65
53
|
fallback
|
|
66
54
|
end
|
|
67
55
|
|
|
68
|
-
# Returns the theme's style for *name*, building a one-token default theme when
|
|
69
|
-
# the active theme doesn't define it. Used as a final fallback for markdown styling.
|
|
70
56
|
def theme_style(name)
|
|
71
57
|
return theme.public_send(name) if theme.respond_to?(name)
|
|
72
58
|
|
|
@@ -75,17 +61,256 @@ module Charming
|
|
|
75
61
|
|
|
76
62
|
private
|
|
77
63
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
64
|
+
def parse_document
|
|
65
|
+
Commonmarker.parse(
|
|
66
|
+
content.to_s,
|
|
67
|
+
options: {
|
|
68
|
+
extension: {
|
|
69
|
+
autolink: true,
|
|
70
|
+
description_lists: true,
|
|
71
|
+
footnotes: true,
|
|
72
|
+
strikethrough: true,
|
|
73
|
+
table: true,
|
|
74
|
+
tasklist: true
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
)
|
|
81
78
|
end
|
|
82
79
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
80
|
+
def render_document(node, context:)
|
|
81
|
+
document_style = context.style[:document]
|
|
82
|
+
body = render_blocks(children_of(node), context: context.with(current_style: document_style))
|
|
83
|
+
document_style.render(document_style.apply_block_layout(body))
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def render_block(node, context:)
|
|
87
|
+
case node.type
|
|
88
|
+
when :paragraph
|
|
89
|
+
render_paragraph(node, context: context)
|
|
90
|
+
when :heading
|
|
91
|
+
render_heading(node, context: context)
|
|
92
|
+
when :block_quote
|
|
93
|
+
render_block_quote(node, context: context)
|
|
94
|
+
when :list
|
|
95
|
+
render_list(node, context: context)
|
|
96
|
+
when :code_block
|
|
97
|
+
render_code_block(node, context: context)
|
|
98
|
+
when :thematic_break
|
|
99
|
+
render_rule(context: context)
|
|
100
|
+
when :table
|
|
101
|
+
render_table(node, context: context)
|
|
102
|
+
when :html_block
|
|
103
|
+
render_html_block(node, context: context)
|
|
104
|
+
else
|
|
105
|
+
render_blocks(children_of(node), context: context)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def render_inline(node, context:)
|
|
110
|
+
case node.type
|
|
111
|
+
when :text
|
|
112
|
+
context.current_style.inherit_visual(context.style[:text]).render(node.string_content)
|
|
113
|
+
when :softbreak
|
|
114
|
+
" "
|
|
115
|
+
when :linebreak
|
|
116
|
+
"\n"
|
|
117
|
+
when :code
|
|
118
|
+
context.inherit(:code).render(node.string_content)
|
|
119
|
+
when :emph
|
|
120
|
+
render_styled_inline(node, :emph, context: context)
|
|
121
|
+
when :strong
|
|
122
|
+
render_styled_inline(node, :strong, context: context)
|
|
123
|
+
when :strikethrough
|
|
124
|
+
render_styled_inline(node, :strikethrough, context: context)
|
|
125
|
+
when :link
|
|
126
|
+
render_link(node, context: context)
|
|
127
|
+
when :image
|
|
128
|
+
render_image(node, context: context)
|
|
129
|
+
when :html_inline
|
|
130
|
+
""
|
|
131
|
+
else
|
|
132
|
+
render_inlines(children_of(node), context: context)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def render_paragraph(node, context:)
|
|
137
|
+
paragraph_style = context.current_style.inherit_visual(context.style[:paragraph])
|
|
138
|
+
body = render_inlines(children_of(node), context: context.with(current_style: paragraph_style))
|
|
139
|
+
render_block_with_style(paragraph_style, wrap(body, width: context.width))
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def render_heading(node, context:)
|
|
143
|
+
heading_style = context.current_style.inherit_visual(context.style.heading(node.header_level))
|
|
144
|
+
body = render_inlines(children_of(node), context: context.with(current_style: heading_style))
|
|
145
|
+
render_block_with_style(heading_style, wrap(body, width: context.width))
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def render_block_quote(node, context:)
|
|
149
|
+
quote_style = context.current_style.inherit_visual(context.style[:block_quote])
|
|
150
|
+
quote_width = context.width ? [context.width - quote_indent_width(quote_style), 1].max : nil
|
|
151
|
+
body = render_blocks(children_of(node), context: context.with(width: quote_width, current_style: quote_style))
|
|
152
|
+
render_block_with_style(quote_style, body)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def render_list(node, context:)
|
|
156
|
+
list_style = context.current_style.inherit_visual(context.style[:list])
|
|
157
|
+
children_of(node).each_with_index.map do |item, index|
|
|
158
|
+
render_list_item(item, index: index, ordered: node.list_type == :ordered, list: node, context: context.with(current_style: list_style))
|
|
159
|
+
end.join("\n")
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def render_list_item(node, index:, ordered:, list:, context:)
|
|
163
|
+
marker_style = context.current_style.inherit_visual(context.style[ordered ? :enumeration : :item])
|
|
164
|
+
marker = if node.type == :taskitem
|
|
165
|
+
task_marker(node, context: context)
|
|
166
|
+
elsif ordered
|
|
167
|
+
"#{list.list_start.to_i + index}. "
|
|
168
|
+
else
|
|
169
|
+
marker_style.block_prefix.empty? ? "- " : marker_style.block_prefix
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
indent = " " * (context.style[:list].level_indent || 2) * context.list_depth
|
|
173
|
+
first_prefix = "#{indent}#{marker}"
|
|
174
|
+
rest_prefix = "#{indent}#{" " * UI::Width.measure(marker)}"
|
|
175
|
+
item_width = context.width ? [context.width - UI::Width.measure(first_prefix), 1].max : nil
|
|
176
|
+
body = render_blocks(children_of(node), context: context.nested_list(width: item_width))
|
|
177
|
+
|
|
178
|
+
body.lines(chomp: true).each_with_index.map do |line, line_index|
|
|
179
|
+
"#{line_index.zero? ? first_prefix : rest_prefix}#{line}"
|
|
180
|
+
end.join("\n")
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def task_marker(node, context:)
|
|
184
|
+
task_style = context.current_style.inherit_visual(context.style[:task])
|
|
185
|
+
checked_task?(node, context: context) ? (task_style.ticked || "[x] ") : (task_style.unticked || "[ ] ")
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def checked_task?(node, context:)
|
|
189
|
+
line = context.source_lines[node.source_position[:start_line].to_i - 1].to_s
|
|
190
|
+
line.match?(/\[[xX]\]/)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def render_code_block(node, context:)
|
|
194
|
+
code_style = context.current_style.inherit_visual(context.style[:code_block])
|
|
195
|
+
code = node.string_content.to_s.chomp
|
|
196
|
+
rendered = if syntax_highlighting
|
|
197
|
+
SyntaxHighlighter.new(theme: theme, style: style).render(code, language: node.fence_info.to_s.split.first)
|
|
198
|
+
else
|
|
199
|
+
code_style.render(code)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
body = rendered.lines(chomp: true).map { |line| " #{line}" }.join("\n")
|
|
203
|
+
syntax_highlighting ? code_style.apply_block_layout(body) : render_block_with_style(code_style, body)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def render_rule(context:)
|
|
207
|
+
rule_style = context.current_style.inherit_visual(context.style[:hr])
|
|
208
|
+
body = rule_style.format.empty? ? "-" * (context.width || DEFAULT_RULE_WIDTH) : rule_style.format
|
|
209
|
+
render_block_with_style(rule_style, body)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def render_table(node, context:)
|
|
213
|
+
table_style = context.current_style.inherit_visual(context.style[:table])
|
|
214
|
+
rows = children_of(node).map do |row|
|
|
215
|
+
children_of(row).map { |cell| render_inlines(children_of(cell), context: context.with(current_style: table_style)) }
|
|
216
|
+
end
|
|
217
|
+
return "" if rows.empty?
|
|
218
|
+
|
|
219
|
+
widths = table_widths(rows)
|
|
220
|
+
rendered_rows = rows.each_with_index.map do |row, index|
|
|
221
|
+
line = table_row(row, widths, table_style)
|
|
222
|
+
index.zero? ? [line, table_separator(widths, table_style)].join("\n") : line
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
render_block_with_style(table_style, rendered_rows.join("\n"))
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def render_html_block(_node, context:)
|
|
229
|
+
html_style = context.current_style.inherit_visual(context.style[:html_block])
|
|
230
|
+
return "" if html_style.format.empty?
|
|
231
|
+
|
|
232
|
+
render_block_with_style(html_style, html_style.format)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def render_styled_inline(node, style_name, context:)
|
|
236
|
+
inline_style = context.inherit(style_name)
|
|
237
|
+
inline_style.render(render_inlines(children_of(node), context: context.with(current_style: inline_style)))
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def render_link(node, context:)
|
|
241
|
+
href = resolve_url(node.url.to_s, context: context)
|
|
242
|
+
text_style = context.inherit(:link_text)
|
|
243
|
+
link_style = context.inherit(:link)
|
|
244
|
+
label = render_inlines(children_of(node), context: context.with(current_style: text_style))
|
|
245
|
+
rendered = if href.empty? || UI::Width.strip_ansi(label) == href
|
|
246
|
+
label
|
|
247
|
+
else
|
|
248
|
+
"#{label} <#{href}>"
|
|
249
|
+
end
|
|
250
|
+
link_style.render(rendered)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def render_image(node, context:)
|
|
254
|
+
href = resolve_url(node.url.to_s, context: context)
|
|
255
|
+
image_style = context.inherit(:image)
|
|
256
|
+
text_style = context.inherit(:image_text)
|
|
257
|
+
alt = render_inlines(children_of(node), context: context.with(current_style: text_style))
|
|
258
|
+
label = if text_style.format.empty?
|
|
259
|
+
"Image: #{UI::Width.strip_ansi(alt)} ->"
|
|
260
|
+
else
|
|
261
|
+
text_style.format.gsub("{{text}}", UI::Width.strip_ansi(alt))
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
image_style.render([label, href].reject(&:empty?).join(" "))
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def render_block_with_style(style, body)
|
|
268
|
+
style.render(style.apply_block_layout(body))
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def table_widths(rows)
|
|
272
|
+
column_count = rows.map(&:length).max || 0
|
|
273
|
+
Array.new(column_count) do |index|
|
|
274
|
+
rows.map { |row| UI::Width.measure(row[index].to_s) }.max || 0
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def table_row(row, widths, style)
|
|
279
|
+
separator = style.column_separator || "|"
|
|
280
|
+
cells = widths.each_with_index.map do |width, index|
|
|
281
|
+
value = row[index].to_s
|
|
282
|
+
" #{value}#{" " * [width - UI::Width.measure(value), 0].max} "
|
|
283
|
+
end
|
|
284
|
+
"#{separator}#{cells.join(separator)}#{separator}"
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def table_separator(widths, style)
|
|
288
|
+
separator = style.column_separator || "|"
|
|
289
|
+
row = style.row_separator || "-"
|
|
290
|
+
"#{separator}#{widths.map { |table_width| row * (table_width + 2) }.join(separator)}#{separator}"
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def quote_indent_width(style)
|
|
294
|
+
return 0 unless style.indent&.positive?
|
|
295
|
+
|
|
296
|
+
UI::Width.measure((style.indent_token || " ") * style.indent)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def resolve_url(value, context:)
|
|
300
|
+
return value if context.base_url.to_s.empty? || value.empty?
|
|
301
|
+
|
|
302
|
+
uri = URI.parse(value)
|
|
303
|
+
return value if uri.absolute?
|
|
304
|
+
|
|
305
|
+
URI.join(context.base_url, value).to_s
|
|
306
|
+
rescue URI::InvalidURIError
|
|
307
|
+
value
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def children_of(node)
|
|
311
|
+
node.each.to_a
|
|
86
312
|
end
|
|
87
313
|
|
|
88
|
-
# Word-wraps a single *line* to *width* display columns using greedy space-splitting.
|
|
89
314
|
def wrap_line(line, width)
|
|
90
315
|
return line if UI::Width.measure(line) <= width
|
|
91
316
|
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Markdown
|
|
5
|
+
class StyleConfig
|
|
6
|
+
ELEMENTS = %i[
|
|
7
|
+
document paragraph block_quote list heading h1 h2 h3 h4 h5 h6 text
|
|
8
|
+
strikethrough emph strong hr item enumeration task link link_text image
|
|
9
|
+
image_text code code_block table definition_list definition_term
|
|
10
|
+
definition_description html_block html_span
|
|
11
|
+
].freeze
|
|
12
|
+
|
|
13
|
+
BUILT_INS = {
|
|
14
|
+
notty: {
|
|
15
|
+
block_quote: {indent: 1, indent_token: "| "},
|
|
16
|
+
list: {level_indent: 2},
|
|
17
|
+
h1: {prefix: "# "},
|
|
18
|
+
h2: {prefix: "## "},
|
|
19
|
+
h3: {prefix: "### "},
|
|
20
|
+
h4: {prefix: "#### "},
|
|
21
|
+
h5: {prefix: "##### "},
|
|
22
|
+
h6: {prefix: "###### "},
|
|
23
|
+
emph: {block_prefix: "*", block_suffix: "*"},
|
|
24
|
+
strong: {block_prefix: "**", block_suffix: "**"},
|
|
25
|
+
strikethrough: {block_prefix: "~~", block_suffix: "~~"},
|
|
26
|
+
hr: {format: "--------"},
|
|
27
|
+
item: {block_prefix: "- "},
|
|
28
|
+
enumeration: {block_prefix: ". "},
|
|
29
|
+
task: {ticked: "[x] ", unticked: "[ ] "},
|
|
30
|
+
code: {block_prefix: "`", block_suffix: "`"},
|
|
31
|
+
code_block: {margin: 1},
|
|
32
|
+
table: {column_separator: "|", row_separator: "-"},
|
|
33
|
+
image_text: {format: "Image: {{text}} ->"}
|
|
34
|
+
},
|
|
35
|
+
dark: {
|
|
36
|
+
document: {color: "252"},
|
|
37
|
+
block_quote: {color: "244", indent: 1, indent_token: "│ "},
|
|
38
|
+
list: {level_indent: 2},
|
|
39
|
+
heading: {color: "39", bold: true},
|
|
40
|
+
h1: {prefix: " ", suffix: " ", color: "228", background_color: "63", bold: true},
|
|
41
|
+
h2: {prefix: "## "},
|
|
42
|
+
h3: {prefix: "### "},
|
|
43
|
+
h4: {prefix: "#### "},
|
|
44
|
+
h5: {prefix: "##### "},
|
|
45
|
+
h6: {prefix: "###### ", color: "35", bold: false},
|
|
46
|
+
strikethrough: {crossed_out: true},
|
|
47
|
+
emph: {italic: true},
|
|
48
|
+
strong: {bold: true},
|
|
49
|
+
hr: {color: "240", format: "--------"},
|
|
50
|
+
item: {block_prefix: "• "},
|
|
51
|
+
enumeration: {block_prefix: ". "},
|
|
52
|
+
task: {ticked: "[✓] ", unticked: "[ ] "},
|
|
53
|
+
link: {color: "30", underline: true},
|
|
54
|
+
link_text: {color: "35", bold: true},
|
|
55
|
+
image: {color: "212", underline: true},
|
|
56
|
+
image_text: {color: "243", format: "Image: {{text}} ->"},
|
|
57
|
+
code: {prefix: " ", suffix: " ", color: "203", background_color: "236"},
|
|
58
|
+
code_block: {color: "244", margin: 1},
|
|
59
|
+
table: {column_separator: "|", row_separator: "-"}
|
|
60
|
+
},
|
|
61
|
+
light: {
|
|
62
|
+
document: {color: "236"},
|
|
63
|
+
block_quote: {color: "244", indent: 1, indent_token: "│ "},
|
|
64
|
+
list: {level_indent: 2},
|
|
65
|
+
heading: {color: "25", bold: true},
|
|
66
|
+
h1: {prefix: " ", suffix: " ", color: "255", background_color: "33", bold: true},
|
|
67
|
+
h2: {prefix: "## "},
|
|
68
|
+
h3: {prefix: "### "},
|
|
69
|
+
h4: {prefix: "#### "},
|
|
70
|
+
h5: {prefix: "##### "},
|
|
71
|
+
h6: {prefix: "###### ", color: "30", bold: false},
|
|
72
|
+
strikethrough: {crossed_out: true},
|
|
73
|
+
emph: {italic: true},
|
|
74
|
+
strong: {bold: true},
|
|
75
|
+
hr: {color: "250", format: "--------"},
|
|
76
|
+
item: {block_prefix: "• "},
|
|
77
|
+
enumeration: {block_prefix: ". "},
|
|
78
|
+
task: {ticked: "[✓] ", unticked: "[ ] "},
|
|
79
|
+
link: {color: "25", underline: true},
|
|
80
|
+
link_text: {color: "90", bold: true},
|
|
81
|
+
image: {color: "162", underline: true},
|
|
82
|
+
image_text: {color: "244", format: "Image: {{text}} ->"},
|
|
83
|
+
code: {prefix: " ", suffix: " ", color: "161", background_color: "255"},
|
|
84
|
+
code_block: {color: "244", margin: 1},
|
|
85
|
+
table: {column_separator: "|", row_separator: "-"}
|
|
86
|
+
}
|
|
87
|
+
}.freeze
|
|
88
|
+
|
|
89
|
+
ATTRIBUTES = %i[bold faint italic underline reverse strikethrough].freeze
|
|
90
|
+
|
|
91
|
+
Style = Data.define(
|
|
92
|
+
:block_prefix, :block_suffix, :prefix, :suffix, :color, :background_color,
|
|
93
|
+
:bold, :faint, :italic, :underline, :reverse, :strikethrough, :format,
|
|
94
|
+
:indent, :indent_token, :margin, :level_indent, :ticked, :unticked,
|
|
95
|
+
:column_separator, :row_separator
|
|
96
|
+
) do
|
|
97
|
+
def self.from(value)
|
|
98
|
+
value = symbolize_keys(value || {})
|
|
99
|
+
new(
|
|
100
|
+
block_prefix: value[:block_prefix].to_s,
|
|
101
|
+
block_suffix: value[:block_suffix].to_s,
|
|
102
|
+
prefix: value[:prefix].to_s,
|
|
103
|
+
suffix: value[:suffix].to_s,
|
|
104
|
+
color: normalize_color(value[:color] || value[:foreground] || value[:fg]),
|
|
105
|
+
background_color: normalize_color(value[:background_color] || value[:background] || value[:bg]),
|
|
106
|
+
bold: value[:bold],
|
|
107
|
+
faint: value[:faint],
|
|
108
|
+
italic: value[:italic],
|
|
109
|
+
underline: value[:underline],
|
|
110
|
+
reverse: value[:reverse] || value[:inverse],
|
|
111
|
+
strikethrough: value[:strikethrough] || value[:crossed_out],
|
|
112
|
+
format: value[:format].to_s,
|
|
113
|
+
indent: value[:indent]&.to_i,
|
|
114
|
+
indent_token: value[:indent_token]&.to_s,
|
|
115
|
+
margin: value[:margin]&.to_i,
|
|
116
|
+
level_indent: value[:level_indent]&.to_i,
|
|
117
|
+
ticked: value[:ticked]&.to_s,
|
|
118
|
+
unticked: value[:unticked]&.to_s,
|
|
119
|
+
column_separator: value[:column_separator]&.to_s,
|
|
120
|
+
row_separator: value[:row_separator]&.to_s
|
|
121
|
+
)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def inherit_visual(child)
|
|
125
|
+
child = self.class.from(child) unless child.is_a?(self.class)
|
|
126
|
+
self.class.new(**child.to_h.merge(
|
|
127
|
+
color: child.color || color,
|
|
128
|
+
background_color: child.background_color || background_color,
|
|
129
|
+
bold: child.bold.nil? ? bold : child.bold,
|
|
130
|
+
faint: child.faint.nil? ? faint : child.faint,
|
|
131
|
+
italic: child.italic.nil? ? italic : child.italic,
|
|
132
|
+
underline: child.underline.nil? ? underline : child.underline,
|
|
133
|
+
reverse: child.reverse.nil? ? reverse : child.reverse,
|
|
134
|
+
strikethrough: child.strikethrough.nil? ? strikethrough : child.strikethrough
|
|
135
|
+
))
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def render(value)
|
|
139
|
+
ansi_codes.apply("#{block_prefix}#{prefix}#{value}#{suffix}#{block_suffix}")
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def apply_block_layout(value)
|
|
143
|
+
lines = value.to_s.lines(chomp: true)
|
|
144
|
+
lines = [""] if lines.empty?
|
|
145
|
+
|
|
146
|
+
if indent&.positive?
|
|
147
|
+
indentation = (indent_token || " ") * indent
|
|
148
|
+
lines = lines.map { |line| "#{indentation}#{line}" }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
rendered = lines.join("\n")
|
|
152
|
+
return rendered unless margin.to_i.positive?
|
|
153
|
+
|
|
154
|
+
blank = Array.new(margin.to_i, "").join("\n")
|
|
155
|
+
"#{blank}\n#{rendered}\n#{blank}"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
private
|
|
159
|
+
|
|
160
|
+
def ansi_codes
|
|
161
|
+
UI::ANSICodes.new(
|
|
162
|
+
attributes: ATTRIBUTES.select { |attribute| public_send(attribute) },
|
|
163
|
+
foreground: color,
|
|
164
|
+
background: background_color
|
|
165
|
+
)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def self.symbolize_keys(value)
|
|
169
|
+
value.to_h.each_with_object({}) { |(key, item), result| result[key.to_sym] = item }
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def self.normalize_color(value)
|
|
173
|
+
return if value.nil?
|
|
174
|
+
return value if value.is_a?(Integer)
|
|
175
|
+
return value.to_i if value.to_s.match?(/\A\d+\z/)
|
|
176
|
+
|
|
177
|
+
value
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def self.builtin(name)
|
|
182
|
+
key = name.to_s.tr("-", "_").to_sym
|
|
183
|
+
raise ArgumentError, "unknown markdown style: #{name.inspect}" unless BUILT_INS.key?(key)
|
|
184
|
+
|
|
185
|
+
from_hash(BUILT_INS.fetch(key))
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def self.from(value)
|
|
189
|
+
return value if value.is_a?(self)
|
|
190
|
+
return builtin(value) if value.is_a?(String) || value.is_a?(Symbol)
|
|
191
|
+
|
|
192
|
+
from_hash(value || BUILT_INS.fetch(:dark))
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def self.from_hash(value)
|
|
196
|
+
new(value.to_h)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def initialize(styles = {})
|
|
200
|
+
styles = styles.transform_keys(&:to_sym)
|
|
201
|
+
@styles = ELEMENTS.each_with_object({}) do |element, result|
|
|
202
|
+
result[element] = Style.from(styles[element] || {})
|
|
203
|
+
end.freeze
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def [](name)
|
|
207
|
+
@styles.fetch(name.to_sym) { Style.from({}) }
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def heading(level)
|
|
211
|
+
self[:heading].inherit_visual(self[:"h#{level}"])
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
@@ -10,8 +10,9 @@ module Charming
|
|
|
10
10
|
# base style (muted italic for comments, title for keywords, etc.).
|
|
11
11
|
class SyntaxHighlighter
|
|
12
12
|
# *theme* is the active Charming theme. Defaults to UI::Theme.default.
|
|
13
|
-
def initialize(theme: UI::Theme.default)
|
|
13
|
+
def initialize(theme: UI::Theme.default, style: nil)
|
|
14
14
|
@theme = theme || UI::Theme.default
|
|
15
|
+
@style = style
|
|
15
16
|
end
|
|
16
17
|
|
|
17
18
|
# Highlights *code* (using Rouge) for the given *language* (auto-detected when nil)
|
|
@@ -27,7 +28,7 @@ module Charming
|
|
|
27
28
|
private
|
|
28
29
|
|
|
29
30
|
# The Charming theme used for token styling.
|
|
30
|
-
attr_reader :theme
|
|
31
|
+
attr_reader :theme, :style
|
|
31
32
|
|
|
32
33
|
# Picks a Rouge lexer for *language* and *code*, falling back to plain text.
|
|
33
34
|
def lexer_for(language, code)
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module Charming
|
|
4
4
|
# Markdown is the namespace for the Markdown rendering pipeline. Parsing is delegated to
|
|
5
|
-
#
|
|
6
|
-
#
|
|
5
|
+
# Commonmarker; `Renderer` renders the AST, and code blocks are highlighted by
|
|
6
|
+
# `SyntaxHighlighter` (Rouge-backed).
|
|
7
7
|
module Markdown
|
|
8
8
|
end
|
|
9
9
|
end
|
|
@@ -81,6 +81,7 @@ module Charming
|
|
|
81
81
|
def screen_layout(background: nil, &)
|
|
82
82
|
layout = Layout::Builder.build(screen: layout_screen, view: self, background: background, &)
|
|
83
83
|
register_layout_focus(layout)
|
|
84
|
+
register_layout_mouse_targets(layout)
|
|
84
85
|
layout.render
|
|
85
86
|
end
|
|
86
87
|
|
|
@@ -129,5 +130,11 @@ module Charming
|
|
|
129
130
|
|
|
130
131
|
assigns[:controller].focus.define_layout(layout.focusable_names)
|
|
131
132
|
end
|
|
133
|
+
|
|
134
|
+
def register_layout_mouse_targets(layout)
|
|
135
|
+
return unless assigns[:controller]
|
|
136
|
+
|
|
137
|
+
assigns[:controller].register_mouse_targets(layout.mouse_targets)
|
|
138
|
+
end
|
|
132
139
|
end
|
|
133
140
|
end
|
data/lib/charming/router.rb
CHANGED
|
@@ -108,20 +108,15 @@ module Charming
|
|
|
108
108
|
end
|
|
109
109
|
end
|
|
110
110
|
|
|
111
|
-
# Splits a camel-case string into words for title derivation (e.g., "my_route" → ["my", "route"]).
|
|
112
|
-
def camelize(value)
|
|
113
|
-
value.split("_").map(&:capitalize).join
|
|
114
|
-
end
|
|
115
|
-
|
|
116
111
|
# Looks up a constant by name in Object. Used to resolve controller strings from route definitions.
|
|
117
112
|
def constantize(name)
|
|
118
|
-
|
|
113
|
+
ActiveSupport::Inflector.constantize(name)
|
|
119
114
|
end
|
|
120
115
|
|
|
121
116
|
# Builds the full controller constant name, prepending the namespace if present.
|
|
122
|
-
# For example: "
|
|
117
|
+
# For example: "home" with namespace "Admin" → "Admin::HomeController".
|
|
123
118
|
def controller_constant_name(controller_name)
|
|
124
|
-
name = "#{camelize(controller_name)}Controller"
|
|
119
|
+
name = "#{ActiveSupport::Inflector.camelize(controller_name)}Controller"
|
|
125
120
|
@namespace.to_s.empty? ? name : "#{@namespace}::#{name}"
|
|
126
121
|
end
|
|
127
122
|
|
data/lib/charming/runtime.rb
CHANGED
|
@@ -186,6 +186,7 @@ module Charming
|
|
|
186
186
|
def setup_terminal
|
|
187
187
|
@backend.enter_alt_screen
|
|
188
188
|
@backend.hide_cursor
|
|
189
|
+
@backend.enable_mouse_tracking if @backend.respond_to?(:enable_mouse_tracking)
|
|
189
190
|
@backend.install_resize_handler if @backend.respond_to?(:install_resize_handler)
|
|
190
191
|
end
|
|
191
192
|
|
|
@@ -200,6 +201,7 @@ module Charming
|
|
|
200
201
|
# the cursor, and leaves the alternative screen buffer.
|
|
201
202
|
def restore_terminal
|
|
202
203
|
@backend.restore_resize_handler if @backend.respond_to?(:restore_resize_handler)
|
|
204
|
+
@backend.disable_mouse_tracking if @backend.respond_to?(:disable_mouse_tracking)
|
|
203
205
|
@backend.show_cursor
|
|
204
206
|
@backend.leave_alt_screen
|
|
205
207
|
end
|
data/lib/charming/version.rb
CHANGED