charming 0.1.3 → 0.2.0

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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -14
  3. data/lib/charming/application.rb +19 -2
  4. data/lib/charming/cli.rb +3 -3
  5. data/lib/charming/controller/component_dispatching.rb +47 -3
  6. data/lib/charming/controller/focus.rb +123 -0
  7. data/lib/charming/controller/focus_management.rb +1 -1
  8. data/lib/charming/controller/rendering.rb +4 -15
  9. data/lib/charming/controller/session_state.rb +11 -0
  10. data/lib/charming/controller.rb +11 -2
  11. data/lib/charming/database/commands.rb +106 -0
  12. data/lib/charming/generators/database_installer.rb +154 -0
  13. data/lib/charming/generators/model_generator.rb +2 -10
  14. data/lib/charming/generators/name.rb +1 -1
  15. data/lib/charming/generators/view_generator.rb +1 -1
  16. data/lib/charming/presentation/components/form/field.rb +1 -1
  17. data/lib/charming/presentation/components/markdown.rb +7 -7
  18. data/lib/charming/presentation/layout/builder.rb +8 -2
  19. data/lib/charming/presentation/layout/pane.rb +40 -108
  20. data/lib/charming/presentation/layout/pane_behavior.rb +39 -0
  21. data/lib/charming/presentation/layout/pane_geometry.rb +71 -0
  22. data/lib/charming/presentation/layout/pane_style.rb +39 -0
  23. data/lib/charming/presentation/layout/rect.rb +5 -0
  24. data/lib/charming/presentation/layout/screen_layout.rb +7 -0
  25. data/lib/charming/presentation/layout/split.rb +7 -0
  26. data/lib/charming/presentation/markdown/render_context.rb +28 -10
  27. data/lib/charming/presentation/markdown/renderer.rb +264 -39
  28. data/lib/charming/presentation/markdown/style_config.rb +215 -0
  29. data/lib/charming/presentation/markdown/syntax_highlighter.rb +3 -2
  30. data/lib/charming/presentation/markdown.rb +2 -2
  31. data/lib/charming/presentation/view.rb +7 -0
  32. data/lib/charming/router.rb +3 -8
  33. data/lib/charming/runtime.rb +2 -0
  34. data/lib/charming/version.rb +1 -1
  35. data/lib/charming.rb +2 -2
  36. metadata +45 -9
  37. data/lib/charming/database_commands.rb +0 -103
  38. data/lib/charming/database_installer.rb +0 -152
  39. data/lib/charming/focus.rb +0 -121
  40. data/lib/charming/presentation/markdown/block_renderers.rb +0 -118
  41. data/lib/charming/presentation/markdown/inline_renderers.rb +0 -66
@@ -1,72 +1,58 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "kramdown"
3
+ require "commonmarker"
4
+ require "uri"
4
5
 
5
6
  module Charming
6
7
  module Markdown
7
- # Renderer is the top-level Markdown-to-ANSI renderer. Parses the *content* with
8
- # Kramdown, then walks the document's block and inline trees to produce styled
9
- # terminal output. Code blocks are highlighted via Rouge when `syntax_highlighting`
10
- # is enabled.
8
+ # Renderer parses CommonMark/GFM with Commonmarker and renders it as ANSI text.
11
9
  class Renderer
12
- # Wrap width used by `render_rule` when no width is otherwise specified.
13
10
  DEFAULT_RULE_WIDTH = 40
14
11
 
15
- # The Markdown source, configured wrap width, theme, and syntax-highlighting flag.
16
- attr_reader :content, :width, :theme, :syntax_highlighting
12
+ attr_reader :content, :width, :theme, :syntax_highlighting, :style, :base_url
17
13
 
18
- # *content* is the Markdown source string. *width* optionally wraps paragraphs to that
19
- # many display columns. *theme* is the Charming theme used to style blocks/inlines.
20
- # *syntax_highlighting* enables Rouge-backed code block highlighting (default true).
21
- def initialize(content:, width: nil, theme: UI::Theme.default, syntax_highlighting: true)
14
+ def initialize(content:, width: nil, theme: UI::Theme.default, syntax_highlighting: true, style: :dark, base_url: nil)
22
15
  @content = content
23
16
  @width = width
24
17
  @theme = theme || UI::Theme.default
25
18
  @syntax_highlighting = syntax_highlighting
19
+ @style = StyleConfig.from(style)
20
+ @base_url = base_url
26
21
  end
27
22
 
28
- # Parses the content and returns the fully-rendered Markdown as a single string.
29
23
  def render
30
- document = Kramdown::Document.new(content.to_s)
31
- render_blocks(document.root.children)
24
+ context = RenderContext.from(
25
+ width: width,
26
+ style: style,
27
+ base_url: base_url,
28
+ source_lines: content.to_s.lines(chomp: true)
29
+ )
30
+ render_document(parse_document, context: context)
32
31
  end
33
32
 
34
- # Renders a list of Kramdown block *elements* into a string, joined by blank lines.
35
- # *list_depth* is forwarded to the render context for list indentation. *width*
36
- # defaults to the renderer's configured width.
37
- def render_blocks(elements, list_depth: 0, width: @width)
38
- context = RenderContext.from(width: width, list_depth: list_depth)
33
+ def render_blocks(elements, context:)
39
34
  elements.filter_map do |element|
40
- rendered = block_renderer.render(element, context: context)
35
+ rendered = render_block(element, context: context)
41
36
  rendered unless rendered.to_s.empty?
42
37
  end.join("\n\n")
43
38
  end
44
39
 
45
- # Renders a list of Kramdown inline *elements* into a single concatenated string.
46
- # *width* defaults to the renderer's configured width.
47
- def render_inlines(elements, width: @width)
48
- context = RenderContext.from(width: width)
49
- elements.map { |element| inline_renderer.render(element, context: context) }.join
40
+ def render_inlines(elements, context:)
41
+ elements.map { |element| render_inline(element, context: context) }.join
50
42
  end
51
43
 
52
- # Word-wraps *value* to *width* display columns (when *width* is given), preserving
53
- # any ANSI styling on each line. Returns *value* unchanged when *width* is nil.
54
44
  def wrap(value, width:)
55
45
  return value unless width
56
46
 
57
47
  value.to_s.lines(chomp: true).map { |line| wrap_line(line, width) }.join("\n")
58
48
  end
59
49
 
60
- # Returns the theme's style for *name* if the theme defines it, otherwise returns
61
- # *fallback*. Lets views override markdown-specific theme tokens.
62
50
  def style_for(name, fallback:)
63
51
  return theme.public_send(name) if theme.respond_to?(name)
64
52
 
65
53
  fallback
66
54
  end
67
55
 
68
- # Returns the theme's style for *name*, building a one-token default theme when
69
- # the active theme doesn't define it. Used as a final fallback for markdown styling.
70
56
  def theme_style(name)
71
57
  return theme.public_send(name) if theme.respond_to?(name)
72
58
 
@@ -75,17 +61,256 @@ module Charming
75
61
 
76
62
  private
77
63
 
78
- # The BlockRenderer instance, lazily built.
79
- def block_renderer
80
- @block_renderer ||= BlockRenderer.new(renderer: self)
64
+ def parse_document
65
+ Commonmarker.parse(
66
+ content.to_s,
67
+ options: {
68
+ extension: {
69
+ autolink: true,
70
+ description_lists: true,
71
+ footnotes: true,
72
+ strikethrough: true,
73
+ table: true,
74
+ tasklist: true
75
+ }
76
+ }
77
+ )
81
78
  end
82
79
 
83
- # The InlineRenderer instance, lazily built.
84
- def inline_renderer
85
- @inline_renderer ||= InlineRenderer.new(renderer: self)
80
+ def render_document(node, context:)
81
+ document_style = context.style[:document]
82
+ body = render_blocks(children_of(node), context: context.with(current_style: document_style))
83
+ document_style.render(document_style.apply_block_layout(body))
84
+ end
85
+
86
+ def render_block(node, context:)
87
+ case node.type
88
+ when :paragraph
89
+ render_paragraph(node, context: context)
90
+ when :heading
91
+ render_heading(node, context: context)
92
+ when :block_quote
93
+ render_block_quote(node, context: context)
94
+ when :list
95
+ render_list(node, context: context)
96
+ when :code_block
97
+ render_code_block(node, context: context)
98
+ when :thematic_break
99
+ render_rule(context: context)
100
+ when :table
101
+ render_table(node, context: context)
102
+ when :html_block
103
+ render_html_block(node, context: context)
104
+ else
105
+ render_blocks(children_of(node), context: context)
106
+ end
107
+ end
108
+
109
+ def render_inline(node, context:)
110
+ case node.type
111
+ when :text
112
+ context.current_style.inherit_visual(context.style[:text]).render(node.string_content)
113
+ when :softbreak
114
+ " "
115
+ when :linebreak
116
+ "\n"
117
+ when :code
118
+ context.inherit(:code).render(node.string_content)
119
+ when :emph
120
+ render_styled_inline(node, :emph, context: context)
121
+ when :strong
122
+ render_styled_inline(node, :strong, context: context)
123
+ when :strikethrough
124
+ render_styled_inline(node, :strikethrough, context: context)
125
+ when :link
126
+ render_link(node, context: context)
127
+ when :image
128
+ render_image(node, context: context)
129
+ when :html_inline
130
+ ""
131
+ else
132
+ render_inlines(children_of(node), context: context)
133
+ end
134
+ end
135
+
136
+ def render_paragraph(node, context:)
137
+ paragraph_style = context.current_style.inherit_visual(context.style[:paragraph])
138
+ body = render_inlines(children_of(node), context: context.with(current_style: paragraph_style))
139
+ render_block_with_style(paragraph_style, wrap(body, width: context.width))
140
+ end
141
+
142
+ def render_heading(node, context:)
143
+ heading_style = context.current_style.inherit_visual(context.style.heading(node.header_level))
144
+ body = render_inlines(children_of(node), context: context.with(current_style: heading_style))
145
+ render_block_with_style(heading_style, wrap(body, width: context.width))
146
+ end
147
+
148
+ def render_block_quote(node, context:)
149
+ quote_style = context.current_style.inherit_visual(context.style[:block_quote])
150
+ quote_width = context.width ? [context.width - quote_indent_width(quote_style), 1].max : nil
151
+ body = render_blocks(children_of(node), context: context.with(width: quote_width, current_style: quote_style))
152
+ render_block_with_style(quote_style, body)
153
+ end
154
+
155
+ def render_list(node, context:)
156
+ list_style = context.current_style.inherit_visual(context.style[:list])
157
+ children_of(node).each_with_index.map do |item, index|
158
+ render_list_item(item, index: index, ordered: node.list_type == :ordered, list: node, context: context.with(current_style: list_style))
159
+ end.join("\n")
160
+ end
161
+
162
+ def render_list_item(node, index:, ordered:, list:, context:)
163
+ marker_style = context.current_style.inherit_visual(context.style[ordered ? :enumeration : :item])
164
+ marker = if node.type == :taskitem
165
+ task_marker(node, context: context)
166
+ elsif ordered
167
+ "#{list.list_start.to_i + index}. "
168
+ else
169
+ marker_style.block_prefix.empty? ? "- " : marker_style.block_prefix
170
+ end
171
+
172
+ indent = " " * (context.style[:list].level_indent || 2) * context.list_depth
173
+ first_prefix = "#{indent}#{marker}"
174
+ rest_prefix = "#{indent}#{" " * UI::Width.measure(marker)}"
175
+ item_width = context.width ? [context.width - UI::Width.measure(first_prefix), 1].max : nil
176
+ body = render_blocks(children_of(node), context: context.nested_list(width: item_width))
177
+
178
+ body.lines(chomp: true).each_with_index.map do |line, line_index|
179
+ "#{line_index.zero? ? first_prefix : rest_prefix}#{line}"
180
+ end.join("\n")
181
+ end
182
+
183
+ def task_marker(node, context:)
184
+ task_style = context.current_style.inherit_visual(context.style[:task])
185
+ checked_task?(node, context: context) ? (task_style.ticked || "[x] ") : (task_style.unticked || "[ ] ")
186
+ end
187
+
188
+ def checked_task?(node, context:)
189
+ line = context.source_lines[node.source_position[:start_line].to_i - 1].to_s
190
+ line.match?(/\[[xX]\]/)
191
+ end
192
+
193
+ def render_code_block(node, context:)
194
+ code_style = context.current_style.inherit_visual(context.style[:code_block])
195
+ code = node.string_content.to_s.chomp
196
+ rendered = if syntax_highlighting
197
+ SyntaxHighlighter.new(theme: theme, style: style).render(code, language: node.fence_info.to_s.split.first)
198
+ else
199
+ code_style.render(code)
200
+ end
201
+
202
+ body = rendered.lines(chomp: true).map { |line| " #{line}" }.join("\n")
203
+ syntax_highlighting ? code_style.apply_block_layout(body) : render_block_with_style(code_style, body)
204
+ end
205
+
206
+ def render_rule(context:)
207
+ rule_style = context.current_style.inherit_visual(context.style[:hr])
208
+ body = rule_style.format.empty? ? "-" * (context.width || DEFAULT_RULE_WIDTH) : rule_style.format
209
+ render_block_with_style(rule_style, body)
210
+ end
211
+
212
+ def render_table(node, context:)
213
+ table_style = context.current_style.inherit_visual(context.style[:table])
214
+ rows = children_of(node).map do |row|
215
+ children_of(row).map { |cell| render_inlines(children_of(cell), context: context.with(current_style: table_style)) }
216
+ end
217
+ return "" if rows.empty?
218
+
219
+ widths = table_widths(rows)
220
+ rendered_rows = rows.each_with_index.map do |row, index|
221
+ line = table_row(row, widths, table_style)
222
+ index.zero? ? [line, table_separator(widths, table_style)].join("\n") : line
223
+ end
224
+
225
+ render_block_with_style(table_style, rendered_rows.join("\n"))
226
+ end
227
+
228
+ def render_html_block(_node, context:)
229
+ html_style = context.current_style.inherit_visual(context.style[:html_block])
230
+ return "" if html_style.format.empty?
231
+
232
+ render_block_with_style(html_style, html_style.format)
233
+ end
234
+
235
+ def render_styled_inline(node, style_name, context:)
236
+ inline_style = context.inherit(style_name)
237
+ inline_style.render(render_inlines(children_of(node), context: context.with(current_style: inline_style)))
238
+ end
239
+
240
+ def render_link(node, context:)
241
+ href = resolve_url(node.url.to_s, context: context)
242
+ text_style = context.inherit(:link_text)
243
+ link_style = context.inherit(:link)
244
+ label = render_inlines(children_of(node), context: context.with(current_style: text_style))
245
+ rendered = if href.empty? || UI::Width.strip_ansi(label) == href
246
+ label
247
+ else
248
+ "#{label} <#{href}>"
249
+ end
250
+ link_style.render(rendered)
251
+ end
252
+
253
+ def render_image(node, context:)
254
+ href = resolve_url(node.url.to_s, context: context)
255
+ image_style = context.inherit(:image)
256
+ text_style = context.inherit(:image_text)
257
+ alt = render_inlines(children_of(node), context: context.with(current_style: text_style))
258
+ label = if text_style.format.empty?
259
+ "Image: #{UI::Width.strip_ansi(alt)} ->"
260
+ else
261
+ text_style.format.gsub("{{text}}", UI::Width.strip_ansi(alt))
262
+ end
263
+
264
+ image_style.render([label, href].reject(&:empty?).join(" "))
265
+ end
266
+
267
+ def render_block_with_style(style, body)
268
+ style.render(style.apply_block_layout(body))
269
+ end
270
+
271
+ def table_widths(rows)
272
+ column_count = rows.map(&:length).max || 0
273
+ Array.new(column_count) do |index|
274
+ rows.map { |row| UI::Width.measure(row[index].to_s) }.max || 0
275
+ end
276
+ end
277
+
278
+ def table_row(row, widths, style)
279
+ separator = style.column_separator || "|"
280
+ cells = widths.each_with_index.map do |width, index|
281
+ value = row[index].to_s
282
+ " #{value}#{" " * [width - UI::Width.measure(value), 0].max} "
283
+ end
284
+ "#{separator}#{cells.join(separator)}#{separator}"
285
+ end
286
+
287
+ def table_separator(widths, style)
288
+ separator = style.column_separator || "|"
289
+ row = style.row_separator || "-"
290
+ "#{separator}#{widths.map { |table_width| row * (table_width + 2) }.join(separator)}#{separator}"
291
+ end
292
+
293
+ def quote_indent_width(style)
294
+ return 0 unless style.indent&.positive?
295
+
296
+ UI::Width.measure((style.indent_token || " ") * style.indent)
297
+ end
298
+
299
+ def resolve_url(value, context:)
300
+ return value if context.base_url.to_s.empty? || value.empty?
301
+
302
+ uri = URI.parse(value)
303
+ return value if uri.absolute?
304
+
305
+ URI.join(context.base_url, value).to_s
306
+ rescue URI::InvalidURIError
307
+ value
308
+ end
309
+
310
+ def children_of(node)
311
+ node.each.to_a
86
312
  end
87
313
 
88
- # Word-wraps a single *line* to *width* display columns using greedy space-splitting.
89
314
  def wrap_line(line, width)
90
315
  return line if UI::Width.measure(line) <= width
91
316
 
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Markdown
5
+ class StyleConfig
6
+ ELEMENTS = %i[
7
+ document paragraph block_quote list heading h1 h2 h3 h4 h5 h6 text
8
+ strikethrough emph strong hr item enumeration task link link_text image
9
+ image_text code code_block table definition_list definition_term
10
+ definition_description html_block html_span
11
+ ].freeze
12
+
13
+ BUILT_INS = {
14
+ notty: {
15
+ block_quote: {indent: 1, indent_token: "| "},
16
+ list: {level_indent: 2},
17
+ h1: {prefix: "# "},
18
+ h2: {prefix: "## "},
19
+ h3: {prefix: "### "},
20
+ h4: {prefix: "#### "},
21
+ h5: {prefix: "##### "},
22
+ h6: {prefix: "###### "},
23
+ emph: {block_prefix: "*", block_suffix: "*"},
24
+ strong: {block_prefix: "**", block_suffix: "**"},
25
+ strikethrough: {block_prefix: "~~", block_suffix: "~~"},
26
+ hr: {format: "--------"},
27
+ item: {block_prefix: "- "},
28
+ enumeration: {block_prefix: ". "},
29
+ task: {ticked: "[x] ", unticked: "[ ] "},
30
+ code: {block_prefix: "`", block_suffix: "`"},
31
+ code_block: {margin: 1},
32
+ table: {column_separator: "|", row_separator: "-"},
33
+ image_text: {format: "Image: {{text}} ->"}
34
+ },
35
+ dark: {
36
+ document: {color: "252"},
37
+ block_quote: {color: "244", indent: 1, indent_token: "│ "},
38
+ list: {level_indent: 2},
39
+ heading: {color: "39", bold: true},
40
+ h1: {prefix: " ", suffix: " ", color: "228", background_color: "63", bold: true},
41
+ h2: {prefix: "## "},
42
+ h3: {prefix: "### "},
43
+ h4: {prefix: "#### "},
44
+ h5: {prefix: "##### "},
45
+ h6: {prefix: "###### ", color: "35", bold: false},
46
+ strikethrough: {crossed_out: true},
47
+ emph: {italic: true},
48
+ strong: {bold: true},
49
+ hr: {color: "240", format: "--------"},
50
+ item: {block_prefix: "• "},
51
+ enumeration: {block_prefix: ". "},
52
+ task: {ticked: "[✓] ", unticked: "[ ] "},
53
+ link: {color: "30", underline: true},
54
+ link_text: {color: "35", bold: true},
55
+ image: {color: "212", underline: true},
56
+ image_text: {color: "243", format: "Image: {{text}} ->"},
57
+ code: {prefix: " ", suffix: " ", color: "203", background_color: "236"},
58
+ code_block: {color: "244", margin: 1},
59
+ table: {column_separator: "|", row_separator: "-"}
60
+ },
61
+ light: {
62
+ document: {color: "236"},
63
+ block_quote: {color: "244", indent: 1, indent_token: "│ "},
64
+ list: {level_indent: 2},
65
+ heading: {color: "25", bold: true},
66
+ h1: {prefix: " ", suffix: " ", color: "255", background_color: "33", bold: true},
67
+ h2: {prefix: "## "},
68
+ h3: {prefix: "### "},
69
+ h4: {prefix: "#### "},
70
+ h5: {prefix: "##### "},
71
+ h6: {prefix: "###### ", color: "30", bold: false},
72
+ strikethrough: {crossed_out: true},
73
+ emph: {italic: true},
74
+ strong: {bold: true},
75
+ hr: {color: "250", format: "--------"},
76
+ item: {block_prefix: "• "},
77
+ enumeration: {block_prefix: ". "},
78
+ task: {ticked: "[✓] ", unticked: "[ ] "},
79
+ link: {color: "25", underline: true},
80
+ link_text: {color: "90", bold: true},
81
+ image: {color: "162", underline: true},
82
+ image_text: {color: "244", format: "Image: {{text}} ->"},
83
+ code: {prefix: " ", suffix: " ", color: "161", background_color: "255"},
84
+ code_block: {color: "244", margin: 1},
85
+ table: {column_separator: "|", row_separator: "-"}
86
+ }
87
+ }.freeze
88
+
89
+ ATTRIBUTES = %i[bold faint italic underline reverse strikethrough].freeze
90
+
91
+ Style = Data.define(
92
+ :block_prefix, :block_suffix, :prefix, :suffix, :color, :background_color,
93
+ :bold, :faint, :italic, :underline, :reverse, :strikethrough, :format,
94
+ :indent, :indent_token, :margin, :level_indent, :ticked, :unticked,
95
+ :column_separator, :row_separator
96
+ ) do
97
+ def self.from(value)
98
+ value = symbolize_keys(value || {})
99
+ new(
100
+ block_prefix: value[:block_prefix].to_s,
101
+ block_suffix: value[:block_suffix].to_s,
102
+ prefix: value[:prefix].to_s,
103
+ suffix: value[:suffix].to_s,
104
+ color: normalize_color(value[:color] || value[:foreground] || value[:fg]),
105
+ background_color: normalize_color(value[:background_color] || value[:background] || value[:bg]),
106
+ bold: value[:bold],
107
+ faint: value[:faint],
108
+ italic: value[:italic],
109
+ underline: value[:underline],
110
+ reverse: value[:reverse] || value[:inverse],
111
+ strikethrough: value[:strikethrough] || value[:crossed_out],
112
+ format: value[:format].to_s,
113
+ indent: value[:indent]&.to_i,
114
+ indent_token: value[:indent_token]&.to_s,
115
+ margin: value[:margin]&.to_i,
116
+ level_indent: value[:level_indent]&.to_i,
117
+ ticked: value[:ticked]&.to_s,
118
+ unticked: value[:unticked]&.to_s,
119
+ column_separator: value[:column_separator]&.to_s,
120
+ row_separator: value[:row_separator]&.to_s
121
+ )
122
+ end
123
+
124
+ def inherit_visual(child)
125
+ child = self.class.from(child) unless child.is_a?(self.class)
126
+ self.class.new(**child.to_h.merge(
127
+ color: child.color || color,
128
+ background_color: child.background_color || background_color,
129
+ bold: child.bold.nil? ? bold : child.bold,
130
+ faint: child.faint.nil? ? faint : child.faint,
131
+ italic: child.italic.nil? ? italic : child.italic,
132
+ underline: child.underline.nil? ? underline : child.underline,
133
+ reverse: child.reverse.nil? ? reverse : child.reverse,
134
+ strikethrough: child.strikethrough.nil? ? strikethrough : child.strikethrough
135
+ ))
136
+ end
137
+
138
+ def render(value)
139
+ ansi_codes.apply("#{block_prefix}#{prefix}#{value}#{suffix}#{block_suffix}")
140
+ end
141
+
142
+ def apply_block_layout(value)
143
+ lines = value.to_s.lines(chomp: true)
144
+ lines = [""] if lines.empty?
145
+
146
+ if indent&.positive?
147
+ indentation = (indent_token || " ") * indent
148
+ lines = lines.map { |line| "#{indentation}#{line}" }
149
+ end
150
+
151
+ rendered = lines.join("\n")
152
+ return rendered unless margin.to_i.positive?
153
+
154
+ blank = Array.new(margin.to_i, "").join("\n")
155
+ "#{blank}\n#{rendered}\n#{blank}"
156
+ end
157
+
158
+ private
159
+
160
+ def ansi_codes
161
+ UI::ANSICodes.new(
162
+ attributes: ATTRIBUTES.select { |attribute| public_send(attribute) },
163
+ foreground: color,
164
+ background: background_color
165
+ )
166
+ end
167
+
168
+ def self.symbolize_keys(value)
169
+ value.to_h.each_with_object({}) { |(key, item), result| result[key.to_sym] = item }
170
+ end
171
+
172
+ def self.normalize_color(value)
173
+ return if value.nil?
174
+ return value if value.is_a?(Integer)
175
+ return value.to_i if value.to_s.match?(/\A\d+\z/)
176
+
177
+ value
178
+ end
179
+ end
180
+
181
+ def self.builtin(name)
182
+ key = name.to_s.tr("-", "_").to_sym
183
+ raise ArgumentError, "unknown markdown style: #{name.inspect}" unless BUILT_INS.key?(key)
184
+
185
+ from_hash(BUILT_INS.fetch(key))
186
+ end
187
+
188
+ def self.from(value)
189
+ return value if value.is_a?(self)
190
+ return builtin(value) if value.is_a?(String) || value.is_a?(Symbol)
191
+
192
+ from_hash(value || BUILT_INS.fetch(:dark))
193
+ end
194
+
195
+ def self.from_hash(value)
196
+ new(value.to_h)
197
+ end
198
+
199
+ def initialize(styles = {})
200
+ styles = styles.transform_keys(&:to_sym)
201
+ @styles = ELEMENTS.each_with_object({}) do |element, result|
202
+ result[element] = Style.from(styles[element] || {})
203
+ end.freeze
204
+ end
205
+
206
+ def [](name)
207
+ @styles.fetch(name.to_sym) { Style.from({}) }
208
+ end
209
+
210
+ def heading(level)
211
+ self[:heading].inherit_visual(self[:"h#{level}"])
212
+ end
213
+ end
214
+ end
215
+ end
@@ -10,8 +10,9 @@ module Charming
10
10
  # base style (muted italic for comments, title for keywords, etc.).
11
11
  class SyntaxHighlighter
12
12
  # *theme* is the active Charming theme. Defaults to UI::Theme.default.
13
- def initialize(theme: UI::Theme.default)
13
+ def initialize(theme: UI::Theme.default, style: nil)
14
14
  @theme = theme || UI::Theme.default
15
+ @style = style
15
16
  end
16
17
 
17
18
  # Highlights *code* (using Rouge) for the given *language* (auto-detected when nil)
@@ -27,7 +28,7 @@ module Charming
27
28
  private
28
29
 
29
30
  # The Charming theme used for token styling.
30
- attr_reader :theme
31
+ attr_reader :theme, :style
31
32
 
32
33
  # Picks a Rouge lexer for *language* and *code*, falling back to plain text.
33
34
  def lexer_for(language, code)
@@ -2,8 +2,8 @@
2
2
 
3
3
  module Charming
4
4
  # Markdown is the namespace for the Markdown rendering pipeline. Parsing is delegated to
5
- # Kramdown; per-block and per-inline element rendering is handled by `BlockRenderer`
6
- # and `InlineRenderer`; code blocks are highlighted by `SyntaxHighlighter` (Rouge-backed).
5
+ # Commonmarker; `Renderer` renders the AST, and code blocks are highlighted by
6
+ # `SyntaxHighlighter` (Rouge-backed).
7
7
  module Markdown
8
8
  end
9
9
  end
@@ -81,6 +81,7 @@ module Charming
81
81
  def screen_layout(background: nil, &)
82
82
  layout = Layout::Builder.build(screen: layout_screen, view: self, background: background, &)
83
83
  register_layout_focus(layout)
84
+ register_layout_mouse_targets(layout)
84
85
  layout.render
85
86
  end
86
87
 
@@ -129,5 +130,11 @@ module Charming
129
130
 
130
131
  assigns[:controller].focus.define_layout(layout.focusable_names)
131
132
  end
133
+
134
+ def register_layout_mouse_targets(layout)
135
+ return unless assigns[:controller]
136
+
137
+ assigns[:controller].register_mouse_targets(layout.mouse_targets)
138
+ end
132
139
  end
133
140
  end
@@ -108,20 +108,15 @@ module Charming
108
108
  end
109
109
  end
110
110
 
111
- # Splits a camel-case string into words for title derivation (e.g., "my_route" → ["my", "route"]).
112
- def camelize(value)
113
- value.split("_").map(&:capitalize).join
114
- end
115
-
116
111
  # Looks up a constant by name in Object. Used to resolve controller strings from route definitions.
117
112
  def constantize(name)
118
- Object.const_get(name)
113
+ ActiveSupport::Inflector.constantize(name)
119
114
  end
120
115
 
121
116
  # Builds the full controller constant name, prepending the namespace if present.
122
- # For example: "HomeController" with namespace "Admin" → "Admin::HomeController".
117
+ # For example: "home" with namespace "Admin" → "Admin::HomeController".
123
118
  def controller_constant_name(controller_name)
124
- name = "#{camelize(controller_name)}Controller"
119
+ name = "#{ActiveSupport::Inflector.camelize(controller_name)}Controller"
125
120
  @namespace.to_s.empty? ? name : "#{@namespace}::#{name}"
126
121
  end
127
122
 
@@ -186,6 +186,7 @@ module Charming
186
186
  def setup_terminal
187
187
  @backend.enter_alt_screen
188
188
  @backend.hide_cursor
189
+ @backend.enable_mouse_tracking if @backend.respond_to?(:enable_mouse_tracking)
189
190
  @backend.install_resize_handler if @backend.respond_to?(:install_resize_handler)
190
191
  end
191
192
 
@@ -200,6 +201,7 @@ module Charming
200
201
  # the cursor, and leaves the alternative screen buffer.
201
202
  def restore_terminal
202
203
  @backend.restore_resize_handler if @backend.respond_to?(:restore_resize_handler)
204
+ @backend.disable_mouse_tracking if @backend.respond_to?(:disable_mouse_tracking)
203
205
  @backend.show_cursor
204
206
  @backend.leave_alt_screen
205
207
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Charming
4
- VERSION = "0.1.3"
4
+ VERSION = "0.2.0"
5
5
  end