charming 0.1.0

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 (64) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +421 -0
  4. data/exe/charming +6 -0
  5. data/lib/charming/application.rb +90 -0
  6. data/lib/charming/application_model.rb +13 -0
  7. data/lib/charming/cli.rb +60 -0
  8. data/lib/charming/component.rb +8 -0
  9. data/lib/charming/components/activity_indicator.rb +158 -0
  10. data/lib/charming/components/command_palette.rb +118 -0
  11. data/lib/charming/components/keyboard_handler.rb +22 -0
  12. data/lib/charming/components/list.rb +105 -0
  13. data/lib/charming/components/modal.rb +48 -0
  14. data/lib/charming/components/progressbar.rb +55 -0
  15. data/lib/charming/components/spinner.rb +37 -0
  16. data/lib/charming/components/table.rb +115 -0
  17. data/lib/charming/components/text_input.rb +103 -0
  18. data/lib/charming/components/viewport.rb +191 -0
  19. data/lib/charming/controller.rb +523 -0
  20. data/lib/charming/focus.rb +65 -0
  21. data/lib/charming/generators/app_file_generator.rb +28 -0
  22. data/lib/charming/generators/app_generator/app_spec_templates.rb +86 -0
  23. data/lib/charming/generators/app_generator/basic_templates.rb +69 -0
  24. data/lib/charming/generators/app_generator/component_templates.rb +36 -0
  25. data/lib/charming/generators/app_generator/controller_template.rb +69 -0
  26. data/lib/charming/generators/app_generator/layout_template.rb +160 -0
  27. data/lib/charming/generators/app_generator/model_templates.rb +30 -0
  28. data/lib/charming/generators/app_generator/screen_spec_templates.rb +70 -0
  29. data/lib/charming/generators/app_generator/view_template.rb +90 -0
  30. data/lib/charming/generators/app_generator.rb +76 -0
  31. data/lib/charming/generators/base.rb +29 -0
  32. data/lib/charming/generators/component_generator.rb +30 -0
  33. data/lib/charming/generators/controller_generator.rb +50 -0
  34. data/lib/charming/generators/name.rb +32 -0
  35. data/lib/charming/generators/screen_generator.rb +154 -0
  36. data/lib/charming/generators/view_generator.rb +34 -0
  37. data/lib/charming/generators.rb +7 -0
  38. data/lib/charming/internal/renderer/differential.rb +53 -0
  39. data/lib/charming/internal/renderer/full_repaint.rb +19 -0
  40. data/lib/charming/internal/terminal/adapter.rb +52 -0
  41. data/lib/charming/internal/terminal/memory_backend.rb +91 -0
  42. data/lib/charming/internal/terminal/tty_backend.rb +250 -0
  43. data/lib/charming/key_event.rb +13 -0
  44. data/lib/charming/mouse_event.rb +40 -0
  45. data/lib/charming/resize_event.rb +7 -0
  46. data/lib/charming/response.rb +33 -0
  47. data/lib/charming/router.rb +137 -0
  48. data/lib/charming/runtime.rb +192 -0
  49. data/lib/charming/screen.rb +8 -0
  50. data/lib/charming/task.rb +7 -0
  51. data/lib/charming/task_event.rb +17 -0
  52. data/lib/charming/task_executor.rb +62 -0
  53. data/lib/charming/timer_event.rb +7 -0
  54. data/lib/charming/ui/border.rb +33 -0
  55. data/lib/charming/ui/style.rb +244 -0
  56. data/lib/charming/ui/theme.rb +178 -0
  57. data/lib/charming/ui/themes/phosphor.json +100 -0
  58. data/lib/charming/ui/width.rb +24 -0
  59. data/lib/charming/ui.rb +230 -0
  60. data/lib/charming/version.rb +5 -0
  61. data/lib/charming/view.rb +116 -0
  62. data/lib/charming.rb +24 -0
  63. data/sig/charming.rbs +3 -0
  64. metadata +225 -0
@@ -0,0 +1,244 @@
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
@@ -0,0 +1,178 @@
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
@@ -0,0 +1,100 @@
1
+ {
2
+ "name": "Phosphor",
3
+ "id": "phosphor",
4
+ "background": "background",
5
+ "palette": {
6
+ "bright": "#9FE8B0",
7
+ "muted": "#5A8A68",
8
+ "subtle": "#788E80",
9
+ "background": "#111A2C",
10
+ "selected": "#18233D",
11
+ "divider": "#2A3752",
12
+ "amber": "#FFB347",
13
+ "cyan": "#6FD0E3"
14
+ },
15
+ "styles": {
16
+ "text": {
17
+ "foreground": "bright",
18
+ "background": "background"
19
+ },
20
+ "muted": {
21
+ "foreground": "muted",
22
+ "background": "background"
23
+ },
24
+ "title": {
25
+ "foreground": "amber",
26
+ "background": "background",
27
+ "bold": true
28
+ },
29
+ "selected": {
30
+ "foreground": "bright",
31
+ "background": "selected",
32
+ "bold": true
33
+ },
34
+ "header": {
35
+ "foreground": "bright",
36
+ "background": "background",
37
+ "border": {
38
+ "style": "normal",
39
+ "sides": ["bottom"],
40
+ "foreground": "divider"
41
+ },
42
+ "padding": [0, 1]
43
+ },
44
+ "header_accent": {
45
+ "foreground": "amber",
46
+ "background": "background",
47
+ "bold": true
48
+ },
49
+ "sidebar": {
50
+ "background": "background",
51
+ "border": {
52
+ "style": "normal",
53
+ "sides": ["right"],
54
+ "foreground": "divider"
55
+ },
56
+ "padding": [1, 1]
57
+ },
58
+ "main": {
59
+ "foreground": "bright",
60
+ "background": "background",
61
+ "padding": [1, 2]
62
+ },
63
+ "footer": {
64
+ "foreground": "subtle",
65
+ "background": "background",
66
+ "border": {
67
+ "style": "normal",
68
+ "sides": ["top"],
69
+ "foreground": "divider"
70
+ },
71
+ "padding": [0, 1]
72
+ },
73
+ "modal": {
74
+ "foreground": "bright",
75
+ "background": "background",
76
+ "border": {
77
+ "style": "rounded",
78
+ "foreground": "amber"
79
+ },
80
+ "padding": [1, 2]
81
+ },
82
+ "palette_accent": {
83
+ "foreground": "amber",
84
+ "background": "background",
85
+ "bold": true
86
+ },
87
+ "border": {
88
+ "foreground": "divider",
89
+ "background": "background"
90
+ },
91
+ "info": {
92
+ "foreground": "cyan",
93
+ "background": "background"
94
+ },
95
+ "warn": {
96
+ "foreground": "amber",
97
+ "background": "background"
98
+ }
99
+ }
100
+ }
@@ -0,0 +1,24 @@
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