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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +421 -0
- data/exe/charming +6 -0
- data/lib/charming/application.rb +90 -0
- data/lib/charming/application_model.rb +13 -0
- data/lib/charming/cli.rb +60 -0
- data/lib/charming/component.rb +8 -0
- data/lib/charming/components/activity_indicator.rb +158 -0
- data/lib/charming/components/command_palette.rb +118 -0
- data/lib/charming/components/keyboard_handler.rb +22 -0
- data/lib/charming/components/list.rb +105 -0
- data/lib/charming/components/modal.rb +48 -0
- data/lib/charming/components/progressbar.rb +55 -0
- data/lib/charming/components/spinner.rb +37 -0
- data/lib/charming/components/table.rb +115 -0
- data/lib/charming/components/text_input.rb +103 -0
- data/lib/charming/components/viewport.rb +191 -0
- data/lib/charming/controller.rb +523 -0
- data/lib/charming/focus.rb +65 -0
- data/lib/charming/generators/app_file_generator.rb +28 -0
- data/lib/charming/generators/app_generator/app_spec_templates.rb +86 -0
- data/lib/charming/generators/app_generator/basic_templates.rb +69 -0
- data/lib/charming/generators/app_generator/component_templates.rb +36 -0
- data/lib/charming/generators/app_generator/controller_template.rb +69 -0
- data/lib/charming/generators/app_generator/layout_template.rb +160 -0
- data/lib/charming/generators/app_generator/model_templates.rb +30 -0
- data/lib/charming/generators/app_generator/screen_spec_templates.rb +70 -0
- data/lib/charming/generators/app_generator/view_template.rb +90 -0
- data/lib/charming/generators/app_generator.rb +76 -0
- data/lib/charming/generators/base.rb +29 -0
- data/lib/charming/generators/component_generator.rb +30 -0
- data/lib/charming/generators/controller_generator.rb +50 -0
- data/lib/charming/generators/name.rb +32 -0
- data/lib/charming/generators/screen_generator.rb +154 -0
- data/lib/charming/generators/view_generator.rb +34 -0
- data/lib/charming/generators.rb +7 -0
- data/lib/charming/internal/renderer/differential.rb +53 -0
- data/lib/charming/internal/renderer/full_repaint.rb +19 -0
- data/lib/charming/internal/terminal/adapter.rb +52 -0
- data/lib/charming/internal/terminal/memory_backend.rb +91 -0
- data/lib/charming/internal/terminal/tty_backend.rb +250 -0
- data/lib/charming/key_event.rb +13 -0
- data/lib/charming/mouse_event.rb +40 -0
- data/lib/charming/resize_event.rb +7 -0
- data/lib/charming/response.rb +33 -0
- data/lib/charming/router.rb +137 -0
- data/lib/charming/runtime.rb +192 -0
- data/lib/charming/screen.rb +8 -0
- data/lib/charming/task.rb +7 -0
- data/lib/charming/task_event.rb +17 -0
- data/lib/charming/task_executor.rb +62 -0
- data/lib/charming/timer_event.rb +7 -0
- data/lib/charming/ui/border.rb +33 -0
- data/lib/charming/ui/style.rb +244 -0
- data/lib/charming/ui/theme.rb +178 -0
- data/lib/charming/ui/themes/phosphor.json +100 -0
- data/lib/charming/ui/width.rb +24 -0
- data/lib/charming/ui.rb +230 -0
- data/lib/charming/version.rb +5 -0
- data/lib/charming/view.rb +116 -0
- data/lib/charming.rb +24 -0
- data/sig/charming.rbs +3 -0
- 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,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
|