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,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Generators
5
+ class Name
6
+ VALID_NAME = /\A[a-z][a-z0-9_]*\z/
7
+
8
+ attr_reader :snake_name
9
+
10
+ def initialize(value)
11
+ @snake_name = value.to_s
12
+ raise Error, "Invalid name: #{value}" unless VALID_NAME.match?(@snake_name)
13
+ end
14
+
15
+ def class_name
16
+ snake_name.split("_").map(&:capitalize).join
17
+ end
18
+
19
+ def controller_class_name
20
+ "#{class_name}Controller"
21
+ end
22
+
23
+ def view_class_name
24
+ "#{class_name}View"
25
+ end
26
+
27
+ def component_class_name
28
+ "#{class_name}Component"
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Generators
5
+ class ScreenGenerator < AppFileGenerator
6
+ include AppGenerator::ScreenSpecTemplates
7
+
8
+ def initialize(name, args, out:, destination:, force: false)
9
+ super
10
+ raise Error, "Usage: charming generate screen NAME" if args.any?
11
+ end
12
+
13
+ def generate
14
+ create_file(model_path, model)
15
+ create_file(controller_path, controller)
16
+ create_file(view_path, view)
17
+ create_file(spec_model_path, spec_model)
18
+ create_file(spec_controller_path, spec_controller)
19
+ create_file(spec_view_path, spec_view)
20
+ insert_route
21
+ insert_command
22
+ end
23
+
24
+ private
25
+
26
+ def suffix
27
+ "screen"
28
+ end
29
+
30
+ def model_path
31
+ File.join("app", "models", "#{name.snake_name}_model.rb")
32
+ end
33
+
34
+ def controller_path
35
+ File.join("app", "controllers", "#{name.snake_name}_controller.rb")
36
+ end
37
+
38
+ def view_path
39
+ File.join("app", "views", "#{name.snake_name}_view.rb")
40
+ end
41
+
42
+ def spec_model_path
43
+ File.join("spec", "models", "#{name.snake_name}_model_spec.rb")
44
+ end
45
+
46
+ def spec_controller_path
47
+ File.join("spec", "controllers", "#{name.snake_name}_controller_spec.rb")
48
+ end
49
+
50
+ def spec_view_path
51
+ File.join("spec", "views", "#{name.snake_name}_view_spec.rb")
52
+ end
53
+
54
+ def route_path
55
+ File.join(destination, "config", "routes.rb")
56
+ end
57
+
58
+ def application_controller_path
59
+ File.join(destination, "app", "controllers", "application_controller.rb")
60
+ end
61
+
62
+ def model
63
+ %(# frozen_string_literal: true
64
+
65
+ module #{app_name.class_name}
66
+ class #{name.class_name}Model < ApplicationModel
67
+ attribute :title, :string, default: "#{name.class_name}"
68
+ end
69
+ end
70
+ )
71
+ end
72
+
73
+ def controller
74
+ %(# frozen_string_literal: true
75
+
76
+ module #{app_name.class_name}
77
+ class #{name.controller_class_name} < ApplicationController
78
+ #{controller_body}
79
+ end
80
+ end
81
+ )
82
+ end
83
+
84
+ def controller_body
85
+ %( def show
86
+ render #{name.view_class_name}.new(
87
+ #{name.snake_name}: #{name.snake_name},
88
+ palette: command_palette,
89
+ screen: screen
90
+ )
91
+ end
92
+
93
+ private
94
+
95
+ def #{name.snake_name}
96
+ model(:#{name.snake_name}, #{name.class_name}Model)
97
+ end)
98
+ end
99
+
100
+ def view
101
+ %(# frozen_string_literal: true
102
+
103
+ module #{app_name.class_name}
104
+ class #{name.view_class_name} < Charming::View
105
+ #{view_body}
106
+ end
107
+ end
108
+ )
109
+ end
110
+
111
+ def view_body
112
+ %( def render
113
+ #{name.snake_name}.title
114
+ end)
115
+ end
116
+
117
+ def insert_route
118
+ route = %( screen "/#{name.snake_name}", to: "#{name.snake_name}#show", title: "#{name.class_name}")
119
+ insert_before_end(route_path, route, "route", "end")
120
+ end
121
+
122
+ def insert_command
123
+ command = %( command "#{name.class_name}" do
124
+ navigate_to "/#{name.snake_name}"
125
+ end)
126
+ insert_before_end(application_controller_path, command, "command", " end")
127
+ end
128
+
129
+ def insert_before_end(path, content, label, end_line)
130
+ raise Error, "Missing file: #{relative_path(path)}" unless File.exist?(path)
131
+
132
+ current = File.read(path)
133
+ return if current.include?(content)
134
+
135
+ lines = current.lines
136
+ index = insertion_index(lines, path, end_line)
137
+ lines.insert(index, "#{content}\n")
138
+ File.write(path, lines.join)
139
+ out.puts "insert #{label} #{relative_path(path)}"
140
+ end
141
+
142
+ def insertion_index(lines, path, end_line)
143
+ index = lines.rindex { |line| line.chomp == end_line }
144
+ raise Error, "Could not update #{relative_path(path)}" unless index
145
+
146
+ index
147
+ end
148
+
149
+ def relative_path(path)
150
+ path.delete_prefix("#{destination}/")
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Generators
5
+ class ViewGenerator < AppFileGenerator
6
+ def generate
7
+ create_file(app_path("app", "views"), view)
8
+ end
9
+
10
+ private
11
+
12
+ def suffix
13
+ "view"
14
+ end
15
+
16
+ def view
17
+ %(# frozen_string_literal: true
18
+
19
+ module #{app_name.class_name}
20
+ class #{name.view_class_name} < Charming::View
21
+ #{view_body}
22
+ end
23
+ end
24
+ )
25
+ end
26
+
27
+ def view_body
28
+ %( def render
29
+ "#{name.class_name}"
30
+ end)
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Generators
5
+ class Error < StandardError; end
6
+ end
7
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Internal
5
+ module Renderer
6
+ class Differential
7
+ def initialize(output, full_renderer: FullRepaint.new(output))
8
+ @output = output
9
+ @full_renderer = full_renderer
10
+ @previous_frame = nil
11
+ end
12
+
13
+ def render(frame)
14
+ frame = frame.to_s
15
+ return render_initial(frame) unless @previous_frame
16
+ return if frame == @previous_frame
17
+
18
+ render_changes(frame)
19
+ end
20
+
21
+ private
22
+
23
+ def render_initial(frame)
24
+ @full_renderer.render(frame)
25
+ @previous_frame = frame
26
+ end
27
+
28
+ def render_changes(frame)
29
+ changes = changed_lines(@previous_frame, frame)
30
+ return @previous_frame = frame if changes.empty?
31
+
32
+ if @output.respond_to?(:write_lines)
33
+ @output.write_lines(changes, frame: frame)
34
+ else
35
+ @full_renderer.render(frame)
36
+ end
37
+ @previous_frame = frame
38
+ end
39
+
40
+ def changed_lines(previous_frame, frame)
41
+ previous_lines = previous_frame.lines(chomp: true)
42
+ lines = frame.lines(chomp: true)
43
+ line_count = [previous_lines.length, lines.length].max
44
+
45
+ line_count.times.filter_map do |index|
46
+ line = lines[index] || ""
47
+ [index + 1, line] unless previous_lines[index] == line
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Internal
5
+ module Renderer
6
+ class FullRepaint
7
+ def initialize(output)
8
+ @output = output
9
+ end
10
+
11
+ def render(frame)
12
+ @output.clear
13
+ @output.move_cursor(1, 1)
14
+ @output.write_frame(frame)
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Internal
5
+ module Terminal
6
+ # Contract for terminal adapters used by Runtime and renderers.
7
+ # Concrete adapters provide input events, terminal dimensions, and output
8
+ # primitives for full or partial frame rendering.
9
+ module Adapter
10
+ def read_event(timeout: nil)
11
+ raise NotImplementedError, "#{self.class} must implement #read_event"
12
+ end
13
+
14
+ def size
15
+ raise NotImplementedError, "#{self.class} must implement #size"
16
+ end
17
+
18
+ def enter_alt_screen
19
+ raise NotImplementedError, "#{self.class} must implement #enter_alt_screen"
20
+ end
21
+
22
+ def leave_alt_screen
23
+ raise NotImplementedError, "#{self.class} must implement #leave_alt_screen"
24
+ end
25
+
26
+ def hide_cursor
27
+ raise NotImplementedError, "#{self.class} must implement #hide_cursor"
28
+ end
29
+
30
+ def show_cursor
31
+ raise NotImplementedError, "#{self.class} must implement #show_cursor"
32
+ end
33
+
34
+ def clear
35
+ raise NotImplementedError, "#{self.class} must implement #clear"
36
+ end
37
+
38
+ def move_cursor(row, column)
39
+ raise NotImplementedError, "#{self.class} must implement #move_cursor"
40
+ end
41
+
42
+ def write_frame(frame)
43
+ raise NotImplementedError, "#{self.class} must implement #write_frame"
44
+ end
45
+
46
+ def write_lines(line_changes, frame: nil)
47
+ raise NotImplementedError, "#{self.class} must implement #write_lines"
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Internal
5
+ module Terminal
6
+ class MemoryBackend
7
+ include Adapter
8
+
9
+ attr_reader :frames, :operations
10
+
11
+ def initialize(events: [], width: 80, height: 24)
12
+ @events = events.dup
13
+ @width = width
14
+ @height = height
15
+ @frames = []
16
+ @operations = []
17
+ @mouse_enabled = false
18
+ end
19
+
20
+ def read_event(timeout: nil)
21
+ @operations << [:read_event, timeout]
22
+ @events.shift
23
+ end
24
+
25
+ def write_frame(frame)
26
+ @current_frame = frame
27
+ @frames << frame
28
+ @operations << [:write_frame, frame]
29
+ end
30
+
31
+ def write_lines(line_changes, frame: nil)
32
+ @current_frame = frame || apply_line_changes(line_changes)
33
+ @frames << @current_frame
34
+ @operations << [:write_lines, line_changes]
35
+ end
36
+
37
+ def enter_alt_screen
38
+ @operations << :enter_alt_screen
39
+ end
40
+
41
+ def leave_alt_screen
42
+ @operations << :leave_alt_screen
43
+ end
44
+
45
+ def show_cursor
46
+ @operations << :show_cursor
47
+ end
48
+
49
+ def hide_cursor
50
+ @operations << :hide_cursor
51
+ end
52
+
53
+ def clear
54
+ @operations << :clear
55
+ end
56
+
57
+ def move_cursor(row, column)
58
+ @operations << [:move_cursor, row, column]
59
+ end
60
+
61
+ def size
62
+ [@width, @height]
63
+ end
64
+
65
+ def enable_mouse_tracking
66
+ @mouse_enabled = true
67
+ @operations << :enable_mouse_tracking
68
+ end
69
+
70
+ def disable_mouse_tracking
71
+ @mouse_enabled = false
72
+ @operations << :disable_mouse_tracking
73
+ end
74
+
75
+ def mouse_enabled?
76
+ @mouse_enabled
77
+ end
78
+
79
+ private
80
+
81
+ def apply_line_changes(line_changes)
82
+ lines = @current_frame.to_s.lines(chomp: true)
83
+ line_changes.each do |row, line|
84
+ lines[row - 1] = line
85
+ end
86
+ lines.join("\n")
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,250 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty-cursor"
4
+ require "tty-reader"
5
+ require "tty-screen"
6
+
7
+ module Charming
8
+ module Internal
9
+ module Terminal
10
+ class TTYBackend
11
+ include Adapter
12
+
13
+ ALT_SCREEN_ON = "\e[?1049h"
14
+ ALT_SCREEN_OFF = "\e[?1049l"
15
+ CTRL_KEY_PATTERN = /\Actrl_(?<key>.+)\z/
16
+ MOUSE_SGR_PATTERN = /\e\[<(\d+);(\d+);(\d+)([HmMhCc]?)(M|m)/
17
+ MOUSE_LEGACY_PATTERN = /\e\[M(.{3})/
18
+ MOUSE_BUTTON_MAP = {
19
+ 0 => :left, 1 => :middle, 2 => :right, 3 => :release,
20
+ 64 => :scroll_up, 65 => :scroll_down,
21
+ 66 => :scroll_up, 67 => :scroll_down
22
+ }.freeze
23
+
24
+ def initialize(input: $stdin, output: $stdout, reader: nil, cursor: TTY::Cursor)
25
+ @input = input
26
+ @output = output
27
+ @reader = reader || TTY::Reader.new(input: input, output: output)
28
+ @cursor = cursor
29
+ @resized = false
30
+ @previous_winch_handler = nil
31
+ @mouse_enabled = false
32
+ end
33
+
34
+ def read_event(timeout: nil)
35
+ return resize_event if resized?
36
+
37
+ raw = @reader.read_keypress(echo: false, raw: true, nonblock: timeout)
38
+ return nil unless raw
39
+
40
+ return mouse_event(raw) if mouse_sequence?(raw)
41
+
42
+ normalize_keypress(raw)
43
+ rescue Errno::EAGAIN, IO::WaitReadable
44
+ nil
45
+ end
46
+
47
+ def install_resize_handler
48
+ @previous_winch_handler = Signal.trap("WINCH") { @resized = true }
49
+ end
50
+
51
+ def install_focus_handler
52
+ # Terminal focus change: some terminals send a special sequence
53
+ # when focus changes. We use this to throttle rendering.
54
+ @previous_focus_handler = Signal.trap("INFO") { @focused = true }
55
+ end
56
+
57
+ def restore_focus_handler
58
+ Signal.trap("INFO", @previous_focus_handler) if @previous_focus_handler
59
+ @previous_focus_handler = nil
60
+ end
61
+
62
+ def restore_resize_handler
63
+ Signal.trap("WINCH", @previous_winch_handler) if @previous_winch_handler
64
+ @previous_winch_handler = nil
65
+ end
66
+
67
+ def enable_mouse_tracking
68
+ return if @mouse_enabled
69
+
70
+ write_control("\e[?1000h")
71
+ write_control("\e[?1002h")
72
+ write_control("\e[?1006h")
73
+ @mouse_enabled = true
74
+ end
75
+
76
+ def disable_mouse_tracking
77
+ return unless @mouse_enabled
78
+
79
+ write_control("\e[?1000l")
80
+ write_control("\e[?1002l")
81
+ write_control("\e[?1003l")
82
+ write_control("\e[?1006l")
83
+ @mouse_enabled = false
84
+ end
85
+
86
+ def mouse_enabled?
87
+ @mouse_enabled
88
+ end
89
+
90
+ def notify_resize
91
+ @resized = true
92
+ end
93
+
94
+ def write_frame(frame)
95
+ @output.write(frame)
96
+ @output.flush
97
+ end
98
+
99
+ def write_lines(line_changes, **)
100
+ write_control(line_changes.map { |row, line| "\e[#{row};1H\e[2K#{line}" }.join)
101
+ end
102
+
103
+ def enter_alt_screen
104
+ write_control(ALT_SCREEN_ON)
105
+ end
106
+
107
+ def leave_alt_screen
108
+ write_control(ALT_SCREEN_OFF)
109
+ end
110
+
111
+ def show_cursor
112
+ write_control(@cursor.show)
113
+ end
114
+
115
+ def hide_cursor
116
+ write_control(@cursor.hide)
117
+ end
118
+
119
+ def clear
120
+ write_control(@cursor.clear_screen)
121
+ end
122
+
123
+ def move_cursor(row, column)
124
+ write_control(@cursor.move_to(column - 1, row - 1))
125
+ end
126
+
127
+ def size = [TTY::Screen.width, TTY::Screen.height]
128
+
129
+ private
130
+
131
+ def mouse_sequence?(raw)
132
+ return false unless raw.is_a?(String)
133
+ return true if raw.match?(MOUSE_SGR_PATTERN)
134
+ return true if raw.start_with?("\e[M")
135
+
136
+ false
137
+ end
138
+
139
+ def mouse_event(raw)
140
+ if raw.match?(MOUSE_SGR_PATTERN)
141
+ parse_sgr_mouse(raw)
142
+ else
143
+ parse_legacy_mouse(raw)
144
+ end
145
+ end
146
+
147
+ def parse_sgr_mouse(raw)
148
+ match = raw.match(MOUSE_SGR_PATTERN)
149
+ return nil unless match
150
+
151
+ # \e[<button>;<col>;<row><mode>M
152
+ button_code = match[1].to_i
153
+ col = match[2].to_i - 1
154
+ row = match[3].to_i - 1
155
+ mode = match[4]
156
+
157
+ ctrl = mode == "C"
158
+ alt = raw.include?("\e[38;5;")
159
+ shift = mode == "M"
160
+
161
+ MouseEvent.new(button: button_code, x: col, y: row, ctrl: ctrl, alt: alt, shift: shift)
162
+ end
163
+
164
+ def parse_legacy_mouse(raw)
165
+ # Legacy format: \e[M + 3 bytes (button, col, row)
166
+ # Each byte is 32 + value (space offset)
167
+ match = raw.match(MOUSE_LEGACY_PATTERN)
168
+ return nil unless match
169
+
170
+ bytes = match[1].bytes
171
+ return nil unless bytes.length == 3
172
+
173
+ button_code = bytes[0] - 32
174
+ col = bytes[1] - 32
175
+ row = bytes[2] - 32
176
+
177
+ MouseEvent.new(button: button_code, x: col, y: row)
178
+ end
179
+
180
+ def resized?
181
+ @resized
182
+ end
183
+
184
+ def resize_event
185
+ @resized = false
186
+ width, height = size
187
+ ResizeEvent.new(width: width, height: height)
188
+ end
189
+
190
+ def normalize_keypress(keypress)
191
+ return nil unless keypress
192
+
193
+ key_name = @reader.console.keys[keypress]
194
+ return character_event(keypress) unless key_name
195
+
196
+ named_event(key_name)
197
+ end
198
+
199
+ def character_event(keypress)
200
+ KeyEvent.new(key: keypress.to_sym, char: keypress)
201
+ end
202
+
203
+ def named_event(key_name)
204
+ normalized = normalize_key_name(key_name)
205
+ KeyEvent.new(
206
+ key: normalized.fetch(:key),
207
+ char: normalized.fetch(:char, nil),
208
+ ctrl: normalized.fetch(:ctrl, false),
209
+ alt: normalized.fetch(:alt, false),
210
+ shift: normalized.fetch(:shift, false)
211
+ )
212
+ end
213
+
214
+ def normalize_key_name(key_name)
215
+ name = key_name.to_s
216
+ return ctrl_key(name) if name.match?(CTRL_KEY_PATTERN)
217
+ return {key: :tab, shift: true} if name == "back_tab"
218
+
219
+ {key: normalized_key(name), char: printable_char(name)}
220
+ end
221
+
222
+ def normalized_key(name)
223
+ return :enter if name == "return"
224
+
225
+ name.to_sym
226
+ end
227
+
228
+ def ctrl_key(name)
229
+ match = name.match(CTRL_KEY_PATTERN)
230
+ {key: match[:key].to_sym, ctrl: true}
231
+ end
232
+
233
+ def printable_char(name)
234
+ case name
235
+ when "space" then " "
236
+ when "enter", "return" then "\n"
237
+ when "tab" then "\t"
238
+ else
239
+ name if name.length == 1 && !name.match?(/[[:cntrl:]]/)
240
+ end
241
+ end
242
+
243
+ def write_control(sequence)
244
+ @output.write(sequence)
245
+ @output.flush
246
+ end
247
+ end
248
+ end
249
+ end
250
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ # KeyEvent represents a terminal key press parsed by the backend. *key* is the normalized semantic
5
+ # action name (e.g., `:up`, `:down`, `:q`), while *char*, *ctrl*, *alt*, and *shift* capture raw
6
+ # input details for custom bindings.
7
+ KeyEvent = Data.define(:key, :char, :ctrl, :alt, :shift) do
8
+ # Constructs a key event with the required *key* symbol, plus optional *char* string and modifier booleans.
9
+ def initialize(key:, char: nil, ctrl: false, alt: false, shift: false)
10
+ super(key: key.to_sym, char: char, ctrl: ctrl, alt: alt, shift: shift)
11
+ end
12
+ end
13
+ end