charming 0.1.1 → 0.1.3

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 (132) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/lib/charming/application.rb +14 -3
  4. data/lib/charming/cli.rb +23 -0
  5. data/lib/charming/controller/class_methods.rb +115 -0
  6. data/lib/charming/controller/command_palette.rb +135 -0
  7. data/lib/charming/controller/component_dispatching.rb +81 -0
  8. data/lib/charming/controller/dispatching.rb +60 -0
  9. data/lib/charming/controller/focus_management.rb +30 -0
  10. data/lib/charming/controller/rendering.rb +127 -0
  11. data/lib/charming/controller/session_state.rb +41 -0
  12. data/lib/charming/controller/sidebar_navigation.rb +111 -0
  13. data/lib/charming/controller.rb +35 -559
  14. data/lib/charming/database_commands.rb +16 -0
  15. data/lib/charming/database_installer.rb +27 -0
  16. data/lib/charming/focus.rb +58 -2
  17. data/lib/charming/generators/app_file_generator.rb +13 -0
  18. data/lib/charming/generators/app_generator.rb +123 -47
  19. data/lib/charming/generators/base.rb +26 -0
  20. data/lib/charming/generators/component_generator.rb +10 -10
  21. data/lib/charming/generators/controller_generator.rb +22 -11
  22. data/lib/charming/generators/model_generator.rb +38 -29
  23. data/lib/charming/generators/name.rb +10 -0
  24. data/lib/charming/generators/screen_generator.rb +78 -32
  25. data/lib/charming/generators/templates/app/Gemfile.template +5 -0
  26. data/lib/charming/generators/templates/app/README.md.template +9 -0
  27. data/lib/charming/generators/templates/app/Rakefile.template +3 -0
  28. data/lib/charming/generators/templates/app/application.template +13 -0
  29. data/lib/charming/generators/templates/app/application_controller.template +19 -0
  30. data/lib/charming/generators/templates/app/application_record.template +7 -0
  31. data/lib/charming/generators/templates/app/application_state.template +6 -0
  32. data/lib/charming/generators/templates/app/database_config.template +12 -0
  33. data/lib/charming/generators/templates/app/executable.template +7 -0
  34. data/lib/charming/generators/templates/app/gemspec.template +6 -0
  35. data/lib/charming/generators/templates/app/home_controller.template +6 -0
  36. data/lib/charming/generators/templates/app/home_state.template +7 -0
  37. data/lib/charming/generators/templates/app/keep.template +0 -0
  38. data/lib/charming/generators/templates/app/layout.template +110 -0
  39. data/lib/charming/generators/templates/app/root_file.template +20 -0
  40. data/lib/charming/generators/templates/app/routes.template +5 -0
  41. data/lib/charming/generators/templates/app/seeds.template +1 -0
  42. data/lib/charming/generators/templates/app/spec_controller.template +17 -0
  43. data/lib/charming/generators/templates/app/spec_helper.template +3 -0
  44. data/lib/charming/generators/templates/app/spec_state.template +17 -0
  45. data/lib/charming/generators/templates/app/spec_view.template +16 -0
  46. data/lib/charming/generators/templates/app/version.template +5 -0
  47. data/lib/charming/generators/templates/app/view.template +21 -0
  48. data/lib/charming/generators/templates/component/component.rb.template +9 -0
  49. data/lib/charming/generators/templates/controller/controller.rb.template +6 -0
  50. data/lib/charming/generators/templates/model/migration.rb.template +9 -0
  51. data/lib/charming/generators/templates/model/model.rb.template +6 -0
  52. data/lib/charming/generators/templates/model/spec.rb.template +9 -0
  53. data/lib/charming/generators/templates/screen/controller.rb.template +7 -0
  54. data/lib/charming/generators/templates/screen/spec_controller.rb.template +17 -0
  55. data/lib/charming/generators/templates/screen/spec_state.rb.template +17 -0
  56. data/lib/charming/generators/templates/screen/spec_view.rb.template +13 -0
  57. data/lib/charming/generators/templates/screen/state.rb.template +7 -0
  58. data/lib/charming/generators/templates/screen/view.rb.template +11 -0
  59. data/lib/charming/generators/templates/view/view.rb.template +11 -0
  60. data/lib/charming/generators/view_generator.rb +19 -3
  61. data/lib/charming/internal/renderer/differential.rb +25 -2
  62. data/lib/charming/internal/renderer/full_repaint.rb +6 -0
  63. data/lib/charming/internal/terminal/adapter.rb +29 -3
  64. data/lib/charming/internal/terminal/key_normalizer.rb +84 -0
  65. data/lib/charming/internal/terminal/memory_backend.rb +28 -1
  66. data/lib/charming/internal/terminal/mouse_parser.rb +81 -0
  67. data/lib/charming/internal/terminal/tty_backend.rb +65 -115
  68. data/lib/charming/presentation/component.rb +3 -5
  69. data/lib/charming/presentation/components/activity_indicator.rb +173 -134
  70. data/lib/charming/presentation/components/command_palette.rb +94 -96
  71. data/lib/charming/presentation/components/command_palette_modal.rb +33 -0
  72. data/lib/charming/presentation/components/empty_state.rb +47 -36
  73. data/lib/charming/presentation/components/form/builder.rb +52 -40
  74. data/lib/charming/presentation/components/form/confirm.rb +50 -39
  75. data/lib/charming/presentation/components/form/field.rb +94 -71
  76. data/lib/charming/presentation/components/form/input.rb +61 -49
  77. data/lib/charming/presentation/components/form/note.rb +27 -20
  78. data/lib/charming/presentation/components/form/select.rb +84 -63
  79. data/lib/charming/presentation/components/form/textarea.rb +67 -53
  80. data/lib/charming/presentation/components/form.rb +120 -93
  81. data/lib/charming/presentation/components/keyboard_handler.rb +41 -43
  82. data/lib/charming/presentation/components/list.rb +123 -97
  83. data/lib/charming/presentation/components/markdown.rb +21 -17
  84. data/lib/charming/presentation/components/modal.rb +55 -43
  85. data/lib/charming/presentation/components/progressbar.rb +61 -50
  86. data/lib/charming/presentation/components/spinner.rb +35 -27
  87. data/lib/charming/presentation/components/table.rb +108 -85
  88. data/lib/charming/presentation/components/text_area.rb +219 -173
  89. data/lib/charming/presentation/components/text_input.rb +120 -98
  90. data/lib/charming/presentation/components/viewport.rb +218 -168
  91. data/lib/charming/presentation/layout/builder.rb +84 -0
  92. data/lib/charming/presentation/layout/overlay.rb +55 -0
  93. data/lib/charming/presentation/layout/pane.rb +149 -0
  94. data/lib/charming/presentation/layout/rect.rb +21 -0
  95. data/lib/charming/presentation/layout/screen_layout.rb +58 -0
  96. data/lib/charming/presentation/layout/split.rb +132 -0
  97. data/lib/charming/presentation/layout.rb +28 -30
  98. data/lib/charming/presentation/markdown/block_renderers.rb +118 -0
  99. data/lib/charming/presentation/markdown/inline_renderers.rb +66 -0
  100. data/lib/charming/presentation/markdown/render_context.rb +20 -0
  101. data/lib/charming/presentation/markdown/renderer.rb +82 -174
  102. data/lib/charming/presentation/markdown/syntax_highlighter.rb +58 -44
  103. data/lib/charming/presentation/markdown.rb +4 -3
  104. data/lib/charming/presentation/template_view.rb +22 -17
  105. data/lib/charming/presentation/templates/erb_handler.rb +4 -6
  106. data/lib/charming/presentation/templates.rb +47 -32
  107. data/lib/charming/presentation/ui/ansi_codes.rb +87 -0
  108. data/lib/charming/presentation/ui/ansi_slicer.rb +92 -0
  109. data/lib/charming/presentation/ui/border.rb +24 -26
  110. data/lib/charming/presentation/ui/border_painter.rb +56 -0
  111. data/lib/charming/presentation/ui/canvas.rb +80 -0
  112. data/lib/charming/presentation/ui/style.rb +170 -205
  113. data/lib/charming/presentation/ui/theme.rb +133 -135
  114. data/lib/charming/presentation/ui/width.rb +12 -14
  115. data/lib/charming/presentation/ui.rb +70 -213
  116. data/lib/charming/presentation/view.rb +107 -92
  117. data/lib/charming/runtime.rb +25 -10
  118. data/lib/charming/tasks/inline_executor.rb +9 -0
  119. data/lib/charming/tasks/task.rb +3 -0
  120. data/lib/charming/tasks/threaded_executor.rb +12 -0
  121. data/lib/charming/version.rb +1 -1
  122. data/lib/charming.rb +16 -2
  123. metadata +60 -10
  124. data/lib/charming/generators/app_generator/app_spec_templates.rb +0 -90
  125. data/lib/charming/generators/app_generator/basic_templates.rb +0 -81
  126. data/lib/charming/generators/app_generator/component_templates.rb +0 -36
  127. data/lib/charming/generators/app_generator/controller_template.rb +0 -60
  128. data/lib/charming/generators/app_generator/database_templates.rb +0 -45
  129. data/lib/charming/generators/app_generator/layout_template.rb +0 -66
  130. data/lib/charming/generators/app_generator/screen_spec_templates.rb +0 -69
  131. data/lib/charming/generators/app_generator/state_templates.rb +0 -30
  132. data/lib/charming/generators/app_generator/view_template.rb +0 -84
@@ -1,27 +1,32 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Charming
4
- module Presentation
5
- class TemplateView < View
6
- def initialize(template:, namespace: nil, **assigns)
7
- super(**assigns)
8
- @template = template
9
- @namespace = namespace
10
- end
4
+ # TemplateView wraps a resolved ERB template and exposes it as a renderable View. The
5
+ # template is rendered with the view's helpers (`text`, `box`, `row`, `column`, `style`,
6
+ # `theme`, etc.) and the view's assigns available as reader methods inside the template.
7
+ class TemplateView < View
8
+ def initialize(template:, namespace: nil, **assigns)
9
+ super(**assigns)
10
+ @template = template
11
+ @namespace = namespace
12
+ end
11
13
 
12
- def render
13
- template.render(self).to_s
14
- end
14
+ # Renders the wrapped template to a string, evaluated in the view's binding context.
15
+ def render
16
+ template.render(self).to_s
17
+ end
15
18
 
16
- def template_binding
17
- return binding unless namespace
19
+ # Returns the binding used by ERB handlers to evaluate the template body. When *namespace*
20
+ # is set, the binding is created by a proc generated in the namespace's context so the
21
+ # template can resolve constants relative to the application.
22
+ def template_binding
23
+ return binding unless namespace
18
24
 
19
- namespace.module_eval("->(view) { view.instance_eval { binding } }", __FILE__, __LINE__).call(self)
20
- end
25
+ namespace.module_eval("->(view) { view.instance_eval { binding } }", __FILE__, __LINE__).call(self)
26
+ end
21
27
 
22
- private
28
+ private
23
29
 
24
- attr_reader :template, :namespace
25
- end
30
+ attr_reader :template, :namespace
26
31
  end
27
32
  end
@@ -3,12 +3,10 @@
3
3
  require "erb"
4
4
 
5
5
  module Charming
6
- module Presentation
7
- module Templates
8
- class ErbHandler
9
- def self.render(path, view)
10
- ERB.new(File.read(path), trim_mode: "-").result(view.template_binding)
11
- end
6
+ module Templates
7
+ class ErbHandler
8
+ def self.render(path, view)
9
+ ERB.new(File.read(path), trim_mode: "-").result(view.template_binding)
12
10
  end
13
11
  end
14
12
  end
@@ -1,50 +1,65 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Charming
4
- module Presentation
5
- module Templates
6
- ResolvedTemplate = Data.define(:path, :handler) do
7
- def render(view)
8
- handler.render(path, view)
9
- end
4
+ # Templates resolves and renders view templates by name. Template handlers are registered
5
+ # for file extensions (e.g., `.tui.erb`) and the resolver searches `app/views/<name><ext>`
6
+ # under the application root, falling back through registered extensions when the first
7
+ # match is not found.
8
+ module Templates
9
+ # A resolved template: an on-disk *path* paired with the *handler* responsible for rendering it.
10
+ ResolvedTemplate = Data.define(:path, :handler) do
11
+ # Renders the template against *view* by delegating to the registered handler.
12
+ def render(view)
13
+ handler.render(path, view)
10
14
  end
15
+ end
11
16
 
12
- MissingTemplateError = Class.new(Error)
13
-
14
- class << self
15
- def register(extension, handler)
16
- handlers[extension] = handler
17
- end
17
+ # Raised when no template file matches the given name under the application root.
18
+ MissingTemplateError = Class.new(Error)
18
19
 
19
- def resolve(name, root: nil)
20
- views_root = File.join(root || Dir.pwd, "app", "views")
21
- searched_paths = candidate_paths(views_root, name.to_s)
20
+ class << self
21
+ # Registers a template *handler* for a file *extension* (e.g., ".tui.erb" => ErbHandler).
22
+ # The handler responds to `.render(path, view)`.
23
+ def register(extension, handler)
24
+ handlers[extension] = handler
25
+ end
22
26
 
23
- searched_paths.each do |path|
24
- next unless File.file?(path)
27
+ # Resolves a template by *name* under `app/views` of *root* (defaults to the current
28
+ # working directory). Raises MissingTemplateError when no matching file exists.
29
+ def resolve(name, root: nil)
30
+ views_root = File.join(root || Dir.pwd, "app", "views")
31
+ searched_paths = candidate_paths(views_root, name.to_s)
25
32
 
26
- return ResolvedTemplate.new(path: path, handler: handler_for(path))
27
- end
33
+ searched_paths.each do |path|
34
+ next unless File.file?(path)
28
35
 
29
- raise MissingTemplateError, "Missing template #{name.inspect}. Searched: #{searched_paths.join(", ")}"
36
+ return ResolvedTemplate.new(path: path, handler: handler_for(path))
30
37
  end
31
38
 
32
- def handlers
33
- @handlers ||= {}
34
- end
39
+ raise MissingTemplateError, "Missing template #{name.inspect}. Searched: #{searched_paths.join(", ")}"
40
+ end
35
41
 
36
- private
42
+ # Hash of registered handlers keyed by extension. Populated by `register`.
43
+ def handlers
44
+ @handlers ||= {}
45
+ end
37
46
 
38
- def candidate_paths(views_root, name)
39
- path = File.expand_path(name, views_root)
40
- return [path] if handler_for(path)
47
+ private
41
48
 
42
- handlers.keys.map { |extension| "#{path}#{extension}" }
43
- end
49
+ # Returns candidate paths under *views_root* for *name*. When the bare path has a known
50
+ # extension, returns it directly; otherwise returns the path with each registered extension
51
+ # appended (in registration order).
52
+ def candidate_paths(views_root, name)
53
+ path = File.expand_path(name, views_root)
54
+ return [path] if handler_for(path)
44
55
 
45
- def handler_for(path)
46
- handlers.find { |extension, _handler| path.end_with?(extension) }&.last
47
- end
56
+ handlers.keys.map { |extension| "#{path}#{extension}" }
57
+ end
58
+
59
+ # Looks up the handler whose registered extension matches the end of *path*. Returns nil
60
+ # when no handler matches.
61
+ def handler_for(path)
62
+ handlers.find { |extension, _handler| path.end_with?(extension) }&.last
48
63
  end
49
64
  end
50
65
  end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module UI
5
+ class ANSICodes
6
+ ATTRIBUTES = {
7
+ bold: 1,
8
+ faint: 2,
9
+ italic: 3,
10
+ underline: 4,
11
+ reverse: 7,
12
+ strikethrough: 9
13
+ }.freeze
14
+
15
+ COLORS = {
16
+ black: 30,
17
+ red: 31,
18
+ green: 32,
19
+ yellow: 33,
20
+ blue: 34,
21
+ magenta: 35,
22
+ cyan: 36,
23
+ white: 37,
24
+ bright_black: 90,
25
+ bright_red: 91,
26
+ bright_green: 92,
27
+ bright_yellow: 93,
28
+ bright_blue: 94,
29
+ bright_magenta: 95,
30
+ bright_cyan: 96,
31
+ bright_white: 97
32
+ }.freeze
33
+
34
+ def initialize(attributes:, foreground:, background:)
35
+ @attributes = attributes
36
+ @foreground = foreground
37
+ @background = background
38
+ end
39
+
40
+ def codes
41
+ @codes ||= attribute_codes +
42
+ color_codes(@foreground, foreground: true) +
43
+ color_codes(@background, foreground: false)
44
+ end
45
+
46
+ def apply(value)
47
+ return value if codes.empty?
48
+
49
+ start = "\e[#{codes.join(";")}m"
50
+ value.split("\n", -1).map { |line| "#{start}#{line.gsub("\e[0m", "\e[0m#{start}")}\e[0m" }.join("\n")
51
+ end
52
+
53
+ private
54
+
55
+ def attribute_codes
56
+ @attributes.map { |attribute| ATTRIBUTES.fetch(attribute) }
57
+ end
58
+
59
+ def color_codes(color, foreground:)
60
+ return [] unless color
61
+ return indexed_color_code(color, foreground: foreground) if color.is_a?(Integer)
62
+ return named_color_code(color, foreground: foreground) if COLORS.key?(color.to_sym)
63
+ return truecolor_codes(color, foreground: foreground) if color.to_s.start_with?("#")
64
+
65
+ raise ArgumentError, "unknown color: #{color.inspect}"
66
+ end
67
+
68
+ def named_color_code(color, foreground:)
69
+ code = COLORS.fetch(color.to_sym)
70
+ [foreground ? code : code + 10]
71
+ end
72
+
73
+ def indexed_color_code(color, foreground:)
74
+ raise ArgumentError, "indexed color must be between 0 and 255" unless color.between?(0, 255)
75
+
76
+ [foreground ? 38 : 48, 5, color]
77
+ end
78
+
79
+ def truecolor_codes(color, foreground:)
80
+ hex = color.to_s.delete_prefix("#")
81
+ raise ArgumentError, "truecolor must be #rrggbb" unless hex.match?(/\A[0-9a-fA-F]{6}\z/)
82
+
83
+ [foreground ? 38 : 48, 2, hex[0..1].to_i(16), hex[2..3].to_i(16), hex[4..5].to_i(16)]
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module UI
5
+ # ANSISlicer extracts a visible substring from a string that may contain ANSI
6
+ # escape sequences, preserving the styling that is active at the start of
7
+ # the slice and emitting a trailing reset if any styled content was copied.
8
+ class ANSISlicer
9
+ def self.slice(line, start_column, width)
10
+ return "" unless width.positive?
11
+
12
+ slice_range(line.to_s, start_column, start_column + width)
13
+ end
14
+
15
+ def self.slice_range(line, start_column, end_column)
16
+ state = {column: 0, output: +"", active: [], started: false, styled: false}
17
+
18
+ each_ansi_or_char(line) do |token, ansi|
19
+ if ansi
20
+ slice_ansi_token(token, state, start_column, end_column)
21
+ else
22
+ slice_char(token, state, start_column, end_column)
23
+ end
24
+ end
25
+
26
+ terminate_slice(state)
27
+ end
28
+
29
+ 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
40
+ end
41
+ end
42
+
43
+ def self.slice_ansi_token(token, state, start_column, end_column)
44
+ started = state[:started]
45
+ update_active_styles(state[:active], token)
46
+ return unless state[:column].between?(start_column, end_column - 1)
47
+
48
+ start_slice(state)
49
+ if started
50
+ state[:output] << token
51
+ state[:styled] = !token.include?("[0m")
52
+ end
53
+ end
54
+
55
+ def self.slice_char(char, state, start_column, end_column)
56
+ char_width = Width.measure(char)
57
+ char_start = state[:column]
58
+ char_end = char_start + char_width
59
+ state[:column] = char_end
60
+ return unless char_end > start_column && char_start < end_column
61
+
62
+ start_slice(state)
63
+ state[:output] << char
64
+ end
65
+
66
+ def self.start_slice(state)
67
+ return if state[:started]
68
+
69
+ state[:output] << state[:active].join
70
+ state[:styled] = true unless state[:active].empty?
71
+ state[:started] = true
72
+ end
73
+
74
+ def self.terminate_slice(state)
75
+ return state[:output] if !state[:styled] || state[:output].empty?
76
+
77
+ "#{state[:output]}\e[0m"
78
+ end
79
+
80
+ def self.update_active_styles(active, token)
81
+ if token.include?("[0m")
82
+ active.clear
83
+ else
84
+ active << token
85
+ end
86
+ end
87
+
88
+ private_class_method :each_ansi_or_char, :slice_ansi_token, :slice_char,
89
+ :start_slice, :terminate_slice, :update_active_styles
90
+ end
91
+ end
92
+ end
@@ -1,35 +1,33 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Charming
4
- module Presentation
5
- module UI
6
- class Border
7
- attr_reader :top_left, :top_right, :bottom_left, :bottom_right, :horizontal, :vertical
4
+ module UI
5
+ class Border
6
+ attr_reader :top_left, :top_right, :bottom_left, :bottom_right, :horizontal, :vertical
8
7
 
9
- def initialize(corners:, edges:)
10
- @top_left, @top_right, @bottom_left, @bottom_right = corners
11
- @horizontal, @vertical = edges
12
- end
13
-
14
- def self.fetch(name)
15
- STYLES.fetch(name.to_sym)
16
- end
8
+ def initialize(corners:, edges:)
9
+ @top_left, @top_right, @bottom_left, @bottom_right = corners
10
+ @horizontal, @vertical = edges
17
11
  end
18
12
 
19
- Border::STYLES = {
20
- normal: Border.new(
21
- corners: ["+", "+", "+", "+"], edges: ["-", "|"]
22
- ),
23
- rounded: Border.new(
24
- corners: ["╭", "╮", "╰", "╯"], edges: ["─", "│"]
25
- ),
26
- thick: Border.new(
27
- corners: ["┏", "┓", "┗", "┛"], edges: ["━", "┃"]
28
- ),
29
- double: Border.new(
30
- corners: ["╔", "╗", "╚", "╝"], edges: ["═", "║"]
31
- )
32
- }.freeze
13
+ def self.fetch(name)
14
+ STYLES.fetch(name.to_sym)
15
+ end
33
16
  end
17
+
18
+ Border::STYLES = {
19
+ normal: Border.new(
20
+ corners: ["+", "+", "+", "+"], edges: ["-", "|"]
21
+ ),
22
+ rounded: Border.new(
23
+ corners: ["╭", "╮", "╰", "╯"], edges: ["─", "│"]
24
+ ),
25
+ thick: Border.new(
26
+ corners: ["┏", "┓", "┗", "┛"], edges: ["━", "┃"]
27
+ ),
28
+ double: Border.new(
29
+ corners: ["╔", "╗", "╚", "╝"], edges: ["═", "║"]
30
+ )
31
+ }.freeze
34
32
  end
35
33
  end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module UI
5
+ class BorderPainter
6
+ DEFAULT_SIDES = %i[top right bottom left].freeze
7
+
8
+ def initialize(border:, sides: nil, foreground: nil, background: nil)
9
+ @border = border
10
+ @sides = Array(sides || DEFAULT_SIDES).map(&:to_sym)
11
+ @foreground = foreground
12
+ @background = background
13
+ end
14
+
15
+ def paint(lines, inner_width)
16
+ horizontal = @border.horizontal * inner_width
17
+ body = lines.map { |line| border_line(line, inner_width) }
18
+
19
+ [top_border(horizontal), *body, bottom_border(horizontal)].compact
20
+ end
21
+
22
+ private
23
+
24
+ def border_line(line, width)
25
+ left = @sides.include?(:left) ? render_border(@border.vertical) : ""
26
+ right = @sides.include?(:right) ? render_border(@border.vertical) : ""
27
+
28
+ "#{left}#{line}#{" " * (width - Width.measure(line))}#{right}"
29
+ end
30
+
31
+ def top_border(horizontal)
32
+ return unless @sides.include?(:top)
33
+ return render_border(horizontal) unless full_horizontal?
34
+
35
+ render_border("#{@border.top_left}#{horizontal}#{@border.top_right}")
36
+ end
37
+
38
+ def bottom_border(horizontal)
39
+ return unless @sides.include?(:bottom)
40
+ return render_border(horizontal) unless full_horizontal?
41
+
42
+ render_border("#{@border.bottom_left}#{horizontal}#{@border.bottom_right}")
43
+ end
44
+
45
+ def full_horizontal?
46
+ @sides.include?(:left) && @sides.include?(:right)
47
+ end
48
+
49
+ def render_border(value)
50
+ return value unless @foreground
51
+
52
+ Style.new(foreground: @foreground, background: @background).render(value)
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module UI
5
+ # Canvas is a 2D character grid of fixed width and height that supports
6
+ # placing content at (row, column) coordinates and overlaying one block
7
+ # on top of another. Construct via .new(width, height) for a blank grid
8
+ # or .parse(string) to reconstruct from rendered output.
9
+ class Canvas
10
+ def initialize(width, height)
11
+ @width = width
12
+ @height = height
13
+ @grid = Array.new(height) { " " * width }
14
+ end
15
+
16
+ def self.parse(string)
17
+ lines = string.to_s.lines(chomp: true)
18
+ width = UI.block_width(lines)
19
+ canvas = new(width, lines.length)
20
+ lines.each_with_index { |line, i| canvas.instance_variable_get(:@grid)[i] = line }
21
+ canvas
22
+ end
23
+
24
+ def to_s
25
+ @grid.join("\n")
26
+ end
27
+
28
+ def place(block, top: 0, left: 0, background: nil)
29
+ lines = block.to_s.lines(chomp: true)
30
+ row = Canvas.offset(top, @height, lines.length)
31
+ column = Canvas.offset(left, @width, UI.block_width(lines))
32
+ draw_lines(lines, row: row, column: column, onto: @grid)
33
+ rendered = to_s
34
+ background ? UI::Style.new.background(background).render(rendered) : rendered
35
+ end
36
+
37
+ def overlay(other, top: :center, left: :center)
38
+ overlay_lines = other.to_s.lines(chomp: true)
39
+ row = Canvas.offset(top, @grid.length, overlay_lines.length)
40
+ column = Canvas.offset(left, @width, UI.block_width(overlay_lines))
41
+ draw_lines(overlay_lines, row: row, column: column, onto: @grid)
42
+ self
43
+ end
44
+
45
+ def self.offset(value, available, size)
46
+ return [(available - size) / 2, 0].max if value == :center
47
+
48
+ value
49
+ end
50
+
51
+ private
52
+
53
+ def draw_lines(lines, row:, column:, onto:)
54
+ lines.each_with_index do |line, index|
55
+ line_index = row + index
56
+ next if line_index.negative? || line_index >= onto.length
57
+
58
+ onto[line_index] = compose_line(onto[line_index], line, column)
59
+ end
60
+ end
61
+
62
+ def compose_line(base_line, overlay_line, column)
63
+ return ANSISlicer.slice(base_line, 0, @width) if column >= @width
64
+ return ANSISlicer.slice(base_line, 0, @width) if column + Width.measure(overlay_line) <= 0
65
+
66
+ target_column = [column, 0].max
67
+ overlay_start = [0 - column, 0].max
68
+ overlay = ANSISlicer.slice(overlay_line, overlay_start, @width - target_column)
69
+ overlay_width = Width.measure(overlay)
70
+ return ANSISlicer.slice(base_line, 0, @width) if overlay_width.zero?
71
+
72
+ right_column = target_column + overlay_width
73
+
74
+ ANSISlicer.slice(base_line, 0, target_column) +
75
+ overlay +
76
+ ANSISlicer.slice(base_line, right_column, [@width - right_column, 0].max)
77
+ end
78
+ end
79
+ end
80
+ end