charming 0.2.0 → 0.2.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 +2 -2
- data/lib/charming/application.rb +96 -9
- data/lib/charming/audio/player.rb +104 -0
- data/lib/charming/audio/system.rb +69 -0
- data/lib/charming/cli.rb +63 -7
- data/lib/charming/controller/action_hooks.rb +124 -0
- data/lib/charming/controller/class_methods.rb +15 -1
- data/lib/charming/controller/dispatching.rb +31 -5
- data/lib/charming/controller/focus.rb +9 -0
- data/lib/charming/controller/focus_management.rb +0 -7
- data/lib/charming/controller/session_state.rb +16 -1
- data/lib/charming/controller/sidebar_navigation.rb +63 -28
- data/lib/charming/controller.rb +62 -10
- data/lib/charming/database/commands.rb +123 -11
- data/lib/charming/events/focus_event.rb +12 -0
- data/lib/charming/events/paste_event.rb +11 -0
- data/lib/charming/events/task_progress_event.rb +21 -0
- data/lib/charming/generators/app_generator.rb +38 -1
- data/lib/charming/generators/database_installer.rb +4 -15
- data/lib/charming/generators/migration_generator.rb +116 -0
- data/lib/charming/generators/migration_timestamp.rb +29 -0
- data/lib/charming/generators/model_generator.rb +4 -2
- data/lib/charming/generators/templates/app/application_controller.template +1 -1
- data/lib/charming/generators/templates/app/database_config.template +3 -1
- data/lib/charming/generators/templates/app/layout.template +1 -1
- data/lib/charming/generators/templates/app/spec_helper.template +2 -1
- data/lib/charming/generators/templates/app/view.template +1 -1
- data/lib/charming/internal/terminal/memory_backend.rb +6 -0
- data/lib/charming/internal/terminal/tty_backend.rb +64 -2
- data/lib/charming/presentation/component.rb +7 -0
- data/lib/charming/presentation/components/audio.rb +31 -0
- data/lib/charming/presentation/components/autocomplete.rb +108 -0
- data/lib/charming/presentation/components/badge.rb +31 -0
- data/lib/charming/presentation/components/breadcrumbs.rb +29 -0
- data/lib/charming/presentation/components/command_palette.rb +8 -5
- data/lib/charming/presentation/components/error_screen.rb +72 -0
- data/lib/charming/presentation/components/form.rb +9 -0
- data/lib/charming/presentation/components/fuzzy_matcher.rb +83 -0
- data/lib/charming/presentation/components/help_overlay.rb +65 -0
- data/lib/charming/presentation/components/markdown.rb +6 -2
- data/lib/charming/presentation/components/modal.rb +45 -5
- data/lib/charming/presentation/components/multi_select_list.rb +85 -0
- data/lib/charming/presentation/components/progressbar.rb +0 -1
- data/lib/charming/presentation/components/status_bar.rb +75 -0
- data/lib/charming/presentation/components/tab_bar.rb +103 -0
- data/lib/charming/presentation/components/table.rb +40 -9
- data/lib/charming/presentation/components/text_area.rb +47 -10
- data/lib/charming/presentation/components/text_input.rb +79 -4
- data/lib/charming/presentation/components/toast.rb +51 -0
- data/lib/charming/presentation/components/tree.rb +176 -0
- data/lib/charming/presentation/components/viewport/content_lines.rb +55 -0
- data/lib/charming/presentation/components/viewport/line_window.rb +71 -0
- data/lib/charming/presentation/components/viewport/position.rb +67 -0
- data/lib/charming/presentation/components/viewport.rb +37 -122
- data/lib/charming/presentation/layout/builder.rb +4 -1
- data/lib/charming/presentation/layout/overlay.rb +6 -4
- data/lib/charming/presentation/layout/pane.rb +2 -1
- data/lib/charming/presentation/layout/pane_geometry.rb +16 -8
- data/lib/charming/presentation/layout/screen_layout.rb +12 -3
- data/lib/charming/presentation/layout/split.rb +37 -3
- data/lib/charming/presentation/markdown/renderer.rb +99 -63
- data/lib/charming/presentation/markdown/style_config.rb +10 -5
- data/lib/charming/presentation/markdown/syntax_highlighter.rb +11 -1
- data/lib/charming/presentation/markdown/table_renderer.rb +60 -0
- data/lib/charming/presentation/markdown/text_wrapper.rb +40 -0
- data/lib/charming/presentation/markdown/url_resolver.rb +27 -0
- data/lib/charming/presentation/templates/erb_handler.rb +35 -2
- data/lib/charming/presentation/ui/ansi_codes.rb +11 -0
- data/lib/charming/presentation/ui/ansi_slicer.rb +20 -13
- data/lib/charming/presentation/ui/color_support.rb +129 -0
- data/lib/charming/presentation/ui/theme.rb +7 -0
- data/lib/charming/presentation/ui/themes/catppuccin-latte.json +35 -0
- data/lib/charming/presentation/ui/themes/catppuccin-mocha.json +35 -0
- data/lib/charming/presentation/ui/themes/gruvbox-dark.json +33 -0
- data/lib/charming/presentation/ui/themes/nord.json +32 -0
- data/lib/charming/presentation/ui/themes/tokyonight.json +34 -0
- data/lib/charming/presentation/ui/width.rb +27 -2
- data/lib/charming/router.rb +1 -1
- data/lib/charming/runtime.rb +122 -15
- data/lib/charming/tasks/cancelled.rb +11 -0
- data/lib/charming/tasks/inline_executor.rb +10 -4
- data/lib/charming/tasks/progress.rb +30 -0
- data/lib/charming/tasks/task.rb +24 -4
- data/lib/charming/tasks/threaded_executor.rb +35 -11
- data/lib/charming/test_helper.rb +120 -0
- data/lib/charming/version.rb +1 -1
- data/lib/charming.rb +43 -1
- metadata +36 -49
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "commonmarker"
|
|
4
|
-
require "uri"
|
|
5
4
|
|
|
6
5
|
module Charming
|
|
7
6
|
module Markdown
|
|
@@ -9,15 +8,19 @@ module Charming
|
|
|
9
8
|
class Renderer
|
|
10
9
|
DEFAULT_RULE_WIDTH = 40
|
|
11
10
|
|
|
12
|
-
attr_reader :content, :width, :theme, :syntax_highlighting, :style, :base_url
|
|
11
|
+
attr_reader :content, :width, :theme, :syntax_highlighting, :style, :base_url, :hyperlinks
|
|
13
12
|
|
|
14
|
-
|
|
13
|
+
# *hyperlinks* (default false) wraps links in OSC 8 escape sequences so modern
|
|
14
|
+
# terminals make them clickable; the ` <url>` suffix is omitted since the target
|
|
15
|
+
# is embedded in the escape.
|
|
16
|
+
def initialize(content:, width: nil, theme: UI::Theme.default, syntax_highlighting: true, style: :dark, base_url: nil, hyperlinks: false)
|
|
15
17
|
@content = content
|
|
16
18
|
@width = width
|
|
17
19
|
@theme = theme || UI::Theme.default
|
|
18
20
|
@syntax_highlighting = syntax_highlighting
|
|
19
21
|
@style = StyleConfig.from(style)
|
|
20
22
|
@base_url = base_url
|
|
23
|
+
@hyperlinks = hyperlinks
|
|
21
24
|
end
|
|
22
25
|
|
|
23
26
|
def render
|
|
@@ -42,9 +45,7 @@ module Charming
|
|
|
42
45
|
end
|
|
43
46
|
|
|
44
47
|
def wrap(value, width:)
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
value.to_s.lines(chomp: true).map { |line| wrap_line(line, width) }.join("\n")
|
|
48
|
+
TextWrapper.new(width: width).wrap(value)
|
|
48
49
|
end
|
|
49
50
|
|
|
50
51
|
def style_for(name, fallback:)
|
|
@@ -99,6 +100,10 @@ module Charming
|
|
|
99
100
|
render_rule(context: context)
|
|
100
101
|
when :table
|
|
101
102
|
render_table(node, context: context)
|
|
103
|
+
when :description_list
|
|
104
|
+
render_definition_list(node, context: context)
|
|
105
|
+
when :footnote_definition
|
|
106
|
+
render_footnote_definition(node, context: context)
|
|
102
107
|
when :html_block
|
|
103
108
|
render_html_block(node, context: context)
|
|
104
109
|
else
|
|
@@ -126,6 +131,8 @@ module Charming
|
|
|
126
131
|
render_link(node, context: context)
|
|
127
132
|
when :image
|
|
128
133
|
render_image(node, context: context)
|
|
134
|
+
when :footnote_reference
|
|
135
|
+
render_footnote_reference(node, context: context)
|
|
129
136
|
when :html_inline
|
|
130
137
|
""
|
|
131
138
|
else
|
|
@@ -185,9 +192,15 @@ module Charming
|
|
|
185
192
|
checked_task?(node, context: context) ? (task_style.ticked || "[x] ") : (task_style.unticked || "[ ] ")
|
|
186
193
|
end
|
|
187
194
|
|
|
195
|
+
# Matches a checked task marker anchored to the list-item prefix, so prose that
|
|
196
|
+
# merely mentions "[x]" can't check the box.
|
|
197
|
+
TASK_CHECKED_PATTERN = /\A\s*(?:[-*+]|\d+[.)])\s+\[[xX]\]/
|
|
198
|
+
|
|
199
|
+
# Commonmarker exposes no checked-state accessor on taskitem nodes, so the
|
|
200
|
+
# original source line is inspected instead.
|
|
188
201
|
def checked_task?(node, context:)
|
|
189
202
|
line = context.source_lines[node.source_position[:start_line].to_i - 1].to_s
|
|
190
|
-
line.match?(
|
|
203
|
+
line.match?(TASK_CHECKED_PATTERN)
|
|
191
204
|
end
|
|
192
205
|
|
|
193
206
|
def render_code_block(node, context:)
|
|
@@ -205,7 +218,10 @@ module Charming
|
|
|
205
218
|
|
|
206
219
|
def render_rule(context:)
|
|
207
220
|
rule_style = context.current_style.inherit_visual(context.style[:hr])
|
|
208
|
-
|
|
221
|
+
available_width = context.width || DEFAULT_RULE_WIDTH
|
|
222
|
+
padding = rule_style.indent.to_i * 2
|
|
223
|
+
rule_width = [available_width - padding, 1].max
|
|
224
|
+
body = repeat_to_width(rule_style.format.empty? ? "-" : rule_style.format, rule_width)
|
|
209
225
|
render_block_with_style(rule_style, body)
|
|
210
226
|
end
|
|
211
227
|
|
|
@@ -214,15 +230,70 @@ module Charming
|
|
|
214
230
|
rows = children_of(node).map do |row|
|
|
215
231
|
children_of(row).map { |cell| render_inlines(children_of(cell), context: context.with(current_style: table_style)) }
|
|
216
232
|
end
|
|
217
|
-
return "" if rows.empty?
|
|
218
233
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
234
|
+
body = TableRenderer.new(rows: rows, style: table_style).render
|
|
235
|
+
return "" if body.empty?
|
|
236
|
+
|
|
237
|
+
render_block_with_style(table_style, body)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Renders `Term / : definition` description lists: terms in the definition_term
|
|
241
|
+
# style, details indented per the definition_description style.
|
|
242
|
+
def render_definition_list(node, context:)
|
|
243
|
+
children_of(node).map { |item| render_definition_item(item, context: context) }.join("\n")
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def render_definition_item(node, context:)
|
|
247
|
+
parts = children_of(node).map do |child|
|
|
248
|
+
case child.type
|
|
249
|
+
when :description_term
|
|
250
|
+
render_definition_term(child, context: context)
|
|
251
|
+
when :description_details
|
|
252
|
+
render_definition_details(child, context: context)
|
|
253
|
+
else
|
|
254
|
+
render_block(child, context: context)
|
|
255
|
+
end
|
|
223
256
|
end
|
|
257
|
+
parts.reject { |part| part.to_s.empty? }.join("\n")
|
|
258
|
+
end
|
|
224
259
|
|
|
225
|
-
|
|
260
|
+
def render_definition_term(node, context:)
|
|
261
|
+
term_style = context.current_style.inherit_visual(context.style[:definition_term])
|
|
262
|
+
term_style.render(render_inlines(children_of(node), context: context.with(current_style: term_style)))
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def render_definition_details(node, context:)
|
|
266
|
+
details_style = context.current_style.inherit_visual(context.style[:definition_description])
|
|
267
|
+
indent = " " * (details_style.indent || 4)
|
|
268
|
+
details_width = context.width ? [context.width - indent.length, 1].max : nil
|
|
269
|
+
body = render_blocks(children_of(node), context: context.with(width: details_width, current_style: details_style))
|
|
270
|
+
body.lines(chomp: true).map { |line| "#{indent}#{line}" }.join("\n")
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Renders an inline `[^name]` reference as a bracketed label in the link style.
|
|
274
|
+
def render_footnote_reference(node, context:)
|
|
275
|
+
context.inherit(:link).render("[#{footnote_name(node)}]")
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Renders a footnote definition as a labeled block with hanging indentation.
|
|
279
|
+
def render_footnote_definition(node, context:)
|
|
280
|
+
label_style = context.current_style.inherit_visual(context.style[:link_text])
|
|
281
|
+
label = "[#{footnote_name(node)}]: "
|
|
282
|
+
indent = " " * UI::Width.measure(label)
|
|
283
|
+
body_width = context.width ? [context.width - UI::Width.measure(label), 1].max : nil
|
|
284
|
+
body = render_blocks(children_of(node), context: context.with(width: body_width))
|
|
285
|
+
lines = body.lines(chomp: true)
|
|
286
|
+
return label_style.render(label.rstrip) if lines.empty?
|
|
287
|
+
|
|
288
|
+
first = "#{label_style.render(label)}#{lines.first}"
|
|
289
|
+
rest = lines.drop(1).map { |line| "#{indent}#{line}" }
|
|
290
|
+
[first, *rest].join("\n")
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Commonmarker exposes no name accessor on footnote nodes; the round-tripped
|
|
294
|
+
# commonmark source (`"[^name]\n"` / `"[^name]:\n..."`) carries the label.
|
|
295
|
+
def footnote_name(node)
|
|
296
|
+
node.to_commonmark[/\A\[\^(.+?)\]/, 1].to_s
|
|
226
297
|
end
|
|
227
298
|
|
|
228
299
|
def render_html_block(_node, context:)
|
|
@@ -242,6 +313,8 @@ module Charming
|
|
|
242
313
|
text_style = context.inherit(:link_text)
|
|
243
314
|
link_style = context.inherit(:link)
|
|
244
315
|
label = render_inlines(children_of(node), context: context.with(current_style: text_style))
|
|
316
|
+
return osc8_hyperlink(href, link_style.render(label)) if hyperlinks && !href.empty?
|
|
317
|
+
|
|
245
318
|
rendered = if href.empty? || UI::Width.strip_ansi(label) == href
|
|
246
319
|
label
|
|
247
320
|
else
|
|
@@ -250,6 +323,12 @@ module Charming
|
|
|
250
323
|
link_style.render(rendered)
|
|
251
324
|
end
|
|
252
325
|
|
|
326
|
+
# Wraps *rendered* in an OSC 8 hyperlink to *href*. Modern terminals make the
|
|
327
|
+
# text clickable; the sequence is invisible to width math (UI::Width strips OSC).
|
|
328
|
+
def osc8_hyperlink(href, rendered)
|
|
329
|
+
"\e]8;;#{href}\e\\#{rendered}\e]8;;\e\\"
|
|
330
|
+
end
|
|
331
|
+
|
|
253
332
|
def render_image(node, context:)
|
|
254
333
|
href = resolve_url(node.url.to_s, context: context)
|
|
255
334
|
image_style = context.inherit(:image)
|
|
@@ -268,26 +347,11 @@ module Charming
|
|
|
268
347
|
style.render(style.apply_block_layout(body))
|
|
269
348
|
end
|
|
270
349
|
|
|
271
|
-
def
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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}"
|
|
350
|
+
def repeat_to_width(value, width)
|
|
351
|
+
token = value.to_s
|
|
352
|
+
token_width = [UI::Width.measure(token), 1].max
|
|
353
|
+
repeated = token * ((width.to_i + token_width - 1) / token_width)
|
|
354
|
+
UI.visible_slice(repeated, 0, width)
|
|
291
355
|
end
|
|
292
356
|
|
|
293
357
|
def quote_indent_width(style)
|
|
@@ -297,40 +361,12 @@ module Charming
|
|
|
297
361
|
end
|
|
298
362
|
|
|
299
363
|
def resolve_url(value, context:)
|
|
300
|
-
|
|
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
|
|
364
|
+
URLResolver.new(base_url: context.base_url).resolve(value)
|
|
308
365
|
end
|
|
309
366
|
|
|
310
367
|
def children_of(node)
|
|
311
368
|
node.each.to_a
|
|
312
369
|
end
|
|
313
|
-
|
|
314
|
-
def wrap_line(line, width)
|
|
315
|
-
return line if UI::Width.measure(line) <= width
|
|
316
|
-
|
|
317
|
-
lines = []
|
|
318
|
-
current = +""
|
|
319
|
-
|
|
320
|
-
line.split(/\s+/).each do |word|
|
|
321
|
-
candidate = current.empty? ? word : "#{current} #{word}"
|
|
322
|
-
|
|
323
|
-
if !current.empty? && UI::Width.measure(candidate) > width
|
|
324
|
-
lines << current.rstrip
|
|
325
|
-
current = word
|
|
326
|
-
else
|
|
327
|
-
current = candidate
|
|
328
|
-
end
|
|
329
|
-
end
|
|
330
|
-
|
|
331
|
-
lines << current.rstrip unless current.empty?
|
|
332
|
-
lines.join("\n")
|
|
333
|
-
end
|
|
334
370
|
end
|
|
335
371
|
end
|
|
336
372
|
end
|
|
@@ -23,13 +23,14 @@ module Charming
|
|
|
23
23
|
emph: {block_prefix: "*", block_suffix: "*"},
|
|
24
24
|
strong: {block_prefix: "**", block_suffix: "**"},
|
|
25
25
|
strikethrough: {block_prefix: "~~", block_suffix: "~~"},
|
|
26
|
-
hr: {format: "
|
|
26
|
+
hr: {format: "-", indent: 2, margin: 1},
|
|
27
27
|
item: {block_prefix: "- "},
|
|
28
28
|
enumeration: {block_prefix: ". "},
|
|
29
29
|
task: {ticked: "[x] ", unticked: "[ ] "},
|
|
30
30
|
code: {block_prefix: "`", block_suffix: "`"},
|
|
31
31
|
code_block: {margin: 1},
|
|
32
32
|
table: {column_separator: "|", row_separator: "-"},
|
|
33
|
+
definition_description: {indent: 4},
|
|
33
34
|
image_text: {format: "Image: {{text}} ->"}
|
|
34
35
|
},
|
|
35
36
|
dark: {
|
|
@@ -46,7 +47,7 @@ module Charming
|
|
|
46
47
|
strikethrough: {crossed_out: true},
|
|
47
48
|
emph: {italic: true},
|
|
48
49
|
strong: {bold: true},
|
|
49
|
-
hr: {color: "240", format: "
|
|
50
|
+
hr: {color: "240", format: "─", indent: 2, margin: 1},
|
|
50
51
|
item: {block_prefix: "• "},
|
|
51
52
|
enumeration: {block_prefix: ". "},
|
|
52
53
|
task: {ticked: "[✓] ", unticked: "[ ] "},
|
|
@@ -56,7 +57,9 @@ module Charming
|
|
|
56
57
|
image_text: {color: "243", format: "Image: {{text}} ->"},
|
|
57
58
|
code: {prefix: " ", suffix: " ", color: "203", background_color: "236"},
|
|
58
59
|
code_block: {color: "244", margin: 1},
|
|
59
|
-
table: {column_separator: "|", row_separator: "-"}
|
|
60
|
+
table: {column_separator: "|", row_separator: "-"},
|
|
61
|
+
definition_term: {bold: true},
|
|
62
|
+
definition_description: {indent: 4, color: "244"}
|
|
60
63
|
},
|
|
61
64
|
light: {
|
|
62
65
|
document: {color: "236"},
|
|
@@ -72,7 +75,7 @@ module Charming
|
|
|
72
75
|
strikethrough: {crossed_out: true},
|
|
73
76
|
emph: {italic: true},
|
|
74
77
|
strong: {bold: true},
|
|
75
|
-
hr: {color: "250", format: "
|
|
78
|
+
hr: {color: "250", format: "─", indent: 2, margin: 1},
|
|
76
79
|
item: {block_prefix: "• "},
|
|
77
80
|
enumeration: {block_prefix: ". "},
|
|
78
81
|
task: {ticked: "[✓] ", unticked: "[ ] "},
|
|
@@ -82,7 +85,9 @@ module Charming
|
|
|
82
85
|
image_text: {color: "244", format: "Image: {{text}} ->"},
|
|
83
86
|
code: {prefix: " ", suffix: " ", color: "161", background_color: "255"},
|
|
84
87
|
code_block: {color: "244", margin: 1},
|
|
85
|
-
table: {column_separator: "|", row_separator: "-"}
|
|
88
|
+
table: {column_separator: "|", row_separator: "-"},
|
|
89
|
+
definition_term: {bold: true},
|
|
90
|
+
definition_description: {indent: 4, color: "244"}
|
|
86
91
|
}
|
|
87
92
|
}.freeze
|
|
88
93
|
|
|
@@ -21,7 +21,7 @@ module Charming
|
|
|
21
21
|
def render(code, language: nil)
|
|
22
22
|
lexer = lexer_for(language, code)
|
|
23
23
|
lexer.lex(code.to_s).map do |token, value|
|
|
24
|
-
|
|
24
|
+
render_token(token, value)
|
|
25
25
|
end.join
|
|
26
26
|
end
|
|
27
27
|
|
|
@@ -30,6 +30,16 @@ module Charming
|
|
|
30
30
|
# The Charming theme used for token styling.
|
|
31
31
|
attr_reader :theme, :style
|
|
32
32
|
|
|
33
|
+
def render_token(token, value)
|
|
34
|
+
token_style = style_for(token)
|
|
35
|
+
value.to_s.each_line(chomp: false).map do |line|
|
|
36
|
+
next "\n" if line == "\n"
|
|
37
|
+
next token_style.render(line.chomp("\n")) + "\n" if line.end_with?("\n")
|
|
38
|
+
|
|
39
|
+
token_style.render(line)
|
|
40
|
+
end.join
|
|
41
|
+
end
|
|
42
|
+
|
|
33
43
|
# Picks a Rouge lexer for *language* and *code*, falling back to plain text.
|
|
34
44
|
def lexer_for(language, code)
|
|
35
45
|
Rouge::Lexer.find_fancy(language, code) || Rouge::Lexers::PlainText
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Markdown
|
|
5
|
+
# TableRenderer formats parsed Markdown table rows for terminal display.
|
|
6
|
+
class TableRenderer
|
|
7
|
+
def initialize(rows:, style:)
|
|
8
|
+
@rows = rows
|
|
9
|
+
@style = style
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def render
|
|
13
|
+
return "" if rows.empty?
|
|
14
|
+
|
|
15
|
+
rows.each_with_index.map { |row, index| render_row(row, index) }.join("\n")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
attr_reader :rows, :style
|
|
21
|
+
|
|
22
|
+
def render_row(row, index)
|
|
23
|
+
line = table_row(row)
|
|
24
|
+
index.zero? ? [line, table_separator].join("\n") : line
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def table_row(row)
|
|
28
|
+
cells = widths.each_with_index.map { |width, index| table_cell(row, width, index) }
|
|
29
|
+
"#{separator}#{cells.join(separator)}#{separator}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def table_cell(row, width, index)
|
|
33
|
+
value = row[index].to_s
|
|
34
|
+
" #{value}#{" " * [width - UI::Width.measure(value), 0].max} "
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def table_separator
|
|
38
|
+
"#{separator}#{widths.map { |table_width| row_separator * (table_width + 2) }.join(separator)}#{separator}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def widths
|
|
42
|
+
@widths ||= Array.new(column_count) do |index|
|
|
43
|
+
rows.map { |row| UI::Width.measure(row[index].to_s) }.max || 0
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def column_count
|
|
48
|
+
rows.map(&:length).max || 0
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def separator
|
|
52
|
+
style.column_separator || "|"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def row_separator
|
|
56
|
+
style.row_separator || "-"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Markdown
|
|
5
|
+
# TextWrapper wraps Markdown text blocks to a configured terminal width.
|
|
6
|
+
class TextWrapper
|
|
7
|
+
def initialize(width:)
|
|
8
|
+
@width = width
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def wrap(value)
|
|
12
|
+
return value unless width
|
|
13
|
+
|
|
14
|
+
value.to_s.lines(chomp: true).map { |line| wrap_line(line) }.join("\n")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
attr_reader :width
|
|
20
|
+
|
|
21
|
+
def wrap_line(line)
|
|
22
|
+
return line if UI::Width.measure(line) <= width
|
|
23
|
+
|
|
24
|
+
wrap_words(line.split(/\s+/))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def wrap_words(words)
|
|
28
|
+
words.each_with_object([]) { |word, lines| append_word(lines, word) }.join("\n")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def append_word(lines, word)
|
|
32
|
+
current = lines.pop.to_s
|
|
33
|
+
candidate = current.empty? ? word : "#{current} #{word}"
|
|
34
|
+
return lines.push(candidate) if current.empty? || UI::Width.measure(candidate) <= width
|
|
35
|
+
|
|
36
|
+
lines.push(current.rstrip, word)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Charming
|
|
6
|
+
module Markdown
|
|
7
|
+
# URLResolver resolves Markdown link destinations against an optional base URL.
|
|
8
|
+
class URLResolver
|
|
9
|
+
def initialize(base_url: nil)
|
|
10
|
+
@base_url = base_url
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def resolve(value)
|
|
14
|
+
return value if base_url.to_s.empty? || value.empty?
|
|
15
|
+
return value if URI.parse(value).absolute?
|
|
16
|
+
|
|
17
|
+
URI.join(base_url, value).to_s
|
|
18
|
+
rescue URI::InvalidURIError
|
|
19
|
+
value
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
attr_reader :base_url
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -4,9 +4,42 @@ require "erb"
|
|
|
4
4
|
|
|
5
5
|
module Charming
|
|
6
6
|
module Templates
|
|
7
|
+
# ErbHandler renders `.tui.erb` / `.txt.erb` templates. Compiled ERB objects are
|
|
8
|
+
# cached per path: in development the cache is invalidated by file mtime so edits
|
|
9
|
+
# show up live; in other environments templates are compiled once.
|
|
7
10
|
class ErbHandler
|
|
8
|
-
|
|
9
|
-
|
|
11
|
+
@cache = {}
|
|
12
|
+
@mutex = Mutex.new
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
def render(path, view)
|
|
16
|
+
erb(path).result(view.template_binding)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Clears the compiled-template cache (used by tests).
|
|
20
|
+
def reset_cache
|
|
21
|
+
@mutex.synchronize { @cache.clear }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
# Returns the compiled ERB for *path*, recompiling in development when the
|
|
27
|
+
# file's mtime changes.
|
|
28
|
+
def erb(path)
|
|
29
|
+
@mutex.synchronize do
|
|
30
|
+
entry = @cache[path]
|
|
31
|
+
mtime = Charming.env.development? ? File.mtime(path) : nil
|
|
32
|
+
if entry.nil? || (mtime && entry[:mtime] != mtime)
|
|
33
|
+
entry = {erb: compile(path), mtime: mtime}
|
|
34
|
+
@cache[path] = entry
|
|
35
|
+
end
|
|
36
|
+
entry[:erb]
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def compile(path)
|
|
41
|
+
ERB.new(File.read(path), trim_mode: "-")
|
|
42
|
+
end
|
|
10
43
|
end
|
|
11
44
|
end
|
|
12
45
|
end
|
|
@@ -56,8 +56,11 @@ module Charming
|
|
|
56
56
|
@attributes.map { |attribute| ATTRIBUTES.fetch(attribute) }
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
+
# Resolves *color* to SGR codes, downconverting to the terminal's capability
|
|
60
|
+
# (see UI::ColorSupport): truecolor → 256 → 16 → none.
|
|
59
61
|
def color_codes(color, foreground:)
|
|
60
62
|
return [] unless color
|
|
63
|
+
return [] if ColorSupport.level == :none
|
|
61
64
|
return indexed_color_code(color, foreground: foreground) if color.is_a?(Integer)
|
|
62
65
|
return named_color_code(color, foreground: foreground) if COLORS.key?(color.to_sym)
|
|
63
66
|
return truecolor_codes(color, foreground: foreground) if color.to_s.start_with?("#")
|
|
@@ -72,6 +75,7 @@ module Charming
|
|
|
72
75
|
|
|
73
76
|
def indexed_color_code(color, foreground:)
|
|
74
77
|
raise ArgumentError, "indexed color must be between 0 and 255" unless color.between?(0, 255)
|
|
78
|
+
return basic_color_code(ColorSupport.index_to_16(color), foreground: foreground) unless ColorSupport.at_least?(:color256)
|
|
75
79
|
|
|
76
80
|
[foreground ? 38 : 48, 5, color]
|
|
77
81
|
end
|
|
@@ -79,9 +83,16 @@ module Charming
|
|
|
79
83
|
def truecolor_codes(color, foreground:)
|
|
80
84
|
hex = color.to_s.delete_prefix("#")
|
|
81
85
|
raise ArgumentError, "truecolor must be #rrggbb" unless hex.match?(/\A[0-9a-fA-F]{6}\z/)
|
|
86
|
+
return [foreground ? 38 : 48, 5, ColorSupport.hex_to_256(hex)] if ColorSupport.level == :color256
|
|
87
|
+
return basic_color_code(ColorSupport.hex_to_16(hex), foreground: foreground) if ColorSupport.level == :color16
|
|
82
88
|
|
|
83
89
|
[foreground ? 38 : 48, 2, hex[0..1].to_i(16), hex[2..3].to_i(16), hex[4..5].to_i(16)]
|
|
84
90
|
end
|
|
91
|
+
|
|
92
|
+
# Wraps a basic SGR foreground code (30-37/90-97) for the requested plane.
|
|
93
|
+
def basic_color_code(code, foreground:)
|
|
94
|
+
[foreground ? code : code + 10]
|
|
95
|
+
end
|
|
85
96
|
end
|
|
86
97
|
end
|
|
87
98
|
end
|
|
@@ -6,6 +6,10 @@ module Charming
|
|
|
6
6
|
# escape sequences, preserving the styling that is active at the start of
|
|
7
7
|
# the slice and emitting a trailing reset if any styled content was copied.
|
|
8
8
|
class ANSISlicer
|
|
9
|
+
# One ANSI escape sequence or one grapheme cluster (`\X`). The ANSI branch
|
|
10
|
+
# comes first so a valid escape is consumed whole rather than as graphemes.
|
|
11
|
+
TOKEN_PATTERN = /#{Width::ANSI_PATTERN}|\X/
|
|
12
|
+
|
|
9
13
|
def self.slice(line, start_column, width)
|
|
10
14
|
return "" unless width.positive?
|
|
11
15
|
|
|
@@ -27,16 +31,12 @@ module Charming
|
|
|
27
31
|
end
|
|
28
32
|
|
|
29
33
|
def self.each_ansi_or_char(line)
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
else
|
|
37
|
-
yield line[index], false
|
|
38
|
-
index += 1
|
|
39
|
-
end
|
|
34
|
+
# Iterate one ANSI escape or one *grapheme cluster* (`\X`) at a time. A
|
|
35
|
+
# single emoji may be several codepoints (ZWJ sequences, skin-tone and
|
|
36
|
+
# variation selectors, e.g. "🧙♂️"); treating it as one unit keeps its full
|
|
37
|
+
# display width together so a slice never splits it mid-glyph.
|
|
38
|
+
line.scan(TOKEN_PATTERN) do |token|
|
|
39
|
+
yield token, token.start_with?("\e")
|
|
40
40
|
end
|
|
41
41
|
end
|
|
42
42
|
|
|
@@ -57,10 +57,17 @@ module Charming
|
|
|
57
57
|
char_start = state[:column]
|
|
58
58
|
char_end = char_start + char_width
|
|
59
59
|
state[:column] = char_end
|
|
60
|
-
|
|
60
|
+
|
|
61
|
+
visible = [char_end, end_column].min - [char_start, start_column].max
|
|
62
|
+
return unless visible.positive?
|
|
61
63
|
|
|
62
64
|
start_slice(state)
|
|
63
|
-
|
|
65
|
+
# A multi-column glyph cut by a slice boundary cannot be partially drawn,
|
|
66
|
+
# so render the in-range columns as spaces (standard terminal behavior).
|
|
67
|
+
# This keeps the slice exactly *width* columns wide regardless of where
|
|
68
|
+
# the boundaries fall relative to wide glyphs.
|
|
69
|
+
fits = char_start >= start_column && char_end <= end_column
|
|
70
|
+
state[:output] << (fits ? char : " " * visible)
|
|
64
71
|
end
|
|
65
72
|
|
|
66
73
|
def self.start_slice(state)
|
|
@@ -81,7 +88,7 @@ module Charming
|
|
|
81
88
|
if token.include?("[0m")
|
|
82
89
|
active.clear
|
|
83
90
|
else
|
|
84
|
-
active << token
|
|
91
|
+
active << token unless active.include?(token)
|
|
85
92
|
end
|
|
86
93
|
end
|
|
87
94
|
|