charming 0.1.0 → 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 (163) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +38 -378
  3. data/lib/charming/application.rb +14 -3
  4. data/lib/charming/{application_model.rb → application_state.rb} +3 -3
  5. data/lib/charming/cli.rb +62 -3
  6. data/lib/charming/controller/class_methods.rb +115 -0
  7. data/lib/charming/controller/command_palette.rb +135 -0
  8. data/lib/charming/controller/component_dispatching.rb +81 -0
  9. data/lib/charming/controller/dispatching.rb +60 -0
  10. data/lib/charming/controller/focus_management.rb +30 -0
  11. data/lib/charming/controller/rendering.rb +127 -0
  12. data/lib/charming/controller/session_state.rb +41 -0
  13. data/lib/charming/controller/sidebar_navigation.rb +111 -0
  14. data/lib/charming/controller.rb +46 -448
  15. data/lib/charming/database_commands.rb +103 -0
  16. data/lib/charming/database_installer.rb +152 -0
  17. data/lib/charming/events/key_event.rb +15 -0
  18. data/lib/charming/events/mouse_event.rb +42 -0
  19. data/lib/charming/events/resize_event.rb +9 -0
  20. data/lib/charming/events/task_event.rb +19 -0
  21. data/lib/charming/events/timer_event.rb +9 -0
  22. data/lib/charming/focus.rb +58 -2
  23. data/lib/charming/generators/app_file_generator.rb +13 -0
  24. data/lib/charming/generators/app_generator.rb +147 -45
  25. data/lib/charming/generators/base.rb +26 -0
  26. data/lib/charming/generators/component_generator.rb +10 -10
  27. data/lib/charming/generators/controller_generator.rb +22 -14
  28. data/lib/charming/generators/model_generator.rb +128 -0
  29. data/lib/charming/generators/name.rb +10 -4
  30. data/lib/charming/generators/screen_generator.rb +84 -52
  31. data/lib/charming/generators/templates/app/Gemfile.template +5 -0
  32. data/lib/charming/generators/templates/app/README.md.template +9 -0
  33. data/lib/charming/generators/templates/app/Rakefile.template +3 -0
  34. data/lib/charming/generators/templates/app/application.template +13 -0
  35. data/lib/charming/generators/templates/app/application_controller.template +19 -0
  36. data/lib/charming/generators/templates/app/application_record.template +7 -0
  37. data/lib/charming/generators/templates/app/application_state.template +6 -0
  38. data/lib/charming/generators/templates/app/database_config.template +12 -0
  39. data/lib/charming/generators/templates/app/executable.template +7 -0
  40. data/lib/charming/generators/templates/app/gemspec.template +6 -0
  41. data/lib/charming/generators/templates/app/home_controller.template +6 -0
  42. data/lib/charming/generators/templates/app/home_state.template +7 -0
  43. data/lib/charming/generators/templates/app/keep.template +0 -0
  44. data/lib/charming/generators/templates/app/layout.template +113 -0
  45. data/lib/charming/generators/templates/app/root_file.template +20 -0
  46. data/lib/charming/generators/templates/app/routes.template +5 -0
  47. data/lib/charming/generators/templates/app/seeds.template +1 -0
  48. data/lib/charming/generators/templates/app/spec_controller.template +17 -0
  49. data/lib/charming/generators/templates/app/spec_helper.template +3 -0
  50. data/lib/charming/generators/templates/app/spec_state.template +17 -0
  51. data/lib/charming/generators/templates/app/spec_view.template +16 -0
  52. data/lib/charming/generators/templates/app/version.template +5 -0
  53. data/lib/charming/generators/templates/app/view.template +21 -0
  54. data/lib/charming/generators/templates/component/component.rb.template +9 -0
  55. data/lib/charming/generators/templates/controller/controller.rb.template +6 -0
  56. data/lib/charming/generators/templates/model/migration.rb.template +9 -0
  57. data/lib/charming/generators/templates/model/model.rb.template +6 -0
  58. data/lib/charming/generators/templates/model/spec.rb.template +9 -0
  59. data/lib/charming/generators/templates/screen/controller.rb.template +7 -0
  60. data/lib/charming/generators/templates/screen/spec_controller.rb.template +17 -0
  61. data/lib/charming/generators/templates/screen/spec_state.rb.template +17 -0
  62. data/lib/charming/generators/templates/screen/spec_view.rb.template +13 -0
  63. data/lib/charming/generators/templates/screen/state.rb.template +7 -0
  64. data/lib/charming/generators/templates/screen/view.rb.template +11 -0
  65. data/lib/charming/generators/templates/view/view.rb.template +11 -0
  66. data/lib/charming/generators/view_generator.rb +26 -13
  67. data/lib/charming/internal/renderer/differential.rb +17 -3
  68. data/lib/charming/internal/renderer/full_repaint.rb +6 -0
  69. data/lib/charming/internal/terminal/adapter.rb +29 -3
  70. data/lib/charming/internal/terminal/key_normalizer.rb +84 -0
  71. data/lib/charming/internal/terminal/memory_backend.rb +28 -1
  72. data/lib/charming/internal/terminal/mouse_parser.rb +81 -0
  73. data/lib/charming/internal/terminal/tty_backend.rb +62 -115
  74. data/lib/charming/presentation/component.rb +10 -0
  75. data/lib/charming/presentation/components/activity_indicator.rb +160 -0
  76. data/lib/charming/presentation/components/command_palette.rb +120 -0
  77. data/lib/charming/presentation/components/empty_state.rb +56 -0
  78. data/lib/charming/presentation/components/form/builder.rb +62 -0
  79. data/lib/charming/presentation/components/form/confirm.rb +69 -0
  80. data/lib/charming/presentation/components/form/field.rb +121 -0
  81. data/lib/charming/presentation/components/form/input.rb +71 -0
  82. data/lib/charming/presentation/components/form/note.rb +41 -0
  83. data/lib/charming/presentation/components/form/select.rb +112 -0
  84. data/lib/charming/presentation/components/form/textarea.rb +86 -0
  85. data/lib/charming/presentation/components/form.rb +156 -0
  86. data/lib/charming/presentation/components/keyboard_handler.rb +58 -0
  87. data/lib/charming/presentation/components/list.rb +132 -0
  88. data/lib/charming/presentation/components/markdown.rb +31 -0
  89. data/lib/charming/presentation/components/modal.rb +64 -0
  90. data/lib/charming/presentation/components/progressbar.rb +70 -0
  91. data/lib/charming/presentation/components/spinner.rb +49 -0
  92. data/lib/charming/presentation/components/table.rb +143 -0
  93. data/lib/charming/presentation/components/text_area.rb +267 -0
  94. data/lib/charming/presentation/components/text_input.rb +129 -0
  95. data/lib/charming/presentation/components/viewport.rb +272 -0
  96. data/lib/charming/presentation/layout/builder.rb +86 -0
  97. data/lib/charming/presentation/layout/overlay.rb +57 -0
  98. data/lib/charming/presentation/layout/pane.rb +145 -0
  99. data/lib/charming/presentation/layout/rect.rb +23 -0
  100. data/lib/charming/presentation/layout/screen_layout.rb +60 -0
  101. data/lib/charming/presentation/layout/split.rb +134 -0
  102. data/lib/charming/presentation/layout.rb +43 -0
  103. data/lib/charming/presentation/markdown/block_renderers.rb +120 -0
  104. data/lib/charming/presentation/markdown/inline_renderers.rb +68 -0
  105. data/lib/charming/presentation/markdown/render_context.rb +22 -0
  106. data/lib/charming/presentation/markdown/renderer.rb +113 -0
  107. data/lib/charming/presentation/markdown/syntax_highlighter.rb +79 -0
  108. data/lib/charming/presentation/markdown.rb +11 -0
  109. data/lib/charming/presentation/template_view.rb +34 -0
  110. data/lib/charming/presentation/templates/erb_handler.rb +15 -0
  111. data/lib/charming/presentation/templates.rb +68 -0
  112. data/lib/charming/presentation/ui/ansi_codes.rb +89 -0
  113. data/lib/charming/presentation/ui/ansi_slicer.rb +94 -0
  114. data/lib/charming/presentation/ui/border.rb +35 -0
  115. data/lib/charming/presentation/ui/border_painter.rb +58 -0
  116. data/lib/charming/presentation/ui/canvas.rb +82 -0
  117. data/lib/charming/presentation/ui/style.rb +213 -0
  118. data/lib/charming/presentation/ui/theme.rb +180 -0
  119. data/lib/charming/{ui → presentation/ui}/themes/phosphor.json +2 -2
  120. data/lib/charming/presentation/ui/width.rb +26 -0
  121. data/lib/charming/presentation/ui.rb +91 -0
  122. data/lib/charming/presentation/view.rb +135 -0
  123. data/lib/charming/runtime.rb +9 -7
  124. data/lib/charming/screen.rb +5 -1
  125. data/lib/charming/tasks/inline_executor.rb +37 -0
  126. data/lib/charming/tasks/task.rb +12 -0
  127. data/lib/charming/tasks/threaded_executor.rb +51 -0
  128. data/lib/charming/version.rb +1 -1
  129. data/lib/charming.rb +17 -0
  130. metadata +170 -36
  131. data/lib/charming/component.rb +0 -8
  132. data/lib/charming/components/activity_indicator.rb +0 -158
  133. data/lib/charming/components/command_palette.rb +0 -118
  134. data/lib/charming/components/keyboard_handler.rb +0 -22
  135. data/lib/charming/components/list.rb +0 -105
  136. data/lib/charming/components/modal.rb +0 -48
  137. data/lib/charming/components/progressbar.rb +0 -55
  138. data/lib/charming/components/spinner.rb +0 -37
  139. data/lib/charming/components/table.rb +0 -115
  140. data/lib/charming/components/text_input.rb +0 -103
  141. data/lib/charming/components/viewport.rb +0 -191
  142. data/lib/charming/generators/app_generator/app_spec_templates.rb +0 -86
  143. data/lib/charming/generators/app_generator/basic_templates.rb +0 -69
  144. data/lib/charming/generators/app_generator/component_templates.rb +0 -36
  145. data/lib/charming/generators/app_generator/controller_template.rb +0 -69
  146. data/lib/charming/generators/app_generator/layout_template.rb +0 -160
  147. data/lib/charming/generators/app_generator/model_templates.rb +0 -30
  148. data/lib/charming/generators/app_generator/screen_spec_templates.rb +0 -70
  149. data/lib/charming/generators/app_generator/view_template.rb +0 -90
  150. data/lib/charming/key_event.rb +0 -13
  151. data/lib/charming/mouse_event.rb +0 -40
  152. data/lib/charming/resize_event.rb +0 -7
  153. data/lib/charming/task.rb +0 -7
  154. data/lib/charming/task_event.rb +0 -17
  155. data/lib/charming/task_executor.rb +0 -62
  156. data/lib/charming/timer_event.rb +0 -7
  157. data/lib/charming/ui/border.rb +0 -33
  158. data/lib/charming/ui/style.rb +0 -244
  159. data/lib/charming/ui/theme.rb +0 -178
  160. data/lib/charming/ui/width.rb +0 -24
  161. data/lib/charming/ui.rb +0 -230
  162. data/lib/charming/view.rb +0 -116
  163. /data/lib/charming/{generators.rb → generators/error.rb} +0 -0
@@ -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,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Presentation
5
+ # Layout contains generic screen-size math and composition helpers. It is
6
+ # intentionally unaware of application shells such as sidebars or nav panes.
7
+ module Layout
8
+ module_function
9
+
10
+ def clamp_size(value, min: nil, max: nil)
11
+ size = value.to_i
12
+ size = [size, min].max if min
13
+ size = [size, max].min if max
14
+ size
15
+ end
16
+
17
+ def available_width(screen, reserved: 0, min: nil, max: nil)
18
+ clamp_size(screen.width - reserved, min: min, max: max)
19
+ end
20
+
21
+ def available_height(screen, reserved: 0, min: nil, max: nil)
22
+ clamp_size(screen.height - reserved, min: min, max: max)
23
+ end
24
+
25
+ def stack_or_row(*blocks, narrow:, gap: 0)
26
+ if narrow
27
+ UI.join_vertical(*blocks, gap: gap)
28
+ else
29
+ UI.join_horizontal(*blocks, gap: gap)
30
+ end
31
+ end
32
+
33
+ def selected_window_start(selected_index:, item_count:, window_size:)
34
+ count = item_count.to_i
35
+ size = [window_size.to_i, 1].max
36
+ selected = selected_index.to_i.clamp(0, [count - 1, 0].max)
37
+ max_start = [count - size, 0].max
38
+
39
+ (selected - size + 1).clamp(0, max_start)
40
+ end
41
+ end
42
+ end
43
+ 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
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "kramdown"
4
+
5
+ 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
28
+
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)
33
+ end
34
+
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")
44
+ end
45
+
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
51
+ end
52
+
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
57
+
58
+ value.to_s.lines(chomp: true).map { |line| wrap_line(line, width) }.join("\n")
59
+ end
60
+
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)
65
+
66
+ fallback
67
+ end
68
+
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)
73
+
74
+ UI::Theme::DEFAULT_TOKENS.fetch(name).then { |token| UI::Theme.new(name => token).public_send(name) }
75
+ end
76
+
77
+ private
78
+
79
+ # The BlockRenderer instance, lazily built.
80
+ def block_renderer
81
+ @block_renderer ||= BlockRenderer.new(renderer: self)
82
+ end
83
+
84
+ # The InlineRenderer instance, lazily built.
85
+ def inline_renderer
86
+ @inline_renderer ||= InlineRenderer.new(renderer: self)
87
+ end
88
+
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
92
+
93
+ lines = []
94
+ current = +""
95
+
96
+ line.split(/\s+/).each do |word|
97
+ candidate = current.empty? ? word : "#{current} #{word}"
98
+
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
106
+
107
+ lines << current.rstrip unless current.empty?
108
+ lines.join("\n")
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rouge"
4
+
5
+ module Charming
6
+ module Presentation
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.).
12
+ class SyntaxHighlighter
13
+ # *theme* is the active Charming theme. Defaults to UI::Theme.default.
14
+ def initialize(theme: UI::Theme.default)
15
+ @theme = theme || UI::Theme.default
16
+ end
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.
21
+ def render(code, language: nil)
22
+ lexer = lexer_for(language, code)
23
+ lexer.lex(code.to_s).map do |token, value|
24
+ style_for(token).render(value)
25
+ end.join
26
+ end
27
+
28
+ private
29
+
30
+ # The Charming theme used for token styling.
31
+ attr_reader :theme
32
+
33
+ # Picks a Rouge lexer for *language* and *code*, falling back to plain text.
34
+ def lexer_for(language, code)
35
+ Rouge::Lexer.find_fancy(language, code) || Rouge::Lexers::PlainText
36
+ end
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.
40
+ def style_for(token)
41
+ name = token_name(token)
42
+
43
+ case name
44
+ when /Comment/
45
+ theme_style(:markdown_code_comment, fallback: theme_style(:muted).italic)
46
+ when /Keyword/
47
+ theme_style(:markdown_code_keyword, fallback: theme_style(:title))
48
+ when /String/
49
+ theme_style(:markdown_code_string, fallback: theme_style(:warn))
50
+ when /Number|Literal/
51
+ theme_style(:markdown_code_literal, fallback: theme_style(:info))
52
+ when /Name\.(Class|Constant|Function|Namespace)/
53
+ theme_style(:markdown_code_constant, fallback: theme_style(:info))
54
+ when /Error/
55
+ theme_style(:markdown_code_error, fallback: theme_style(:warn).bold)
56
+ else
57
+ theme_style(:markdown_code, fallback: theme_style(:text))
58
+ end
59
+ end
60
+
61
+ # Returns the qualified token name when the token object supports it, otherwise
62
+ # the token's default `to_s`.
63
+ def token_name(token)
64
+ return token.qualname if token.respond_to?(:qualname)
65
+
66
+ token.to_s
67
+ end
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.
71
+ def theme_style(name, fallback: nil)
72
+ return theme.public_send(name) if theme.respond_to?(name)
73
+
74
+ fallback || UI.style
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
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).
8
+ module Markdown
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
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.
8
+ class TemplateView < View
9
+ def initialize(template:, namespace: nil, **assigns)
10
+ super(**assigns)
11
+ @template = template
12
+ @namespace = namespace
13
+ end
14
+
15
+ # Renders the wrapped template to a string, evaluated in the view's binding context.
16
+ def render
17
+ template.render(self).to_s
18
+ end
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.
23
+ def template_binding
24
+ return binding unless namespace
25
+
26
+ namespace.module_eval("->(view) { view.instance_eval { binding } }", __FILE__, __LINE__).call(self)
27
+ end
28
+
29
+ private
30
+
31
+ attr_reader :template, :namespace
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+
5
+ module Charming
6
+ module Presentation
7
+ module Templates
8
+ class ErbHandler
9
+ def self.render(path, view)
10
+ ERB.new(File.read(path), trim_mode: "-").result(view.template_binding)
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end