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
@@ -1,244 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Charming
4
- module UI
5
- class Style
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(options = {})
35
- @options = {
36
- attributes: [],
37
- padding: [0, 0, 0, 0],
38
- align: :left
39
- }.merge(options)
40
- end
41
-
42
- def foreground(color)
43
- with(foreground: color)
44
- end
45
- alias_method :fg, :foreground
46
-
47
- def background(color)
48
- with(background: color)
49
- end
50
- alias_method :bg, :background
51
-
52
- ATTRIBUTES.each_key do |attribute|
53
- define_method(attribute) do
54
- with(attributes: (@options.fetch(:attributes) + [attribute]).uniq)
55
- end
56
- end
57
-
58
- def padding(*values)
59
- with(padding: expand_box_values(values))
60
- end
61
-
62
- def border(style = :normal, sides: nil, foreground: nil)
63
- with(border: style, border_sides: sides, border_foreground: foreground)
64
- end
65
-
66
- def width(value)
67
- with(width: value)
68
- end
69
-
70
- def height(value)
71
- with(height: value)
72
- end
73
-
74
- def align(value)
75
- with(align: value)
76
- end
77
-
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
- def with(changes)
88
- self.class.new(@options.merge(changes))
89
- end
90
-
91
- def apply_dimensions(lines)
92
- content_width = target_content_width(lines)
93
- dimensioned = lines.map { |line| align_line(fit_line(line, content_width), content_width) }
94
- apply_height(dimensioned, content_width)
95
- end
96
-
97
- def target_content_width(lines)
98
- explicit_width = @options[:width]
99
- natural_width = lines.map { |line| Width.measure(line) }.max || 0
100
- explicit_width || natural_width
101
- end
102
-
103
- def fit_line(line, width)
104
- return line if Width.measure(line) <= width
105
-
106
- UI.visible_slice(line, 0, width)
107
- end
108
-
109
- def apply_height(lines, width)
110
- height = @options[:height]
111
- return lines unless height
112
-
113
- visible = lines.first(height)
114
- visible + Array.new([height - visible.length, 0].max) { " " * width }
115
- end
116
-
117
- def apply_padding(lines)
118
- top, right, bottom, left = @options.fetch(:padding)
119
- inner_width = lines.map { |line| Width.measure(line) }.max || 0
120
- empty = " " * (left + inner_width + right)
121
- padded = lines.map do |line|
122
- pad_line(line, inner_width, left, right)
123
- end
124
-
125
- Array.new(top, empty) + padded + Array.new(bottom, empty)
126
- end
127
-
128
- def apply_border(lines)
129
- border_name = @options[:border]
130
- return lines unless border_name
131
-
132
- border = Border.fetch(border_name)
133
- sides = Array(@options[:border_sides] || %i[top right bottom left]).map(&:to_sym)
134
- width = lines.map { |line| Width.measure(line) }.max || 0
135
- horizontal = border.horizontal * width
136
- body = lines.map { |line| border_line(line, width, border, sides) }
137
-
138
- [top_border(border, horizontal, sides), *body, bottom_border(border, horizontal, sides)].compact
139
- end
140
-
141
- def pad_line(line, inner_width, left, right)
142
- (" " * left) + line + (" " * (inner_width - Width.measure(line) + right))
143
- end
144
-
145
- def border_line(line, width, border, sides)
146
- left = sides.include?(:left) ? render_border(border.vertical) : ""
147
- right = sides.include?(:right) ? render_border(border.vertical) : ""
148
-
149
- "#{left}#{line}#{" " * (width - Width.measure(line))}#{right}"
150
- end
151
-
152
- def top_border(border, horizontal, sides)
153
- return unless sides.include?(:top)
154
- return render_border(horizontal) unless full_horizontal_border?(sides)
155
-
156
- render_border("#{border.top_left}#{horizontal}#{border.top_right}")
157
- end
158
-
159
- def bottom_border(border, horizontal, sides)
160
- return unless sides.include?(:bottom)
161
- return render_border(horizontal) unless full_horizontal_border?(sides)
162
-
163
- render_border("#{border.bottom_left}#{horizontal}#{border.bottom_right}")
164
- end
165
-
166
- def full_horizontal_border?(sides)
167
- sides.include?(:left) && sides.include?(:right)
168
- end
169
-
170
- def render_border(value)
171
- border_foreground = @options[:border_foreground]
172
- return value unless border_foreground
173
-
174
- Style.new(foreground: border_foreground, background: @options[:background]).render(value)
175
- end
176
-
177
- def apply_ansi(value)
178
- codes = ansi_codes
179
- return value if codes.empty?
180
-
181
- start = "\e[#{codes.join(";")}m"
182
- value.split("\n", -1).map { |line| "#{start}#{line.gsub("\e[0m", "\e[0m#{start}")}\e[0m" }.join("\n")
183
- end
184
-
185
- def ansi_codes
186
- @options.fetch(:attributes).map { |attribute| ATTRIBUTES.fetch(attribute) } +
187
- color_codes(@options[:foreground], foreground: true) +
188
- color_codes(@options[:background], foreground: false)
189
- end
190
-
191
- def color_codes(color, foreground:)
192
- return [] unless color
193
- return indexed_color_code(color, foreground: foreground) if color.is_a?(Integer)
194
- return named_color_code(color, foreground: foreground) if COLORS.key?(color.to_sym)
195
- return truecolor_codes(color, foreground: foreground) if color.to_s.start_with?("#")
196
-
197
- raise ArgumentError, "unknown color: #{color.inspect}"
198
- end
199
-
200
- def named_color_code(color, foreground:)
201
- code = COLORS.fetch(color.to_sym)
202
- [foreground ? code : code + 10]
203
- end
204
-
205
- def indexed_color_code(color, foreground:)
206
- raise ArgumentError, "indexed color must be between 0 and 255" unless color.between?(0, 255)
207
-
208
- [foreground ? 38 : 48, 5, color]
209
- end
210
-
211
- def truecolor_codes(color, foreground:)
212
- hex = color.to_s.delete_prefix("#")
213
- raise ArgumentError, "truecolor must be #rrggbb" unless hex.match?(/\A[0-9a-fA-F]{6}\z/)
214
-
215
- [foreground ? 38 : 48, 2, hex[0..1].to_i(16), hex[2..3].to_i(16), hex[4..5].to_i(16)]
216
- end
217
-
218
- def align_line(line, width)
219
- remaining = width - Width.measure(line)
220
- return line if remaining <= 0
221
-
222
- case @options.fetch(:align)
223
- when :right
224
- (" " * remaining) + line
225
- when :center
226
- left = remaining / 2
227
- (" " * left) + line + (" " * (remaining - left))
228
- else
229
- line + (" " * remaining)
230
- end
231
- end
232
-
233
- def expand_box_values(values)
234
- case values.length
235
- when 1 then [values[0], values[0], values[0], values[0]]
236
- when 2 then [values[0], values[1], values[0], values[1]]
237
- when 4 then values
238
- else
239
- raise ArgumentError, "padding expects 1, 2, or 4 values"
240
- end
241
- end
242
- end
243
- end
244
- end
@@ -1,178 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
-
5
- module Charming
6
- module UI
7
- class Theme
8
- BUILT_IN_ROOT = File.expand_path("themes", __dir__)
9
-
10
- DEFAULT_TOKENS = {
11
- text: {foreground: :bright_white},
12
- title: {foreground: :bright_cyan, bold: true},
13
- muted: {foreground: :bright_black},
14
- border: {foreground: :bright_magenta},
15
- selected: {reverse: true},
16
- info: {foreground: :bright_cyan},
17
- warn: {foreground: :yellow}
18
- }.freeze
19
-
20
- def self.default
21
- @default ||= load_builtin("phosphor")
22
- end
23
-
24
- def self.load_file(path)
25
- from_hash(JSON.parse(File.read(path)))
26
- end
27
-
28
- def self.load_builtin(name)
29
- load_file(built_in_path(name))
30
- end
31
-
32
- def self.built_in_names
33
- Dir.glob(File.join(BUILT_IN_ROOT, "*.json")).map { |path| File.basename(path, ".json") }.sort
34
- end
35
-
36
- def self.from_hash(value)
37
- raise ArgumentError, "theme file must contain an object" unless value.is_a?(Hash)
38
-
39
- styles = value.fetch("styles") do
40
- raise ArgumentError, "theme file must contain styles"
41
- end
42
-
43
- palette = value.fetch("palette", {})
44
- new(
45
- resolve_palette_references(styles, palette),
46
- background: resolve_background(value["background"], palette)
47
- )
48
- end
49
-
50
- def self.resolve_background(value, palette)
51
- return unless value
52
-
53
- deep_resolve_colors(value, normalize_colors(palette))
54
- end
55
-
56
- def self.built_in_path(name)
57
- slug = name.to_s
58
- raise ArgumentError, "unknown built-in theme: #{name.inspect}" unless built_in_names.include?(slug)
59
-
60
- File.join(BUILT_IN_ROOT, "#{slug}.json")
61
- end
62
-
63
- def self.resolve_palette_references(styles, palette)
64
- palette = normalize_colors(palette)
65
- deep_resolve_colors(styles, palette)
66
- end
67
-
68
- def self.deep_resolve_colors(value, palette)
69
- case value
70
- when Hash
71
- value.transform_values { |item| deep_resolve_colors(item, palette) }
72
- when Array
73
- value.map { |item| deep_resolve_colors(item, palette) }
74
- when String
75
- palette.fetch(value, normalize_color(value) || value)
76
- else
77
- value
78
- end
79
- end
80
-
81
- def self.normalize_colors(values)
82
- values.transform_values { |value| normalize_color(value) }.compact
83
- end
84
-
85
- def self.normalize_color(value)
86
- return unless value.is_a?(String)
87
-
88
- case value
89
- when /\A#([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])(?:[0-9a-fA-F])?\z/
90
- "#{$1 * 2}#{$2 * 2}#{$3 * 2}".prepend("#")
91
- when /\A#[0-9a-fA-F]{6}(?:[0-9a-fA-F]{2})?\z/
92
- value[0, 7]
93
- end
94
- end
95
-
96
- attr_reader :background
97
-
98
- def initialize(tokens = {}, background: nil)
99
- @tokens = symbolize_keys(tokens)
100
- @background = background
101
- end
102
-
103
- def style(name)
104
- spec = @tokens.fetch(name.to_sym) do
105
- raise ArgumentError, "unknown theme token: #{name.inspect}"
106
- end
107
-
108
- build_style(spec)
109
- end
110
- alias_method :[], :style
111
-
112
- def method_missing(name, ...)
113
- return style(name) if @tokens.key?(name)
114
-
115
- super
116
- end
117
-
118
- def respond_to_missing?(name, include_private = false)
119
- @tokens.key?(name) || super
120
- end
121
-
122
- private
123
-
124
- def build_style(spec)
125
- return spec if spec.is_a?(Style)
126
- return UI.style.foreground(spec) unless spec.is_a?(Hash)
127
-
128
- apply_options(UI.style, symbolize_keys(spec))
129
- end
130
-
131
- def apply_options(base_style, spec)
132
- styled = apply_colors(base_style, spec)
133
- styled = apply_attributes(styled, spec)
134
- apply_layout(styled, spec)
135
- end
136
-
137
- def apply_colors(base_style, spec)
138
- styled = base_style
139
- styled = styled.foreground(spec[:foreground] || spec[:fg]) if spec.key?(:foreground) || spec.key?(:fg)
140
- styled = styled.background(spec[:background] || spec[:bg]) if spec.key?(:background) || spec.key?(:bg)
141
- styled
142
- end
143
-
144
- def apply_attributes(base_style, spec)
145
- Style::ATTRIBUTES.each_key.reduce(base_style) do |styled, attribute|
146
- spec[attribute] ? styled.public_send(attribute) : styled
147
- end
148
- end
149
-
150
- def apply_layout(base_style, spec)
151
- styled = base_style
152
- styled = styled.padding(*Array(spec[:padding])) if spec.key?(:padding)
153
- styled = apply_border(styled, spec[:border]) if spec.key?(:border)
154
- styled = styled.width(spec[:width]) if spec.key?(:width)
155
- styled = styled.height(spec[:height]) if spec.key?(:height)
156
- styled = styled.align(spec[:align].to_sym) if spec.key?(:align)
157
- styled
158
- end
159
-
160
- def apply_border(base_style, border_spec)
161
- return base_style.border(border_spec) unless border_spec.is_a?(Hash)
162
-
163
- border_spec = symbolize_keys(border_spec)
164
- base_style.border(
165
- border_spec.fetch(:style, :normal),
166
- sides: border_spec[:sides],
167
- foreground: border_spec[:foreground] || border_spec[:fg]
168
- )
169
- end
170
-
171
- def symbolize_keys(value)
172
- value.each_with_object({}) do |(key, item), result|
173
- result[key.to_sym] = item.is_a?(Hash) ? symbolize_keys(item) : item
174
- end
175
- end
176
- end
177
- end
178
- end
@@ -1,24 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "unicode/display_width"
4
-
5
- module Charming
6
- module UI
7
- # Width is a namespace for measuring and normalising the visual width of strings that may contain
8
- # ANSI escape sequences. It delegates to `Unicode::DisplayWidth` while automatically stripping
9
- # formatting codes so layout primitives can calculate exact character positions.
10
- module Width
11
- ANSI_PATTERN = /\e\[[0-9;]*m/
12
-
13
- module_function
14
-
15
- def measure(value)
16
- Unicode::DisplayWidth.of(strip_ansi(value.to_s))
17
- end
18
-
19
- def strip_ansi(value)
20
- value.to_s.gsub(ANSI_PATTERN, "")
21
- end
22
- end
23
- end
24
- end
data/lib/charming/ui.rb DELETED
@@ -1,230 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Charming
4
- # UI is a module of layout primitives for composing and positioning ANSI-styled
5
- # terminal text. It provides functions to join blocks horizontally or vertically,
6
- # place content on fixed-size canvases, overlay elements, and slice strings that
7
- # contain ANSI escape sequences while preserving their styling.
8
- module UI
9
- module_function
10
-
11
- # Builds a new {Style} instance for chaining color, padding, alignment, and other visual properties.
12
- def style
13
- Style.new
14
- end
15
-
16
- # Horizontally concatenates *blocks* into a single multi-line string, padding each block's
17
- # rows to match the widest row. A *gap* argument (in spaces) can separate adjacent columns.
18
- def join_horizontal(*blocks, gap: 0)
19
- normalized = normalize_blocks(blocks)
20
- widths = block_widths(normalized)
21
- separator = " " * gap
22
-
23
- Array.new(block_height(normalized)) do |index|
24
- horizontal_line(normalized, widths, index).join(separator)
25
- end.join("\n")
26
- end
27
-
28
- # Stacks *blocks* vertically separated by one or more blank lines. A *gap* of N inserts N
29
- # extra newline characters between blocks (1 gap = 1 blank line, 2 gaps = 2 blank lines, etc.).
30
- def join_vertical(*blocks, gap: 0)
31
- blocks.join("\n" * (gap + 1))
32
- end
33
-
34
- # Centers a *block* within a canvas of the given *width* and *height*, then returns the result.
35
- def center(block, width:, height:, background: nil)
36
- place(block, width: width, height: height, top: :center, left: :center, background: background)
37
- end
38
-
39
- # Draws *overlay* on top of a base at the specified *top* (row) and *left* (column) coordinates,
40
- # defaulting to center in both directions. ANSI styling on the base content is preserved underneath.
41
- def overlay(base, overlay, top: :center, left: :center)
42
- base_lines = base.to_s.lines(chomp: true)
43
- overlay_lines = overlay.to_s.lines(chomp: true)
44
- width = block_width(base_lines)
45
- row = offset(top, base_lines.length, overlay_lines.length)
46
- column = offset(left, width, block_width(overlay_lines))
47
-
48
- draw_lines(base_lines, overlay_lines, row: row, column: column, width: width)
49
- end
50
-
51
- # Places a *block* onto a blank canvas of *width* × *height* at an offset determined by *top* (row)
52
- # and *left* (column). Non-:center values are treated as absolute positions. When *background* is
53
- # given, the assembled frame is wrapped so the theme bg paints the entire canvas — overlay content
54
- # with its own bg overrides per-cell; resets re-apply the canvas bg.
55
- def place(block, width:, height:, top: 0, left: 0, background: nil)
56
- lines = block.to_s.lines(chomp: true)
57
- row = offset(top, height, lines.length)
58
- column = offset(left, width, block_width(lines))
59
- canvas = Array.new(height) { " " * width }
60
- composed = draw_lines(canvas, lines, row: row, column: column, width: width)
61
- return composed unless background
62
-
63
- Style.new.background(background).render(composed)
64
- end
65
-
66
- # Normalizes an array of mixed objects into arrays of lines by calling `#to_s` on each element.
67
- def normalize_blocks(blocks)
68
- blocks.map { |block| block.to_s.lines(chomp: true) }
69
- end
70
-
71
- # Measures the displayed (visual) width of each normalised block, returning an array of integer widths.
72
- def block_widths(blocks)
73
- blocks.map { |lines| lines.map { |line| Width.measure(line) }.max || 0 }
74
- end
75
-
76
- # Returns the maximum visual character width across all *lines*, accounting for multi-column characters
77
- # (e.g., full-width CJK glyphs) and invisible ANSI escape sequences.
78
- def block_width(lines)
79
- lines.map { |line| Width.measure(line) }.max || 0
80
- end
81
-
82
- # Returns the height in rows of each normalised block, taking the maximum across all blocks.
83
- def block_height(blocks)
84
- blocks.map(&:length).max || 0
85
- end
86
-
87
- # Builds a single horizontal row by concatenating one line from each *block* at index *index*, padding
88
- # every segment to its corresponding *width* in spaces. Returns the assembled array of padded segments.
89
- def horizontal_line(blocks, widths, index)
90
- blocks.each_with_index.map do |lines, block_index|
91
- line = lines[index] || ""
92
- line + (" " * (widths[block_index] - Width.measure(line)))
93
- end
94
- end
95
-
96
- # Computes a placement coordinate: if *value* is `:center` the result centres the *size* within *available*;
97
- # otherwise *value* is returned verbatim as an absolute integer position.
98
- def offset(value, available, size)
99
- return [(available - size) / 2, 0].max if value == :center
100
-
101
- value
102
- end
103
-
104
- # Merges an *overlay_line* into a *base_line* at the given *column*, returning the combined string. The
105
- # overlay replaces (covers) underlying characters; anything to the right that exceeds *width* is truncated.
106
- def composed_overlay_line(base_line, overlay_line, column, width)
107
- return visible_slice(base_line, 0, width) if column >= width
108
- return visible_slice(base_line, 0, width) if column + Width.measure(overlay_line) <= 0
109
-
110
- target_column = [column, 0].max
111
- overlay_start = [0 - column, 0].max
112
- overlay = visible_slice(overlay_line, overlay_start, width - target_column)
113
- overlay_width = Width.measure(overlay)
114
- return visible_slice(base_line, 0, width) if overlay_width.zero?
115
-
116
- right_column = target_column + overlay_width
117
-
118
- visible_slice(base_line, 0, target_column) +
119
- overlay +
120
- visible_slice(base_line, right_column, [width - right_column, 0].max)
121
- end
122
-
123
- # Returns a visible-slice of *line* starting at *start_column* spanning *width* characters, preserving any
124
- # ANSI escape sequences that were active at the start of the slice. Non-positive widths return `""`.
125
- def visible_slice(line, start_column, width)
126
- return "" unless width.positive?
127
-
128
- slice_visible_text(line.to_s, start_column, start_column + width)
129
- end
130
-
131
- # Slices a string by visible terminal columns while preserving ANSI style state.
132
- def slice_visible_text(line, start_column, end_column)
133
- state = {column: 0, output: +"", active: [], started: false, styled: false}
134
-
135
- each_ansi_or_char(line) do |token, ansi|
136
- if ansi
137
- slice_ansi(token, state, start_column, end_column)
138
- else
139
- slice_char(token, state, start_column, end_column)
140
- end
141
- end
142
-
143
- terminate_slice(state)
144
- end
145
-
146
- # Splits a *line* into token-range pieces bounded by *start_column* and *end_column*, preserving ANSI escapes
147
- # that fall within the visible range. Yields each character or escape sequence along with whether it is ANSI.
148
- def each_ansi_or_char(line)
149
- index = 0
150
- while index < line.length
151
- match = line.match(Width::ANSI_PATTERN, index)
152
- if match&.begin(0) == index
153
- yield match[0], true
154
- index = match.end(0)
155
- else
156
- char = line[index]
157
- yield char, false
158
- index += 1
159
- end
160
- end
161
- end
162
-
163
- # Slices an ANSI *token* (escape sequence) into *state*, writing active markers to the output if the current
164
- # *column* falls within the [start_column, end_column) range. Resets styles on `[0m` sequences.
165
- def slice_ansi(token, state, start_column, end_column)
166
- started = state[:started]
167
- update_active_styles(state[:active], token)
168
- return unless state[:column].between?(start_column, end_column - 1)
169
-
170
- start_slice(state)
171
- if started
172
- state[:output] << token
173
- state[:styled] = !token.include?("[0m")
174
- end
175
- end
176
-
177
- # Slices a plain *char* into *state*, advancing the column tracker by the character's visual width. If the
178
- # character overlaps with the [start_column, end_column) range it is appended to the output.
179
- def slice_char(char, state, start_column, end_column)
180
- char_width = Width.measure(char)
181
- char_start = state[:column]
182
- char_end = char_start + char_width
183
- state[:column] = char_end
184
- return unless char_end > start_column && char_start < end_column
185
-
186
- start_slice(state)
187
- state[:output] << char
188
- end
189
-
190
- # Starts writing to the output buffer, flushing any active ANSI markers if this is the first character placed.
191
- def start_slice(state)
192
- return if state[:started]
193
-
194
- state[:output] << state[:active].join
195
- state[:styled] = true unless state[:active].empty?
196
- state[:started] = true
197
- end
198
-
199
- # Closes the slice by appending a final `[0m` reset escape to the output unless no active styling exists or
200
- # nothing was written. Returns the fully constructed output string with trailing reset applied.
201
- def terminate_slice(state)
202
- return state[:output] if !state[:styled] || state[:output].empty?
203
-
204
- "#{state[:output]}\e[0m"
205
- end
206
-
207
- # Updates *state*[:active] with an ANSI *token*: resets all active styles on `[0m` or appends the token as a
208
- # new active marker otherwise. Called during each_ansi_or_char iteration.
209
- def update_active_styles(active, token)
210
- if token.include?("[0m")
211
- active.clear
212
- else
213
- active << token
214
- end
215
- end
216
-
217
- # Overlays *lines* onto a *canvas* starting at (*row*, *column*), writing each overlaid line into the canvas
218
- # via `composed_overlay_line`. Returns the final canvas joined by newlines.
219
- def draw_lines(canvas, lines, row:, column:, width:)
220
- lines.each_with_index do |line, index|
221
- line_index = row + index
222
- next if line_index.negative? || line_index >= canvas.length
223
-
224
- canvas[line_index] = composed_overlay_line(canvas[line_index], line, column, width)
225
- end
226
-
227
- canvas.join("\n")
228
- end
229
- end
230
- end