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
|
@@ -7,63 +7,78 @@ 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"
|
|
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
20
|
|
|
21
|
+
# Escape sequences for disabling/enabling automatic line wrapping during frame writes.
|
|
22
|
+
AUTO_WRAP_OFF = "\e[?7l"
|
|
23
|
+
AUTO_WRAP_ON = "\e[?7h"
|
|
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.
|
|
24
28
|
def initialize(input: $stdin, output: $stdout, reader: nil, cursor: TTY::Cursor)
|
|
25
29
|
@input = input
|
|
26
30
|
@output = output
|
|
27
31
|
@reader = reader || TTY::Reader.new(input: input, output: output)
|
|
28
32
|
@cursor = cursor
|
|
33
|
+
@key_normalizer = KeyNormalizer.new(@reader)
|
|
29
34
|
@resized = false
|
|
30
35
|
@previous_winch_handler = nil
|
|
31
36
|
@mouse_enabled = false
|
|
32
37
|
end
|
|
33
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.
|
|
34
42
|
def read_event(timeout: nil)
|
|
35
43
|
return resize_event if resized?
|
|
36
44
|
|
|
37
45
|
raw = @reader.read_keypress(echo: false, raw: true, nonblock: timeout)
|
|
38
46
|
return nil unless raw
|
|
47
|
+
return MouseParser.parse(raw) if MouseParser.sequence?(raw)
|
|
39
48
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
normalize_keypress(raw)
|
|
49
|
+
@key_normalizer.normalize(raw)
|
|
43
50
|
rescue Errno::EAGAIN, IO::WaitReadable
|
|
44
51
|
nil
|
|
45
52
|
end
|
|
46
53
|
|
|
54
|
+
# Installs a SIGWINCH handler that sets the internal `@resized` flag, returning
|
|
55
|
+
# the previous handler so it can be restored on teardown.
|
|
47
56
|
def install_resize_handler
|
|
48
57
|
@previous_winch_handler = Signal.trap("WINCH") { @resized = true }
|
|
49
58
|
end
|
|
50
59
|
|
|
60
|
+
# Installs a SIGINFO handler that marks the terminal as having received focus.
|
|
61
|
+
# SIGINFO is sent by some terminals (notably macOS Terminal.app) on focus changes.
|
|
51
62
|
def install_focus_handler
|
|
52
63
|
# Terminal focus change: some terminals send a special sequence
|
|
53
64
|
# when focus changes. We use this to throttle rendering.
|
|
54
65
|
@previous_focus_handler = Signal.trap("INFO") { @focused = true }
|
|
55
66
|
end
|
|
56
67
|
|
|
68
|
+
# Restores the previous SIGINFO handler.
|
|
57
69
|
def restore_focus_handler
|
|
58
70
|
Signal.trap("INFO", @previous_focus_handler) if @previous_focus_handler
|
|
59
71
|
@previous_focus_handler = nil
|
|
60
72
|
end
|
|
61
73
|
|
|
74
|
+
# Restores the previous SIGWINCH handler captured by `install_resize_handler`.
|
|
62
75
|
def restore_resize_handler
|
|
63
76
|
Signal.trap("WINCH", @previous_winch_handler) if @previous_winch_handler
|
|
64
77
|
@previous_winch_handler = nil
|
|
65
78
|
end
|
|
66
79
|
|
|
80
|
+
# Emits the ANSI sequences that enable terminal mouse reporting (press, motion, SGR).
|
|
81
|
+
# Idempotent: skipped when mouse tracking is already enabled.
|
|
67
82
|
def enable_mouse_tracking
|
|
68
83
|
return if @mouse_enabled
|
|
69
84
|
|
|
@@ -73,6 +88,7 @@ module Charming
|
|
|
73
88
|
@mouse_enabled = true
|
|
74
89
|
end
|
|
75
90
|
|
|
91
|
+
# Emits the ANSI sequences that disable terminal mouse reporting. Idempotent.
|
|
76
92
|
def disable_mouse_tracking
|
|
77
93
|
return unless @mouse_enabled
|
|
78
94
|
|
|
@@ -83,165 +99,96 @@ module Charming
|
|
|
83
99
|
@mouse_enabled = false
|
|
84
100
|
end
|
|
85
101
|
|
|
102
|
+
# Returns whether mouse tracking is currently enabled on this backend.
|
|
86
103
|
def mouse_enabled?
|
|
87
104
|
@mouse_enabled
|
|
88
105
|
end
|
|
89
106
|
|
|
107
|
+
# Manually flags the backend as resized (used by tests or external integrations).
|
|
90
108
|
def notify_resize
|
|
91
109
|
@resized = true
|
|
92
110
|
end
|
|
93
111
|
|
|
112
|
+
# Writes a full multi-line *frame* to the terminal, disabling auto-wrap during
|
|
113
|
+
# the write so overlong lines don't disturb the screen layout.
|
|
94
114
|
def write_frame(frame)
|
|
95
|
-
|
|
96
|
-
|
|
115
|
+
without_auto_wrap do
|
|
116
|
+
write_positioned_lines(frame.to_s.lines(chomp: true))
|
|
117
|
+
end
|
|
97
118
|
end
|
|
98
119
|
|
|
120
|
+
# Writes a partial frame composed of [row, line] tuples (1-based rows).
|
|
99
121
|
def write_lines(line_changes, **)
|
|
100
|
-
|
|
122
|
+
without_auto_wrap do
|
|
123
|
+
write_control(line_changes.map { |row, line| "\e[#{row};1H\e[2K#{line}" }.join)
|
|
124
|
+
end
|
|
101
125
|
end
|
|
102
126
|
|
|
127
|
+
# Enters the alternate screen buffer.
|
|
103
128
|
def enter_alt_screen
|
|
104
129
|
write_control(ALT_SCREEN_ON)
|
|
105
130
|
end
|
|
106
131
|
|
|
132
|
+
# Leaves the alternate screen buffer.
|
|
107
133
|
def leave_alt_screen
|
|
108
134
|
write_control(ALT_SCREEN_OFF)
|
|
109
135
|
end
|
|
110
136
|
|
|
137
|
+
# Shows the terminal cursor.
|
|
111
138
|
def show_cursor
|
|
112
139
|
write_control(@cursor.show)
|
|
113
140
|
end
|
|
114
141
|
|
|
142
|
+
# Hides the terminal cursor.
|
|
115
143
|
def hide_cursor
|
|
116
144
|
write_control(@cursor.hide)
|
|
117
145
|
end
|
|
118
146
|
|
|
147
|
+
# Clears the terminal screen and moves the cursor to (1, 1).
|
|
119
148
|
def clear
|
|
120
149
|
write_control(@cursor.clear_screen)
|
|
121
150
|
end
|
|
122
151
|
|
|
152
|
+
# Moves the terminal cursor to the given 1-based (row, column).
|
|
123
153
|
def move_cursor(row, column)
|
|
124
154
|
write_control(@cursor.move_to(column - 1, row - 1))
|
|
125
155
|
end
|
|
126
156
|
|
|
157
|
+
# Returns the current terminal dimensions as [width, height] via TTY::Screen.
|
|
127
158
|
def size = [TTY::Screen.width, TTY::Screen.height]
|
|
128
159
|
|
|
129
160
|
private
|
|
130
161
|
|
|
131
|
-
|
|
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
|
-
|
|
162
|
+
# True when the SIGWINCH flag has been set since the last read_event.
|
|
180
163
|
def resized?
|
|
181
164
|
@resized
|
|
182
165
|
end
|
|
183
166
|
|
|
167
|
+
# Consumes the resize flag, measures the current terminal, and returns a ResizeEvent.
|
|
184
168
|
def resize_event
|
|
185
169
|
@resized = false
|
|
186
170
|
width, height = size
|
|
187
|
-
ResizeEvent.new(width: width, height: height)
|
|
171
|
+
Events::ResizeEvent.new(width: width, height: height)
|
|
188
172
|
end
|
|
189
173
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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}
|
|
174
|
+
# Writes a raw escape *sequence* to the output stream and flushes.
|
|
175
|
+
def write_control(sequence)
|
|
176
|
+
@output.write(sequence)
|
|
177
|
+
@output.flush
|
|
231
178
|
end
|
|
232
179
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
when "tab" then "\t"
|
|
238
|
-
else
|
|
239
|
-
name if name.length == 1 && !name.match?(/[[:cntrl:]]/)
|
|
240
|
-
end
|
|
180
|
+
# Writes *lines* one row at a time, with each line preceded by an ANSI cursor
|
|
181
|
+
# position and a clear-to-end-of-line sequence.
|
|
182
|
+
def write_positioned_lines(lines)
|
|
183
|
+
write_control(lines.each_with_index.map { |line, index| "\e[#{index + 1};1H\e[2K#{line}" }.join)
|
|
241
184
|
end
|
|
242
185
|
|
|
243
|
-
|
|
244
|
-
|
|
186
|
+
# Disables auto-wrap, yields, then re-enables it and flushes the output.
|
|
187
|
+
def without_auto_wrap
|
|
188
|
+
@output.write(AUTO_WRAP_OFF)
|
|
189
|
+
yield
|
|
190
|
+
ensure
|
|
191
|
+
@output.write(AUTO_WRAP_ON)
|
|
245
192
|
@output.flush
|
|
246
193
|
end
|
|
247
194
|
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Presentation
|
|
5
|
+
# Component is the base class for all reusable terminal widgets. It inherits from View to gain assigns,
|
|
6
|
+
# helper methods (text, box, row, column, etc.), and rendering via render.
|
|
7
|
+
class Component < View
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Presentation
|
|
5
|
+
module Components
|
|
6
|
+
# ActivityIndicator renders a color-gradient progress or loading indicator
|
|
7
|
+
# as styled text. It produces a fixed-width row of characters whose colors
|
|
8
|
+
# interpolate between two gradient endpoints (or cycle through a single
|
|
9
|
+
# color). A label can be appended after the bar and an ellipsis that cycles
|
|
10
|
+
# through frames, useful for "loading" state display. Call `tick` to advance
|
|
11
|
+
# the frame counter, and call `render` to produce the styled output string.
|
|
12
|
+
class ActivityIndicator < Component
|
|
13
|
+
# Default character pool used for generating each position's character via stable hashing.
|
|
14
|
+
DEFAULT_CHARS = "0123456789abcdefABCDEF~!@#$%^&*+=_".chars.freeze
|
|
15
|
+
|
|
16
|
+
# The default two-color gradient applied across the bar width (red to cyan).
|
|
17
|
+
# The cyan endpoint mirrors the Phosphor theme palette's "cyan" token so the bar
|
|
18
|
+
# remains legible on Phosphor's dark navy background; gradient: accepts raw hex,
|
|
19
|
+
# so callers using a different theme should pass their own endpoints.
|
|
20
|
+
DEFAULT_GRADIENT = ["#ff0000", "#6FD0E3"].freeze
|
|
21
|
+
|
|
22
|
+
# The default label color for ellipsis and text portions when no custom
|
|
23
|
+
# label_style is provided.
|
|
24
|
+
DEFAULT_LABEL_COLOR = "#cccccc"
|
|
25
|
+
|
|
26
|
+
# Ellipsis frame sequence: four states cycle through "., "..", "...", and "" (empty).
|
|
27
|
+
ELLIPSIS_FRAMES = [".", "..", "...", ""].freeze
|
|
28
|
+
|
|
29
|
+
# Number of frames in the animation cycle before the indicator pattern repeats.
|
|
30
|
+
FRAME_COUNT = 10
|
|
31
|
+
|
|
32
|
+
# FNV-1a variant constants used by stable_hash for reproducible character selection per position.
|
|
33
|
+
FNV_OFFSET = 2_166_136_261
|
|
34
|
+
FNV_PRIME = 16_777_619
|
|
35
|
+
FNV_MASK = 0xffffffff
|
|
36
|
+
|
|
37
|
+
attr_reader :width, :label, :index, :seed, :chars, :gradient, :label_style
|
|
38
|
+
|
|
39
|
+
# Initializes a new ActivityIndicator with configurable visual parameters.
|
|
40
|
+
# width — Display width of the gradient bar in characters (minimum 1). Default: 10.
|
|
41
|
+
# label — Optional text label shown adjacent to the indicator.
|
|
42
|
+
# index — Initial frame index for the ellipsis/frame animations. Default: 0.
|
|
43
|
+
# seed — Hash seed that determines which characters appear at each position.
|
|
44
|
+
# chars — Character pool to draw from (default is DEFAULT_CHARS).
|
|
45
|
+
# gradient — Two-element array of hex color strings ["#rrggbb", "#rrggbb"] for interpolation.
|
|
46
|
+
# label_style — A Style object to use for rendering the label text; falls back to a gray foreground.
|
|
47
|
+
def initialize(width: 10, label: nil, index: 0, seed: 0, chars: DEFAULT_CHARS,
|
|
48
|
+
gradient: DEFAULT_GRADIENT, label_style: nil)
|
|
49
|
+
super()
|
|
50
|
+
raise ArgumentError, "chars cannot be empty" if chars.empty?
|
|
51
|
+
|
|
52
|
+
@width = [width.to_i, 1].max
|
|
53
|
+
@label = label
|
|
54
|
+
@index = index.to_i
|
|
55
|
+
@seed = seed
|
|
56
|
+
@chars = chars.map(&:to_s)
|
|
57
|
+
@gradient = gradient
|
|
58
|
+
@label_style = label_style
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Advances the frame counter forward by +count+ steps, allowing the displayed pattern to change.
|
|
62
|
+
# Accepts an integer count (converted via +to_i+). Returns self for chaining.
|
|
63
|
+
def tick(count = 1)
|
|
64
|
+
@index += count.to_i
|
|
65
|
+
self
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Renders the activity indicator as a styled string. If a label was provided,
|
|
69
|
+
# produces "bar ellipsis" alongside it; otherwise produces only the gradient bar.
|
|
70
|
+
# Returns a formatted string suitable for terminal rendering.
|
|
71
|
+
def render
|
|
72
|
+
return indicator unless label
|
|
73
|
+
|
|
74
|
+
"#{indicator} #{styled_label}#{styled_ellipsis}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
# Renders the full gradient bar as an array of styled characters joined into a single string.
|
|
80
|
+
# Each character at +position+ is selected by hashing together seed, frame, and position —
|
|
81
|
+
# making the pattern stable across renders — then styled with the interpolated gradient color
|
|
82
|
+
# at that position.
|
|
83
|
+
def indicator
|
|
84
|
+
Array.new(width) { |position| styled_char(position) }.join
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Selects a character for the bar at the given +position+, styles it with the gradient color
|
|
88
|
+
# interpolated for that position, and returns the result as a formatted string via +render+.
|
|
89
|
+
def styled_char(position)
|
|
90
|
+
style.foreground(color_at(position)).render(char_at(position))
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Chooses a character from self.chars by hashing seed:frame:position together with a stable
|
|
94
|
+
# FNV-1a hash. The resulting index is modulated against the character pool length, ensuring
|
|
95
|
+
# reproducible output across renders.
|
|
96
|
+
def char_at(position)
|
|
97
|
+
chars.fetch(stable_hash("#{seed}:#{frame}:#{position}") % chars.length)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Renders the label text in its own style (or fallback gray color) via a Style renderer call.
|
|
101
|
+
def styled_label
|
|
102
|
+
label_style_or_default.render(label.to_s)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Renders an ellipsis frame (".", "..", "...", or empty) based on (index / 4) mod 4, styled with the label style.
|
|
106
|
+
def styled_ellipsis
|
|
107
|
+
label_style_or_default.render(ellipsis_frame)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Returns the current ellipsis frame string: one of ".", "..", "...", "". Cycles through four frames per tick.
|
|
111
|
+
def ellipsis_frame
|
|
112
|
+
ELLIPSIS_FRAMES.fetch((index / 4) % ELLIPSIS_FRAMES.length)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Returns the label style if set, otherwise produces a gray foreground style for fallback rendering.
|
|
116
|
+
def label_style_or_default
|
|
117
|
+
label_style || style.foreground(DEFAULT_LABEL_COLOR)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Interpolates between gradient[0] and gradient[1] at the fractional +position+ (0.0 to 1.0).
|
|
121
|
+
# Returns the first gradient color if width is 1; otherwise returns a blended hex string based on position.
|
|
122
|
+
def color_at(position)
|
|
123
|
+
return gradient.first unless width > 1
|
|
124
|
+
|
|
125
|
+
blend(gradient.first, gradient.last, position / (width - 1).to_f)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Blends two hex colors by interpolating their red/green/blue components at fractional +amount+.
|
|
129
|
+
# Accepts strings like "#ff0000" and produces a new "#rrggbb" string.
|
|
130
|
+
def blend(start_hex, end_hex, amount)
|
|
131
|
+
start_rgb = rgb(start_hex)
|
|
132
|
+
end_rgb = rgb(end_hex)
|
|
133
|
+
mixed = start_rgb.zip(end_rgb).map { |from, to| (from + ((to - from) * amount)).round }
|
|
134
|
+
"#%02x%02x%02x" % mixed
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Decomposes a hex color string ("#rrggbb") into an array of three integers [r, g, b].
|
|
138
|
+
def rgb(hex)
|
|
139
|
+
value = hex.to_s.delete_prefix("#")
|
|
140
|
+
raise ArgumentError, "gradient colors must be #rrggbb" unless value.match?(/\A[0-9a-fA-F]{6}\z/)
|
|
141
|
+
|
|
142
|
+
[value[0..1], value[2..3], value[4..5]].map { |part| part.to_i(16) }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Advances the animation frame counter, wrapping around after +FRAME_COUNT+ (10) steps.
|
|
146
|
+
def frame
|
|
147
|
+
index % FRAME_COUNT
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Produces a deterministic integer hash from the input string using FNV-1a hashing, ensuring the same
|
|
151
|
+
# characters appear at the same positions across multiple renderings of this indicator.
|
|
152
|
+
def stable_hash(value)
|
|
153
|
+
value.bytes.reduce(FNV_OFFSET) do |hash, byte|
|
|
154
|
+
((hash ^ byte) * FNV_PRIME) & FNV_MASK
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Presentation
|
|
5
|
+
module Components
|
|
6
|
+
# CommandPalette renders a fuzzy-searchable command picker UI. It wraps a TextInput for search
|
|
7
|
+
# input and a List for result display, dispatching key events between them. Users type to filter
|
|
8
|
+
# the registered commands by label match, navigate with up/down/home/end keys (delegated to List),
|
|
9
|
+
# confirm a selection with Enter (returns [:selected, command]), or cancel with Escape (returns :cancelled).
|
|
10
|
+
# State is serializable as a hash of value/cursor/selected_index for session persistence.
|
|
11
|
+
class CommandPalette < Component
|
|
12
|
+
Command = Data.define(:label, :value)
|
|
13
|
+
|
|
14
|
+
# A single command palette entry: a human-readable +label+ and a callable or
|
|
15
|
+
# method symbol +value+ that gets executed when the user selects it.
|
|
16
|
+
attr_reader :commands, :input
|
|
17
|
+
|
|
18
|
+
# Initializes the dropdown widget with a list of Command entries and search
|
|
19
|
+
# parameters for building the underlying TextInput (placeholder text, cursor
|
|
20
|
+
# position, value) and List (display height, initial selection). Returns void;
|
|
21
|
+
# the state is later serializable via +state+ for session persistence.
|
|
22
|
+
def initialize(commands:, placeholder: "Search commands", height: nil, value: "", cursor: nil, selected_index: 0, theme: nil)
|
|
23
|
+
super(theme: theme)
|
|
24
|
+
@commands = commands
|
|
25
|
+
@height = height
|
|
26
|
+
@input = TextInput.new(value: value, placeholder: placeholder, cursor: cursor)
|
|
27
|
+
@list = build_list(selected_index: selected_index)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Returns the currently displayed Command entry in the List at the time of calling.
|
|
31
|
+
# Returns nil if no entry is highlighted (i.e., user has opened the palette but not
|
|
32
|
+
# moved the selection). Useful for retrieving the result after key handling.
|
|
33
|
+
def selected_command
|
|
34
|
+
list.selected_item
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Collects the current state of the TextInput and List into a serializable hash
|
|
38
|
+
# suitable for round-trip storage in session. Returns {value:, cursor:, selected_index:}.
|
|
39
|
+
def state
|
|
40
|
+
{
|
|
41
|
+
value: input.value,
|
|
42
|
+
cursor: input.cursor,
|
|
43
|
+
selected_index: list.selected_index
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Handles key events by routing them to the appropriate sub-component: Escape kills the
|
|
48
|
+
# palette returning :cancelled; up/down/home/end keys go to the List selection handler
|
|
49
|
+
# via handle_list_key; all other keys (including typed characters) are passed to the TextInput
|
|
50
|
+
# which manages cursor position and input filtering. If a list key match fails, falls through
|
|
51
|
+
# to the TextInput handler. Returns nil/nil if no handler consumed the event, or :cancelled when
|
|
52
|
+
# Escape is pressed.
|
|
53
|
+
def handle_key(event)
|
|
54
|
+
key = Charming.key_of(event)
|
|
55
|
+
return :cancelled if key == :escape
|
|
56
|
+
|
|
57
|
+
return handle_list_key(event) if list_key?(key)
|
|
58
|
+
|
|
59
|
+
handle_input_key(event)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Renders the command palette as a vertically-stacked text representation: the search TextInput
|
|
63
|
+
# row on line 1, and then the filtered List results (or "No commands found") on subsequent lines.
|
|
64
|
+
# Returns a multiline string suitable for terminal rendering.
|
|
65
|
+
def render
|
|
66
|
+
[input.render, render_results].join("\n")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
attr_reader :height, :list
|
|
72
|
+
|
|
73
|
+
# Delegates key handling entirely to the internal List widget, which manages up/down/home/end selection.
|
|
74
|
+
# Returns whatever the List's handle_key returns (typically nil or the symbol from the subclass).
|
|
75
|
+
def handle_list_key(event)
|
|
76
|
+
list.handle_key(event)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Passes the key event to the TextInput for cursor position and search text management.
|
|
80
|
+
# If the input returns :handled, rebuilds the List so that filtering is re-evaluated against
|
|
81
|
+
# the new input value. Returns nil/nil if no handler consumed the event.
|
|
82
|
+
def handle_input_key(event)
|
|
83
|
+
result = input.handle_key(event)
|
|
84
|
+
@list = build_list if result == :handled
|
|
85
|
+
result
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Checks whether the given key is a List-navigation key (up/down/home/end). Returns true for those keys
|
|
89
|
+
# so they can be dispatched via +handle_list_key+ rather than falling through to TextInput.
|
|
90
|
+
def list_key?(key)
|
|
91
|
+
%i[up down home end enter].include?(key)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Renders the filtered results section below the search input. If no commands match the current filter text,
|
|
95
|
+
# returns "No commands found"; otherwise renders the List widget's styled display string. Returns a single-line string.
|
|
96
|
+
def render_results
|
|
97
|
+
return "No commands found" if filtered_commands.empty?
|
|
98
|
+
|
|
99
|
+
list.render
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Builds a new List from the currently filtered commands at the given selected_index height and label extractor.
|
|
103
|
+
# The +selected_index+ parameter defaults to the last known value in +list+ to preserve scroll position across rebuilds.
|
|
104
|
+
def build_list(selected_index: list&.selected_index || 0)
|
|
105
|
+
List.new(items: filtered_commands, selected_index: selected_index, height: height, label: :label.to_proc, theme: theme)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Returns the full commands array when input value is empty; otherwise a subset whose labels match case-insensitively
|
|
109
|
+
# against the current TextInput value. Used to drive the fuzzy search behavior. Returns an Array of Command entries.
|
|
110
|
+
def filtered_commands
|
|
111
|
+
return commands if input.value.empty?
|
|
112
|
+
|
|
113
|
+
commands.select do |command|
|
|
114
|
+
command.label.downcase.include?(input.value.downcase)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Presentation
|
|
5
|
+
module Components
|
|
6
|
+
# EmptyState is a placeholder component for screens with no content. Renders one of three
|
|
7
|
+
# states: a default "nothing to show" message, a "loading…" message, or an error message
|
|
8
|
+
# with optional help text.
|
|
9
|
+
class EmptyState < Component
|
|
10
|
+
# *message* is shown in the default state. *loading* switches to the loading message
|
|
11
|
+
# (overrides *message*). *loading_message* is the string rendered in the loading state.
|
|
12
|
+
# *error* and *error_message* switch to the error state (the string form takes precedence).
|
|
13
|
+
# *help* is an optional muted line shown below the error message.
|
|
14
|
+
def initialize(message: "Nothing to show.", loading: false, loading_message: "Loading...", error: nil, error_message: nil, help: nil, theme: nil)
|
|
15
|
+
super(theme: theme)
|
|
16
|
+
@message = message
|
|
17
|
+
@loading = loading
|
|
18
|
+
@loading_message = loading_message
|
|
19
|
+
@error = error
|
|
20
|
+
@error_message = error_message
|
|
21
|
+
@help = help
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Renders the appropriate state as styled text: loading → loading message, error →
|
|
25
|
+
# error message + help, otherwise the default message.
|
|
26
|
+
def render
|
|
27
|
+
return loading_state if @loading
|
|
28
|
+
return error_state if error?
|
|
29
|
+
|
|
30
|
+
text @message, style: theme.muted
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# Renders the loading state as a muted line.
|
|
36
|
+
def loading_state
|
|
37
|
+
text @loading_message, style: theme.muted
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Renders the error state: the error message styled with the theme's warn style,
|
|
41
|
+
# optionally followed by a muted help line.
|
|
42
|
+
def error_state
|
|
43
|
+
lines = [text(@error_message || @error.to_s, style: theme.warn)]
|
|
44
|
+
lines << text(@help, style: theme.muted) if @help.to_s.strip != ""
|
|
45
|
+
|
|
46
|
+
column(*lines)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# True when either the *error* or *error_message* string is non-blank.
|
|
50
|
+
def error?
|
|
51
|
+
@error.to_s.strip != "" || @error_message.to_s.strip != ""
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|