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.
- checksums.yaml +4 -4
- data/README.md +2 -2
- data/lib/charming/application.rb +11 -0
- data/lib/charming/cli.rb +23 -0
- data/lib/charming/controller/class_methods.rb +115 -0
- data/lib/charming/controller/command_palette.rb +135 -0
- data/lib/charming/controller/component_dispatching.rb +81 -0
- data/lib/charming/controller/dispatching.rb +60 -0
- data/lib/charming/controller/focus_management.rb +30 -0
- data/lib/charming/controller/rendering.rb +127 -0
- data/lib/charming/controller/session_state.rb +41 -0
- data/lib/charming/controller/sidebar_navigation.rb +111 -0
- data/lib/charming/controller.rb +35 -559
- data/lib/charming/database_commands.rb +16 -0
- data/lib/charming/database_installer.rb +27 -0
- data/lib/charming/focus.rb +58 -2
- data/lib/charming/generators/app_file_generator.rb +13 -0
- data/lib/charming/generators/app_generator.rb +123 -47
- data/lib/charming/generators/base.rb +26 -0
- data/lib/charming/generators/component_generator.rb +10 -10
- data/lib/charming/generators/controller_generator.rb +22 -11
- data/lib/charming/generators/model_generator.rb +38 -29
- data/lib/charming/generators/name.rb +10 -0
- data/lib/charming/generators/screen_generator.rb +78 -32
- data/lib/charming/generators/templates/app/Gemfile.template +5 -0
- data/lib/charming/generators/templates/app/README.md.template +9 -0
- data/lib/charming/generators/templates/app/Rakefile.template +3 -0
- data/lib/charming/generators/templates/app/application.template +13 -0
- data/lib/charming/generators/templates/app/application_controller.template +19 -0
- data/lib/charming/generators/templates/app/application_record.template +7 -0
- data/lib/charming/generators/templates/app/application_state.template +6 -0
- data/lib/charming/generators/templates/app/database_config.template +12 -0
- data/lib/charming/generators/templates/app/executable.template +7 -0
- data/lib/charming/generators/templates/app/gemspec.template +6 -0
- data/lib/charming/generators/templates/app/home_controller.template +6 -0
- data/lib/charming/generators/templates/app/home_state.template +7 -0
- data/lib/charming/generators/templates/app/keep.template +0 -0
- data/lib/charming/generators/templates/app/layout.template +113 -0
- data/lib/charming/generators/templates/app/root_file.template +20 -0
- data/lib/charming/generators/templates/app/routes.template +5 -0
- data/lib/charming/generators/templates/app/seeds.template +1 -0
- data/lib/charming/generators/templates/app/spec_controller.template +17 -0
- data/lib/charming/generators/templates/app/spec_helper.template +3 -0
- data/lib/charming/generators/templates/app/spec_state.template +17 -0
- data/lib/charming/generators/templates/app/spec_view.template +16 -0
- data/lib/charming/generators/templates/app/version.template +5 -0
- data/lib/charming/generators/templates/app/view.template +21 -0
- data/lib/charming/generators/templates/component/component.rb.template +9 -0
- data/lib/charming/generators/templates/controller/controller.rb.template +6 -0
- data/lib/charming/generators/templates/model/migration.rb.template +9 -0
- data/lib/charming/generators/templates/model/model.rb.template +6 -0
- data/lib/charming/generators/templates/model/spec.rb.template +9 -0
- data/lib/charming/generators/templates/screen/controller.rb.template +7 -0
- data/lib/charming/generators/templates/screen/spec_controller.rb.template +17 -0
- data/lib/charming/generators/templates/screen/spec_state.rb.template +17 -0
- data/lib/charming/generators/templates/screen/spec_view.rb.template +13 -0
- data/lib/charming/generators/templates/screen/state.rb.template +7 -0
- data/lib/charming/generators/templates/screen/view.rb.template +11 -0
- data/lib/charming/generators/templates/view/view.rb.template +11 -0
- data/lib/charming/generators/view_generator.rb +19 -3
- data/lib/charming/internal/renderer/differential.rb +15 -0
- data/lib/charming/internal/renderer/full_repaint.rb +6 -0
- data/lib/charming/internal/terminal/adapter.rb +29 -3
- data/lib/charming/internal/terminal/key_normalizer.rb +84 -0
- data/lib/charming/internal/terminal/memory_backend.rb +28 -1
- data/lib/charming/internal/terminal/mouse_parser.rb +81 -0
- data/lib/charming/internal/terminal/tty_backend.rb +43 -113
- data/lib/charming/presentation/components/empty_state.rb +13 -0
- data/lib/charming/presentation/components/form/builder.rb +14 -0
- data/lib/charming/presentation/components/form/confirm.rb +13 -0
- data/lib/charming/presentation/components/form/field.rb +25 -0
- data/lib/charming/presentation/components/form/input.rb +14 -0
- data/lib/charming/presentation/components/form/note.rb +9 -0
- data/lib/charming/presentation/components/form/select.rb +23 -0
- data/lib/charming/presentation/components/form/textarea.rb +16 -0
- data/lib/charming/presentation/components/form.rb +29 -0
- data/lib/charming/presentation/components/list.rb +28 -0
- data/lib/charming/presentation/components/markdown.rb +6 -0
- data/lib/charming/presentation/components/modal.rb +14 -0
- data/lib/charming/presentation/components/progressbar.rb +13 -0
- data/lib/charming/presentation/components/spinner.rb +10 -0
- data/lib/charming/presentation/components/table.rb +25 -0
- data/lib/charming/presentation/components/text_area.rb +48 -0
- data/lib/charming/presentation/components/text_input.rb +24 -0
- data/lib/charming/presentation/components/viewport.rb +52 -0
- data/lib/charming/presentation/layout/builder.rb +86 -0
- data/lib/charming/presentation/layout/overlay.rb +57 -0
- data/lib/charming/presentation/layout/pane.rb +145 -0
- data/lib/charming/presentation/layout/rect.rb +23 -0
- data/lib/charming/presentation/layout/screen_layout.rb +60 -0
- data/lib/charming/presentation/layout/split.rb +134 -0
- data/lib/charming/presentation/markdown/block_renderers.rb +120 -0
- data/lib/charming/presentation/markdown/inline_renderers.rb +68 -0
- data/lib/charming/presentation/markdown/render_context.rb +22 -0
- data/lib/charming/presentation/markdown/renderer.rb +45 -135
- data/lib/charming/presentation/markdown/syntax_highlighter.rb +16 -0
- data/lib/charming/presentation/markdown.rb +3 -0
- data/lib/charming/presentation/template_view.rb +7 -0
- data/lib/charming/presentation/templates.rb +17 -0
- data/lib/charming/presentation/ui/ansi_codes.rb +89 -0
- data/lib/charming/presentation/ui/ansi_slicer.rb +94 -0
- data/lib/charming/presentation/ui/border_painter.rb +58 -0
- data/lib/charming/presentation/ui/canvas.rb +82 -0
- data/lib/charming/presentation/ui/style.rb +62 -95
- data/lib/charming/presentation/ui.rb +15 -156
- data/lib/charming/presentation/view.rb +17 -0
- data/lib/charming/runtime.rb +2 -0
- data/lib/charming/tasks/inline_executor.rb +9 -0
- data/lib/charming/tasks/task.rb +3 -0
- data/lib/charming/tasks/threaded_executor.rb +12 -0
- data/lib/charming/version.rb +1 -1
- data/lib/charming.rb +13 -0
- metadata +59 -10
- data/lib/charming/generators/app_generator/app_spec_templates.rb +0 -90
- data/lib/charming/generators/app_generator/basic_templates.rb +0 -81
- data/lib/charming/generators/app_generator/component_templates.rb +0 -36
- data/lib/charming/generators/app_generator/controller_template.rb +0 -60
- data/lib/charming/generators/app_generator/database_templates.rb +0 -45
- data/lib/charming/generators/app_generator/layout_template.rb +0 -66
- data/lib/charming/generators/app_generator/screen_spec_templates.rb +0 -69
- data/lib/charming/generators/app_generator/state_templates.rb +0 -30
- 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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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 =
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
58
|
+
value.to_s.lines(chomp: true).map { |line| wrap_line(line, width) }.join("\n")
|
|
118
59
|
end
|
|
119
60
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
125
|
-
elements.map { |element| render_inline(element) }.join
|
|
66
|
+
fallback
|
|
126
67
|
end
|
|
127
68
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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
|
-
|
|
157
|
-
return element.options[:lang] if element.options[:lang]
|
|
77
|
+
private
|
|
158
78
|
|
|
159
|
-
|
|
79
|
+
# The BlockRenderer instance, lazily built.
|
|
80
|
+
def block_renderer
|
|
81
|
+
@block_renderer ||= BlockRenderer.new(renderer: self)
|
|
160
82
|
end
|
|
161
83
|
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|