charming 0.1.3 → 0.1.4

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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/lib/charming/application.rb +19 -2
  3. data/lib/charming/cli.rb +3 -3
  4. data/lib/charming/controller/component_dispatching.rb +47 -3
  5. data/lib/charming/controller/focus.rb +123 -0
  6. data/lib/charming/controller/focus_management.rb +1 -1
  7. data/lib/charming/controller/rendering.rb +4 -15
  8. data/lib/charming/controller/session_state.rb +11 -0
  9. data/lib/charming/controller.rb +11 -2
  10. data/lib/charming/database/commands.rb +106 -0
  11. data/lib/charming/generators/database_installer.rb +154 -0
  12. data/lib/charming/generators/model_generator.rb +2 -10
  13. data/lib/charming/generators/name.rb +1 -1
  14. data/lib/charming/generators/view_generator.rb +1 -1
  15. data/lib/charming/presentation/components/form/field.rb +1 -1
  16. data/lib/charming/presentation/components/markdown.rb +7 -7
  17. data/lib/charming/presentation/layout/pane.rb +7 -0
  18. data/lib/charming/presentation/layout/rect.rb +5 -0
  19. data/lib/charming/presentation/layout/screen_layout.rb +7 -0
  20. data/lib/charming/presentation/layout/split.rb +7 -0
  21. data/lib/charming/presentation/markdown/render_context.rb +28 -10
  22. data/lib/charming/presentation/markdown/renderer.rb +264 -39
  23. data/lib/charming/presentation/markdown/style_config.rb +215 -0
  24. data/lib/charming/presentation/markdown/syntax_highlighter.rb +3 -2
  25. data/lib/charming/presentation/markdown.rb +2 -2
  26. data/lib/charming/presentation/view.rb +7 -0
  27. data/lib/charming/router.rb +3 -8
  28. data/lib/charming/runtime.rb +2 -0
  29. data/lib/charming/version.rb +1 -1
  30. data/lib/charming.rb +2 -2
  31. metadata +42 -9
  32. data/lib/charming/database_commands.rb +0 -103
  33. data/lib/charming/database_installer.rb +0 -152
  34. data/lib/charming/focus.rb +0 -121
  35. data/lib/charming/presentation/markdown/block_renderers.rb +0 -118
  36. data/lib/charming/presentation/markdown/inline_renderers.rb +0 -66
@@ -2,17 +2,15 @@
2
2
 
3
3
  module Charming
4
4
  module Components
5
- # Markdown renders Markdown source as ANSI-styled terminal text. Parsing is delegated to
6
- # `Charming::Markdown::Renderer`; set *syntax_highlighting* to false to disable
7
- # Rouge-backed code block highlighting.
5
+ # Markdown renders CommonMark/GFM source as ANSI-styled terminal text.
8
6
  class Markdown < Component
9
- # *content* is the Markdown source string. *width* optionally sets the wrap width.
10
- # *syntax_highlighting* enables Rouge for code blocks (defaults to true).
11
- def initialize(content:, width: nil, theme: nil, syntax_highlighting: true)
7
+ def initialize(content:, width: nil, theme: nil, syntax_highlighting: true, style: :dark, base_url: nil)
12
8
  super(theme: theme)
13
9
  @content = content
14
10
  @width = width
15
11
  @syntax_highlighting = syntax_highlighting
12
+ @style = style
13
+ @base_url = base_url
16
14
  end
17
15
 
18
16
  # Renders the Markdown body to a styled, terminal-safe string.
@@ -21,7 +19,9 @@ module Charming
21
19
  content: @content,
22
20
  width: @width,
23
21
  theme: theme,
24
- syntax_highlighting: @syntax_highlighting
22
+ syntax_highlighting: @syntax_highlighting,
23
+ style: @style,
24
+ base_url: @base_url
25
25
  ).render
26
26
  end
27
27
  end
@@ -44,6 +44,13 @@ module Charming
44
44
  (focus && name) ? [name] : []
45
45
  end
46
46
 
47
+ # Returns the mouse target represented by this pane, if it has a name.
48
+ def mouse_targets(rect)
49
+ return [] unless name
50
+
51
+ [{name: name, rect: rect, inner_rect: inner_rect(rect)}]
52
+ end
53
+
47
54
  # Renders the pane into *rect*, applying the configured style, border, and padding
48
55
  # around the evaluated content.
49
56
  def render(rect)
@@ -6,6 +6,11 @@ module Charming
6
6
  # (width, height). Layout operations produce new Rect instances rather than mutating
7
7
  # existing ones.
8
8
  Rect = Data.define(:x, :y, :width, :height) do
9
+ # Returns true when the zero-based cell coordinate falls within this rectangle.
10
+ def cover?(point_x, point_y)
11
+ point_x >= x && point_x < x + width && point_y >= y && point_y < y + height
12
+ end
13
+
9
14
  # Returns a new Rect inset by *top*/*right*/*bottom*/*left* cells. The result is
10
15
  # clamped to a minimum width/height of 0.
11
16
  def inset(top: 0, right: 0, bottom: 0, left: 0)
@@ -32,6 +32,13 @@ module Charming
32
32
  child ? child.focusable_names : []
33
33
  end
34
34
 
35
+ # Returns all named pane mouse targets for the full screen layout.
36
+ def mouse_targets
37
+ return [] unless child
38
+
39
+ child.mouse_targets(Rect.new(x: 0, y: 0, width: screen.width, height: screen.height))
40
+ end
41
+
35
42
  # Renders the child into the full-screen rect, then overlays each registered overlay
36
43
  # on top in order.
37
44
  def render
@@ -32,6 +32,13 @@ module Charming
32
32
  children.flat_map(&:focusable_names)
33
33
  end
34
34
 
35
+ # Returns all named pane mouse targets in this split, preserving declaration order.
36
+ def mouse_targets(rect)
37
+ child_rects(rect).zip(children).flat_map do |child_rect, child|
38
+ child.respond_to?(:mouse_targets) ? child.mouse_targets(child_rect) : []
39
+ end
40
+ end
41
+
35
42
  # Renders each child into its own sub-rect, then overlays them on a blank canvas
36
43
  # of the parent's dimensions.
37
44
  def render(rect)
@@ -2,18 +2,36 @@
2
2
 
3
3
  module Charming
4
4
  module Markdown
5
- # RenderContext carries the state needed to render nested Markdown blocks: the current
6
- # list nesting depth (used for indentation) and the wrap width.
7
- RenderContext = Data.define(:list_depth, :width) do
8
- # Builds a new RenderContext with the given *width* and optional starting *list_depth*.
9
- def self.from(width:, list_depth: 0)
10
- new(list_depth: list_depth, width: width)
5
+ # RenderContext carries render-time state that needs to flow down the Markdown AST.
6
+ RenderContext = Data.define(:width, :list_depth, :style, :current_style, :base_url, :source_lines) do
7
+ def self.from(width:, style:, base_url: nil, source_lines: [], list_depth: 0, current_style: nil)
8
+ new(
9
+ width: width,
10
+ list_depth: list_depth,
11
+ style: style,
12
+ current_style: current_style || style[:document],
13
+ base_url: base_url,
14
+ source_lines: source_lines
15
+ )
11
16
  end
12
17
 
13
- # Returns a derived context with the list depth incremented by *depth_increment*
14
- # and the wrap width overridden to *width* (defaults to the current width).
15
- def nested(depth_increment: 0, width: self.width)
16
- self.class.new(list_depth: list_depth + depth_increment, width: width)
18
+ def with(width: self.width, list_depth: self.list_depth, current_style: self.current_style)
19
+ self.class.new(
20
+ width: width,
21
+ list_depth: list_depth,
22
+ style: style,
23
+ current_style: current_style,
24
+ base_url: base_url,
25
+ source_lines: source_lines
26
+ )
27
+ end
28
+
29
+ def nested_list(width: self.width)
30
+ with(width: width, list_depth: list_depth + 1)
31
+ end
32
+
33
+ def inherit(style_name)
34
+ current_style.inherit_visual(style[style_name])
17
35
  end
18
36
  end
19
37
  end
@@ -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