charming 0.1.1 → 0.1.3

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 (132) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/lib/charming/application.rb +14 -3
  4. data/lib/charming/cli.rb +23 -0
  5. data/lib/charming/controller/class_methods.rb +115 -0
  6. data/lib/charming/controller/command_palette.rb +135 -0
  7. data/lib/charming/controller/component_dispatching.rb +81 -0
  8. data/lib/charming/controller/dispatching.rb +60 -0
  9. data/lib/charming/controller/focus_management.rb +30 -0
  10. data/lib/charming/controller/rendering.rb +127 -0
  11. data/lib/charming/controller/session_state.rb +41 -0
  12. data/lib/charming/controller/sidebar_navigation.rb +111 -0
  13. data/lib/charming/controller.rb +35 -559
  14. data/lib/charming/database_commands.rb +16 -0
  15. data/lib/charming/database_installer.rb +27 -0
  16. data/lib/charming/focus.rb +58 -2
  17. data/lib/charming/generators/app_file_generator.rb +13 -0
  18. data/lib/charming/generators/app_generator.rb +123 -47
  19. data/lib/charming/generators/base.rb +26 -0
  20. data/lib/charming/generators/component_generator.rb +10 -10
  21. data/lib/charming/generators/controller_generator.rb +22 -11
  22. data/lib/charming/generators/model_generator.rb +38 -29
  23. data/lib/charming/generators/name.rb +10 -0
  24. data/lib/charming/generators/screen_generator.rb +78 -32
  25. data/lib/charming/generators/templates/app/Gemfile.template +5 -0
  26. data/lib/charming/generators/templates/app/README.md.template +9 -0
  27. data/lib/charming/generators/templates/app/Rakefile.template +3 -0
  28. data/lib/charming/generators/templates/app/application.template +13 -0
  29. data/lib/charming/generators/templates/app/application_controller.template +19 -0
  30. data/lib/charming/generators/templates/app/application_record.template +7 -0
  31. data/lib/charming/generators/templates/app/application_state.template +6 -0
  32. data/lib/charming/generators/templates/app/database_config.template +12 -0
  33. data/lib/charming/generators/templates/app/executable.template +7 -0
  34. data/lib/charming/generators/templates/app/gemspec.template +6 -0
  35. data/lib/charming/generators/templates/app/home_controller.template +6 -0
  36. data/lib/charming/generators/templates/app/home_state.template +7 -0
  37. data/lib/charming/generators/templates/app/keep.template +0 -0
  38. data/lib/charming/generators/templates/app/layout.template +110 -0
  39. data/lib/charming/generators/templates/app/root_file.template +20 -0
  40. data/lib/charming/generators/templates/app/routes.template +5 -0
  41. data/lib/charming/generators/templates/app/seeds.template +1 -0
  42. data/lib/charming/generators/templates/app/spec_controller.template +17 -0
  43. data/lib/charming/generators/templates/app/spec_helper.template +3 -0
  44. data/lib/charming/generators/templates/app/spec_state.template +17 -0
  45. data/lib/charming/generators/templates/app/spec_view.template +16 -0
  46. data/lib/charming/generators/templates/app/version.template +5 -0
  47. data/lib/charming/generators/templates/app/view.template +21 -0
  48. data/lib/charming/generators/templates/component/component.rb.template +9 -0
  49. data/lib/charming/generators/templates/controller/controller.rb.template +6 -0
  50. data/lib/charming/generators/templates/model/migration.rb.template +9 -0
  51. data/lib/charming/generators/templates/model/model.rb.template +6 -0
  52. data/lib/charming/generators/templates/model/spec.rb.template +9 -0
  53. data/lib/charming/generators/templates/screen/controller.rb.template +7 -0
  54. data/lib/charming/generators/templates/screen/spec_controller.rb.template +17 -0
  55. data/lib/charming/generators/templates/screen/spec_state.rb.template +17 -0
  56. data/lib/charming/generators/templates/screen/spec_view.rb.template +13 -0
  57. data/lib/charming/generators/templates/screen/state.rb.template +7 -0
  58. data/lib/charming/generators/templates/screen/view.rb.template +11 -0
  59. data/lib/charming/generators/templates/view/view.rb.template +11 -0
  60. data/lib/charming/generators/view_generator.rb +19 -3
  61. data/lib/charming/internal/renderer/differential.rb +25 -2
  62. data/lib/charming/internal/renderer/full_repaint.rb +6 -0
  63. data/lib/charming/internal/terminal/adapter.rb +29 -3
  64. data/lib/charming/internal/terminal/key_normalizer.rb +84 -0
  65. data/lib/charming/internal/terminal/memory_backend.rb +28 -1
  66. data/lib/charming/internal/terminal/mouse_parser.rb +81 -0
  67. data/lib/charming/internal/terminal/tty_backend.rb +65 -115
  68. data/lib/charming/presentation/component.rb +3 -5
  69. data/lib/charming/presentation/components/activity_indicator.rb +173 -134
  70. data/lib/charming/presentation/components/command_palette.rb +94 -96
  71. data/lib/charming/presentation/components/command_palette_modal.rb +33 -0
  72. data/lib/charming/presentation/components/empty_state.rb +47 -36
  73. data/lib/charming/presentation/components/form/builder.rb +52 -40
  74. data/lib/charming/presentation/components/form/confirm.rb +50 -39
  75. data/lib/charming/presentation/components/form/field.rb +94 -71
  76. data/lib/charming/presentation/components/form/input.rb +61 -49
  77. data/lib/charming/presentation/components/form/note.rb +27 -20
  78. data/lib/charming/presentation/components/form/select.rb +84 -63
  79. data/lib/charming/presentation/components/form/textarea.rb +67 -53
  80. data/lib/charming/presentation/components/form.rb +120 -93
  81. data/lib/charming/presentation/components/keyboard_handler.rb +41 -43
  82. data/lib/charming/presentation/components/list.rb +123 -97
  83. data/lib/charming/presentation/components/markdown.rb +21 -17
  84. data/lib/charming/presentation/components/modal.rb +55 -43
  85. data/lib/charming/presentation/components/progressbar.rb +61 -50
  86. data/lib/charming/presentation/components/spinner.rb +35 -27
  87. data/lib/charming/presentation/components/table.rb +108 -85
  88. data/lib/charming/presentation/components/text_area.rb +219 -173
  89. data/lib/charming/presentation/components/text_input.rb +120 -98
  90. data/lib/charming/presentation/components/viewport.rb +218 -168
  91. data/lib/charming/presentation/layout/builder.rb +84 -0
  92. data/lib/charming/presentation/layout/overlay.rb +55 -0
  93. data/lib/charming/presentation/layout/pane.rb +149 -0
  94. data/lib/charming/presentation/layout/rect.rb +21 -0
  95. data/lib/charming/presentation/layout/screen_layout.rb +58 -0
  96. data/lib/charming/presentation/layout/split.rb +132 -0
  97. data/lib/charming/presentation/layout.rb +28 -30
  98. data/lib/charming/presentation/markdown/block_renderers.rb +118 -0
  99. data/lib/charming/presentation/markdown/inline_renderers.rb +66 -0
  100. data/lib/charming/presentation/markdown/render_context.rb +20 -0
  101. data/lib/charming/presentation/markdown/renderer.rb +82 -174
  102. data/lib/charming/presentation/markdown/syntax_highlighter.rb +58 -44
  103. data/lib/charming/presentation/markdown.rb +4 -3
  104. data/lib/charming/presentation/template_view.rb +22 -17
  105. data/lib/charming/presentation/templates/erb_handler.rb +4 -6
  106. data/lib/charming/presentation/templates.rb +47 -32
  107. data/lib/charming/presentation/ui/ansi_codes.rb +87 -0
  108. data/lib/charming/presentation/ui/ansi_slicer.rb +92 -0
  109. data/lib/charming/presentation/ui/border.rb +24 -26
  110. data/lib/charming/presentation/ui/border_painter.rb +56 -0
  111. data/lib/charming/presentation/ui/canvas.rb +80 -0
  112. data/lib/charming/presentation/ui/style.rb +170 -205
  113. data/lib/charming/presentation/ui/theme.rb +133 -135
  114. data/lib/charming/presentation/ui/width.rb +12 -14
  115. data/lib/charming/presentation/ui.rb +70 -213
  116. data/lib/charming/presentation/view.rb +107 -92
  117. data/lib/charming/runtime.rb +25 -10
  118. data/lib/charming/tasks/inline_executor.rb +9 -0
  119. data/lib/charming/tasks/task.rb +3 -0
  120. data/lib/charming/tasks/threaded_executor.rb +12 -0
  121. data/lib/charming/version.rb +1 -1
  122. data/lib/charming.rb +16 -2
  123. metadata +60 -10
  124. data/lib/charming/generators/app_generator/app_spec_templates.rb +0 -90
  125. data/lib/charming/generators/app_generator/basic_templates.rb +0 -81
  126. data/lib/charming/generators/app_generator/component_templates.rb +0 -36
  127. data/lib/charming/generators/app_generator/controller_template.rb +0 -60
  128. data/lib/charming/generators/app_generator/database_templates.rb +0 -45
  129. data/lib/charming/generators/app_generator/layout_template.rb +0 -66
  130. data/lib/charming/generators/app_generator/screen_spec_templates.rb +0 -69
  131. data/lib/charming/generators/app_generator/state_templates.rb +0 -30
  132. data/lib/charming/generators/app_generator/view_template.rb +0 -84
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Markdown
5
+ # BlockRenderer dispatches Kramdown block-level elements (paragraph, header, list,
6
+ # code block, etc.) to their individual rendering handlers. Handlers are built once
7
+ # at construction time as a frozen hash of element-type symbols to callables.
8
+ class BlockRenderer
9
+ # *renderer* is the parent Renderer (used to wrap text, render inlines, and look up styles).
10
+ def initialize(renderer:)
11
+ @renderer = renderer
12
+ build_handlers
13
+ end
14
+
15
+ # Renders *element* using the handler registered for `element.type`. Unknown types
16
+ # fall through to `render_unknown`.
17
+ def render(element, context:)
18
+ handler = @handlers[element.type] || method(:render_unknown)
19
+ handler.call(element, context)
20
+ end
21
+
22
+ private
23
+
24
+ # The frozen hash of element-type → handler mapping.
25
+ attr_reader :handlers
26
+
27
+ # Builds the handler hash. Each handler is a small lambda that calls back into the
28
+ # parent renderer (or one of the private render_* methods below).
29
+ def build_handlers
30
+ r = @renderer
31
+ @handlers = {
32
+ p: ->(element, context) { r.wrap(r.render_inlines(element.children), width: context.width) },
33
+ header: ->(element, context) { send(:render_header, element, context) },
34
+ blockquote: ->(element, context) { send(:render_blockquote, element, context) },
35
+ ul: ->(element, context) { send(:render_list, element, ordered: false, context: context) },
36
+ ol: ->(element, context) { send(:render_list, element, ordered: true, context: context) },
37
+ li: ->(element, context) { r.render_blocks(element.children, list_depth: context.list_depth, width: context.width) },
38
+ codeblock: ->(element, _context) { send(:render_codeblock, element) },
39
+ hr: ->(element, context) { send(:render_rule, width: context.width) },
40
+ blank: ->(_element, _context) {}
41
+ }.freeze
42
+ end
43
+
44
+ # Fallback for unknown block types: wraps the raw value when there are no children,
45
+ # otherwise recurses into the children.
46
+ def render_unknown(element, context)
47
+ return @renderer.wrap(element.value.to_s, width: context.width) if element.children.empty?
48
+
49
+ @renderer.render_blocks(element.children, list_depth: context.list_depth, width: context.width)
50
+ end
51
+
52
+ # Renders a header element, using the `markdown_heading` style for h1 and the
53
+ # `markdown_subheading` style for h2+.
54
+ def render_header(element, context)
55
+ rendered = @renderer.wrap(@renderer.render_inlines(element.children), width: context.width)
56
+ style = if element.options[:level].to_i == 1
57
+ @renderer.style_for(:markdown_heading, fallback: @renderer.theme_style(:title))
58
+ else
59
+ @renderer.style_for(:markdown_subheading, fallback: @renderer.theme_style(:title))
60
+ end
61
+ style.render(rendered)
62
+ end
63
+
64
+ def render_blockquote(element, context)
65
+ quote_width = context.width ? [context.width - 2, 1].max : nil
66
+ rendered = @renderer.render_blocks(element.children, list_depth: context.list_depth, width: quote_width)
67
+ border = @renderer.style_for(:markdown_quote_border, fallback: @renderer.theme_style(:border)).render("|")
68
+ quote_style = @renderer.style_for(:markdown_quote, fallback: @renderer.theme_style(:muted))
69
+
70
+ rendered.lines(chomp: true).map { |line| "#{border} #{quote_style.render(line)}" }.join("\n")
71
+ end
72
+
73
+ def render_list(element, ordered:, context:)
74
+ element.children.each_with_index.map do |item, index|
75
+ marker = ordered ? "#{ordered_start(element) + index}." : "-"
76
+ render_list_item(item, marker: marker, context: context)
77
+ end.join("\n")
78
+ end
79
+
80
+ def render_list_item(element, marker:, context:)
81
+ indent = " " * context.list_depth
82
+ first_prefix = "#{indent}#{marker} "
83
+ rest_prefix = "#{indent}#{" " * (marker.length + 1)}"
84
+ item_width = context.width ? [context.width - UI::Width.measure(first_prefix), 1].max : nil
85
+ body = @renderer.render_blocks(element.children, list_depth: context.list_depth + 1, width: item_width)
86
+
87
+ body.lines(chomp: true).each_with_index.map do |line, index|
88
+ "#{index.zero? ? first_prefix : rest_prefix}#{line}"
89
+ end.join("\n")
90
+ end
91
+
92
+ def ordered_start(element)
93
+ element.options.fetch(:start, 1).to_i
94
+ end
95
+
96
+ def render_codeblock(element)
97
+ code = element.value.to_s
98
+ rendered = if @renderer.syntax_highlighting
99
+ SyntaxHighlighter.new(theme: @renderer.theme).render(code, language: code_language(element))
100
+ else
101
+ @renderer.style_for(:markdown_code, fallback: @renderer.theme_style(:warn)).render(code)
102
+ end
103
+
104
+ rendered.lines(chomp: true).map { |line| " #{line}" }.join("\n")
105
+ end
106
+
107
+ def render_rule(width:)
108
+ @renderer.style_for(:markdown_rule, fallback: @renderer.theme_style(:border)).render("-" * (width || Renderer::DEFAULT_RULE_WIDTH))
109
+ end
110
+
111
+ def code_language(element)
112
+ return element.options[:lang] if element.options[:lang]
113
+
114
+ element.attr["class"].to_s[/language-([^\s]+)/, 1]
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Markdown
5
+ # InlineRenderer dispatches Kramdown inline-level elements (text, strong, em,
6
+ # codespan, link, line break, HTML entity) to their individual rendering handlers.
7
+ # Handlers are built once at construction as a frozen hash of element-type symbols
8
+ # to callables.
9
+ class InlineRenderer
10
+ # *renderer* is the parent Renderer (used to render nested inlines and look up styles).
11
+ def initialize(renderer:)
12
+ @renderer = renderer
13
+ build_handlers
14
+ end
15
+
16
+ # Renders *element* using the handler registered for `element.type`. Unknown types
17
+ # fall through to `render_unknown`.
18
+ def render(element, context:)
19
+ handler = @handlers[element.type] || method(:render_unknown)
20
+ handler.call(element, context)
21
+ end
22
+
23
+ private
24
+
25
+ # The frozen hash of element-type → handler mapping.
26
+ attr_reader :handlers
27
+
28
+ # Builds the handler hash for text, strong, em, codespan, link, br, and entity.
29
+ def build_handlers
30
+ r = @renderer
31
+ @handlers = {
32
+ text: ->(element, _context) { element.value.to_s },
33
+ strong: ->(element, context) { render_styled(element, context, :markdown_strong) { |s| s.bold } },
34
+ em: ->(element, context) { render_styled(element, context, :markdown_emphasis) { |s| s.italic } },
35
+ codespan: ->(element, _context) { r.style_for(:markdown_inline_code, fallback: r.theme_style(:warn)).render(element.value.to_s) },
36
+ a: ->(element, context) { send(:render_link, element, context) },
37
+ br: ->(_element, _context) { "\n" },
38
+ entity: ->(element, _context) { element.value.respond_to?(:char) ? element.value.char : element.value.to_s }
39
+ }.freeze
40
+ end
41
+
42
+ # Renders a styled inline (strong/em) by first rendering children, then applying
43
+ # the theme style and the block-form (e.g., `bold`/`italic`) decoration.
44
+ def render_styled(element, context, style_name)
45
+ rendered = @renderer.render_inlines(element.children, width: context.width)
46
+ style = @renderer.style_for(style_name, fallback: yield(@renderer.theme_style(:text)))
47
+ style.render(rendered)
48
+ end
49
+
50
+ # Renders a Markdown link as "label <href>" (URL omitted when empty), styled with
51
+ # the markdown_link theme token or the info+underline fallback.
52
+ def render_link(element, context)
53
+ label = @renderer.render_inlines(element.children, width: context.width)
54
+ href = element.attr["href"].to_s
55
+ rendered = href.empty? ? label : "#{label} <#{href}>"
56
+ @renderer.style_for(:markdown_link, fallback: @renderer.theme_style(:info).underline).render(rendered)
57
+ end
58
+
59
+ # Fallback for unknown inline types: returns the value when there are no children,
60
+ # otherwise recurses into the children.
61
+ def render_unknown(element, context)
62
+ element.children.empty? ? element.value.to_s : @renderer.render_inlines(element.children, width: context.width)
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Markdown
5
+ # RenderContext carries the state needed to render nested Markdown blocks: the current
6
+ # list nesting depth (used for indentation) and the wrap width.
7
+ RenderContext = Data.define(:list_depth, :width) do
8
+ # Builds a new RenderContext with the given *width* and optional starting *list_depth*.
9
+ def self.from(width:, list_depth: 0)
10
+ new(list_depth: list_depth, width: width)
11
+ end
12
+
13
+ # Returns a derived context with the list depth incremented by *depth_increment*
14
+ # and the wrap width overridden to *width* (defaults to the current width).
15
+ def nested(depth_increment: 0, width: self.width)
16
+ self.class.new(list_depth: list_depth + depth_increment, width: width)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -3,200 +3,108 @@
3
3
  require "kramdown"
4
4
 
5
5
  module Charming
6
- module Presentation
7
- module Markdown
8
- class Renderer
9
- DEFAULT_RULE_WIDTH = 40
10
-
11
- def initialize(content:, width: nil, theme: UI::Theme.default, syntax_highlighting: true)
12
- @content = content
13
- @width = width
14
- @theme = theme || UI::Theme.default
15
- @syntax_highlighting = syntax_highlighting
16
- end
17
-
18
- def render
19
- document = Kramdown::Document.new(content.to_s)
20
- render_blocks(document.root.children)
21
- end
22
-
23
- private
24
-
25
- attr_reader :content, :width, :theme
6
+ module Markdown
7
+ # Renderer is the top-level Markdown-to-ANSI renderer. Parses the *content* with
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.
11
+ class Renderer
12
+ # Wrap width used by `render_rule` when no width is otherwise specified.
13
+ DEFAULT_RULE_WIDTH = 40
14
+
15
+ # The Markdown source, configured wrap width, theme, and syntax-highlighting flag.
16
+ attr_reader :content, :width, :theme, :syntax_highlighting
17
+
18
+ # *content* is the Markdown source string. *width* optionally wraps paragraphs to that
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)
22
+ @content = content
23
+ @width = width
24
+ @theme = theme || UI::Theme.default
25
+ @syntax_highlighting = syntax_highlighting
26
+ end
26
27
 
27
- def render_blocks(elements, list_depth: 0, width: @width)
28
- elements.filter_map do |element|
29
- rendered = render_block(element, list_depth: list_depth, width: width)
30
- rendered unless rendered.to_s.empty?
31
- end.join("\n\n")
32
- end
28
+ # Parses the content and returns the fully-rendered Markdown as a single string.
29
+ def render
30
+ document = Kramdown::Document.new(content.to_s)
31
+ render_blocks(document.root.children)
32
+ end
33
33
 
34
- def render_block(element, list_depth: 0, width: @width)
35
- case element.type
36
- when :p
37
- wrap(render_inlines(element.children), width: width)
38
- when :header
39
- render_header(element, width: width)
40
- when :blockquote
41
- render_blockquote(element, list_depth: list_depth, width: width)
42
- when :ul
43
- render_list(element, ordered: false, list_depth: list_depth, width: width)
44
- when :ol
45
- render_list(element, ordered: true, list_depth: list_depth, width: width)
46
- when :li
47
- render_blocks(element.children, list_depth: list_depth, width: width)
48
- when :codeblock
49
- render_codeblock(element)
50
- when :hr
51
- render_rule(width: width)
52
- when :blank
53
- nil
54
- else
55
- render_unknown(element, list_depth: list_depth, width: width)
56
- end
57
- end
34
+ # Renders a list of Kramdown block *elements* into a string, joined by blank lines.
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)
39
+ elements.filter_map do |element|
40
+ rendered = block_renderer.render(element, context: context)
41
+ rendered unless rendered.to_s.empty?
42
+ end.join("\n\n")
43
+ end
58
44
 
59
- def render_unknown(element, list_depth:, width:)
60
- return wrap(element.value.to_s, width: width) if element.children.empty?
45
+ # Renders a list of Kramdown inline *elements* into a single concatenated string.
46
+ # *width* defaults to the renderer's configured width.
47
+ def render_inlines(elements, width: @width)
48
+ context = RenderContext.from(width: width)
49
+ elements.map { |element| inline_renderer.render(element, context: context) }.join
50
+ end
61
51
 
62
- render_blocks(element.children, list_depth: list_depth, width: width)
63
- end
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
+ def wrap(value, width:)
55
+ return value unless width
64
56
 
65
- def render_header(element, width:)
66
- rendered = wrap(render_inlines(element.children), width: width)
67
- style = if element.options[:level].to_i == 1
68
- style_for(:markdown_heading, fallback: theme_style(:title))
69
- else
70
- style_for(:markdown_subheading, fallback: theme_style(:title))
71
- end
72
- style.render(rendered)
73
- end
57
+ value.to_s.lines(chomp: true).map { |line| wrap_line(line, width) }.join("\n")
58
+ end
74
59
 
75
- def render_blockquote(element, list_depth:, width:)
76
- quote_width = width ? [width - 2, 1].max : nil
77
- rendered = render_blocks(element.children, list_depth: list_depth, width: quote_width)
78
- border = style_for(:markdown_quote_border, fallback: theme_style(:border)).render("|")
79
- quote_style = style_for(:markdown_quote, fallback: theme_style(:muted))
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
+ def style_for(name, fallback:)
63
+ return theme.public_send(name) if theme.respond_to?(name)
80
64
 
81
- rendered.lines(chomp: true).map do |line|
82
- "#{border} #{quote_style.render(line)}"
83
- end.join("\n")
84
- end
65
+ fallback
66
+ end
85
67
 
86
- def render_list(element, ordered:, list_depth:, width:)
87
- element.children.each_with_index.map do |item, index|
88
- marker = ordered ? "#{ordered_start(element) + index}." : "-"
89
- render_list_item(item, marker: marker, list_depth: list_depth, width: width)
90
- end.join("\n")
91
- end
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
+ def theme_style(name)
71
+ return theme.public_send(name) if theme.respond_to?(name)
92
72
 
93
- def render_list_item(element, marker:, list_depth:, width:)
94
- indent = " " * list_depth
95
- first_prefix = "#{indent}#{marker} "
96
- rest_prefix = "#{indent}#{" " * (marker.length + 1)}"
97
- item_width = width ? [width - UI::Width.measure(first_prefix), 1].max : nil
98
- body = render_blocks(element.children, list_depth: list_depth + 1, width: item_width)
73
+ UI::Theme::DEFAULT_TOKENS.fetch(name).then { |token| UI::Theme.new(name => token).public_send(name) }
74
+ end
99
75
 
100
- body.lines(chomp: true).each_with_index.map do |line, index|
101
- "#{index.zero? ? first_prefix : rest_prefix}#{line}"
102
- end.join("\n")
103
- end
76
+ private
104
77
 
105
- def ordered_start(element)
106
- element.options.fetch(:start, 1).to_i
107
- end
78
+ # The BlockRenderer instance, lazily built.
79
+ def block_renderer
80
+ @block_renderer ||= BlockRenderer.new(renderer: self)
81
+ end
108
82
 
109
- def render_codeblock(element)
110
- code = element.value.to_s
111
- rendered = if @syntax_highlighting
112
- SyntaxHighlighter.new(theme: theme).render(code, language: code_language(element))
113
- else
114
- style_for(:markdown_code, fallback: theme_style(:warn)).render(code)
115
- end
83
+ # The InlineRenderer instance, lazily built.
84
+ def inline_renderer
85
+ @inline_renderer ||= InlineRenderer.new(renderer: self)
86
+ end
116
87
 
117
- rendered.lines(chomp: true).map { |line| " #{line}" }.join("\n")
118
- end
88
+ # Word-wraps a single *line* to *width* display columns using greedy space-splitting.
89
+ def wrap_line(line, width)
90
+ return line if UI::Width.measure(line) <= width
119
91
 
120
- def render_rule(width:)
121
- style_for(:markdown_rule, fallback: theme_style(:border)).render("-" * (width || DEFAULT_RULE_WIDTH))
122
- end
92
+ lines = []
93
+ current = +""
123
94
 
124
- def render_inlines(elements)
125
- elements.map { |element| render_inline(element) }.join
126
- end
95
+ line.split(/\s+/).each do |word|
96
+ candidate = current.empty? ? word : "#{current} #{word}"
127
97
 
128
- def render_inline(element)
129
- case element.type
130
- when :text
131
- element.value.to_s
132
- when :strong
133
- style_for(:markdown_strong, fallback: theme_style(:text).bold).render(render_inlines(element.children))
134
- when :em
135
- style_for(:markdown_emphasis, fallback: theme_style(:text).italic).render(render_inlines(element.children))
136
- when :codespan
137
- style_for(:markdown_inline_code, fallback: theme_style(:warn)).render(element.value.to_s)
138
- when :a
139
- render_link(element)
140
- when :br
141
- "\n"
142
- when :entity
143
- element.value.respond_to?(:char) ? element.value.char : element.value.to_s
98
+ if !current.empty? && UI::Width.measure(candidate) > width
99
+ lines << current.rstrip
100
+ current = word
144
101
  else
145
- element.children.empty? ? element.value.to_s : render_inlines(element.children)
102
+ current = candidate
146
103
  end
147
104
  end
148
105
 
149
- def render_link(element)
150
- label = render_inlines(element.children)
151
- href = element.attr["href"].to_s
152
- rendered = href.empty? ? label : "#{label} <#{href}>"
153
- style_for(:markdown_link, fallback: theme_style(:info).underline).render(rendered)
154
- end
155
-
156
- def code_language(element)
157
- return element.options[:lang] if element.options[:lang]
158
-
159
- element.attr["class"].to_s[/language-([^\s]+)/, 1]
160
- end
161
-
162
- def wrap(value, width:)
163
- return value unless width
164
-
165
- value.to_s.lines(chomp: true).map { |line| wrap_line(line, width) }.join("\n")
166
- end
167
-
168
- def wrap_line(line, width)
169
- return line if UI::Width.measure(line) <= width
170
-
171
- lines = []
172
- current = +""
173
-
174
- line.split(/\s+/).each do |word|
175
- candidate = current.empty? ? word : "#{current} #{word}"
176
-
177
- if !current.empty? && UI::Width.measure(candidate) > width
178
- lines << current.rstrip
179
- current = word
180
- else
181
- current = candidate
182
- end
183
- end
184
-
185
- lines << current.rstrip unless current.empty?
186
- lines.join("\n")
187
- end
188
-
189
- def style_for(name, fallback:)
190
- return theme.public_send(name) if theme.respond_to?(name)
191
-
192
- fallback
193
- end
194
-
195
- def theme_style(name)
196
- return theme.public_send(name) if theme.respond_to?(name)
197
-
198
- UI::Theme::DEFAULT_TOKENS.fetch(name).then { |token| UI::Theme.new(name => token).public_send(name) }
199
- end
106
+ lines << current.rstrip unless current.empty?
107
+ lines.join("\n")
200
108
  end
201
109
  end
202
110
  end
@@ -3,60 +3,74 @@
3
3
  require "rouge"
4
4
 
5
5
  module Charming
6
- module Presentation
7
- module Markdown
8
- class SyntaxHighlighter
9
- def initialize(theme: UI::Theme.default)
10
- @theme = theme || UI::Theme.default
11
- end
6
+ module Markdown
7
+ # SyntaxHighlighter turns a code block string into ANSI-styled terminal text using
8
+ # Rouge lexers. The theme provides markdown_code_* tokens for per-token styling;
9
+ # when a token is undefined in the theme, the highlighter falls back to a sensible
10
+ # base style (muted italic for comments, title for keywords, etc.).
11
+ class SyntaxHighlighter
12
+ # *theme* is the active Charming theme. Defaults to UI::Theme.default.
13
+ def initialize(theme: UI::Theme.default)
14
+ @theme = theme || UI::Theme.default
15
+ end
12
16
 
13
- def render(code, language: nil)
14
- lexer = lexer_for(language, code)
15
- lexer.lex(code.to_s).map do |token, value|
16
- style_for(token).render(value)
17
- end.join
18
- end
17
+ # Highlights *code* (using Rouge) for the given *language* (auto-detected when nil)
18
+ # and returns a styled multi-line string. Each Rouge token is rendered with the
19
+ # theme style matching its token type.
20
+ def render(code, language: nil)
21
+ lexer = lexer_for(language, code)
22
+ lexer.lex(code.to_s).map do |token, value|
23
+ style_for(token).render(value)
24
+ end.join
25
+ end
19
26
 
20
- private
27
+ private
21
28
 
22
- attr_reader :theme
29
+ # The Charming theme used for token styling.
30
+ attr_reader :theme
23
31
 
24
- def lexer_for(language, code)
25
- Rouge::Lexer.find_fancy(language, code) || Rouge::Lexers::PlainText
26
- end
32
+ # Picks a Rouge lexer for *language* and *code*, falling back to plain text.
33
+ def lexer_for(language, code)
34
+ Rouge::Lexer.find_fancy(language, code) || Rouge::Lexers::PlainText
35
+ end
27
36
 
28
- def style_for(token)
29
- name = token_name(token)
30
-
31
- case name
32
- when /Comment/
33
- theme_style(:markdown_code_comment, fallback: theme_style(:muted).italic)
34
- when /Keyword/
35
- theme_style(:markdown_code_keyword, fallback: theme_style(:title))
36
- when /String/
37
- theme_style(:markdown_code_string, fallback: theme_style(:warn))
38
- when /Number|Literal/
39
- theme_style(:markdown_code_literal, fallback: theme_style(:info))
40
- when /Name\.(Class|Constant|Function|Namespace)/
41
- theme_style(:markdown_code_constant, fallback: theme_style(:info))
42
- when /Error/
43
- theme_style(:markdown_code_error, fallback: theme_style(:warn).bold)
44
- else
45
- theme_style(:markdown_code, fallback: theme_style(:text))
46
- end
37
+ # Returns the Charming style for a given Rouge *token*, mapping token qualifiers
38
+ # to theme tokens and falling back to a sensible base style per category.
39
+ def style_for(token)
40
+ name = token_name(token)
41
+
42
+ case name
43
+ when /Comment/
44
+ theme_style(:markdown_code_comment, fallback: theme_style(:muted).italic)
45
+ when /Keyword/
46
+ theme_style(:markdown_code_keyword, fallback: theme_style(:title))
47
+ when /String/
48
+ theme_style(:markdown_code_string, fallback: theme_style(:warn))
49
+ when /Number|Literal/
50
+ theme_style(:markdown_code_literal, fallback: theme_style(:info))
51
+ when /Name\.(Class|Constant|Function|Namespace)/
52
+ theme_style(:markdown_code_constant, fallback: theme_style(:info))
53
+ when /Error/
54
+ theme_style(:markdown_code_error, fallback: theme_style(:warn).bold)
55
+ else
56
+ theme_style(:markdown_code, fallback: theme_style(:text))
47
57
  end
58
+ end
48
59
 
49
- def token_name(token)
50
- return token.qualname if token.respond_to?(:qualname)
60
+ # Returns the qualified token name when the token object supports it, otherwise
61
+ # the token's default `to_s`.
62
+ def token_name(token)
63
+ return token.qualname if token.respond_to?(:qualname)
51
64
 
52
- token.to_s
53
- end
65
+ token.to_s
66
+ end
54
67
 
55
- def theme_style(name, fallback: nil)
56
- return theme.public_send(name) if theme.respond_to?(name)
68
+ # Returns the theme's style for *name*, falling back to *fallback* (or a default
69
+ # empty style) when the theme doesn't define it.
70
+ def theme_style(name, fallback: nil)
71
+ return theme.public_send(name) if theme.respond_to?(name)
57
72
 
58
- fallback || UI.style
59
- end
73
+ fallback || UI.style
60
74
  end
61
75
  end
62
76
  end
@@ -1,8 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Charming
4
- module Presentation
5
- module Markdown
6
- end
4
+ # Markdown is the namespace for the Markdown rendering pipeline. Parsing is delegated to
5
+ # Kramdown; per-block and per-inline element rendering is handled by `BlockRenderer`
6
+ # and `InlineRenderer`; code blocks are highlighted by `SyntaxHighlighter` (Rouge-backed).
7
+ module Markdown
7
8
  end
8
9
  end