charming 0.1.1 → 0.1.2

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 (122) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/lib/charming/application.rb +11 -0
  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 +113 -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 +15 -0
  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 +43 -113
  68. data/lib/charming/presentation/components/empty_state.rb +13 -0
  69. data/lib/charming/presentation/components/form/builder.rb +14 -0
  70. data/lib/charming/presentation/components/form/confirm.rb +13 -0
  71. data/lib/charming/presentation/components/form/field.rb +25 -0
  72. data/lib/charming/presentation/components/form/input.rb +14 -0
  73. data/lib/charming/presentation/components/form/note.rb +9 -0
  74. data/lib/charming/presentation/components/form/select.rb +23 -0
  75. data/lib/charming/presentation/components/form/textarea.rb +16 -0
  76. data/lib/charming/presentation/components/form.rb +29 -0
  77. data/lib/charming/presentation/components/list.rb +28 -0
  78. data/lib/charming/presentation/components/markdown.rb +6 -0
  79. data/lib/charming/presentation/components/modal.rb +14 -0
  80. data/lib/charming/presentation/components/progressbar.rb +13 -0
  81. data/lib/charming/presentation/components/spinner.rb +10 -0
  82. data/lib/charming/presentation/components/table.rb +25 -0
  83. data/lib/charming/presentation/components/text_area.rb +48 -0
  84. data/lib/charming/presentation/components/text_input.rb +24 -0
  85. data/lib/charming/presentation/components/viewport.rb +52 -0
  86. data/lib/charming/presentation/layout/builder.rb +86 -0
  87. data/lib/charming/presentation/layout/overlay.rb +57 -0
  88. data/lib/charming/presentation/layout/pane.rb +145 -0
  89. data/lib/charming/presentation/layout/rect.rb +23 -0
  90. data/lib/charming/presentation/layout/screen_layout.rb +60 -0
  91. data/lib/charming/presentation/layout/split.rb +134 -0
  92. data/lib/charming/presentation/markdown/block_renderers.rb +120 -0
  93. data/lib/charming/presentation/markdown/inline_renderers.rb +68 -0
  94. data/lib/charming/presentation/markdown/render_context.rb +22 -0
  95. data/lib/charming/presentation/markdown/renderer.rb +45 -135
  96. data/lib/charming/presentation/markdown/syntax_highlighter.rb +16 -0
  97. data/lib/charming/presentation/markdown.rb +3 -0
  98. data/lib/charming/presentation/template_view.rb +7 -0
  99. data/lib/charming/presentation/templates.rb +17 -0
  100. data/lib/charming/presentation/ui/ansi_codes.rb +89 -0
  101. data/lib/charming/presentation/ui/ansi_slicer.rb +94 -0
  102. data/lib/charming/presentation/ui/border_painter.rb +58 -0
  103. data/lib/charming/presentation/ui/canvas.rb +82 -0
  104. data/lib/charming/presentation/ui/style.rb +62 -95
  105. data/lib/charming/presentation/ui.rb +15 -156
  106. data/lib/charming/presentation/view.rb +17 -0
  107. data/lib/charming/runtime.rb +2 -0
  108. data/lib/charming/tasks/inline_executor.rb +9 -0
  109. data/lib/charming/tasks/task.rb +3 -0
  110. data/lib/charming/tasks/threaded_executor.rb +12 -0
  111. data/lib/charming/version.rb +1 -1
  112. data/lib/charming.rb +13 -0
  113. metadata +59 -10
  114. data/lib/charming/generators/app_generator/app_spec_templates.rb +0 -90
  115. data/lib/charming/generators/app_generator/basic_templates.rb +0 -81
  116. data/lib/charming/generators/app_generator/component_templates.rb +0 -36
  117. data/lib/charming/generators/app_generator/controller_template.rb +0 -60
  118. data/lib/charming/generators/app_generator/database_templates.rb +0 -45
  119. data/lib/charming/generators/app_generator/layout_template.rb +0 -66
  120. data/lib/charming/generators/app_generator/screen_spec_templates.rb +0 -69
  121. data/lib/charming/generators/app_generator/state_templates.rb +0 -30
  122. data/lib/charming/generators/app_generator/view_template.rb +0 -84
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Presentation
5
+ module Layout
6
+ # Split divides a parent Rect among its child nodes horizontally or vertically.
7
+ # Children with a configured `width`/`height` are placed at that fixed size; children
8
+ # without a fixed size share the remaining space according to their `grow` weight.
9
+ class Split
10
+ # The fixed width/height of the split (when set) and the grow weight for the split itself.
11
+ attr_reader :width, :height, :grow
12
+
13
+ # *direction* is `:horizontal` or `:vertical`. *gap* (in cells) separates children.
14
+ # *width*/*height* are optional fixed dimensions for the split as a whole.
15
+ # *grow* is the weight for distributing remaining space (used when this Split is a
16
+ # child of another Split).
17
+ def initialize(direction:, gap: 0, width: nil, height: nil, grow: nil)
18
+ @direction = direction.to_sym
19
+ @gap = gap.to_i
20
+ @width = width
21
+ @height = height
22
+ @grow = grow
23
+ @children = []
24
+ end
25
+
26
+ # Appends *node* (a child Split or Pane) to this Split.
27
+ def add_child(node)
28
+ children << node
29
+ end
30
+
31
+ # Returns the flattened list of focusable names from all child nodes.
32
+ def focusable_names
33
+ children.flat_map(&:focusable_names)
34
+ end
35
+
36
+ # Renders each child into its own sub-rect, then overlays them on a blank canvas
37
+ # of the parent's dimensions.
38
+ def render(rect)
39
+ frame = UI.place("", width: rect.width, height: rect.height)
40
+
41
+ child_rects(rect).zip(children).each do |child_rect, child|
42
+ frame = UI.overlay(frame, child.render(child_rect), top: child_rect.y - rect.y, left: child_rect.x - rect.x)
43
+ end
44
+
45
+ frame
46
+ end
47
+
48
+ private
49
+
50
+ # The split direction (`:horizontal` or `:vertical`) and the inter-child gap.
51
+ attr_reader :direction, :gap, :children
52
+
53
+ # Returns an array of child rects sized according to each child's fixed dimensions
54
+ # and grow weights. Raises ArgumentError when *direction* is neither horizontal nor vertical.
55
+ def child_rects(rect)
56
+ return horizontal_rects(rect) if direction == :horizontal
57
+ return vertical_rects(rect) if direction == :vertical
58
+
59
+ raise ArgumentError, "unknown split direction: #{direction.inspect}"
60
+ end
61
+
62
+ # Computes per-child rects for a horizontal split.
63
+ def horizontal_rects(rect)
64
+ sizes = child_sizes(axis: :horizontal, available: rect.width)
65
+ left = rect.x
66
+
67
+ sizes.map do |width|
68
+ child_rect = Rect.new(x: left, y: rect.y, width: width, height: rect.height)
69
+ left += width + gap
70
+ child_rect
71
+ end
72
+ end
73
+
74
+ # Computes per-child rects for a vertical split.
75
+ def vertical_rects(rect)
76
+ sizes = child_sizes(axis: :vertical, available: rect.height)
77
+ top = rect.y
78
+
79
+ sizes.map do |height|
80
+ child_rect = Rect.new(x: rect.x, y: top, width: rect.width, height: height)
81
+ top += height + gap
82
+ child_rect
83
+ end
84
+ end
85
+
86
+ # Computes the size of each child along the *axis* given the *available* cells.
87
+ # Subtracts the total gap, allocates fixed sizes first, and distributes the remainder
88
+ # among flexible (non-fixed) children by their grow weights.
89
+ def child_sizes(axis:, available:)
90
+ gap_size = gap * [children.length - 1, 0].max
91
+ available_for_children = [available - gap_size, 0].max
92
+ fixed = children.map { |child| fixed_size(child, axis) }
93
+ flexible_indexes = fixed.each_index.select { |index| fixed[index].nil? }
94
+ sizes = fixed.map { |size| size&.to_i }
95
+ remaining = [available_for_children - sizes.compact.sum, 0].max
96
+
97
+ distribute_remaining(sizes, flexible_indexes, remaining)
98
+ end
99
+
100
+ # Returns the fixed size of *child* along *axis* (`:horizontal` reads width, `:vertical` reads height).
101
+ def fixed_size(child, axis)
102
+ (axis == :horizontal) ? child.width : child.height
103
+ end
104
+
105
+ # Distributes the *remaining* cells across *flexible_indexes* by grow weight, with the
106
+ # last flexible child absorbing any rounding remainder.
107
+ def distribute_remaining(sizes, flexible_indexes, remaining)
108
+ return sizes.map { |size| size || 0 } if flexible_indexes.empty?
109
+
110
+ total_grow = flexible_indexes.sum { |index| grow_weight(children[index]) }
111
+ used = 0
112
+
113
+ flexible_indexes.each_with_index do |index, flexible_index|
114
+ size = if flexible_index == flexible_indexes.length - 1
115
+ remaining - used
116
+ else
117
+ (remaining * grow_weight(children[index]) / total_grow).floor
118
+ end
119
+
120
+ sizes[index] = size
121
+ used += size
122
+ end
123
+
124
+ sizes
125
+ end
126
+
127
+ # Returns the grow weight of *child*, defaulting to 1 when unset.
128
+ def grow_weight(child)
129
+ [child.grow.to_i, 1].max
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Presentation
5
+ module Markdown
6
+ # BlockRenderer dispatches Kramdown block-level elements (paragraph, header, list,
7
+ # code block, etc.) to their individual rendering handlers. Handlers are built once
8
+ # at construction time as a frozen hash of element-type symbols to callables.
9
+ class BlockRenderer
10
+ # *renderer* is the parent Renderer (used to wrap text, render 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. Each handler is a small lambda that calls back into the
29
+ # parent renderer (or one of the private render_* methods below).
30
+ def build_handlers
31
+ r = @renderer
32
+ @handlers = {
33
+ p: ->(element, context) { r.wrap(r.render_inlines(element.children), width: context.width) },
34
+ header: ->(element, context) { send(:render_header, element, context) },
35
+ blockquote: ->(element, context) { send(:render_blockquote, element, context) },
36
+ ul: ->(element, context) { send(:render_list, element, ordered: false, context: context) },
37
+ ol: ->(element, context) { send(:render_list, element, ordered: true, context: context) },
38
+ li: ->(element, context) { r.render_blocks(element.children, list_depth: context.list_depth, width: context.width) },
39
+ codeblock: ->(element, _context) { send(:render_codeblock, element) },
40
+ hr: ->(element, context) { send(:render_rule, width: context.width) },
41
+ blank: ->(_element, _context) {}
42
+ }.freeze
43
+ end
44
+
45
+ # Fallback for unknown block types: wraps the raw value when there are no children,
46
+ # otherwise recurses into the children.
47
+ def render_unknown(element, context)
48
+ return @renderer.wrap(element.value.to_s, width: context.width) if element.children.empty?
49
+
50
+ @renderer.render_blocks(element.children, list_depth: context.list_depth, width: context.width)
51
+ end
52
+
53
+ # Renders a header element, using the `markdown_heading` style for h1 and the
54
+ # `markdown_subheading` style for h2+.
55
+ def render_header(element, context)
56
+ rendered = @renderer.wrap(@renderer.render_inlines(element.children), width: context.width)
57
+ style = if element.options[:level].to_i == 1
58
+ @renderer.style_for(:markdown_heading, fallback: @renderer.theme_style(:title))
59
+ else
60
+ @renderer.style_for(:markdown_subheading, fallback: @renderer.theme_style(:title))
61
+ end
62
+ style.render(rendered)
63
+ end
64
+
65
+ def render_blockquote(element, context)
66
+ quote_width = context.width ? [context.width - 2, 1].max : nil
67
+ rendered = @renderer.render_blocks(element.children, list_depth: context.list_depth, width: quote_width)
68
+ border = @renderer.style_for(:markdown_quote_border, fallback: @renderer.theme_style(:border)).render("|")
69
+ quote_style = @renderer.style_for(:markdown_quote, fallback: @renderer.theme_style(:muted))
70
+
71
+ rendered.lines(chomp: true).map { |line| "#{border} #{quote_style.render(line)}" }.join("\n")
72
+ end
73
+
74
+ def render_list(element, ordered:, context:)
75
+ element.children.each_with_index.map do |item, index|
76
+ marker = ordered ? "#{ordered_start(element) + index}." : "-"
77
+ render_list_item(item, marker: marker, context: context)
78
+ end.join("\n")
79
+ end
80
+
81
+ def render_list_item(element, marker:, context:)
82
+ indent = " " * context.list_depth
83
+ first_prefix = "#{indent}#{marker} "
84
+ rest_prefix = "#{indent}#{" " * (marker.length + 1)}"
85
+ item_width = context.width ? [context.width - UI::Width.measure(first_prefix), 1].max : nil
86
+ body = @renderer.render_blocks(element.children, list_depth: context.list_depth + 1, width: item_width)
87
+
88
+ body.lines(chomp: true).each_with_index.map do |line, index|
89
+ "#{index.zero? ? first_prefix : rest_prefix}#{line}"
90
+ end.join("\n")
91
+ end
92
+
93
+ def ordered_start(element)
94
+ element.options.fetch(:start, 1).to_i
95
+ end
96
+
97
+ def render_codeblock(element)
98
+ code = element.value.to_s
99
+ rendered = if @renderer.syntax_highlighting
100
+ SyntaxHighlighter.new(theme: @renderer.theme).render(code, language: code_language(element))
101
+ else
102
+ @renderer.style_for(:markdown_code, fallback: @renderer.theme_style(:warn)).render(code)
103
+ end
104
+
105
+ rendered.lines(chomp: true).map { |line| " #{line}" }.join("\n")
106
+ end
107
+
108
+ def render_rule(width:)
109
+ @renderer.style_for(:markdown_rule, fallback: @renderer.theme_style(:border)).render("-" * (width || Renderer::DEFAULT_RULE_WIDTH))
110
+ end
111
+
112
+ def code_language(element)
113
+ return element.options[:lang] if element.options[:lang]
114
+
115
+ element.attr["class"].to_s[/language-([^\s]+)/, 1]
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Presentation
5
+ module Markdown
6
+ # InlineRenderer dispatches Kramdown inline-level elements (text, strong, em,
7
+ # codespan, link, line break, HTML entity) to their individual rendering handlers.
8
+ # Handlers are built once at construction as a frozen hash of element-type symbols
9
+ # to callables.
10
+ class InlineRenderer
11
+ # *renderer* is the parent Renderer (used to render nested inlines and look up styles).
12
+ def initialize(renderer:)
13
+ @renderer = renderer
14
+ build_handlers
15
+ end
16
+
17
+ # Renders *element* using the handler registered for `element.type`. Unknown types
18
+ # fall through to `render_unknown`.
19
+ def render(element, context:)
20
+ handler = @handlers[element.type] || method(:render_unknown)
21
+ handler.call(element, context)
22
+ end
23
+
24
+ private
25
+
26
+ # The frozen hash of element-type → handler mapping.
27
+ attr_reader :handlers
28
+
29
+ # Builds the handler hash for text, strong, em, codespan, link, br, and entity.
30
+ def build_handlers
31
+ r = @renderer
32
+ @handlers = {
33
+ text: ->(element, _context) { element.value.to_s },
34
+ strong: ->(element, context) { render_styled(element, context, :markdown_strong) { |s| s.bold } },
35
+ em: ->(element, context) { render_styled(element, context, :markdown_emphasis) { |s| s.italic } },
36
+ codespan: ->(element, _context) { r.style_for(:markdown_inline_code, fallback: r.theme_style(:warn)).render(element.value.to_s) },
37
+ a: ->(element, context) { send(:render_link, element, context) },
38
+ br: ->(_element, _context) { "\n" },
39
+ entity: ->(element, _context) { element.value.respond_to?(:char) ? element.value.char : element.value.to_s }
40
+ }.freeze
41
+ end
42
+
43
+ # Renders a styled inline (strong/em) by first rendering children, then applying
44
+ # the theme style and the block-form (e.g., `bold`/`italic`) decoration.
45
+ def render_styled(element, context, style_name)
46
+ rendered = @renderer.render_inlines(element.children, width: context.width)
47
+ style = @renderer.style_for(style_name, fallback: yield(@renderer.theme_style(:text)))
48
+ style.render(rendered)
49
+ end
50
+
51
+ # Renders a Markdown link as "label <href>" (URL omitted when empty), styled with
52
+ # the markdown_link theme token or the info+underline fallback.
53
+ def render_link(element, context)
54
+ label = @renderer.render_inlines(element.children, width: context.width)
55
+ href = element.attr["href"].to_s
56
+ rendered = href.empty? ? label : "#{label} <#{href}>"
57
+ @renderer.style_for(:markdown_link, fallback: @renderer.theme_style(:info).underline).render(rendered)
58
+ end
59
+
60
+ # Fallback for unknown inline types: returns the value when there are no children,
61
+ # otherwise recurses into the children.
62
+ def render_unknown(element, context)
63
+ element.children.empty? ? element.value.to_s : @renderer.render_inlines(element.children, width: context.width)
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Presentation
5
+ module Markdown
6
+ # RenderContext carries the state needed to render nested Markdown blocks: the current
7
+ # list nesting depth (used for indentation) and the wrap width.
8
+ RenderContext = Data.define(:list_depth, :width) do
9
+ # Builds a new RenderContext with the given *width* and optional starting *list_depth*.
10
+ def self.from(width:, list_depth: 0)
11
+ new(list_depth: list_depth, width: width)
12
+ end
13
+
14
+ # Returns a derived context with the list depth incremented by *depth_increment*
15
+ # and the wrap width overridden to *width* (defaults to the current width).
16
+ def nested(depth_increment: 0, width: self.width)
17
+ self.class.new(list_depth: list_depth + depth_increment, width: width)
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -5,9 +5,20 @@ require "kramdown"
5
5
  module Charming
6
6
  module Presentation
7
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.
8
12
  class Renderer
13
+ # Wrap width used by `render_rule` when no width is otherwise specified.
9
14
  DEFAULT_RULE_WIDTH = 40
10
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).
11
22
  def initialize(content:, width: nil, theme: UI::Theme.default, syntax_highlighting: true)
12
23
  @content = content
13
24
  @width = width
@@ -15,156 +26,67 @@ module Charming
15
26
  @syntax_highlighting = syntax_highlighting
16
27
  end
17
28
 
29
+ # Parses the content and returns the fully-rendered Markdown as a single string.
18
30
  def render
19
31
  document = Kramdown::Document.new(content.to_s)
20
32
  render_blocks(document.root.children)
21
33
  end
22
34
 
23
- private
24
-
25
- attr_reader :content, :width, :theme
26
-
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.
27
38
  def render_blocks(elements, list_depth: 0, width: @width)
39
+ context = RenderContext.from(width: width, list_depth: list_depth)
28
40
  elements.filter_map do |element|
29
- rendered = render_block(element, list_depth: list_depth, width: width)
41
+ rendered = block_renderer.render(element, context: context)
30
42
  rendered unless rendered.to_s.empty?
31
43
  end.join("\n\n")
32
44
  end
33
45
 
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
58
-
59
- def render_unknown(element, list_depth:, width:)
60
- return wrap(element.value.to_s, width: width) if element.children.empty?
61
-
62
- render_blocks(element.children, list_depth: list_depth, width: width)
63
- end
64
-
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
74
-
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))
80
-
81
- rendered.lines(chomp: true).map do |line|
82
- "#{border} #{quote_style.render(line)}"
83
- end.join("\n")
84
- end
85
-
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")
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
91
51
  end
92
52
 
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)
99
-
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
104
-
105
- def ordered_start(element)
106
- element.options.fetch(:start, 1).to_i
107
- end
108
-
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
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
116
57
 
117
- rendered.lines(chomp: true).map { |line| " #{line}" }.join("\n")
58
+ value.to_s.lines(chomp: true).map { |line| wrap_line(line, width) }.join("\n")
118
59
  end
119
60
 
120
- def render_rule(width:)
121
- style_for(:markdown_rule, fallback: theme_style(:border)).render("-" * (width || DEFAULT_RULE_WIDTH))
122
- end
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)
123
65
 
124
- def render_inlines(elements)
125
- elements.map { |element| render_inline(element) }.join
66
+ fallback
126
67
  end
127
68
 
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
144
- else
145
- element.children.empty? ? element.value.to_s : render_inlines(element.children)
146
- end
147
- end
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)
148
73
 
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)
74
+ UI::Theme::DEFAULT_TOKENS.fetch(name).then { |token| UI::Theme.new(name => token).public_send(name) }
154
75
  end
155
76
 
156
- def code_language(element)
157
- return element.options[:lang] if element.options[:lang]
77
+ private
158
78
 
159
- element.attr["class"].to_s[/language-([^\s]+)/, 1]
79
+ # The BlockRenderer instance, lazily built.
80
+ def block_renderer
81
+ @block_renderer ||= BlockRenderer.new(renderer: self)
160
82
  end
161
83
 
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")
84
+ # The InlineRenderer instance, lazily built.
85
+ def inline_renderer
86
+ @inline_renderer ||= InlineRenderer.new(renderer: self)
166
87
  end
167
88
 
89
+ # Word-wraps a single *line* to *width* display columns using greedy space-splitting.
168
90
  def wrap_line(line, width)
169
91
  return line if UI::Width.measure(line) <= width
170
92
 
@@ -185,18 +107,6 @@ module Charming
185
107
  lines << current.rstrip unless current.empty?
186
108
  lines.join("\n")
187
109
  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
200
110
  end
201
111
  end
202
112
  end
@@ -5,11 +5,19 @@ require "rouge"
5
5
  module Charming
6
6
  module Presentation
7
7
  module Markdown
8
+ # SyntaxHighlighter turns a code block string into ANSI-styled terminal text using
9
+ # Rouge lexers. The theme provides markdown_code_* tokens for per-token styling;
10
+ # when a token is undefined in the theme, the highlighter falls back to a sensible
11
+ # base style (muted italic for comments, title for keywords, etc.).
8
12
  class SyntaxHighlighter
13
+ # *theme* is the active Charming theme. Defaults to UI::Theme.default.
9
14
  def initialize(theme: UI::Theme.default)
10
15
  @theme = theme || UI::Theme.default
11
16
  end
12
17
 
18
+ # Highlights *code* (using Rouge) for the given *language* (auto-detected when nil)
19
+ # and returns a styled multi-line string. Each Rouge token is rendered with the
20
+ # theme style matching its token type.
13
21
  def render(code, language: nil)
14
22
  lexer = lexer_for(language, code)
15
23
  lexer.lex(code.to_s).map do |token, value|
@@ -19,12 +27,16 @@ module Charming
19
27
 
20
28
  private
21
29
 
30
+ # The Charming theme used for token styling.
22
31
  attr_reader :theme
23
32
 
33
+ # Picks a Rouge lexer for *language* and *code*, falling back to plain text.
24
34
  def lexer_for(language, code)
25
35
  Rouge::Lexer.find_fancy(language, code) || Rouge::Lexers::PlainText
26
36
  end
27
37
 
38
+ # Returns the Charming style for a given Rouge *token*, mapping token qualifiers
39
+ # to theme tokens and falling back to a sensible base style per category.
28
40
  def style_for(token)
29
41
  name = token_name(token)
30
42
 
@@ -46,12 +58,16 @@ module Charming
46
58
  end
47
59
  end
48
60
 
61
+ # Returns the qualified token name when the token object supports it, otherwise
62
+ # the token's default `to_s`.
49
63
  def token_name(token)
50
64
  return token.qualname if token.respond_to?(:qualname)
51
65
 
52
66
  token.to_s
53
67
  end
54
68
 
69
+ # Returns the theme's style for *name*, falling back to *fallback* (or a default
70
+ # empty style) when the theme doesn't define it.
55
71
  def theme_style(name, fallback: nil)
56
72
  return theme.public_send(name) if theme.respond_to?(name)
57
73
 
@@ -2,6 +2,9 @@
2
2
 
3
3
  module Charming
4
4
  module Presentation
5
+ # Markdown is the namespace for the Markdown rendering pipeline. Parsing is delegated to
6
+ # Kramdown; per-block and per-inline element rendering is handled by `BlockRenderer`
7
+ # and `InlineRenderer`; code blocks are highlighted by `SyntaxHighlighter` (Rouge-backed).
5
8
  module Markdown
6
9
  end
7
10
  end
@@ -2,6 +2,9 @@
2
2
 
3
3
  module Charming
4
4
  module Presentation
5
+ # TemplateView wraps a resolved ERB template and exposes it as a renderable View. The
6
+ # template is rendered with the view's helpers (`text`, `box`, `row`, `column`, `style`,
7
+ # `theme`, etc.) and the view's assigns available as reader methods inside the template.
5
8
  class TemplateView < View
6
9
  def initialize(template:, namespace: nil, **assigns)
7
10
  super(**assigns)
@@ -9,10 +12,14 @@ module Charming
9
12
  @namespace = namespace
10
13
  end
11
14
 
15
+ # Renders the wrapped template to a string, evaluated in the view's binding context.
12
16
  def render
13
17
  template.render(self).to_s
14
18
  end
15
19
 
20
+ # Returns the binding used by ERB handlers to evaluate the template body. When *namespace*
21
+ # is set, the binding is created by a proc generated in the namespace's context so the
22
+ # template can resolve constants relative to the application.
16
23
  def template_binding
17
24
  return binding unless namespace
18
25