charming 0.1.1 → 0.1.3
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 +2 -2
- data/lib/charming/application.rb +14 -3
- data/lib/charming/cli.rb +23 -0
- 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 +35 -559
- data/lib/charming/database_commands.rb +16 -0
- data/lib/charming/database_installer.rb +27 -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 +123 -47
- 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 -11
- data/lib/charming/generators/model_generator.rb +38 -29
- data/lib/charming/generators/name.rb +10 -0
- data/lib/charming/generators/screen_generator.rb +78 -32
- 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 +110 -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 +19 -3
- data/lib/charming/internal/renderer/differential.rb +25 -2
- 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 +65 -115
- data/lib/charming/presentation/component.rb +3 -5
- data/lib/charming/presentation/components/activity_indicator.rb +173 -134
- data/lib/charming/presentation/components/command_palette.rb +94 -96
- data/lib/charming/presentation/components/command_palette_modal.rb +33 -0
- data/lib/charming/presentation/components/empty_state.rb +47 -36
- data/lib/charming/presentation/components/form/builder.rb +52 -40
- data/lib/charming/presentation/components/form/confirm.rb +50 -39
- data/lib/charming/presentation/components/form/field.rb +94 -71
- data/lib/charming/presentation/components/form/input.rb +61 -49
- data/lib/charming/presentation/components/form/note.rb +27 -20
- data/lib/charming/presentation/components/form/select.rb +84 -63
- data/lib/charming/presentation/components/form/textarea.rb +67 -53
- data/lib/charming/presentation/components/form.rb +120 -93
- data/lib/charming/presentation/components/keyboard_handler.rb +41 -43
- data/lib/charming/presentation/components/list.rb +123 -97
- data/lib/charming/presentation/components/markdown.rb +21 -17
- data/lib/charming/presentation/components/modal.rb +55 -43
- data/lib/charming/presentation/components/progressbar.rb +61 -50
- data/lib/charming/presentation/components/spinner.rb +35 -27
- data/lib/charming/presentation/components/table.rb +108 -85
- data/lib/charming/presentation/components/text_area.rb +219 -173
- data/lib/charming/presentation/components/text_input.rb +120 -98
- data/lib/charming/presentation/components/viewport.rb +218 -168
- data/lib/charming/presentation/layout/builder.rb +84 -0
- data/lib/charming/presentation/layout/overlay.rb +55 -0
- data/lib/charming/presentation/layout/pane.rb +149 -0
- data/lib/charming/presentation/layout/rect.rb +21 -0
- data/lib/charming/presentation/layout/screen_layout.rb +58 -0
- data/lib/charming/presentation/layout/split.rb +132 -0
- data/lib/charming/presentation/layout.rb +28 -30
- data/lib/charming/presentation/markdown/block_renderers.rb +118 -0
- data/lib/charming/presentation/markdown/inline_renderers.rb +66 -0
- data/lib/charming/presentation/markdown/render_context.rb +20 -0
- data/lib/charming/presentation/markdown/renderer.rb +82 -174
- data/lib/charming/presentation/markdown/syntax_highlighter.rb +58 -44
- data/lib/charming/presentation/markdown.rb +4 -3
- data/lib/charming/presentation/template_view.rb +22 -17
- data/lib/charming/presentation/templates/erb_handler.rb +4 -6
- data/lib/charming/presentation/templates.rb +47 -32
- data/lib/charming/presentation/ui/ansi_codes.rb +87 -0
- data/lib/charming/presentation/ui/ansi_slicer.rb +92 -0
- data/lib/charming/presentation/ui/border.rb +24 -26
- data/lib/charming/presentation/ui/border_painter.rb +56 -0
- data/lib/charming/presentation/ui/canvas.rb +80 -0
- data/lib/charming/presentation/ui/style.rb +170 -205
- data/lib/charming/presentation/ui/theme.rb +133 -135
- data/lib/charming/presentation/ui/width.rb +12 -14
- data/lib/charming/presentation/ui.rb +70 -213
- data/lib/charming/presentation/view.rb +107 -92
- data/lib/charming/runtime.rb +25 -10
- data/lib/charming/tasks/inline_executor.rb +9 -0
- data/lib/charming/tasks/task.rb +3 -0
- data/lib/charming/tasks/threaded_executor.rb +12 -0
- data/lib/charming/version.rb +1 -1
- data/lib/charming.rb +16 -2
- metadata +60 -10
- data/lib/charming/generators/app_generator/app_spec_templates.rb +0 -90
- data/lib/charming/generators/app_generator/basic_templates.rb +0 -81
- data/lib/charming/generators/app_generator/component_templates.rb +0 -36
- data/lib/charming/generators/app_generator/controller_template.rb +0 -60
- data/lib/charming/generators/app_generator/database_templates.rb +0 -45
- data/lib/charming/generators/app_generator/layout_template.rb +0 -66
- data/lib/charming/generators/app_generator/screen_spec_templates.rb +0 -69
- data/lib/charming/generators/app_generator/state_templates.rb +0 -30
- data/lib/charming/generators/app_generator/view_template.rb +0 -84
|
@@ -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
|
|
@@ -7,65 +7,92 @@ require "tty-screen"
|
|
|
7
7
|
module Charming
|
|
8
8
|
module Internal
|
|
9
9
|
module Terminal
|
|
10
|
+
# TTYBackend is the production terminal backend. It reads key and mouse events from
|
|
11
|
+
# a TTY::Reader, normalizes them via KeyNormalizer and MouseParser, and writes output
|
|
12
|
+
# frames using TTY::Cursor and TTY::Screen. It also installs SIGWINCH and SIGINFO
|
|
13
|
+
# handlers so the runtime can react to terminal resize and focus changes.
|
|
10
14
|
class TTYBackend
|
|
11
15
|
include Adapter
|
|
12
16
|
|
|
17
|
+
# Escape sequences for entering/leaving the alternate screen buffer.
|
|
13
18
|
ALT_SCREEN_ON = "\e[?1049h"
|
|
14
19
|
ALT_SCREEN_OFF = "\e[?1049l"
|
|
20
|
+
|
|
21
|
+
# Escape sequences for disabling/enabling automatic line wrapping during frame writes.
|
|
15
22
|
AUTO_WRAP_OFF = "\e[?7l"
|
|
16
23
|
AUTO_WRAP_ON = "\e[?7h"
|
|
17
|
-
CTRL_KEY_PATTERN = /\Actrl_(?<key>.+)\z/
|
|
18
|
-
MOUSE_SGR_PATTERN = /\e\[<(\d+);(\d+);(\d+)([HmMhCc]?)(M|m)/
|
|
19
|
-
MOUSE_LEGACY_PATTERN = /\e\[M(.{3})/
|
|
20
|
-
MOUSE_BUTTON_MAP = {
|
|
21
|
-
0 => :left, 1 => :middle, 2 => :right, 3 => :release,
|
|
22
|
-
64 => :scroll_up, 65 => :scroll_down,
|
|
23
|
-
66 => :scroll_up, 67 => :scroll_down
|
|
24
|
-
}.freeze
|
|
25
24
|
|
|
25
|
+
# *input* and *output* default to `$stdin`/`$stdout` for normal terminal use;
|
|
26
|
+
# tests can inject IO objects. *reader* is a TTY::Reader instance (created from
|
|
27
|
+
# *input*/*output* when nil). *cursor* is the TTY::Cursor class used for cursor control.
|
|
26
28
|
def initialize(input: $stdin, output: $stdout, reader: nil, cursor: TTY::Cursor)
|
|
27
29
|
@input = input
|
|
28
30
|
@output = output
|
|
29
31
|
@reader = reader || TTY::Reader.new(input: input, output: output)
|
|
30
32
|
@cursor = cursor
|
|
33
|
+
@key_normalizer = KeyNormalizer.new(@reader)
|
|
31
34
|
@resized = false
|
|
32
35
|
@previous_winch_handler = nil
|
|
33
36
|
@mouse_enabled = false
|
|
34
37
|
end
|
|
35
38
|
|
|
39
|
+
# Reads the next event. If a SIGWINCH was received, returns a ResizeEvent with the
|
|
40
|
+
# current terminal dimensions. Mouse escape sequences are parsed by MouseParser;
|
|
41
|
+
# other input is normalized via KeyNormalizer. Returns nil on timeout.
|
|
36
42
|
def read_event(timeout: nil)
|
|
37
43
|
return resize_event if resized?
|
|
38
44
|
|
|
39
45
|
raw = @reader.read_keypress(echo: false, raw: true, nonblock: timeout)
|
|
40
46
|
return nil unless raw
|
|
47
|
+
return MouseParser.parse(raw) if MouseParser.sequence?(raw)
|
|
41
48
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
normalize_keypress(raw)
|
|
49
|
+
@key_normalizer.normalize(raw)
|
|
45
50
|
rescue Errno::EAGAIN, IO::WaitReadable
|
|
46
51
|
nil
|
|
47
52
|
end
|
|
48
53
|
|
|
54
|
+
# Keeps terminal input in raw/no-echo mode for the duration of a TUI run. Reading a
|
|
55
|
+
# single keypress in raw mode is not enough: keys pressed while rendering or dispatching
|
|
56
|
+
# events can otherwise be echoed into the alternate screen before the next read.
|
|
57
|
+
def with_raw_input
|
|
58
|
+
return yield unless @input.respond_to?(:tty?) && @input.tty?
|
|
59
|
+
return yield unless @input.respond_to?(:raw) && @input.respond_to?(:noecho)
|
|
60
|
+
|
|
61
|
+
@input.raw do
|
|
62
|
+
@input.noecho do
|
|
63
|
+
yield
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Installs a SIGWINCH handler that sets the internal `@resized` flag, returning
|
|
69
|
+
# the previous handler so it can be restored on teardown.
|
|
49
70
|
def install_resize_handler
|
|
50
71
|
@previous_winch_handler = Signal.trap("WINCH") { @resized = true }
|
|
51
72
|
end
|
|
52
73
|
|
|
74
|
+
# Installs a SIGINFO handler that marks the terminal as having received focus.
|
|
75
|
+
# SIGINFO is sent by some terminals (notably macOS Terminal.app) on focus changes.
|
|
53
76
|
def install_focus_handler
|
|
54
77
|
# Terminal focus change: some terminals send a special sequence
|
|
55
78
|
# when focus changes. We use this to throttle rendering.
|
|
56
79
|
@previous_focus_handler = Signal.trap("INFO") { @focused = true }
|
|
57
80
|
end
|
|
58
81
|
|
|
82
|
+
# Restores the previous SIGINFO handler.
|
|
59
83
|
def restore_focus_handler
|
|
60
84
|
Signal.trap("INFO", @previous_focus_handler) if @previous_focus_handler
|
|
61
85
|
@previous_focus_handler = nil
|
|
62
86
|
end
|
|
63
87
|
|
|
88
|
+
# Restores the previous SIGWINCH handler captured by `install_resize_handler`.
|
|
64
89
|
def restore_resize_handler
|
|
65
90
|
Signal.trap("WINCH", @previous_winch_handler) if @previous_winch_handler
|
|
66
91
|
@previous_winch_handler = nil
|
|
67
92
|
end
|
|
68
93
|
|
|
94
|
+
# Emits the ANSI sequences that enable terminal mouse reporting (press, motion, SGR).
|
|
95
|
+
# Idempotent: skipped when mouse tracking is already enabled.
|
|
69
96
|
def enable_mouse_tracking
|
|
70
97
|
return if @mouse_enabled
|
|
71
98
|
|
|
@@ -75,6 +102,7 @@ module Charming
|
|
|
75
102
|
@mouse_enabled = true
|
|
76
103
|
end
|
|
77
104
|
|
|
105
|
+
# Emits the ANSI sequences that disable terminal mouse reporting. Idempotent.
|
|
78
106
|
def disable_mouse_tracking
|
|
79
107
|
return unless @mouse_enabled
|
|
80
108
|
|
|
@@ -85,175 +113,97 @@ module Charming
|
|
|
85
113
|
@mouse_enabled = false
|
|
86
114
|
end
|
|
87
115
|
|
|
116
|
+
# Returns whether mouse tracking is currently enabled on this backend.
|
|
88
117
|
def mouse_enabled?
|
|
89
118
|
@mouse_enabled
|
|
90
119
|
end
|
|
91
120
|
|
|
121
|
+
# Manually flags the backend as resized (used by tests or external integrations).
|
|
92
122
|
def notify_resize
|
|
93
123
|
@resized = true
|
|
94
124
|
end
|
|
95
125
|
|
|
126
|
+
# Writes a full multi-line *frame* to the terminal, disabling auto-wrap during
|
|
127
|
+
# the write so overlong lines don't disturb the screen layout.
|
|
96
128
|
def write_frame(frame)
|
|
97
129
|
without_auto_wrap do
|
|
98
130
|
write_positioned_lines(frame.to_s.lines(chomp: true))
|
|
99
131
|
end
|
|
100
132
|
end
|
|
101
133
|
|
|
134
|
+
# Writes a partial frame composed of [row, line] tuples (1-based rows).
|
|
102
135
|
def write_lines(line_changes, **)
|
|
103
136
|
without_auto_wrap do
|
|
104
|
-
write_control(line_changes.map { |row, line|
|
|
137
|
+
write_control(line_changes.map { |row, line| positioned_line(row, line) }.join)
|
|
105
138
|
end
|
|
106
139
|
end
|
|
107
140
|
|
|
141
|
+
# Enters the alternate screen buffer.
|
|
108
142
|
def enter_alt_screen
|
|
109
143
|
write_control(ALT_SCREEN_ON)
|
|
110
144
|
end
|
|
111
145
|
|
|
146
|
+
# Leaves the alternate screen buffer.
|
|
112
147
|
def leave_alt_screen
|
|
113
148
|
write_control(ALT_SCREEN_OFF)
|
|
114
149
|
end
|
|
115
150
|
|
|
151
|
+
# Shows the terminal cursor.
|
|
116
152
|
def show_cursor
|
|
117
153
|
write_control(@cursor.show)
|
|
118
154
|
end
|
|
119
155
|
|
|
156
|
+
# Hides the terminal cursor.
|
|
120
157
|
def hide_cursor
|
|
121
158
|
write_control(@cursor.hide)
|
|
122
159
|
end
|
|
123
160
|
|
|
161
|
+
# Clears the terminal screen and moves the cursor to (1, 1).
|
|
124
162
|
def clear
|
|
125
163
|
write_control(@cursor.clear_screen)
|
|
126
164
|
end
|
|
127
165
|
|
|
166
|
+
# Moves the terminal cursor to the given 1-based (row, column).
|
|
128
167
|
def move_cursor(row, column)
|
|
129
168
|
write_control(@cursor.move_to(column - 1, row - 1))
|
|
130
169
|
end
|
|
131
170
|
|
|
171
|
+
# Returns the current terminal dimensions as [width, height] via TTY::Screen.
|
|
132
172
|
def size = [TTY::Screen.width, TTY::Screen.height]
|
|
133
173
|
|
|
134
174
|
private
|
|
135
175
|
|
|
136
|
-
|
|
137
|
-
return false unless raw.is_a?(String)
|
|
138
|
-
return true if raw.match?(MOUSE_SGR_PATTERN)
|
|
139
|
-
return true if raw.start_with?("\e[M")
|
|
140
|
-
|
|
141
|
-
false
|
|
142
|
-
end
|
|
143
|
-
|
|
144
|
-
def mouse_event(raw)
|
|
145
|
-
if raw.match?(MOUSE_SGR_PATTERN)
|
|
146
|
-
parse_sgr_mouse(raw)
|
|
147
|
-
else
|
|
148
|
-
parse_legacy_mouse(raw)
|
|
149
|
-
end
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
def parse_sgr_mouse(raw)
|
|
153
|
-
match = raw.match(MOUSE_SGR_PATTERN)
|
|
154
|
-
return nil unless match
|
|
155
|
-
|
|
156
|
-
# \e[<button>;<col>;<row><mode>M
|
|
157
|
-
button_code = match[1].to_i
|
|
158
|
-
col = match[2].to_i - 1
|
|
159
|
-
row = match[3].to_i - 1
|
|
160
|
-
mode = match[4]
|
|
161
|
-
|
|
162
|
-
ctrl = mode == "C"
|
|
163
|
-
alt = raw.include?("\e[38;5;")
|
|
164
|
-
shift = mode == "M"
|
|
165
|
-
|
|
166
|
-
Events::MouseEvent.new(button: button_code, x: col, y: row, ctrl: ctrl, alt: alt, shift: shift)
|
|
167
|
-
end
|
|
168
|
-
|
|
169
|
-
def parse_legacy_mouse(raw)
|
|
170
|
-
# Legacy format: \e[M + 3 bytes (button, col, row)
|
|
171
|
-
# Each byte is 32 + value (space offset)
|
|
172
|
-
match = raw.match(MOUSE_LEGACY_PATTERN)
|
|
173
|
-
return nil unless match
|
|
174
|
-
|
|
175
|
-
bytes = match[1].bytes
|
|
176
|
-
return nil unless bytes.length == 3
|
|
177
|
-
|
|
178
|
-
button_code = bytes[0] - 32
|
|
179
|
-
col = bytes[1] - 32
|
|
180
|
-
row = bytes[2] - 32
|
|
181
|
-
|
|
182
|
-
Events::MouseEvent.new(button: button_code, x: col, y: row)
|
|
183
|
-
end
|
|
184
|
-
|
|
176
|
+
# True when the SIGWINCH flag has been set since the last read_event.
|
|
185
177
|
def resized?
|
|
186
178
|
@resized
|
|
187
179
|
end
|
|
188
180
|
|
|
181
|
+
# Consumes the resize flag, measures the current terminal, and returns a ResizeEvent.
|
|
189
182
|
def resize_event
|
|
190
183
|
@resized = false
|
|
191
184
|
width, height = size
|
|
192
185
|
Events::ResizeEvent.new(width: width, height: height)
|
|
193
186
|
end
|
|
194
187
|
|
|
195
|
-
|
|
196
|
-
return nil unless keypress
|
|
197
|
-
|
|
198
|
-
key_name = @reader.console.keys[keypress]
|
|
199
|
-
return character_event(keypress) unless key_name
|
|
200
|
-
|
|
201
|
-
named_event(key_name)
|
|
202
|
-
end
|
|
203
|
-
|
|
204
|
-
def character_event(keypress)
|
|
205
|
-
Events::KeyEvent.new(key: keypress.to_sym, char: keypress)
|
|
206
|
-
end
|
|
207
|
-
|
|
208
|
-
def named_event(key_name)
|
|
209
|
-
normalized = normalize_key_name(key_name)
|
|
210
|
-
Events::KeyEvent.new(
|
|
211
|
-
key: normalized.fetch(:key),
|
|
212
|
-
char: normalized.fetch(:char, nil),
|
|
213
|
-
ctrl: normalized.fetch(:ctrl, false),
|
|
214
|
-
alt: normalized.fetch(:alt, false),
|
|
215
|
-
shift: normalized.fetch(:shift, false)
|
|
216
|
-
)
|
|
217
|
-
end
|
|
218
|
-
|
|
219
|
-
def normalize_key_name(key_name)
|
|
220
|
-
name = key_name.to_s
|
|
221
|
-
return ctrl_key(name) if name.match?(CTRL_KEY_PATTERN)
|
|
222
|
-
return {key: :tab, shift: true} if name == "back_tab"
|
|
223
|
-
|
|
224
|
-
{key: normalized_key(name), char: printable_char(name)}
|
|
225
|
-
end
|
|
226
|
-
|
|
227
|
-
def normalized_key(name)
|
|
228
|
-
return :enter if name == "return"
|
|
229
|
-
|
|
230
|
-
name.to_sym
|
|
231
|
-
end
|
|
232
|
-
|
|
233
|
-
def ctrl_key(name)
|
|
234
|
-
match = name.match(CTRL_KEY_PATTERN)
|
|
235
|
-
{key: match[:key].to_sym, ctrl: true}
|
|
236
|
-
end
|
|
237
|
-
|
|
238
|
-
def printable_char(name)
|
|
239
|
-
case name
|
|
240
|
-
when "space" then " "
|
|
241
|
-
when "enter", "return" then "\n"
|
|
242
|
-
when "tab" then "\t"
|
|
243
|
-
else
|
|
244
|
-
name if name.length == 1 && !name.match?(/[[:cntrl:]]/)
|
|
245
|
-
end
|
|
246
|
-
end
|
|
247
|
-
|
|
188
|
+
# Writes a raw escape *sequence* to the output stream and flushes.
|
|
248
189
|
def write_control(sequence)
|
|
249
190
|
@output.write(sequence)
|
|
250
191
|
@output.flush
|
|
251
192
|
end
|
|
252
193
|
|
|
194
|
+
# Writes *lines* one row at a time, with each line preceded by an ANSI cursor
|
|
195
|
+
# position and a clear-to-end-of-line sequence.
|
|
253
196
|
def write_positioned_lines(lines)
|
|
254
|
-
write_control(lines.each_with_index.map { |line, index|
|
|
197
|
+
write_control(lines.each_with_index.map { |line, index| positioned_line(index + 1, line) }.join)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Resets SGR before and after each row so partial repaint rows cannot inherit
|
|
201
|
+
# colors/backgrounds from the previous physical terminal line.
|
|
202
|
+
def positioned_line(row, line)
|
|
203
|
+
"\e[0m\e[#{row};1H\e[2K#{line}\e[0m"
|
|
255
204
|
end
|
|
256
205
|
|
|
206
|
+
# Disables auto-wrap, yields, then re-enables it and flushes the output.
|
|
257
207
|
def without_auto_wrap
|
|
258
208
|
@output.write(AUTO_WRAP_OFF)
|
|
259
209
|
yield
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Charming
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
class Component < View
|
|
8
|
-
end
|
|
4
|
+
# Component is the base class for all reusable terminal widgets. It inherits from View to gain assigns,
|
|
5
|
+
# helper methods (text, box, row, column, etc.), and rendering via render.
|
|
6
|
+
class Component < View
|
|
9
7
|
end
|
|
10
8
|
end
|