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.
- checksums.yaml +4 -4
- data/README.md +38 -378
- data/lib/charming/application.rb +14 -3
- data/lib/charming/{application_model.rb → application_state.rb} +3 -3
- data/lib/charming/cli.rb +62 -3
- data/lib/charming/controller/class_methods.rb +115 -0
- data/lib/charming/controller/command_palette.rb +135 -0
- data/lib/charming/controller/component_dispatching.rb +81 -0
- data/lib/charming/controller/dispatching.rb +60 -0
- data/lib/charming/controller/focus_management.rb +30 -0
- data/lib/charming/controller/rendering.rb +127 -0
- data/lib/charming/controller/session_state.rb +41 -0
- data/lib/charming/controller/sidebar_navigation.rb +111 -0
- data/lib/charming/controller.rb +46 -448
- data/lib/charming/database_commands.rb +103 -0
- data/lib/charming/database_installer.rb +152 -0
- data/lib/charming/events/key_event.rb +15 -0
- data/lib/charming/events/mouse_event.rb +42 -0
- data/lib/charming/events/resize_event.rb +9 -0
- data/lib/charming/events/task_event.rb +19 -0
- data/lib/charming/events/timer_event.rb +9 -0
- data/lib/charming/focus.rb +58 -2
- data/lib/charming/generators/app_file_generator.rb +13 -0
- data/lib/charming/generators/app_generator.rb +147 -45
- data/lib/charming/generators/base.rb +26 -0
- data/lib/charming/generators/component_generator.rb +10 -10
- data/lib/charming/generators/controller_generator.rb +22 -14
- data/lib/charming/generators/model_generator.rb +128 -0
- data/lib/charming/generators/name.rb +10 -4
- data/lib/charming/generators/screen_generator.rb +84 -52
- data/lib/charming/generators/templates/app/Gemfile.template +5 -0
- data/lib/charming/generators/templates/app/README.md.template +9 -0
- data/lib/charming/generators/templates/app/Rakefile.template +3 -0
- data/lib/charming/generators/templates/app/application.template +13 -0
- data/lib/charming/generators/templates/app/application_controller.template +19 -0
- data/lib/charming/generators/templates/app/application_record.template +7 -0
- data/lib/charming/generators/templates/app/application_state.template +6 -0
- data/lib/charming/generators/templates/app/database_config.template +12 -0
- data/lib/charming/generators/templates/app/executable.template +7 -0
- data/lib/charming/generators/templates/app/gemspec.template +6 -0
- data/lib/charming/generators/templates/app/home_controller.template +6 -0
- data/lib/charming/generators/templates/app/home_state.template +7 -0
- data/lib/charming/generators/templates/app/keep.template +0 -0
- data/lib/charming/generators/templates/app/layout.template +113 -0
- data/lib/charming/generators/templates/app/root_file.template +20 -0
- data/lib/charming/generators/templates/app/routes.template +5 -0
- data/lib/charming/generators/templates/app/seeds.template +1 -0
- data/lib/charming/generators/templates/app/spec_controller.template +17 -0
- data/lib/charming/generators/templates/app/spec_helper.template +3 -0
- data/lib/charming/generators/templates/app/spec_state.template +17 -0
- data/lib/charming/generators/templates/app/spec_view.template +16 -0
- data/lib/charming/generators/templates/app/version.template +5 -0
- data/lib/charming/generators/templates/app/view.template +21 -0
- data/lib/charming/generators/templates/component/component.rb.template +9 -0
- data/lib/charming/generators/templates/controller/controller.rb.template +6 -0
- data/lib/charming/generators/templates/model/migration.rb.template +9 -0
- data/lib/charming/generators/templates/model/model.rb.template +6 -0
- data/lib/charming/generators/templates/model/spec.rb.template +9 -0
- data/lib/charming/generators/templates/screen/controller.rb.template +7 -0
- data/lib/charming/generators/templates/screen/spec_controller.rb.template +17 -0
- data/lib/charming/generators/templates/screen/spec_state.rb.template +17 -0
- data/lib/charming/generators/templates/screen/spec_view.rb.template +13 -0
- data/lib/charming/generators/templates/screen/state.rb.template +7 -0
- data/lib/charming/generators/templates/screen/view.rb.template +11 -0
- data/lib/charming/generators/templates/view/view.rb.template +11 -0
- data/lib/charming/generators/view_generator.rb +26 -13
- data/lib/charming/internal/renderer/differential.rb +17 -3
- data/lib/charming/internal/renderer/full_repaint.rb +6 -0
- data/lib/charming/internal/terminal/adapter.rb +29 -3
- data/lib/charming/internal/terminal/key_normalizer.rb +84 -0
- data/lib/charming/internal/terminal/memory_backend.rb +28 -1
- data/lib/charming/internal/terminal/mouse_parser.rb +81 -0
- data/lib/charming/internal/terminal/tty_backend.rb +62 -115
- data/lib/charming/presentation/component.rb +10 -0
- data/lib/charming/presentation/components/activity_indicator.rb +160 -0
- data/lib/charming/presentation/components/command_palette.rb +120 -0
- data/lib/charming/presentation/components/empty_state.rb +56 -0
- data/lib/charming/presentation/components/form/builder.rb +62 -0
- data/lib/charming/presentation/components/form/confirm.rb +69 -0
- data/lib/charming/presentation/components/form/field.rb +121 -0
- data/lib/charming/presentation/components/form/input.rb +71 -0
- data/lib/charming/presentation/components/form/note.rb +41 -0
- data/lib/charming/presentation/components/form/select.rb +112 -0
- data/lib/charming/presentation/components/form/textarea.rb +86 -0
- data/lib/charming/presentation/components/form.rb +156 -0
- data/lib/charming/presentation/components/keyboard_handler.rb +58 -0
- data/lib/charming/presentation/components/list.rb +132 -0
- data/lib/charming/presentation/components/markdown.rb +31 -0
- data/lib/charming/presentation/components/modal.rb +64 -0
- data/lib/charming/presentation/components/progressbar.rb +70 -0
- data/lib/charming/presentation/components/spinner.rb +49 -0
- data/lib/charming/presentation/components/table.rb +143 -0
- data/lib/charming/presentation/components/text_area.rb +267 -0
- data/lib/charming/presentation/components/text_input.rb +129 -0
- data/lib/charming/presentation/components/viewport.rb +272 -0
- data/lib/charming/presentation/layout/builder.rb +86 -0
- data/lib/charming/presentation/layout/overlay.rb +57 -0
- data/lib/charming/presentation/layout/pane.rb +145 -0
- data/lib/charming/presentation/layout/rect.rb +23 -0
- data/lib/charming/presentation/layout/screen_layout.rb +60 -0
- data/lib/charming/presentation/layout/split.rb +134 -0
- data/lib/charming/presentation/layout.rb +43 -0
- data/lib/charming/presentation/markdown/block_renderers.rb +120 -0
- data/lib/charming/presentation/markdown/inline_renderers.rb +68 -0
- data/lib/charming/presentation/markdown/render_context.rb +22 -0
- data/lib/charming/presentation/markdown/renderer.rb +113 -0
- data/lib/charming/presentation/markdown/syntax_highlighter.rb +79 -0
- data/lib/charming/presentation/markdown.rb +11 -0
- data/lib/charming/presentation/template_view.rb +34 -0
- data/lib/charming/presentation/templates/erb_handler.rb +15 -0
- data/lib/charming/presentation/templates.rb +68 -0
- data/lib/charming/presentation/ui/ansi_codes.rb +89 -0
- data/lib/charming/presentation/ui/ansi_slicer.rb +94 -0
- data/lib/charming/presentation/ui/border.rb +35 -0
- data/lib/charming/presentation/ui/border_painter.rb +58 -0
- data/lib/charming/presentation/ui/canvas.rb +82 -0
- data/lib/charming/presentation/ui/style.rb +213 -0
- data/lib/charming/presentation/ui/theme.rb +180 -0
- data/lib/charming/{ui → presentation/ui}/themes/phosphor.json +2 -2
- data/lib/charming/presentation/ui/width.rb +26 -0
- data/lib/charming/presentation/ui.rb +91 -0
- data/lib/charming/presentation/view.rb +135 -0
- data/lib/charming/runtime.rb +9 -7
- data/lib/charming/screen.rb +5 -1
- data/lib/charming/tasks/inline_executor.rb +37 -0
- data/lib/charming/tasks/task.rb +12 -0
- data/lib/charming/tasks/threaded_executor.rb +51 -0
- data/lib/charming/version.rb +1 -1
- data/lib/charming.rb +17 -0
- metadata +170 -36
- data/lib/charming/component.rb +0 -8
- data/lib/charming/components/activity_indicator.rb +0 -158
- data/lib/charming/components/command_palette.rb +0 -118
- data/lib/charming/components/keyboard_handler.rb +0 -22
- data/lib/charming/components/list.rb +0 -105
- data/lib/charming/components/modal.rb +0 -48
- data/lib/charming/components/progressbar.rb +0 -55
- data/lib/charming/components/spinner.rb +0 -37
- data/lib/charming/components/table.rb +0 -115
- data/lib/charming/components/text_input.rb +0 -103
- data/lib/charming/components/viewport.rb +0 -191
- data/lib/charming/generators/app_generator/app_spec_templates.rb +0 -86
- data/lib/charming/generators/app_generator/basic_templates.rb +0 -69
- data/lib/charming/generators/app_generator/component_templates.rb +0 -36
- data/lib/charming/generators/app_generator/controller_template.rb +0 -69
- data/lib/charming/generators/app_generator/layout_template.rb +0 -160
- data/lib/charming/generators/app_generator/model_templates.rb +0 -30
- data/lib/charming/generators/app_generator/screen_spec_templates.rb +0 -70
- data/lib/charming/generators/app_generator/view_template.rb +0 -90
- data/lib/charming/key_event.rb +0 -13
- data/lib/charming/mouse_event.rb +0 -40
- data/lib/charming/resize_event.rb +0 -7
- data/lib/charming/task.rb +0 -7
- data/lib/charming/task_event.rb +0 -17
- data/lib/charming/task_executor.rb +0 -62
- data/lib/charming/timer_event.rb +0 -7
- data/lib/charming/ui/border.rb +0 -33
- data/lib/charming/ui/style.rb +0 -244
- data/lib/charming/ui/theme.rb +0 -178
- data/lib/charming/ui/width.rb +0 -24
- data/lib/charming/ui.rb +0 -230
- data/lib/charming/view.rb +0 -116
- /data/lib/charming/{generators.rb → generators/error.rb} +0 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "__APP_SNAKE__"
|
|
4
|
+
|
|
5
|
+
RSpec.describe __APP_CLASS__::__STATE_CLASS__ do
|
|
6
|
+
describe "#title" do
|
|
7
|
+
it "has the correct default string value" do
|
|
8
|
+
instance = described_class.new
|
|
9
|
+
expect(instance.title).to eq("__TITLE__")
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
it "accepts overridden title values" do
|
|
13
|
+
instance = described_class.new(title: "Alternative")
|
|
14
|
+
expect(instance.title).to eq("Alternative")
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "__APP_SNAKE__"
|
|
4
|
+
|
|
5
|
+
RSpec.describe __APP_CLASS__::__RESOURCE_MODULE__::ShowView do
|
|
6
|
+
describe "#render" do
|
|
7
|
+
it "renders the state title" do
|
|
8
|
+
view = described_class.new(__SCREEN_NAME__: double(title: "__TITLE__"))
|
|
9
|
+
|
|
10
|
+
expect(view.render).to eq("__TITLE__")
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -2,32 +2,45 @@
|
|
|
2
2
|
|
|
3
3
|
module Charming
|
|
4
4
|
module Generators
|
|
5
|
+
# ViewGenerator implements `charming generate view NAME [ACTION]`. Writes a single
|
|
6
|
+
# view class file at `app/views/<name>/<action>_view.rb`. The *action* defaults to `show`.
|
|
5
7
|
class ViewGenerator < AppFileGenerator
|
|
8
|
+
# *name* is the resource name. *args* may contain a single action name (defaults to "show").
|
|
9
|
+
def initialize(name, args, out:, destination:, force: false)
|
|
10
|
+
super
|
|
11
|
+
raise Error, "Usage: charming generate view NAME [ACTION]" if args.length > 1
|
|
12
|
+
|
|
13
|
+
@action = args.fetch(0, "show")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Writes the view file to `app/views/<name>/<action>_view.rb`.
|
|
6
17
|
def generate
|
|
7
|
-
create_file(
|
|
18
|
+
create_file(File.join("app", "views", name.snake_name, "#{action}_view.rb"), view)
|
|
8
19
|
end
|
|
9
20
|
|
|
10
21
|
private
|
|
11
22
|
|
|
23
|
+
# The action name (e.g., "show", "edit").
|
|
24
|
+
attr_reader :action
|
|
25
|
+
|
|
26
|
+
# The file-name suffix used by `app_path` (sets "view" so the file is
|
|
27
|
+
# `<name>_view.rb`). Not used directly by this generator but required by the parent.
|
|
12
28
|
def suffix
|
|
13
29
|
"view"
|
|
14
30
|
end
|
|
15
31
|
|
|
32
|
+
# The full source of the generated view class.
|
|
16
33
|
def view
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
end
|
|
23
|
-
end
|
|
24
|
-
)
|
|
34
|
+
render_template("view/view.rb.template",
|
|
35
|
+
app_class: app_name.class_name,
|
|
36
|
+
resource_module: name.class_name,
|
|
37
|
+
resource_name: name.class_name,
|
|
38
|
+
action_view_class: action_class_name)
|
|
25
39
|
end
|
|
26
40
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
end)
|
|
41
|
+
# CamelCase rendering of the action name (e.g., "user_settings" → "UserSettings").
|
|
42
|
+
def action_class_name
|
|
43
|
+
action.split("_").map(&:capitalize).join
|
|
31
44
|
end
|
|
32
45
|
end
|
|
33
46
|
end
|
|
@@ -3,13 +3,22 @@
|
|
|
3
3
|
module Charming
|
|
4
4
|
module Internal
|
|
5
5
|
module Renderer
|
|
6
|
+
# Differential renders frame updates by emitting only the lines that changed since
|
|
7
|
+
# the previous frame. On the first frame it falls back to a full repaint; when no
|
|
8
|
+
# lines changed it returns without writing anything.
|
|
6
9
|
class Differential
|
|
10
|
+
# *output* is the terminal backend (must support `write_lines` for the differential
|
|
11
|
+
# path or `write_frame` for the fallback). *full_renderer* is the FullRepaint used
|
|
12
|
+
# for the initial frame and as a fallback when *output* doesn't support partial writes.
|
|
7
13
|
def initialize(output, full_renderer: FullRepaint.new(output))
|
|
8
14
|
@output = output
|
|
9
15
|
@full_renderer = full_renderer
|
|
10
16
|
@previous_frame = nil
|
|
11
17
|
end
|
|
12
18
|
|
|
19
|
+
# Renders *frame*. The first call performs a full repaint and stores the frame.
|
|
20
|
+
# Subsequent calls compute the per-line diff and emit only changed rows. Returns nil
|
|
21
|
+
# when the frame is identical to the previous one.
|
|
13
22
|
def render(frame)
|
|
14
23
|
frame = frame.to_s
|
|
15
24
|
return render_initial(frame) unless @previous_frame
|
|
@@ -20,11 +29,15 @@ module Charming
|
|
|
20
29
|
|
|
21
30
|
private
|
|
22
31
|
|
|
32
|
+
# Performs the initial full repaint and records the first frame.
|
|
23
33
|
def render_initial(frame)
|
|
24
34
|
@full_renderer.render(frame)
|
|
25
35
|
@previous_frame = frame
|
|
26
36
|
end
|
|
27
37
|
|
|
38
|
+
# Computes the per-line diff against the previous frame, writes the changed lines,
|
|
39
|
+
# and records the new frame. Falls back to a full repaint when the output backend
|
|
40
|
+
# doesn't support partial writes.
|
|
28
41
|
def render_changes(frame)
|
|
29
42
|
changes = changed_lines(@previous_frame, frame)
|
|
30
43
|
return @previous_frame = frame if changes.empty?
|
|
@@ -37,14 +50,15 @@ module Charming
|
|
|
37
50
|
@previous_frame = frame
|
|
38
51
|
end
|
|
39
52
|
|
|
53
|
+
# Returns an array of [1-based-row, line] tuples covering the larger of the two
|
|
54
|
+
# frames' line counts, with empty strings padding the shorter frame.
|
|
40
55
|
def changed_lines(previous_frame, frame)
|
|
41
56
|
previous_lines = previous_frame.lines(chomp: true)
|
|
42
57
|
lines = frame.lines(chomp: true)
|
|
43
58
|
line_count = [previous_lines.length, lines.length].max
|
|
44
59
|
|
|
45
|
-
line_count.times.
|
|
46
|
-
|
|
47
|
-
[index + 1, line] unless previous_lines[index] == line
|
|
60
|
+
line_count.times.map do |index|
|
|
61
|
+
[index + 1, lines[index] || ""]
|
|
48
62
|
end
|
|
49
63
|
end
|
|
50
64
|
end
|
|
@@ -3,11 +3,17 @@
|
|
|
3
3
|
module Charming
|
|
4
4
|
module Internal
|
|
5
5
|
module Renderer
|
|
6
|
+
# FullRepaint clears the screen and rewrites the entire frame on every render. It is
|
|
7
|
+
# used as the initial render path by Differential and as a fallback for backends that
|
|
8
|
+
# don't support partial line writes.
|
|
6
9
|
class FullRepaint
|
|
10
|
+
# *output* is the terminal backend (must support `clear`, `move_cursor`, and
|
|
11
|
+
# `write_frame` per the Adapter contract).
|
|
7
12
|
def initialize(output)
|
|
8
13
|
@output = output
|
|
9
14
|
end
|
|
10
15
|
|
|
16
|
+
# Clears the screen, homes the cursor, and writes the entire *frame* string.
|
|
11
17
|
def render(frame)
|
|
12
18
|
@output.clear
|
|
13
19
|
@output.move_cursor(1, 1)
|
|
@@ -3,46 +3,72 @@
|
|
|
3
3
|
module Charming
|
|
4
4
|
module Internal
|
|
5
5
|
module Terminal
|
|
6
|
-
#
|
|
7
|
-
# Concrete adapters
|
|
8
|
-
#
|
|
6
|
+
# Adapter defines the duck-typed interface that terminal backends must implement.
|
|
7
|
+
# Concrete adapters (`TTYBackend`, `MemoryBackend`) mix this module in and provide
|
|
8
|
+
# the actual implementations; the methods below raise NotImplementedError to make
|
|
9
|
+
# missing implementations fail loudly.
|
|
10
|
+
#
|
|
11
|
+
# Input methods:
|
|
12
|
+
# - `read_event(timeout:)` returns the next event (KeyEvent, MouseEvent, or nil on timeout)
|
|
13
|
+
#
|
|
14
|
+
# Output methods:
|
|
15
|
+
# - `size` returns the [width, height] of the terminal
|
|
16
|
+
# - `enter_alt_screen` / `leave_alt_screen` switch to/from the alternate screen buffer
|
|
17
|
+
# - `hide_cursor` / `show_cursor` toggle the cursor
|
|
18
|
+
# - `clear` clears the screen
|
|
19
|
+
# - `move_cursor(row, column)` positions the cursor (1-based)
|
|
20
|
+
# - `write_frame(frame)` writes a full multi-line frame string
|
|
21
|
+
# - `write_lines(line_changes, frame: nil)` writes a partial frame of [row, line] changes
|
|
9
22
|
module Adapter
|
|
23
|
+
# Reads the next event from the backend. Returns nil when no event is available
|
|
24
|
+
# within *timeout* seconds. Must be implemented by the including class.
|
|
10
25
|
def read_event(timeout: nil)
|
|
11
26
|
raise NotImplementedError, "#{self.class} must implement #read_event"
|
|
12
27
|
end
|
|
13
28
|
|
|
29
|
+
# Returns the current terminal dimensions as [width, height] in cells.
|
|
14
30
|
def size
|
|
15
31
|
raise NotImplementedError, "#{self.class} must implement #size"
|
|
16
32
|
end
|
|
17
33
|
|
|
34
|
+
# Switches the terminal into the alternate screen buffer (used to keep the host
|
|
35
|
+
# terminal scrollback untouched while a TUI app is running).
|
|
18
36
|
def enter_alt_screen
|
|
19
37
|
raise NotImplementedError, "#{self.class} must implement #enter_alt_screen"
|
|
20
38
|
end
|
|
21
39
|
|
|
40
|
+
# Returns the terminal to the primary screen buffer (paired with `enter_alt_screen`).
|
|
22
41
|
def leave_alt_screen
|
|
23
42
|
raise NotImplementedError, "#{self.class} must implement #leave_alt_screen"
|
|
24
43
|
end
|
|
25
44
|
|
|
45
|
+
# Hides the terminal cursor.
|
|
26
46
|
def hide_cursor
|
|
27
47
|
raise NotImplementedError, "#{self.class} must implement #hide_cursor"
|
|
28
48
|
end
|
|
29
49
|
|
|
50
|
+
# Shows the terminal cursor.
|
|
30
51
|
def show_cursor
|
|
31
52
|
raise NotImplementedError, "#{self.class} must implement #show_cursor"
|
|
32
53
|
end
|
|
33
54
|
|
|
55
|
+
# Clears the entire screen and homes the cursor.
|
|
34
56
|
def clear
|
|
35
57
|
raise NotImplementedError, "#{self.class} must implement #clear"
|
|
36
58
|
end
|
|
37
59
|
|
|
60
|
+
# Moves the cursor to the given 1-based (row, column) position.
|
|
38
61
|
def move_cursor(row, column)
|
|
39
62
|
raise NotImplementedError, "#{self.class} must implement #move_cursor"
|
|
40
63
|
end
|
|
41
64
|
|
|
65
|
+
# Writes a full multi-line frame string to the terminal in one operation.
|
|
42
66
|
def write_frame(frame)
|
|
43
67
|
raise NotImplementedError, "#{self.class} must implement #write_frame"
|
|
44
68
|
end
|
|
45
69
|
|
|
70
|
+
# Writes a partial frame composed of [row, line] tuples. Optional *frame:* is the
|
|
71
|
+
# full frame string for backends that want to track it (e.g., the MemoryBackend).
|
|
46
72
|
def write_lines(line_changes, frame: nil)
|
|
47
73
|
raise NotImplementedError, "#{self.class} must implement #write_lines"
|
|
48
74
|
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Internal
|
|
5
|
+
module Terminal
|
|
6
|
+
# KeyNormalizer turns raw keypress strings (from tty-reader) into normalized
|
|
7
|
+
# KeyEvent objects with a semantic key symbol and the printable character (when
|
|
8
|
+
# applicable). Handles ctrl-modifier naming, special keys (return, tab, space),
|
|
9
|
+
# and the back-tab (Shift+Tab) variant.
|
|
10
|
+
class KeyNormalizer
|
|
11
|
+
# Matches key names like "ctrl_a" → captures "a" so the modifier can be split out.
|
|
12
|
+
CTRL_KEY_PATTERN = /\Actrl_(?<key>.+)\z/
|
|
13
|
+
|
|
14
|
+
# *reader* is a TTY::Reader used to look up canonical key names from raw keypresses.
|
|
15
|
+
def initialize(reader)
|
|
16
|
+
@reader = reader
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Converts a raw *keypress* string into a KeyEvent. Returns nil when *keypress* is nil.
|
|
20
|
+
def normalize(keypress)
|
|
21
|
+
return nil unless keypress
|
|
22
|
+
|
|
23
|
+
key_name = @reader.console.keys[keypress]
|
|
24
|
+
return character_event(keypress) unless key_name
|
|
25
|
+
|
|
26
|
+
named_event(key_name)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
# Builds a KeyEvent for a raw character keypress (no semantic name was matched).
|
|
32
|
+
def character_event(keypress)
|
|
33
|
+
Events::KeyEvent.new(key: keypress.to_sym, char: keypress)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Builds a KeyEvent for a named key, splitting out modifiers and the printable char.
|
|
37
|
+
def named_event(key_name)
|
|
38
|
+
normalized = normalize_key_name(key_name)
|
|
39
|
+
Events::KeyEvent.new(
|
|
40
|
+
key: normalized.fetch(:key),
|
|
41
|
+
char: normalized.fetch(:char, nil),
|
|
42
|
+
ctrl: normalized.fetch(:ctrl, false),
|
|
43
|
+
alt: normalized.fetch(:alt, false),
|
|
44
|
+
shift: normalized.fetch(:shift, false)
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Splits a key name into its semantic key, printable char, and modifier flags.
|
|
49
|
+
def normalize_key_name(key_name)
|
|
50
|
+
name = key_name.to_s
|
|
51
|
+
return ctrl_key(name) if name.match?(CTRL_KEY_PATTERN)
|
|
52
|
+
return {key: :tab, shift: true} if name == "back_tab"
|
|
53
|
+
|
|
54
|
+
{key: normalized_key(name), char: printable_char(name)}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Returns the semantic key symbol for *name* (e.g., "return" → :enter).
|
|
58
|
+
def normalized_key(name)
|
|
59
|
+
return :enter if name == "return"
|
|
60
|
+
|
|
61
|
+
name.to_sym
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Returns a key/ctrl-modifier pair for a `ctrl_*` key name.
|
|
65
|
+
def ctrl_key(name)
|
|
66
|
+
match = name.match(CTRL_KEY_PATTERN)
|
|
67
|
+
{key: match[:key].to_sym, ctrl: true}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Returns the printable character for *name* (e.g., "space" → " "), or nil when the
|
|
71
|
+
# name has no single-character printable form.
|
|
72
|
+
def printable_char(name)
|
|
73
|
+
case name
|
|
74
|
+
when "space" then " "
|
|
75
|
+
when "enter", "return" then "\n"
|
|
76
|
+
when "tab" then "\t"
|
|
77
|
+
else
|
|
78
|
+
name if name.length == 1 && !name.match?(/[[:cntrl:]]/)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -3,11 +3,21 @@
|
|
|
3
3
|
module Charming
|
|
4
4
|
module Internal
|
|
5
5
|
module Terminal
|
|
6
|
+
# MemoryBackend is an in-memory implementation of the terminal Adapter used by
|
|
7
|
+
# RSpec specs. It serves events from a fixed `events:` list and records every
|
|
8
|
+
# output operation in `frames` (rendered output) and `operations` (every method
|
|
9
|
+
# call with its arguments), so tests can assert against observed output.
|
|
6
10
|
class MemoryBackend
|
|
7
11
|
include Adapter
|
|
8
12
|
|
|
9
|
-
|
|
13
|
+
# The array of rendered frame strings (one per `write_frame` or `write_lines` call).
|
|
14
|
+
attr_reader :frames
|
|
10
15
|
|
|
16
|
+
# The array of recorded operation tuples: [:method_name, *args].
|
|
17
|
+
attr_reader :operations
|
|
18
|
+
|
|
19
|
+
# *events* is the queue of pre-seeded events to return from `read_event`.
|
|
20
|
+
# *width*/*height* set the initial terminal dimensions reported by `size`.
|
|
11
21
|
def initialize(events: [], width: 80, height: 24)
|
|
12
22
|
@events = events.dup
|
|
13
23
|
@width = width
|
|
@@ -17,67 +27,84 @@ module Charming
|
|
|
17
27
|
@mouse_enabled = false
|
|
18
28
|
end
|
|
19
29
|
|
|
30
|
+
# Pops the next pre-seeded event from the queue. Returns nil when the queue is empty.
|
|
20
31
|
def read_event(timeout: nil)
|
|
21
32
|
@operations << [:read_event, timeout]
|
|
22
33
|
@events.shift
|
|
23
34
|
end
|
|
24
35
|
|
|
36
|
+
# Stores *frame* as the current frame and appends it to `frames`.
|
|
25
37
|
def write_frame(frame)
|
|
26
38
|
@current_frame = frame
|
|
27
39
|
@frames << frame
|
|
28
40
|
@operations << [:write_frame, frame]
|
|
29
41
|
end
|
|
30
42
|
|
|
43
|
+
# Applies the [row, line] *line_changes* to the current frame, then stores and
|
|
44
|
+
# records the result. The full frame is taken from the optional *frame:* argument
|
|
45
|
+
# (when provided) or built by overlaying the changes on the previous frame.
|
|
31
46
|
def write_lines(line_changes, frame: nil)
|
|
32
47
|
@current_frame = frame || apply_line_changes(line_changes)
|
|
33
48
|
@frames << @current_frame
|
|
34
49
|
@operations << [:write_lines, line_changes]
|
|
35
50
|
end
|
|
36
51
|
|
|
52
|
+
# Records an enter-alt-screen operation.
|
|
37
53
|
def enter_alt_screen
|
|
38
54
|
@operations << :enter_alt_screen
|
|
39
55
|
end
|
|
40
56
|
|
|
57
|
+
# Records a leave-alt-screen operation.
|
|
41
58
|
def leave_alt_screen
|
|
42
59
|
@operations << :leave_alt_screen
|
|
43
60
|
end
|
|
44
61
|
|
|
62
|
+
# Records a show-cursor operation.
|
|
45
63
|
def show_cursor
|
|
46
64
|
@operations << :show_cursor
|
|
47
65
|
end
|
|
48
66
|
|
|
67
|
+
# Records a hide-cursor operation.
|
|
49
68
|
def hide_cursor
|
|
50
69
|
@operations << :hide_cursor
|
|
51
70
|
end
|
|
52
71
|
|
|
72
|
+
# Records a clear-screen operation.
|
|
53
73
|
def clear
|
|
54
74
|
@operations << :clear
|
|
55
75
|
end
|
|
56
76
|
|
|
77
|
+
# Records a move-cursor operation at the given (row, column) (1-based).
|
|
57
78
|
def move_cursor(row, column)
|
|
58
79
|
@operations << [:move_cursor, row, column]
|
|
59
80
|
end
|
|
60
81
|
|
|
82
|
+
# Returns the configured terminal dimensions as [width, height].
|
|
61
83
|
def size
|
|
62
84
|
[@width, @height]
|
|
63
85
|
end
|
|
64
86
|
|
|
87
|
+
# Marks the backend as having mouse tracking enabled and records the operation.
|
|
65
88
|
def enable_mouse_tracking
|
|
66
89
|
@mouse_enabled = true
|
|
67
90
|
@operations << :enable_mouse_tracking
|
|
68
91
|
end
|
|
69
92
|
|
|
93
|
+
# Marks the backend as having mouse tracking disabled and records the operation.
|
|
70
94
|
def disable_mouse_tracking
|
|
71
95
|
@mouse_enabled = false
|
|
72
96
|
@operations << :disable_mouse_tracking
|
|
73
97
|
end
|
|
74
98
|
|
|
99
|
+
# Returns whether mouse tracking is currently enabled.
|
|
75
100
|
def mouse_enabled?
|
|
76
101
|
@mouse_enabled
|
|
77
102
|
end
|
|
78
103
|
|
|
79
104
|
private
|
|
80
105
|
|
|
106
|
+
# Overlays each [row, line] from *line_changes* onto a copy of the current frame
|
|
107
|
+
# (1-based row indexing). Used when `write_lines` is called without a *frame:* argument.
|
|
81
108
|
def apply_line_changes(line_changes)
|
|
82
109
|
lines = @current_frame.to_s.lines(chomp: true)
|
|
83
110
|
line_changes.each do |row, line|
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Internal
|
|
5
|
+
module Terminal
|
|
6
|
+
# MouseParser parses raw terminal escape sequences into MouseEvent objects.
|
|
7
|
+
# Supports both modern SGR sequences (the most common, used by current terminals)
|
|
8
|
+
# and the older 3-byte legacy sequences. The public API is class methods; no
|
|
9
|
+
# instance state is required.
|
|
10
|
+
class MouseParser
|
|
11
|
+
# Matches an SGR-encoded mouse sequence: "\e[<button;col;row[mode]M"
|
|
12
|
+
SGR_PATTERN = /\e\[<(\d+);(\d+);(\d+)([HmMhCc]?)(M|m)/
|
|
13
|
+
|
|
14
|
+
# Matches the legacy 3-byte mouse sequence: "\e[M" followed by 3 bytes.
|
|
15
|
+
LEGACY_PATTERN = /\e\[M(.{3})/
|
|
16
|
+
|
|
17
|
+
# Maps raw button codes to semantic symbols used by MouseEvent#button_name.
|
|
18
|
+
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
|
+
# Returns true when *raw* looks like a recognizable mouse sequence (SGR or legacy).
|
|
25
|
+
# Lets the TTYBackend short-circuit and dispatch to MouseParser without allocation.
|
|
26
|
+
def self.sequence?(raw)
|
|
27
|
+
return false unless raw.is_a?(String)
|
|
28
|
+
return true if raw.match?(SGR_PATTERN)
|
|
29
|
+
return true if raw.start_with?("\e[M")
|
|
30
|
+
|
|
31
|
+
false
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Parses *raw* into a MouseEvent, or returns nil when the string is not a mouse
|
|
35
|
+
# sequence or cannot be decoded.
|
|
36
|
+
def self.parse(raw)
|
|
37
|
+
return nil unless raw.is_a?(String)
|
|
38
|
+
return parse_sgr(raw) if raw.match?(SGR_PATTERN)
|
|
39
|
+
return parse_legacy(raw) if raw.start_with?("\e[M")
|
|
40
|
+
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Parses an SGR-format mouse sequence. Decodes button code, 1-based (col, row),
|
|
45
|
+
# the modifier "C" (ctrl) and "M" (shift) suffix, and the highlight alt (256-color)
|
|
46
|
+
# sequence as a heuristic for the alt modifier.
|
|
47
|
+
def self.parse_sgr(raw)
|
|
48
|
+
match = raw.match(SGR_PATTERN)
|
|
49
|
+
return nil unless match
|
|
50
|
+
|
|
51
|
+
button_code = match[1].to_i
|
|
52
|
+
col = match[2].to_i - 1
|
|
53
|
+
row = match[3].to_i - 1
|
|
54
|
+
mode = match[4]
|
|
55
|
+
|
|
56
|
+
ctrl = mode == "C"
|
|
57
|
+
alt = raw.include?("\e[38;5;")
|
|
58
|
+
shift = mode == "M"
|
|
59
|
+
|
|
60
|
+
Events::MouseEvent.new(button: button_code, x: col, y: row, ctrl: ctrl, alt: alt, shift: shift)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Parses a legacy 3-byte mouse sequence. Each of the 3 bytes has 32 subtracted
|
|
64
|
+
# to recover the (button, col, row) values.
|
|
65
|
+
def self.parse_legacy(raw)
|
|
66
|
+
match = raw.match(LEGACY_PATTERN)
|
|
67
|
+
return nil unless match
|
|
68
|
+
|
|
69
|
+
bytes = match[1].bytes
|
|
70
|
+
return nil unless bytes.length == 3
|
|
71
|
+
|
|
72
|
+
button_code = bytes[0] - 32
|
|
73
|
+
col = bytes[1] - 32
|
|
74
|
+
row = bytes[2] - 32
|
|
75
|
+
|
|
76
|
+
Events::MouseEvent.new(button: button_code, x: col, y: row)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|