charming 0.1.4 → 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 +3 -16
- 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 +11 -2
- data/lib/charming/presentation/layout/overlay.rb +6 -4
- data/lib/charming/presentation/layout/pane.rb +35 -109
- data/lib/charming/presentation/layout/pane_behavior.rb +39 -0
- data/lib/charming/presentation/layout/pane_geometry.rb +79 -0
- data/lib/charming/presentation/layout/pane_style.rb +39 -0
- 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 +39 -49
|
@@ -39,12 +39,12 @@ module Charming
|
|
|
39
39
|
child.mouse_targets(Rect.new(x: 0, y: 0, width: screen.width, height: screen.height))
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
-
# Renders the child into the full-screen rect, then
|
|
43
|
-
#
|
|
42
|
+
# Renders the child into the full-screen rect, then composites each overlay on top —
|
|
43
|
+
# ordered by z_index (higher paints last), with registration order breaking ties.
|
|
44
44
|
def render
|
|
45
45
|
body = UI.place(render_child, width: screen.width, height: screen.height, background: background)
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
stacked_overlays.reduce(body) do |current, overlay|
|
|
48
48
|
UI.overlay(current, overlay.render, top: overlay.top, left: overlay.left)
|
|
49
49
|
end
|
|
50
50
|
end
|
|
@@ -60,6 +60,15 @@ module Charming
|
|
|
60
60
|
|
|
61
61
|
child.render(Rect.new(x: 0, y: 0, width: screen.width, height: screen.height))
|
|
62
62
|
end
|
|
63
|
+
|
|
64
|
+
# Overlays sorted by z_index (stable: registration order breaks ties). Overlays
|
|
65
|
+
# without a z_index reader (custom nodes) sort at 0.
|
|
66
|
+
def stacked_overlays
|
|
67
|
+
overlays.each_with_index.sort_by do |overlay, index|
|
|
68
|
+
z = overlay.respond_to?(:z_index) ? overlay.z_index.to_i : 0
|
|
69
|
+
[z, index]
|
|
70
|
+
end.map(&:first)
|
|
71
|
+
end
|
|
63
72
|
end
|
|
64
73
|
end
|
|
65
74
|
end
|
|
@@ -90,8 +90,10 @@ module Charming
|
|
|
90
90
|
end
|
|
91
91
|
|
|
92
92
|
# Computes the size of each child along the *axis* given the *available* cells.
|
|
93
|
-
# Subtracts the total gap, allocates fixed sizes first,
|
|
94
|
-
# among flexible (non-fixed) children by their grow weights
|
|
93
|
+
# Subtracts the total gap, allocates fixed sizes first, distributes the remainder
|
|
94
|
+
# among flexible (non-fixed) children by their grow weights, then clamps every
|
|
95
|
+
# child to its min/max constraints (re-balancing the difference onto flexible
|
|
96
|
+
# children with remaining slack).
|
|
95
97
|
def child_sizes(axis:, available:)
|
|
96
98
|
gap_size = gap * [children.length - 1, 0].max
|
|
97
99
|
available_for_children = [available - gap_size, 0].max
|
|
@@ -100,7 +102,39 @@ module Charming
|
|
|
100
102
|
sizes = fixed.map { |size| size&.to_i }
|
|
101
103
|
remaining = [available_for_children - sizes.compact.sum, 0].max
|
|
102
104
|
|
|
103
|
-
distribute_remaining(sizes, flexible_indexes, remaining)
|
|
105
|
+
sizes = distribute_remaining(sizes, flexible_indexes, remaining)
|
|
106
|
+
apply_constraints(sizes, flexible_indexes, axis, available_for_children)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Clamps each child's size to its min/max constraints, then pushes the resulting
|
|
110
|
+
# surplus or deficit onto the last flexible child that can absorb it without
|
|
111
|
+
# violating its own constraints.
|
|
112
|
+
def apply_constraints(sizes, flexible_indexes, axis, available)
|
|
113
|
+
constrained = sizes.each_with_index.map { |size, index| clamp_size(size, children[index], axis) }
|
|
114
|
+
difference = available - constrained.sum
|
|
115
|
+
return constrained if difference.zero?
|
|
116
|
+
|
|
117
|
+
absorber = flexible_indexes.rfind do |index|
|
|
118
|
+
adjusted = constrained[index] + difference
|
|
119
|
+
adjusted >= 0 && adjusted == clamp_size(adjusted, children[index], axis)
|
|
120
|
+
end
|
|
121
|
+
constrained[absorber] += difference if absorber
|
|
122
|
+
constrained
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Clamps *size* to the child's min/max constraint along *axis* (when declared).
|
|
126
|
+
def clamp_size(size, child, axis)
|
|
127
|
+
min = constraint(child, (axis == :horizontal) ? :min_width : :min_height)
|
|
128
|
+
max = constraint(child, (axis == :horizontal) ? :max_width : :max_height)
|
|
129
|
+
size = [size, min].max if min
|
|
130
|
+
size = [size, max].min if max
|
|
131
|
+
size
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Reads a constraint reader from *child* when it responds to it (Panes do, nested
|
|
135
|
+
# Splits currently don't).
|
|
136
|
+
def constraint(child, name)
|
|
137
|
+
child.respond_to?(name) ? child.public_send(name) : nil
|
|
104
138
|
end
|
|
105
139
|
|
|
106
140
|
# Returns the fixed size of *child* along *axis* (`:horizontal` reads width, `:vertical` reads height).
|
|
@@ -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
|