charming 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +38 -378
  3. data/lib/charming/application.rb +14 -3
  4. data/lib/charming/{application_model.rb → application_state.rb} +3 -3
  5. data/lib/charming/cli.rb +62 -3
  6. data/lib/charming/controller/class_methods.rb +115 -0
  7. data/lib/charming/controller/command_palette.rb +135 -0
  8. data/lib/charming/controller/component_dispatching.rb +81 -0
  9. data/lib/charming/controller/dispatching.rb +60 -0
  10. data/lib/charming/controller/focus_management.rb +30 -0
  11. data/lib/charming/controller/rendering.rb +127 -0
  12. data/lib/charming/controller/session_state.rb +41 -0
  13. data/lib/charming/controller/sidebar_navigation.rb +111 -0
  14. data/lib/charming/controller.rb +46 -448
  15. data/lib/charming/database_commands.rb +103 -0
  16. data/lib/charming/database_installer.rb +152 -0
  17. data/lib/charming/events/key_event.rb +15 -0
  18. data/lib/charming/events/mouse_event.rb +42 -0
  19. data/lib/charming/events/resize_event.rb +9 -0
  20. data/lib/charming/events/task_event.rb +19 -0
  21. data/lib/charming/events/timer_event.rb +9 -0
  22. data/lib/charming/focus.rb +58 -2
  23. data/lib/charming/generators/app_file_generator.rb +13 -0
  24. data/lib/charming/generators/app_generator.rb +147 -45
  25. data/lib/charming/generators/base.rb +26 -0
  26. data/lib/charming/generators/component_generator.rb +10 -10
  27. data/lib/charming/generators/controller_generator.rb +22 -14
  28. data/lib/charming/generators/model_generator.rb +128 -0
  29. data/lib/charming/generators/name.rb +10 -4
  30. data/lib/charming/generators/screen_generator.rb +84 -52
  31. data/lib/charming/generators/templates/app/Gemfile.template +5 -0
  32. data/lib/charming/generators/templates/app/README.md.template +9 -0
  33. data/lib/charming/generators/templates/app/Rakefile.template +3 -0
  34. data/lib/charming/generators/templates/app/application.template +13 -0
  35. data/lib/charming/generators/templates/app/application_controller.template +19 -0
  36. data/lib/charming/generators/templates/app/application_record.template +7 -0
  37. data/lib/charming/generators/templates/app/application_state.template +6 -0
  38. data/lib/charming/generators/templates/app/database_config.template +12 -0
  39. data/lib/charming/generators/templates/app/executable.template +7 -0
  40. data/lib/charming/generators/templates/app/gemspec.template +6 -0
  41. data/lib/charming/generators/templates/app/home_controller.template +6 -0
  42. data/lib/charming/generators/templates/app/home_state.template +7 -0
  43. data/lib/charming/generators/templates/app/keep.template +0 -0
  44. data/lib/charming/generators/templates/app/layout.template +113 -0
  45. data/lib/charming/generators/templates/app/root_file.template +20 -0
  46. data/lib/charming/generators/templates/app/routes.template +5 -0
  47. data/lib/charming/generators/templates/app/seeds.template +1 -0
  48. data/lib/charming/generators/templates/app/spec_controller.template +17 -0
  49. data/lib/charming/generators/templates/app/spec_helper.template +3 -0
  50. data/lib/charming/generators/templates/app/spec_state.template +17 -0
  51. data/lib/charming/generators/templates/app/spec_view.template +16 -0
  52. data/lib/charming/generators/templates/app/version.template +5 -0
  53. data/lib/charming/generators/templates/app/view.template +21 -0
  54. data/lib/charming/generators/templates/component/component.rb.template +9 -0
  55. data/lib/charming/generators/templates/controller/controller.rb.template +6 -0
  56. data/lib/charming/generators/templates/model/migration.rb.template +9 -0
  57. data/lib/charming/generators/templates/model/model.rb.template +6 -0
  58. data/lib/charming/generators/templates/model/spec.rb.template +9 -0
  59. data/lib/charming/generators/templates/screen/controller.rb.template +7 -0
  60. data/lib/charming/generators/templates/screen/spec_controller.rb.template +17 -0
  61. data/lib/charming/generators/templates/screen/spec_state.rb.template +17 -0
  62. data/lib/charming/generators/templates/screen/spec_view.rb.template +13 -0
  63. data/lib/charming/generators/templates/screen/state.rb.template +7 -0
  64. data/lib/charming/generators/templates/screen/view.rb.template +11 -0
  65. data/lib/charming/generators/templates/view/view.rb.template +11 -0
  66. data/lib/charming/generators/view_generator.rb +26 -13
  67. data/lib/charming/internal/renderer/differential.rb +17 -3
  68. data/lib/charming/internal/renderer/full_repaint.rb +6 -0
  69. data/lib/charming/internal/terminal/adapter.rb +29 -3
  70. data/lib/charming/internal/terminal/key_normalizer.rb +84 -0
  71. data/lib/charming/internal/terminal/memory_backend.rb +28 -1
  72. data/lib/charming/internal/terminal/mouse_parser.rb +81 -0
  73. data/lib/charming/internal/terminal/tty_backend.rb +62 -115
  74. data/lib/charming/presentation/component.rb +10 -0
  75. data/lib/charming/presentation/components/activity_indicator.rb +160 -0
  76. data/lib/charming/presentation/components/command_palette.rb +120 -0
  77. data/lib/charming/presentation/components/empty_state.rb +56 -0
  78. data/lib/charming/presentation/components/form/builder.rb +62 -0
  79. data/lib/charming/presentation/components/form/confirm.rb +69 -0
  80. data/lib/charming/presentation/components/form/field.rb +121 -0
  81. data/lib/charming/presentation/components/form/input.rb +71 -0
  82. data/lib/charming/presentation/components/form/note.rb +41 -0
  83. data/lib/charming/presentation/components/form/select.rb +112 -0
  84. data/lib/charming/presentation/components/form/textarea.rb +86 -0
  85. data/lib/charming/presentation/components/form.rb +156 -0
  86. data/lib/charming/presentation/components/keyboard_handler.rb +58 -0
  87. data/lib/charming/presentation/components/list.rb +132 -0
  88. data/lib/charming/presentation/components/markdown.rb +31 -0
  89. data/lib/charming/presentation/components/modal.rb +64 -0
  90. data/lib/charming/presentation/components/progressbar.rb +70 -0
  91. data/lib/charming/presentation/components/spinner.rb +49 -0
  92. data/lib/charming/presentation/components/table.rb +143 -0
  93. data/lib/charming/presentation/components/text_area.rb +267 -0
  94. data/lib/charming/presentation/components/text_input.rb +129 -0
  95. data/lib/charming/presentation/components/viewport.rb +272 -0
  96. data/lib/charming/presentation/layout/builder.rb +86 -0
  97. data/lib/charming/presentation/layout/overlay.rb +57 -0
  98. data/lib/charming/presentation/layout/pane.rb +145 -0
  99. data/lib/charming/presentation/layout/rect.rb +23 -0
  100. data/lib/charming/presentation/layout/screen_layout.rb +60 -0
  101. data/lib/charming/presentation/layout/split.rb +134 -0
  102. data/lib/charming/presentation/layout.rb +43 -0
  103. data/lib/charming/presentation/markdown/block_renderers.rb +120 -0
  104. data/lib/charming/presentation/markdown/inline_renderers.rb +68 -0
  105. data/lib/charming/presentation/markdown/render_context.rb +22 -0
  106. data/lib/charming/presentation/markdown/renderer.rb +113 -0
  107. data/lib/charming/presentation/markdown/syntax_highlighter.rb +79 -0
  108. data/lib/charming/presentation/markdown.rb +11 -0
  109. data/lib/charming/presentation/template_view.rb +34 -0
  110. data/lib/charming/presentation/templates/erb_handler.rb +15 -0
  111. data/lib/charming/presentation/templates.rb +68 -0
  112. data/lib/charming/presentation/ui/ansi_codes.rb +89 -0
  113. data/lib/charming/presentation/ui/ansi_slicer.rb +94 -0
  114. data/lib/charming/presentation/ui/border.rb +35 -0
  115. data/lib/charming/presentation/ui/border_painter.rb +58 -0
  116. data/lib/charming/presentation/ui/canvas.rb +82 -0
  117. data/lib/charming/presentation/ui/style.rb +213 -0
  118. data/lib/charming/presentation/ui/theme.rb +180 -0
  119. data/lib/charming/{ui → presentation/ui}/themes/phosphor.json +2 -2
  120. data/lib/charming/presentation/ui/width.rb +26 -0
  121. data/lib/charming/presentation/ui.rb +91 -0
  122. data/lib/charming/presentation/view.rb +135 -0
  123. data/lib/charming/runtime.rb +9 -7
  124. data/lib/charming/screen.rb +5 -1
  125. data/lib/charming/tasks/inline_executor.rb +37 -0
  126. data/lib/charming/tasks/task.rb +12 -0
  127. data/lib/charming/tasks/threaded_executor.rb +51 -0
  128. data/lib/charming/version.rb +1 -1
  129. data/lib/charming.rb +17 -0
  130. metadata +170 -36
  131. data/lib/charming/component.rb +0 -8
  132. data/lib/charming/components/activity_indicator.rb +0 -158
  133. data/lib/charming/components/command_palette.rb +0 -118
  134. data/lib/charming/components/keyboard_handler.rb +0 -22
  135. data/lib/charming/components/list.rb +0 -105
  136. data/lib/charming/components/modal.rb +0 -48
  137. data/lib/charming/components/progressbar.rb +0 -55
  138. data/lib/charming/components/spinner.rb +0 -37
  139. data/lib/charming/components/table.rb +0 -115
  140. data/lib/charming/components/text_input.rb +0 -103
  141. data/lib/charming/components/viewport.rb +0 -191
  142. data/lib/charming/generators/app_generator/app_spec_templates.rb +0 -86
  143. data/lib/charming/generators/app_generator/basic_templates.rb +0 -69
  144. data/lib/charming/generators/app_generator/component_templates.rb +0 -36
  145. data/lib/charming/generators/app_generator/controller_template.rb +0 -69
  146. data/lib/charming/generators/app_generator/layout_template.rb +0 -160
  147. data/lib/charming/generators/app_generator/model_templates.rb +0 -30
  148. data/lib/charming/generators/app_generator/screen_spec_templates.rb +0 -70
  149. data/lib/charming/generators/app_generator/view_template.rb +0 -90
  150. data/lib/charming/key_event.rb +0 -13
  151. data/lib/charming/mouse_event.rb +0 -40
  152. data/lib/charming/resize_event.rb +0 -7
  153. data/lib/charming/task.rb +0 -7
  154. data/lib/charming/task_event.rb +0 -17
  155. data/lib/charming/task_executor.rb +0 -62
  156. data/lib/charming/timer_event.rb +0 -7
  157. data/lib/charming/ui/border.rb +0 -33
  158. data/lib/charming/ui/style.rb +0 -244
  159. data/lib/charming/ui/theme.rb +0 -178
  160. data/lib/charming/ui/width.rb +0 -24
  161. data/lib/charming/ui.rb +0 -230
  162. data/lib/charming/view.rb +0 -116
  163. /data/lib/charming/{generators.rb → generators/error.rb} +0 -0
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Presentation
5
+ # Templates resolves and renders view templates by name. Template handlers are registered
6
+ # for file extensions (e.g., `.tui.erb`) and the resolver searches `app/views/<name><ext>`
7
+ # under the application root, falling back through registered extensions when the first
8
+ # match is not found.
9
+ module Templates
10
+ # A resolved template: an on-disk *path* paired with the *handler* responsible for rendering it.
11
+ ResolvedTemplate = Data.define(:path, :handler) do
12
+ # Renders the template against *view* by delegating to the registered handler.
13
+ def render(view)
14
+ handler.render(path, view)
15
+ end
16
+ end
17
+
18
+ # Raised when no template file matches the given name under the application root.
19
+ MissingTemplateError = Class.new(Error)
20
+
21
+ class << self
22
+ # Registers a template *handler* for a file *extension* (e.g., ".tui.erb" => ErbHandler).
23
+ # The handler responds to `.render(path, view)`.
24
+ def register(extension, handler)
25
+ handlers[extension] = handler
26
+ end
27
+
28
+ # Resolves a template by *name* under `app/views` of *root* (defaults to the current
29
+ # working directory). Raises MissingTemplateError when no matching file exists.
30
+ def resolve(name, root: nil)
31
+ views_root = File.join(root || Dir.pwd, "app", "views")
32
+ searched_paths = candidate_paths(views_root, name.to_s)
33
+
34
+ searched_paths.each do |path|
35
+ next unless File.file?(path)
36
+
37
+ return ResolvedTemplate.new(path: path, handler: handler_for(path))
38
+ end
39
+
40
+ raise MissingTemplateError, "Missing template #{name.inspect}. Searched: #{searched_paths.join(", ")}"
41
+ end
42
+
43
+ # Hash of registered handlers keyed by extension. Populated by `register`.
44
+ def handlers
45
+ @handlers ||= {}
46
+ end
47
+
48
+ private
49
+
50
+ # Returns candidate paths under *views_root* for *name*. When the bare path has a known
51
+ # extension, returns it directly; otherwise returns the path with each registered extension
52
+ # appended (in registration order).
53
+ def candidate_paths(views_root, name)
54
+ path = File.expand_path(name, views_root)
55
+ return [path] if handler_for(path)
56
+
57
+ handlers.keys.map { |extension| "#{path}#{extension}" }
58
+ end
59
+
60
+ # Looks up the handler whose registered extension matches the end of *path*. Returns nil
61
+ # when no handler matches.
62
+ def handler_for(path)
63
+ handlers.find { |extension, _handler| path.end_with?(extension) }&.last
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Presentation
5
+ module UI
6
+ class ANSICodes
7
+ ATTRIBUTES = {
8
+ bold: 1,
9
+ faint: 2,
10
+ italic: 3,
11
+ underline: 4,
12
+ reverse: 7,
13
+ strikethrough: 9
14
+ }.freeze
15
+
16
+ COLORS = {
17
+ black: 30,
18
+ red: 31,
19
+ green: 32,
20
+ yellow: 33,
21
+ blue: 34,
22
+ magenta: 35,
23
+ cyan: 36,
24
+ white: 37,
25
+ bright_black: 90,
26
+ bright_red: 91,
27
+ bright_green: 92,
28
+ bright_yellow: 93,
29
+ bright_blue: 94,
30
+ bright_magenta: 95,
31
+ bright_cyan: 96,
32
+ bright_white: 97
33
+ }.freeze
34
+
35
+ def initialize(attributes:, foreground:, background:)
36
+ @attributes = attributes
37
+ @foreground = foreground
38
+ @background = background
39
+ end
40
+
41
+ def codes
42
+ @codes ||= attribute_codes +
43
+ color_codes(@foreground, foreground: true) +
44
+ color_codes(@background, foreground: false)
45
+ end
46
+
47
+ def apply(value)
48
+ return value if codes.empty?
49
+
50
+ start = "\e[#{codes.join(";")}m"
51
+ value.split("\n", -1).map { |line| "#{start}#{line.gsub("\e[0m", "\e[0m#{start}")}\e[0m" }.join("\n")
52
+ end
53
+
54
+ private
55
+
56
+ def attribute_codes
57
+ @attributes.map { |attribute| ATTRIBUTES.fetch(attribute) }
58
+ end
59
+
60
+ def color_codes(color, foreground:)
61
+ return [] unless color
62
+ return indexed_color_code(color, foreground: foreground) if color.is_a?(Integer)
63
+ return named_color_code(color, foreground: foreground) if COLORS.key?(color.to_sym)
64
+ return truecolor_codes(color, foreground: foreground) if color.to_s.start_with?("#")
65
+
66
+ raise ArgumentError, "unknown color: #{color.inspect}"
67
+ end
68
+
69
+ def named_color_code(color, foreground:)
70
+ code = COLORS.fetch(color.to_sym)
71
+ [foreground ? code : code + 10]
72
+ end
73
+
74
+ def indexed_color_code(color, foreground:)
75
+ raise ArgumentError, "indexed color must be between 0 and 255" unless color.between?(0, 255)
76
+
77
+ [foreground ? 38 : 48, 5, color]
78
+ end
79
+
80
+ def truecolor_codes(color, foreground:)
81
+ hex = color.to_s.delete_prefix("#")
82
+ raise ArgumentError, "truecolor must be #rrggbb" unless hex.match?(/\A[0-9a-fA-F]{6}\z/)
83
+
84
+ [foreground ? 38 : 48, 2, hex[0..1].to_i(16), hex[2..3].to_i(16), hex[4..5].to_i(16)]
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Presentation
5
+ module UI
6
+ # ANSISlicer extracts a visible substring from a string that may contain ANSI
7
+ # escape sequences, preserving the styling that is active at the start of
8
+ # the slice and emitting a trailing reset if any styled content was copied.
9
+ class ANSISlicer
10
+ def self.slice(line, start_column, width)
11
+ return "" unless width.positive?
12
+
13
+ slice_range(line.to_s, start_column, start_column + width)
14
+ end
15
+
16
+ def self.slice_range(line, start_column, end_column)
17
+ state = {column: 0, output: +"", active: [], started: false, styled: false}
18
+
19
+ each_ansi_or_char(line) do |token, ansi|
20
+ if ansi
21
+ slice_ansi_token(token, state, start_column, end_column)
22
+ else
23
+ slice_char(token, state, start_column, end_column)
24
+ end
25
+ end
26
+
27
+ terminate_slice(state)
28
+ end
29
+
30
+ def self.each_ansi_or_char(line)
31
+ index = 0
32
+ while index < line.length
33
+ match = line.match(Width::ANSI_PATTERN, index)
34
+ if match&.begin(0) == index
35
+ yield match[0], true
36
+ index = match.end(0)
37
+ else
38
+ yield line[index], false
39
+ index += 1
40
+ end
41
+ end
42
+ end
43
+
44
+ def self.slice_ansi_token(token, state, start_column, end_column)
45
+ started = state[:started]
46
+ update_active_styles(state[:active], token)
47
+ return unless state[:column].between?(start_column, end_column - 1)
48
+
49
+ start_slice(state)
50
+ if started
51
+ state[:output] << token
52
+ state[:styled] = !token.include?("[0m")
53
+ end
54
+ end
55
+
56
+ def self.slice_char(char, state, start_column, end_column)
57
+ char_width = Width.measure(char)
58
+ char_start = state[:column]
59
+ char_end = char_start + char_width
60
+ state[:column] = char_end
61
+ return unless char_end > start_column && char_start < end_column
62
+
63
+ start_slice(state)
64
+ state[:output] << char
65
+ end
66
+
67
+ def self.start_slice(state)
68
+ return if state[:started]
69
+
70
+ state[:output] << state[:active].join
71
+ state[:styled] = true unless state[:active].empty?
72
+ state[:started] = true
73
+ end
74
+
75
+ def self.terminate_slice(state)
76
+ return state[:output] if !state[:styled] || state[:output].empty?
77
+
78
+ "#{state[:output]}\e[0m"
79
+ end
80
+
81
+ def self.update_active_styles(active, token)
82
+ if token.include?("[0m")
83
+ active.clear
84
+ else
85
+ active << token
86
+ end
87
+ end
88
+
89
+ private_class_method :each_ansi_or_char, :slice_ansi_token, :slice_char,
90
+ :start_slice, :terminate_slice, :update_active_styles
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
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
8
+
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
17
+ end
18
+
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
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Presentation
5
+ module UI
6
+ class BorderPainter
7
+ DEFAULT_SIDES = %i[top right bottom left].freeze
8
+
9
+ def initialize(border:, sides: nil, foreground: nil, background: nil)
10
+ @border = border
11
+ @sides = Array(sides || DEFAULT_SIDES).map(&:to_sym)
12
+ @foreground = foreground
13
+ @background = background
14
+ end
15
+
16
+ def paint(lines, inner_width)
17
+ horizontal = @border.horizontal * inner_width
18
+ body = lines.map { |line| border_line(line, inner_width) }
19
+
20
+ [top_border(horizontal), *body, bottom_border(horizontal)].compact
21
+ end
22
+
23
+ private
24
+
25
+ def border_line(line, width)
26
+ left = @sides.include?(:left) ? render_border(@border.vertical) : ""
27
+ right = @sides.include?(:right) ? render_border(@border.vertical) : ""
28
+
29
+ "#{left}#{line}#{" " * (width - Width.measure(line))}#{right}"
30
+ end
31
+
32
+ def top_border(horizontal)
33
+ return unless @sides.include?(:top)
34
+ return render_border(horizontal) unless full_horizontal?
35
+
36
+ render_border("#{@border.top_left}#{horizontal}#{@border.top_right}")
37
+ end
38
+
39
+ def bottom_border(horizontal)
40
+ return unless @sides.include?(:bottom)
41
+ return render_border(horizontal) unless full_horizontal?
42
+
43
+ render_border("#{@border.bottom_left}#{horizontal}#{@border.bottom_right}")
44
+ end
45
+
46
+ def full_horizontal?
47
+ @sides.include?(:left) && @sides.include?(:right)
48
+ end
49
+
50
+ def render_border(value)
51
+ return value unless @foreground
52
+
53
+ Style.new(foreground: @foreground, background: @background).render(value)
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Presentation
5
+ module UI
6
+ # Canvas is a 2D character grid of fixed width and height that supports
7
+ # placing content at (row, column) coordinates and overlaying one block
8
+ # on top of another. Construct via .new(width, height) for a blank grid
9
+ # or .parse(string) to reconstruct from rendered output.
10
+ class Canvas
11
+ def initialize(width, height)
12
+ @width = width
13
+ @height = height
14
+ @grid = Array.new(height) { " " * width }
15
+ end
16
+
17
+ def self.parse(string)
18
+ lines = string.to_s.lines(chomp: true)
19
+ width = UI.block_width(lines)
20
+ canvas = new(width, lines.length)
21
+ lines.each_with_index { |line, i| canvas.instance_variable_get(:@grid)[i] = line }
22
+ canvas
23
+ end
24
+
25
+ def to_s
26
+ @grid.join("\n")
27
+ end
28
+
29
+ def place(block, top: 0, left: 0, background: nil)
30
+ lines = block.to_s.lines(chomp: true)
31
+ row = Canvas.offset(top, @height, lines.length)
32
+ column = Canvas.offset(left, @width, UI.block_width(lines))
33
+ draw_lines(lines, row: row, column: column, onto: @grid)
34
+ rendered = to_s
35
+ background ? UI::Style.new.background(background).render(rendered) : rendered
36
+ end
37
+
38
+ def overlay(other, top: :center, left: :center)
39
+ overlay_lines = other.to_s.lines(chomp: true)
40
+ row = Canvas.offset(top, @grid.length, overlay_lines.length)
41
+ column = Canvas.offset(left, @width, UI.block_width(overlay_lines))
42
+ draw_lines(overlay_lines, row: row, column: column, onto: @grid)
43
+ self
44
+ end
45
+
46
+ def self.offset(value, available, size)
47
+ return [(available - size) / 2, 0].max if value == :center
48
+
49
+ value
50
+ end
51
+
52
+ private
53
+
54
+ def draw_lines(lines, row:, column:, onto:)
55
+ lines.each_with_index do |line, index|
56
+ line_index = row + index
57
+ next if line_index.negative? || line_index >= onto.length
58
+
59
+ onto[line_index] = compose_line(onto[line_index], line, column)
60
+ end
61
+ end
62
+
63
+ def compose_line(base_line, overlay_line, column)
64
+ return ANSISlicer.slice(base_line, 0, @width) if column >= @width
65
+ return ANSISlicer.slice(base_line, 0, @width) if column + Width.measure(overlay_line) <= 0
66
+
67
+ target_column = [column, 0].max
68
+ overlay_start = [0 - column, 0].max
69
+ overlay = ANSISlicer.slice(overlay_line, overlay_start, @width - target_column)
70
+ overlay_width = Width.measure(overlay)
71
+ return ANSISlicer.slice(base_line, 0, @width) if overlay_width.zero?
72
+
73
+ right_column = target_column + overlay_width
74
+
75
+ ANSISlicer.slice(base_line, 0, target_column) +
76
+ overlay +
77
+ ANSISlicer.slice(base_line, right_column, [@width - right_column, 0].max)
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Presentation
5
+ module UI
6
+ # Style is an immutable builder for terminal text styling. Every method returns a new
7
+ # Style instance with the requested attribute added, so styles can be safely chained and
8
+ # shared across views. `render(value)` applies the accumulated style to a string.
9
+ class Style
10
+ ATTRIBUTES = ANSICodes::ATTRIBUTES
11
+
12
+ COLORS = ANSICodes::COLORS
13
+
14
+ # Initializes a new style with an optional options hash. Recognized keys: `:attributes`
15
+ # (array of attribute symbols), `:padding` ([top, right, bottom, left]), `:align`
16
+ # (`:left`/`:right`/`:center`), and any of `:foreground`, `:background`, `:border`,
17
+ # `:border_sides`, `:border_foreground`, `:width`, `:height`.
18
+ def initialize(options = {})
19
+ @options = {
20
+ attributes: [],
21
+ padding: [0, 0, 0, 0],
22
+ align: :left
23
+ }.merge(options)
24
+ end
25
+
26
+ # Returns a new Style with the foreground *color* set. *color* is a color name (":red"),
27
+ # 256-color index (integer), or hex string ("#rrggbb").
28
+ def foreground(color)
29
+ with(foreground: color)
30
+ end
31
+ alias_method :fg, :foreground
32
+
33
+ # Returns a new Style with the background *color* set.
34
+ def background(color)
35
+ with(background: color)
36
+ end
37
+ alias_method :bg, :background
38
+
39
+ # Attribute methods (bold, italic, underline, …) are defined dynamically by the
40
+ # metaprogramming loop below. Each toggles a single text attribute on the style.
41
+ ATTRIBUTES.each_key do |attribute|
42
+ define_method(attribute) do
43
+ with(attributes: (@options.fetch(:attributes) + [attribute]).uniq)
44
+ end
45
+ end
46
+
47
+ # Returns a new Style with the padding set. Accepts 1, 2, or 4 values following CSS-style
48
+ # shorthand: 1 → all sides, 2 → [vertical, horizontal], 4 → [top, right, bottom, left].
49
+ def padding(*values)
50
+ with(padding: expand_box_values(values))
51
+ end
52
+
53
+ # Returns a new Style with the border set. *style* is a border name (e.g., :normal,
54
+ # :rounded). *sides* optionally restricts the border to specific sides. *foreground*
55
+ # sets the border color.
56
+ def border(style = :normal, sides: nil, foreground: nil)
57
+ with(border: style, border_sides: sides, border_foreground: foreground)
58
+ end
59
+
60
+ # Returns a new Style that fixes the rendered width to *value* (in display columns).
61
+ def width(value)
62
+ with(width: value)
63
+ end
64
+
65
+ # Returns a new Style that fixes the rendered height to *value* (in rows).
66
+ def height(value)
67
+ with(height: value)
68
+ end
69
+
70
+ # Returns a new Style with horizontal alignment set (`:left`, `:right`, or `:center`).
71
+ def align(value)
72
+ with(align: value)
73
+ end
74
+
75
+ # Applies the configured style to *value* and returns the styled string. Steps:
76
+ # 1. wrap to `:width`, 2. align horizontally, 3. expand to `:height`, 4. apply padding,
77
+ # 5. paint border, 6. emit ANSI attribute/foreground/background escapes.
78
+ def render(value)
79
+ lines = apply_dimensions(value.to_s.lines(chomp: true))
80
+ lines = apply_padding(lines)
81
+ lines = apply_border(lines)
82
+ apply_ansi(lines.join("\n"))
83
+ end
84
+
85
+ private
86
+
87
+ # Returns a copy of self with *changes* merged into the options hash.
88
+ def with(changes)
89
+ self.class.new(@options.merge(changes))
90
+ end
91
+
92
+ # Wraps each line to the target width and applies horizontal alignment, then expands
93
+ # to the target height.
94
+ def apply_dimensions(lines)
95
+ content_width = target_content_width(lines)
96
+ dimensioned = lines.map { |line| align_line(fit_line(line, content_width), content_width) }
97
+ apply_height(dimensioned, content_width)
98
+ end
99
+
100
+ # Returns the target content width: the explicit :width if set, otherwise the natural
101
+ # max display width of the lines.
102
+ def target_content_width(lines)
103
+ explicit_width = @options[:width]
104
+ natural_width = lines.map { |line| Width.measure(line) }.max || 0
105
+ explicit_width || natural_width
106
+ end
107
+
108
+ # Clips *line* to *width* display columns, preserving ANSI styling where possible.
109
+ def fit_line(line, width)
110
+ return line if Width.measure(line) <= width
111
+
112
+ UI.visible_slice(line, 0, width)
113
+ end
114
+
115
+ # Truncates or pads the lines array to *height* rows, filling with blank rows.
116
+ def apply_height(lines, width)
117
+ height = @options[:height]
118
+ return lines unless height
119
+
120
+ visible = lines.first(height)
121
+ visible + Array.new([height - visible.length, 0].max) { " " * width }
122
+ end
123
+
124
+ # Applies padding by prepending/appending blank rows (vertical) and indenting each
125
+ # line (horizontal).
126
+ def apply_padding(lines)
127
+ top, right, bottom, left = @options.fetch(:padding)
128
+ inner_width = lines.map { |line| Width.measure(line) }.max || 0
129
+ empty = " " * (left + inner_width + right)
130
+ padded = lines.map do |line|
131
+ pad_line(line, inner_width, left, right)
132
+ end
133
+
134
+ Array.new(top, empty) + padded + Array.new(bottom, empty)
135
+ end
136
+
137
+ # Paints the configured border around the lines, when :border is set.
138
+ def apply_border(lines)
139
+ border_name = @options[:border]
140
+ return lines unless border_name
141
+
142
+ border_painter(border_name).paint(lines, content_width(lines))
143
+ end
144
+
145
+ # Pads a single line to *inner_width*, with *left* and *right* padding spaces.
146
+ def pad_line(line, inner_width, left, right)
147
+ (" " * left) + line + (" " * (inner_width - Width.measure(line) + right))
148
+ end
149
+
150
+ # Builds a BorderPainter configured for the current border options.
151
+ def border_painter(border_name)
152
+ BorderPainter.new(
153
+ border: Border.fetch(border_name),
154
+ sides: @options[:border_sides],
155
+ foreground: @options[:border_foreground],
156
+ background: @options[:background]
157
+ )
158
+ end
159
+
160
+ # Returns the natural display width of the longest line in *lines*.
161
+ def content_width(lines)
162
+ lines.map { |line| Width.measure(line) }.max || 0
163
+ end
164
+
165
+ # Applies the active ANSI attribute/foreground/background codes to *value*.
166
+ def apply_ansi(value)
167
+ ansi_codes_obj.apply(value)
168
+ end
169
+
170
+ # The list of active ANSI escape sequence strings (attribute + foreground + background).
171
+ def ansi_codes
172
+ ansi_codes_obj.codes
173
+ end
174
+
175
+ # Builds an ANSICodes object from the active attributes, foreground, and background.
176
+ def ansi_codes_obj
177
+ ANSICodes.new(
178
+ attributes: @options.fetch(:attributes),
179
+ foreground: @options[:foreground],
180
+ background: @options[:background]
181
+ )
182
+ end
183
+
184
+ # Pads *line* on the left or right (or both, for :center) according to :align.
185
+ def align_line(line, width)
186
+ remaining = width - Width.measure(line)
187
+ return line if remaining <= 0
188
+
189
+ case @options.fetch(:align)
190
+ when :right
191
+ (" " * remaining) + line
192
+ when :center
193
+ left = remaining / 2
194
+ (" " * left) + line + (" " * (remaining - left))
195
+ else
196
+ line + (" " * remaining)
197
+ end
198
+ end
199
+
200
+ # Normalizes 1/2/4 padding value arguments into a [top, right, bottom, left] array.
201
+ def expand_box_values(values)
202
+ case values.length
203
+ when 1 then [values[0], values[0], values[0], values[0]]
204
+ when 2 then [values[0], values[1], values[0], values[1]]
205
+ when 4 then values
206
+ else
207
+ raise ArgumentError, "padding expects 1, 2, or 4 values"
208
+ end
209
+ end
210
+ end
211
+ end
212
+ end
213
+ end