charming 0.2.0 → 0.2.1

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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/lib/charming/application.rb +96 -9
  4. data/lib/charming/audio/player.rb +104 -0
  5. data/lib/charming/audio/system.rb +69 -0
  6. data/lib/charming/cli.rb +63 -7
  7. data/lib/charming/controller/action_hooks.rb +124 -0
  8. data/lib/charming/controller/class_methods.rb +15 -1
  9. data/lib/charming/controller/dispatching.rb +31 -5
  10. data/lib/charming/controller/focus.rb +9 -0
  11. data/lib/charming/controller/focus_management.rb +0 -7
  12. data/lib/charming/controller/session_state.rb +16 -1
  13. data/lib/charming/controller/sidebar_navigation.rb +63 -28
  14. data/lib/charming/controller.rb +62 -10
  15. data/lib/charming/database/commands.rb +123 -11
  16. data/lib/charming/events/focus_event.rb +12 -0
  17. data/lib/charming/events/paste_event.rb +11 -0
  18. data/lib/charming/events/task_progress_event.rb +21 -0
  19. data/lib/charming/generators/app_generator.rb +38 -1
  20. data/lib/charming/generators/database_installer.rb +4 -15
  21. data/lib/charming/generators/migration_generator.rb +116 -0
  22. data/lib/charming/generators/migration_timestamp.rb +29 -0
  23. data/lib/charming/generators/model_generator.rb +4 -2
  24. data/lib/charming/generators/templates/app/application_controller.template +1 -1
  25. data/lib/charming/generators/templates/app/database_config.template +3 -1
  26. data/lib/charming/generators/templates/app/layout.template +1 -1
  27. data/lib/charming/generators/templates/app/spec_helper.template +2 -1
  28. data/lib/charming/generators/templates/app/view.template +1 -1
  29. data/lib/charming/internal/terminal/memory_backend.rb +6 -0
  30. data/lib/charming/internal/terminal/tty_backend.rb +64 -2
  31. data/lib/charming/presentation/component.rb +7 -0
  32. data/lib/charming/presentation/components/audio.rb +31 -0
  33. data/lib/charming/presentation/components/autocomplete.rb +108 -0
  34. data/lib/charming/presentation/components/badge.rb +31 -0
  35. data/lib/charming/presentation/components/breadcrumbs.rb +29 -0
  36. data/lib/charming/presentation/components/command_palette.rb +8 -5
  37. data/lib/charming/presentation/components/error_screen.rb +72 -0
  38. data/lib/charming/presentation/components/form.rb +9 -0
  39. data/lib/charming/presentation/components/fuzzy_matcher.rb +83 -0
  40. data/lib/charming/presentation/components/help_overlay.rb +65 -0
  41. data/lib/charming/presentation/components/markdown.rb +6 -2
  42. data/lib/charming/presentation/components/modal.rb +45 -5
  43. data/lib/charming/presentation/components/multi_select_list.rb +85 -0
  44. data/lib/charming/presentation/components/progressbar.rb +0 -1
  45. data/lib/charming/presentation/components/status_bar.rb +75 -0
  46. data/lib/charming/presentation/components/tab_bar.rb +103 -0
  47. data/lib/charming/presentation/components/table.rb +40 -9
  48. data/lib/charming/presentation/components/text_area.rb +47 -10
  49. data/lib/charming/presentation/components/text_input.rb +79 -4
  50. data/lib/charming/presentation/components/toast.rb +51 -0
  51. data/lib/charming/presentation/components/tree.rb +176 -0
  52. data/lib/charming/presentation/components/viewport/content_lines.rb +55 -0
  53. data/lib/charming/presentation/components/viewport/line_window.rb +71 -0
  54. data/lib/charming/presentation/components/viewport/position.rb +67 -0
  55. data/lib/charming/presentation/components/viewport.rb +37 -122
  56. data/lib/charming/presentation/layout/builder.rb +4 -1
  57. data/lib/charming/presentation/layout/overlay.rb +6 -4
  58. data/lib/charming/presentation/layout/pane.rb +2 -1
  59. data/lib/charming/presentation/layout/pane_geometry.rb +16 -8
  60. data/lib/charming/presentation/layout/screen_layout.rb +12 -3
  61. data/lib/charming/presentation/layout/split.rb +37 -3
  62. data/lib/charming/presentation/markdown/renderer.rb +99 -63
  63. data/lib/charming/presentation/markdown/style_config.rb +10 -5
  64. data/lib/charming/presentation/markdown/syntax_highlighter.rb +11 -1
  65. data/lib/charming/presentation/markdown/table_renderer.rb +60 -0
  66. data/lib/charming/presentation/markdown/text_wrapper.rb +40 -0
  67. data/lib/charming/presentation/markdown/url_resolver.rb +27 -0
  68. data/lib/charming/presentation/templates/erb_handler.rb +35 -2
  69. data/lib/charming/presentation/ui/ansi_codes.rb +11 -0
  70. data/lib/charming/presentation/ui/ansi_slicer.rb +20 -13
  71. data/lib/charming/presentation/ui/color_support.rb +129 -0
  72. data/lib/charming/presentation/ui/theme.rb +7 -0
  73. data/lib/charming/presentation/ui/themes/catppuccin-latte.json +35 -0
  74. data/lib/charming/presentation/ui/themes/catppuccin-mocha.json +35 -0
  75. data/lib/charming/presentation/ui/themes/gruvbox-dark.json +33 -0
  76. data/lib/charming/presentation/ui/themes/nord.json +32 -0
  77. data/lib/charming/presentation/ui/themes/tokyonight.json +34 -0
  78. data/lib/charming/presentation/ui/width.rb +27 -2
  79. data/lib/charming/router.rb +1 -1
  80. data/lib/charming/runtime.rb +122 -15
  81. data/lib/charming/tasks/cancelled.rb +11 -0
  82. data/lib/charming/tasks/inline_executor.rb +10 -4
  83. data/lib/charming/tasks/progress.rb +30 -0
  84. data/lib/charming/tasks/task.rb +24 -4
  85. data/lib/charming/tasks/threaded_executor.rb +35 -11
  86. data/lib/charming/test_helper.rb +120 -0
  87. data/lib/charming/version.rb +1 -1
  88. data/lib/charming.rb +43 -1
  89. metadata +36 -49
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "commonmarker"
4
- require "uri"
5
4
 
6
5
  module Charming
7
6
  module Markdown
@@ -9,15 +8,19 @@ module Charming
9
8
  class Renderer
10
9
  DEFAULT_RULE_WIDTH = 40
11
10
 
12
- attr_reader :content, :width, :theme, :syntax_highlighting, :style, :base_url
11
+ attr_reader :content, :width, :theme, :syntax_highlighting, :style, :base_url, :hyperlinks
13
12
 
14
- def initialize(content:, width: nil, theme: UI::Theme.default, syntax_highlighting: true, style: :dark, base_url: nil)
13
+ # *hyperlinks* (default false) wraps links in OSC 8 escape sequences so modern
14
+ # terminals make them clickable; the ` <url>` suffix is omitted since the target
15
+ # is embedded in the escape.
16
+ def initialize(content:, width: nil, theme: UI::Theme.default, syntax_highlighting: true, style: :dark, base_url: nil, hyperlinks: false)
15
17
  @content = content
16
18
  @width = width
17
19
  @theme = theme || UI::Theme.default
18
20
  @syntax_highlighting = syntax_highlighting
19
21
  @style = StyleConfig.from(style)
20
22
  @base_url = base_url
23
+ @hyperlinks = hyperlinks
21
24
  end
22
25
 
23
26
  def render
@@ -42,9 +45,7 @@ module Charming
42
45
  end
43
46
 
44
47
  def wrap(value, width:)
45
- return value unless width
46
-
47
- value.to_s.lines(chomp: true).map { |line| wrap_line(line, width) }.join("\n")
48
+ TextWrapper.new(width: width).wrap(value)
48
49
  end
49
50
 
50
51
  def style_for(name, fallback:)
@@ -99,6 +100,10 @@ module Charming
99
100
  render_rule(context: context)
100
101
  when :table
101
102
  render_table(node, context: context)
103
+ when :description_list
104
+ render_definition_list(node, context: context)
105
+ when :footnote_definition
106
+ render_footnote_definition(node, context: context)
102
107
  when :html_block
103
108
  render_html_block(node, context: context)
104
109
  else
@@ -126,6 +131,8 @@ module Charming
126
131
  render_link(node, context: context)
127
132
  when :image
128
133
  render_image(node, context: context)
134
+ when :footnote_reference
135
+ render_footnote_reference(node, context: context)
129
136
  when :html_inline
130
137
  ""
131
138
  else
@@ -185,9 +192,15 @@ module Charming
185
192
  checked_task?(node, context: context) ? (task_style.ticked || "[x] ") : (task_style.unticked || "[ ] ")
186
193
  end
187
194
 
195
+ # Matches a checked task marker anchored to the list-item prefix, so prose that
196
+ # merely mentions "[x]" can't check the box.
197
+ TASK_CHECKED_PATTERN = /\A\s*(?:[-*+]|\d+[.)])\s+\[[xX]\]/
198
+
199
+ # Commonmarker exposes no checked-state accessor on taskitem nodes, so the
200
+ # original source line is inspected instead.
188
201
  def checked_task?(node, context:)
189
202
  line = context.source_lines[node.source_position[:start_line].to_i - 1].to_s
190
- line.match?(/\[[xX]\]/)
203
+ line.match?(TASK_CHECKED_PATTERN)
191
204
  end
192
205
 
193
206
  def render_code_block(node, context:)
@@ -205,7 +218,10 @@ module Charming
205
218
 
206
219
  def render_rule(context:)
207
220
  rule_style = context.current_style.inherit_visual(context.style[:hr])
208
- body = rule_style.format.empty? ? "-" * (context.width || DEFAULT_RULE_WIDTH) : rule_style.format
221
+ available_width = context.width || DEFAULT_RULE_WIDTH
222
+ padding = rule_style.indent.to_i * 2
223
+ rule_width = [available_width - padding, 1].max
224
+ body = repeat_to_width(rule_style.format.empty? ? "-" : rule_style.format, rule_width)
209
225
  render_block_with_style(rule_style, body)
210
226
  end
211
227
 
@@ -214,15 +230,70 @@ module Charming
214
230
  rows = children_of(node).map do |row|
215
231
  children_of(row).map { |cell| render_inlines(children_of(cell), context: context.with(current_style: table_style)) }
216
232
  end
217
- return "" if rows.empty?
218
233
 
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
234
+ body = TableRenderer.new(rows: rows, style: table_style).render
235
+ return "" if body.empty?
236
+
237
+ render_block_with_style(table_style, body)
238
+ end
239
+
240
+ # Renders `Term / : definition` description lists: terms in the definition_term
241
+ # style, details indented per the definition_description style.
242
+ def render_definition_list(node, context:)
243
+ children_of(node).map { |item| render_definition_item(item, context: context) }.join("\n")
244
+ end
245
+
246
+ def render_definition_item(node, context:)
247
+ parts = children_of(node).map do |child|
248
+ case child.type
249
+ when :description_term
250
+ render_definition_term(child, context: context)
251
+ when :description_details
252
+ render_definition_details(child, context: context)
253
+ else
254
+ render_block(child, context: context)
255
+ end
223
256
  end
257
+ parts.reject { |part| part.to_s.empty? }.join("\n")
258
+ end
224
259
 
225
- render_block_with_style(table_style, rendered_rows.join("\n"))
260
+ def render_definition_term(node, context:)
261
+ term_style = context.current_style.inherit_visual(context.style[:definition_term])
262
+ term_style.render(render_inlines(children_of(node), context: context.with(current_style: term_style)))
263
+ end
264
+
265
+ def render_definition_details(node, context:)
266
+ details_style = context.current_style.inherit_visual(context.style[:definition_description])
267
+ indent = " " * (details_style.indent || 4)
268
+ details_width = context.width ? [context.width - indent.length, 1].max : nil
269
+ body = render_blocks(children_of(node), context: context.with(width: details_width, current_style: details_style))
270
+ body.lines(chomp: true).map { |line| "#{indent}#{line}" }.join("\n")
271
+ end
272
+
273
+ # Renders an inline `[^name]` reference as a bracketed label in the link style.
274
+ def render_footnote_reference(node, context:)
275
+ context.inherit(:link).render("[#{footnote_name(node)}]")
276
+ end
277
+
278
+ # Renders a footnote definition as a labeled block with hanging indentation.
279
+ def render_footnote_definition(node, context:)
280
+ label_style = context.current_style.inherit_visual(context.style[:link_text])
281
+ label = "[#{footnote_name(node)}]: "
282
+ indent = " " * UI::Width.measure(label)
283
+ body_width = context.width ? [context.width - UI::Width.measure(label), 1].max : nil
284
+ body = render_blocks(children_of(node), context: context.with(width: body_width))
285
+ lines = body.lines(chomp: true)
286
+ return label_style.render(label.rstrip) if lines.empty?
287
+
288
+ first = "#{label_style.render(label)}#{lines.first}"
289
+ rest = lines.drop(1).map { |line| "#{indent}#{line}" }
290
+ [first, *rest].join("\n")
291
+ end
292
+
293
+ # Commonmarker exposes no name accessor on footnote nodes; the round-tripped
294
+ # commonmark source (`"[^name]\n"` / `"[^name]:\n..."`) carries the label.
295
+ def footnote_name(node)
296
+ node.to_commonmark[/\A\[\^(.+?)\]/, 1].to_s
226
297
  end
227
298
 
228
299
  def render_html_block(_node, context:)
@@ -242,6 +313,8 @@ module Charming
242
313
  text_style = context.inherit(:link_text)
243
314
  link_style = context.inherit(:link)
244
315
  label = render_inlines(children_of(node), context: context.with(current_style: text_style))
316
+ return osc8_hyperlink(href, link_style.render(label)) if hyperlinks && !href.empty?
317
+
245
318
  rendered = if href.empty? || UI::Width.strip_ansi(label) == href
246
319
  label
247
320
  else
@@ -250,6 +323,12 @@ module Charming
250
323
  link_style.render(rendered)
251
324
  end
252
325
 
326
+ # Wraps *rendered* in an OSC 8 hyperlink to *href*. Modern terminals make the
327
+ # text clickable; the sequence is invisible to width math (UI::Width strips OSC).
328
+ def osc8_hyperlink(href, rendered)
329
+ "\e]8;;#{href}\e\\#{rendered}\e]8;;\e\\"
330
+ end
331
+
253
332
  def render_image(node, context:)
254
333
  href = resolve_url(node.url.to_s, context: context)
255
334
  image_style = context.inherit(:image)
@@ -268,26 +347,11 @@ module Charming
268
347
  style.render(style.apply_block_layout(body))
269
348
  end
270
349
 
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}"
350
+ def repeat_to_width(value, width)
351
+ token = value.to_s
352
+ token_width = [UI::Width.measure(token), 1].max
353
+ repeated = token * ((width.to_i + token_width - 1) / token_width)
354
+ UI.visible_slice(repeated, 0, width)
291
355
  end
292
356
 
293
357
  def quote_indent_width(style)
@@ -297,40 +361,12 @@ module Charming
297
361
  end
298
362
 
299
363
  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
364
+ URLResolver.new(base_url: context.base_url).resolve(value)
308
365
  end
309
366
 
310
367
  def children_of(node)
311
368
  node.each.to_a
312
369
  end
313
-
314
- def wrap_line(line, width)
315
- return line if UI::Width.measure(line) <= width
316
-
317
- lines = []
318
- current = +""
319
-
320
- line.split(/\s+/).each do |word|
321
- candidate = current.empty? ? word : "#{current} #{word}"
322
-
323
- if !current.empty? && UI::Width.measure(candidate) > width
324
- lines << current.rstrip
325
- current = word
326
- else
327
- current = candidate
328
- end
329
- end
330
-
331
- lines << current.rstrip unless current.empty?
332
- lines.join("\n")
333
- end
334
370
  end
335
371
  end
336
372
  end
@@ -23,13 +23,14 @@ module Charming
23
23
  emph: {block_prefix: "*", block_suffix: "*"},
24
24
  strong: {block_prefix: "**", block_suffix: "**"},
25
25
  strikethrough: {block_prefix: "~~", block_suffix: "~~"},
26
- hr: {format: "--------"},
26
+ hr: {format: "-", indent: 2, margin: 1},
27
27
  item: {block_prefix: "- "},
28
28
  enumeration: {block_prefix: ". "},
29
29
  task: {ticked: "[x] ", unticked: "[ ] "},
30
30
  code: {block_prefix: "`", block_suffix: "`"},
31
31
  code_block: {margin: 1},
32
32
  table: {column_separator: "|", row_separator: "-"},
33
+ definition_description: {indent: 4},
33
34
  image_text: {format: "Image: {{text}} ->"}
34
35
  },
35
36
  dark: {
@@ -46,7 +47,7 @@ module Charming
46
47
  strikethrough: {crossed_out: true},
47
48
  emph: {italic: true},
48
49
  strong: {bold: true},
49
- hr: {color: "240", format: "--------"},
50
+ hr: {color: "240", format: "", indent: 2, margin: 1},
50
51
  item: {block_prefix: "• "},
51
52
  enumeration: {block_prefix: ". "},
52
53
  task: {ticked: "[✓] ", unticked: "[ ] "},
@@ -56,7 +57,9 @@ module Charming
56
57
  image_text: {color: "243", format: "Image: {{text}} ->"},
57
58
  code: {prefix: " ", suffix: " ", color: "203", background_color: "236"},
58
59
  code_block: {color: "244", margin: 1},
59
- table: {column_separator: "|", row_separator: "-"}
60
+ table: {column_separator: "|", row_separator: "-"},
61
+ definition_term: {bold: true},
62
+ definition_description: {indent: 4, color: "244"}
60
63
  },
61
64
  light: {
62
65
  document: {color: "236"},
@@ -72,7 +75,7 @@ module Charming
72
75
  strikethrough: {crossed_out: true},
73
76
  emph: {italic: true},
74
77
  strong: {bold: true},
75
- hr: {color: "250", format: "--------"},
78
+ hr: {color: "250", format: "", indent: 2, margin: 1},
76
79
  item: {block_prefix: "• "},
77
80
  enumeration: {block_prefix: ". "},
78
81
  task: {ticked: "[✓] ", unticked: "[ ] "},
@@ -82,7 +85,9 @@ module Charming
82
85
  image_text: {color: "244", format: "Image: {{text}} ->"},
83
86
  code: {prefix: " ", suffix: " ", color: "161", background_color: "255"},
84
87
  code_block: {color: "244", margin: 1},
85
- table: {column_separator: "|", row_separator: "-"}
88
+ table: {column_separator: "|", row_separator: "-"},
89
+ definition_term: {bold: true},
90
+ definition_description: {indent: 4, color: "244"}
86
91
  }
87
92
  }.freeze
88
93
 
@@ -21,7 +21,7 @@ module Charming
21
21
  def render(code, language: nil)
22
22
  lexer = lexer_for(language, code)
23
23
  lexer.lex(code.to_s).map do |token, value|
24
- style_for(token).render(value)
24
+ render_token(token, value)
25
25
  end.join
26
26
  end
27
27
 
@@ -30,6 +30,16 @@ module Charming
30
30
  # The Charming theme used for token styling.
31
31
  attr_reader :theme, :style
32
32
 
33
+ def render_token(token, value)
34
+ token_style = style_for(token)
35
+ value.to_s.each_line(chomp: false).map do |line|
36
+ next "\n" if line == "\n"
37
+ next token_style.render(line.chomp("\n")) + "\n" if line.end_with?("\n")
38
+
39
+ token_style.render(line)
40
+ end.join
41
+ end
42
+
33
43
  # Picks a Rouge lexer for *language* and *code*, falling back to plain text.
34
44
  def lexer_for(language, code)
35
45
  Rouge::Lexer.find_fancy(language, code) || Rouge::Lexers::PlainText
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Markdown
5
+ # TableRenderer formats parsed Markdown table rows for terminal display.
6
+ class TableRenderer
7
+ def initialize(rows:, style:)
8
+ @rows = rows
9
+ @style = style
10
+ end
11
+
12
+ def render
13
+ return "" if rows.empty?
14
+
15
+ rows.each_with_index.map { |row, index| render_row(row, index) }.join("\n")
16
+ end
17
+
18
+ private
19
+
20
+ attr_reader :rows, :style
21
+
22
+ def render_row(row, index)
23
+ line = table_row(row)
24
+ index.zero? ? [line, table_separator].join("\n") : line
25
+ end
26
+
27
+ def table_row(row)
28
+ cells = widths.each_with_index.map { |width, index| table_cell(row, width, index) }
29
+ "#{separator}#{cells.join(separator)}#{separator}"
30
+ end
31
+
32
+ def table_cell(row, width, index)
33
+ value = row[index].to_s
34
+ " #{value}#{" " * [width - UI::Width.measure(value), 0].max} "
35
+ end
36
+
37
+ def table_separator
38
+ "#{separator}#{widths.map { |table_width| row_separator * (table_width + 2) }.join(separator)}#{separator}"
39
+ end
40
+
41
+ def widths
42
+ @widths ||= Array.new(column_count) do |index|
43
+ rows.map { |row| UI::Width.measure(row[index].to_s) }.max || 0
44
+ end
45
+ end
46
+
47
+ def column_count
48
+ rows.map(&:length).max || 0
49
+ end
50
+
51
+ def separator
52
+ style.column_separator || "|"
53
+ end
54
+
55
+ def row_separator
56
+ style.row_separator || "-"
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Markdown
5
+ # TextWrapper wraps Markdown text blocks to a configured terminal width.
6
+ class TextWrapper
7
+ def initialize(width:)
8
+ @width = width
9
+ end
10
+
11
+ def wrap(value)
12
+ return value unless width
13
+
14
+ value.to_s.lines(chomp: true).map { |line| wrap_line(line) }.join("\n")
15
+ end
16
+
17
+ private
18
+
19
+ attr_reader :width
20
+
21
+ def wrap_line(line)
22
+ return line if UI::Width.measure(line) <= width
23
+
24
+ wrap_words(line.split(/\s+/))
25
+ end
26
+
27
+ def wrap_words(words)
28
+ words.each_with_object([]) { |word, lines| append_word(lines, word) }.join("\n")
29
+ end
30
+
31
+ def append_word(lines, word)
32
+ current = lines.pop.to_s
33
+ candidate = current.empty? ? word : "#{current} #{word}"
34
+ return lines.push(candidate) if current.empty? || UI::Width.measure(candidate) <= width
35
+
36
+ lines.push(current.rstrip, word)
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Charming
6
+ module Markdown
7
+ # URLResolver resolves Markdown link destinations against an optional base URL.
8
+ class URLResolver
9
+ def initialize(base_url: nil)
10
+ @base_url = base_url
11
+ end
12
+
13
+ def resolve(value)
14
+ return value if base_url.to_s.empty? || value.empty?
15
+ return value if URI.parse(value).absolute?
16
+
17
+ URI.join(base_url, value).to_s
18
+ rescue URI::InvalidURIError
19
+ value
20
+ end
21
+
22
+ private
23
+
24
+ attr_reader :base_url
25
+ end
26
+ end
27
+ end
@@ -4,9 +4,42 @@ require "erb"
4
4
 
5
5
  module Charming
6
6
  module Templates
7
+ # ErbHandler renders `.tui.erb` / `.txt.erb` templates. Compiled ERB objects are
8
+ # cached per path: in development the cache is invalidated by file mtime so edits
9
+ # show up live; in other environments templates are compiled once.
7
10
  class ErbHandler
8
- def self.render(path, view)
9
- ERB.new(File.read(path), trim_mode: "-").result(view.template_binding)
11
+ @cache = {}
12
+ @mutex = Mutex.new
13
+
14
+ class << self
15
+ def render(path, view)
16
+ erb(path).result(view.template_binding)
17
+ end
18
+
19
+ # Clears the compiled-template cache (used by tests).
20
+ def reset_cache
21
+ @mutex.synchronize { @cache.clear }
22
+ end
23
+
24
+ private
25
+
26
+ # Returns the compiled ERB for *path*, recompiling in development when the
27
+ # file's mtime changes.
28
+ def erb(path)
29
+ @mutex.synchronize do
30
+ entry = @cache[path]
31
+ mtime = Charming.env.development? ? File.mtime(path) : nil
32
+ if entry.nil? || (mtime && entry[:mtime] != mtime)
33
+ entry = {erb: compile(path), mtime: mtime}
34
+ @cache[path] = entry
35
+ end
36
+ entry[:erb]
37
+ end
38
+ end
39
+
40
+ def compile(path)
41
+ ERB.new(File.read(path), trim_mode: "-")
42
+ end
10
43
  end
11
44
  end
12
45
  end
@@ -56,8 +56,11 @@ module Charming
56
56
  @attributes.map { |attribute| ATTRIBUTES.fetch(attribute) }
57
57
  end
58
58
 
59
+ # Resolves *color* to SGR codes, downconverting to the terminal's capability
60
+ # (see UI::ColorSupport): truecolor → 256 → 16 → none.
59
61
  def color_codes(color, foreground:)
60
62
  return [] unless color
63
+ return [] if ColorSupport.level == :none
61
64
  return indexed_color_code(color, foreground: foreground) if color.is_a?(Integer)
62
65
  return named_color_code(color, foreground: foreground) if COLORS.key?(color.to_sym)
63
66
  return truecolor_codes(color, foreground: foreground) if color.to_s.start_with?("#")
@@ -72,6 +75,7 @@ module Charming
72
75
 
73
76
  def indexed_color_code(color, foreground:)
74
77
  raise ArgumentError, "indexed color must be between 0 and 255" unless color.between?(0, 255)
78
+ return basic_color_code(ColorSupport.index_to_16(color), foreground: foreground) unless ColorSupport.at_least?(:color256)
75
79
 
76
80
  [foreground ? 38 : 48, 5, color]
77
81
  end
@@ -79,9 +83,16 @@ module Charming
79
83
  def truecolor_codes(color, foreground:)
80
84
  hex = color.to_s.delete_prefix("#")
81
85
  raise ArgumentError, "truecolor must be #rrggbb" unless hex.match?(/\A[0-9a-fA-F]{6}\z/)
86
+ return [foreground ? 38 : 48, 5, ColorSupport.hex_to_256(hex)] if ColorSupport.level == :color256
87
+ return basic_color_code(ColorSupport.hex_to_16(hex), foreground: foreground) if ColorSupport.level == :color16
82
88
 
83
89
  [foreground ? 38 : 48, 2, hex[0..1].to_i(16), hex[2..3].to_i(16), hex[4..5].to_i(16)]
84
90
  end
91
+
92
+ # Wraps a basic SGR foreground code (30-37/90-97) for the requested plane.
93
+ def basic_color_code(code, foreground:)
94
+ [foreground ? code : code + 10]
95
+ end
85
96
  end
86
97
  end
87
98
  end
@@ -6,6 +6,10 @@ module Charming
6
6
  # escape sequences, preserving the styling that is active at the start of
7
7
  # the slice and emitting a trailing reset if any styled content was copied.
8
8
  class ANSISlicer
9
+ # One ANSI escape sequence or one grapheme cluster (`\X`). The ANSI branch
10
+ # comes first so a valid escape is consumed whole rather than as graphemes.
11
+ TOKEN_PATTERN = /#{Width::ANSI_PATTERN}|\X/
12
+
9
13
  def self.slice(line, start_column, width)
10
14
  return "" unless width.positive?
11
15
 
@@ -27,16 +31,12 @@ module Charming
27
31
  end
28
32
 
29
33
  def self.each_ansi_or_char(line)
30
- index = 0
31
- while index < line.length
32
- match = line.match(Width::ANSI_PATTERN, index)
33
- if match&.begin(0) == index
34
- yield match[0], true
35
- index = match.end(0)
36
- else
37
- yield line[index], false
38
- index += 1
39
- end
34
+ # Iterate one ANSI escape or one *grapheme cluster* (`\X`) at a time. A
35
+ # single emoji may be several codepoints (ZWJ sequences, skin-tone and
36
+ # variation selectors, e.g. "🧙‍♂️"); treating it as one unit keeps its full
37
+ # display width together so a slice never splits it mid-glyph.
38
+ line.scan(TOKEN_PATTERN) do |token|
39
+ yield token, token.start_with?("\e")
40
40
  end
41
41
  end
42
42
 
@@ -57,10 +57,17 @@ module Charming
57
57
  char_start = state[:column]
58
58
  char_end = char_start + char_width
59
59
  state[:column] = char_end
60
- return unless char_end > start_column && char_start < end_column
60
+
61
+ visible = [char_end, end_column].min - [char_start, start_column].max
62
+ return unless visible.positive?
61
63
 
62
64
  start_slice(state)
63
- state[:output] << char
65
+ # A multi-column glyph cut by a slice boundary cannot be partially drawn,
66
+ # so render the in-range columns as spaces (standard terminal behavior).
67
+ # This keeps the slice exactly *width* columns wide regardless of where
68
+ # the boundaries fall relative to wide glyphs.
69
+ fits = char_start >= start_column && char_end <= end_column
70
+ state[:output] << (fits ? char : " " * visible)
64
71
  end
65
72
 
66
73
  def self.start_slice(state)
@@ -81,7 +88,7 @@ module Charming
81
88
  if token.include?("[0m")
82
89
  active.clear
83
90
  else
84
- active << token
91
+ active << token unless active.include?(token)
85
92
  end
86
93
  end
87
94