charming 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. checksums.yaml +4 -4
  2. data/lib/charming/application.rb +22 -5
  3. data/lib/charming/cli.rb +3 -3
  4. data/lib/charming/controller/class_methods.rb +2 -2
  5. data/lib/charming/controller/command_palette.rb +2 -2
  6. data/lib/charming/controller/component_dispatching.rb +47 -3
  7. data/lib/charming/controller/focus.rb +123 -0
  8. data/lib/charming/controller/focus_management.rb +1 -1
  9. data/lib/charming/controller/rendering.rb +6 -17
  10. data/lib/charming/controller/session_state.rb +12 -1
  11. data/lib/charming/controller.rb +11 -2
  12. data/lib/charming/database/commands.rb +106 -0
  13. data/lib/charming/generators/component_generator.rb +1 -1
  14. data/lib/charming/generators/database_installer.rb +154 -0
  15. data/lib/charming/generators/model_generator.rb +2 -10
  16. data/lib/charming/generators/name.rb +1 -1
  17. data/lib/charming/generators/templates/app/application.template +1 -1
  18. data/lib/charming/generators/templates/app/layout.template +3 -6
  19. data/lib/charming/generators/templates/app/view.template +1 -1
  20. data/lib/charming/generators/templates/component/component.rb.template +1 -1
  21. data/lib/charming/generators/templates/screen/view.rb.template +1 -1
  22. data/lib/charming/generators/templates/view/view.rb.template +1 -1
  23. data/lib/charming/generators/view_generator.rb +1 -1
  24. data/lib/charming/internal/renderer/differential.rb +13 -5
  25. data/lib/charming/internal/terminal/tty_backend.rb +22 -2
  26. data/lib/charming/presentation/component.rb +3 -5
  27. data/lib/charming/presentation/components/activity_indicator.rb +173 -134
  28. data/lib/charming/presentation/components/command_palette.rb +94 -96
  29. data/lib/charming/presentation/components/command_palette_modal.rb +33 -0
  30. data/lib/charming/presentation/components/empty_state.rb +47 -49
  31. data/lib/charming/presentation/components/form/builder.rb +52 -54
  32. data/lib/charming/presentation/components/form/confirm.rb +49 -51
  33. data/lib/charming/presentation/components/form/field.rb +94 -96
  34. data/lib/charming/presentation/components/form/input.rb +53 -55
  35. data/lib/charming/presentation/components/form/note.rb +27 -29
  36. data/lib/charming/presentation/components/form/select.rb +84 -86
  37. data/lib/charming/presentation/components/form/textarea.rb +67 -69
  38. data/lib/charming/presentation/components/form.rb +120 -122
  39. data/lib/charming/presentation/components/keyboard_handler.rb +41 -43
  40. data/lib/charming/presentation/components/list.rb +123 -125
  41. data/lib/charming/presentation/components/markdown.rb +21 -23
  42. data/lib/charming/presentation/components/modal.rb +46 -48
  43. data/lib/charming/presentation/components/progressbar.rb +51 -53
  44. data/lib/charming/presentation/components/spinner.rb +40 -42
  45. data/lib/charming/presentation/components/table.rb +109 -111
  46. data/lib/charming/presentation/components/text_area.rb +219 -221
  47. data/lib/charming/presentation/components/text_input.rb +120 -122
  48. data/lib/charming/presentation/components/viewport.rb +218 -220
  49. data/lib/charming/presentation/layout/builder.rb +64 -66
  50. data/lib/charming/presentation/layout/overlay.rb +48 -50
  51. data/lib/charming/presentation/layout/pane.rb +129 -118
  52. data/lib/charming/presentation/layout/rect.rb +19 -16
  53. data/lib/charming/presentation/layout/screen_layout.rb +47 -42
  54. data/lib/charming/presentation/layout/split.rb +107 -102
  55. data/lib/charming/presentation/layout.rb +28 -30
  56. data/lib/charming/presentation/markdown/render_context.rb +30 -14
  57. data/lib/charming/presentation/markdown/renderer.rb +302 -79
  58. data/lib/charming/presentation/markdown/style_config.rb +215 -0
  59. data/lib/charming/presentation/markdown/syntax_highlighter.rb +58 -59
  60. data/lib/charming/presentation/markdown.rb +4 -6
  61. data/lib/charming/presentation/template_view.rb +22 -24
  62. data/lib/charming/presentation/templates/erb_handler.rb +4 -6
  63. data/lib/charming/presentation/templates.rb +47 -49
  64. data/lib/charming/presentation/ui/ansi_codes.rb +66 -68
  65. data/lib/charming/presentation/ui/ansi_slicer.rb +67 -69
  66. data/lib/charming/presentation/ui/border.rb +24 -26
  67. data/lib/charming/presentation/ui/border_painter.rb +37 -39
  68. data/lib/charming/presentation/ui/canvas.rb +59 -61
  69. data/lib/charming/presentation/ui/style.rb +173 -175
  70. data/lib/charming/presentation/ui/theme.rb +133 -135
  71. data/lib/charming/presentation/ui/width.rb +12 -14
  72. data/lib/charming/presentation/ui.rb +69 -71
  73. data/lib/charming/presentation/view.rb +110 -105
  74. data/lib/charming/router.rb +3 -8
  75. data/lib/charming/runtime.rb +25 -10
  76. data/lib/charming/version.rb +1 -1
  77. data/lib/charming.rb +5 -4
  78. metadata +43 -9
  79. data/lib/charming/database_commands.rb +0 -103
  80. data/lib/charming/database_installer.rb +0 -152
  81. data/lib/charming/focus.rb +0 -121
  82. data/lib/charming/presentation/markdown/block_renderers.rb +0 -120
  83. data/lib/charming/presentation/markdown/inline_renderers.rb +0 -68
@@ -1,112 +1,335 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "kramdown"
3
+ require "commonmarker"
4
+ require "uri"
4
5
 
5
6
  module Charming
6
- module Presentation
7
- module Markdown
8
- # Renderer is the top-level Markdown-to-ANSI renderer. Parses the *content* with
9
- # Kramdown, then walks the document's block and inline trees to produce styled
10
- # terminal output. Code blocks are highlighted via Rouge when `syntax_highlighting`
11
- # is enabled.
12
- class Renderer
13
- # Wrap width used by `render_rule` when no width is otherwise specified.
14
- DEFAULT_RULE_WIDTH = 40
15
-
16
- # The Markdown source, configured wrap width, theme, and syntax-highlighting flag.
17
- attr_reader :content, :width, :theme, :syntax_highlighting
18
-
19
- # *content* is the Markdown source string. *width* optionally wraps paragraphs to that
20
- # many display columns. *theme* is the Charming theme used to style blocks/inlines.
21
- # *syntax_highlighting* enables Rouge-backed code block highlighting (default true).
22
- def initialize(content:, width: nil, theme: UI::Theme.default, syntax_highlighting: true)
23
- @content = content
24
- @width = width
25
- @theme = theme || UI::Theme.default
26
- @syntax_highlighting = syntax_highlighting
27
- end
7
+ module Markdown
8
+ # Renderer parses CommonMark/GFM with Commonmarker and renders it as ANSI text.
9
+ class Renderer
10
+ DEFAULT_RULE_WIDTH = 40
11
+
12
+ attr_reader :content, :width, :theme, :syntax_highlighting, :style, :base_url
13
+
14
+ def initialize(content:, width: nil, theme: UI::Theme.default, syntax_highlighting: true, style: :dark, base_url: nil)
15
+ @content = content
16
+ @width = width
17
+ @theme = theme || UI::Theme.default
18
+ @syntax_highlighting = syntax_highlighting
19
+ @style = StyleConfig.from(style)
20
+ @base_url = base_url
21
+ end
22
+
23
+ def render
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)
31
+ end
32
+
33
+ def render_blocks(elements, context:)
34
+ elements.filter_map do |element|
35
+ rendered = render_block(element, context: context)
36
+ rendered unless rendered.to_s.empty?
37
+ end.join("\n\n")
38
+ end
39
+
40
+ def render_inlines(elements, context:)
41
+ elements.map { |element| render_inline(element, context: context) }.join
42
+ end
43
+
44
+ def wrap(value, width:)
45
+ return value unless width
46
+
47
+ value.to_s.lines(chomp: true).map { |line| wrap_line(line, width) }.join("\n")
48
+ end
49
+
50
+ def style_for(name, fallback:)
51
+ return theme.public_send(name) if theme.respond_to?(name)
52
+
53
+ fallback
54
+ end
55
+
56
+ def theme_style(name)
57
+ return theme.public_send(name) if theme.respond_to?(name)
58
+
59
+ UI::Theme::DEFAULT_TOKENS.fetch(name).then { |token| UI::Theme.new(name => token).public_send(name) }
60
+ end
61
+
62
+ private
28
63
 
29
- # Parses the content and returns the fully-rendered Markdown as a single string.
30
- def render
31
- document = Kramdown::Document.new(content.to_s)
32
- render_blocks(document.root.children)
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
+ )
78
+ end
79
+
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)
33
106
  end
107
+ end
34
108
 
35
- # Renders a list of Kramdown block *elements* into a string, joined by blank lines.
36
- # *list_depth* is forwarded to the render context for list indentation. *width*
37
- # defaults to the renderer's configured width.
38
- def render_blocks(elements, list_depth: 0, width: @width)
39
- context = RenderContext.from(width: width, list_depth: list_depth)
40
- elements.filter_map do |element|
41
- rendered = block_renderer.render(element, context: context)
42
- rendered unless rendered.to_s.empty?
43
- end.join("\n\n")
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)
44
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
45
154
 
46
- # Renders a list of Kramdown inline *elements* into a single concatenated string.
47
- # *width* defaults to the renderer's configured width.
48
- def render_inlines(elements, width: @width)
49
- context = RenderContext.from(width: width)
50
- elements.map { |element| inline_renderer.render(element, context: context) }.join
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
51
170
  end
52
171
 
53
- # Word-wraps *value* to *width* display columns (when *width* is given), preserving
54
- # any ANSI styling on each line. Returns *value* unchanged when *width* is nil.
55
- def wrap(value, width:)
56
- return value unless width
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
57
187
 
58
- value.to_s.lines(chomp: true).map { |line| wrap_line(line, width) }.join("\n")
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)
59
200
  end
60
201
 
61
- # Returns the theme's style for *name* if the theme defines it, otherwise returns
62
- # *fallback*. Lets views override markdown-specific theme tokens.
63
- def style_for(name, fallback:)
64
- return theme.public_send(name) if theme.respond_to?(name)
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?
65
218
 
66
- fallback
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
67
223
  end
68
224
 
69
- # Returns the theme's style for *name*, building a one-token default theme when
70
- # the active theme doesn't define it. Used as a final fallback for markdown styling.
71
- def theme_style(name)
72
- return theme.public_send(name) if theme.respond_to?(name)
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
73
252
 
74
- UI::Theme::DEFAULT_TOKENS.fetch(name).then { |token| UI::Theme.new(name => token).public_send(name) }
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))
75
262
  end
76
263
 
77
- private
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
78
270
 
79
- # The BlockRenderer instance, lazily built.
80
- def block_renderer
81
- @block_renderer ||= BlockRenderer.new(renderer: self)
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
82
275
  end
276
+ end
83
277
 
84
- # The InlineRenderer instance, lazily built.
85
- def inline_renderer
86
- @inline_renderer ||= InlineRenderer.new(renderer: self)
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} "
87
283
  end
284
+ "#{separator}#{cells.join(separator)}#{separator}"
285
+ end
88
286
 
89
- # Word-wraps a single *line* to *width* display columns using greedy space-splitting.
90
- def wrap_line(line, width)
91
- return line if UI::Width.measure(line) <= width
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?
92
295
 
93
- lines = []
94
- current = +""
296
+ UI::Width.measure((style.indent_token || " ") * style.indent)
297
+ end
95
298
 
96
- line.split(/\s+/).each do |word|
97
- candidate = current.empty? ? word : "#{current} #{word}"
299
+ def resolve_url(value, context:)
300
+ return value if context.base_url.to_s.empty? || value.empty?
98
301
 
99
- if !current.empty? && UI::Width.measure(candidate) > width
100
- lines << current.rstrip
101
- current = word
102
- else
103
- current = candidate
104
- end
105
- end
302
+ uri = URI.parse(value)
303
+ return value if uri.absolute?
106
304
 
107
- lines << current.rstrip unless current.empty?
108
- lines.join("\n")
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
312
+ 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
109
329
  end
330
+
331
+ lines << current.rstrip unless current.empty?
332
+ lines.join("\n")
110
333
  end
111
334
  end
112
335
  end
@@ -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