charming 0.1.1 → 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 +2 -2
- data/lib/charming/application.rb +11 -0
- 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 +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 +19 -3
- data/lib/charming/internal/renderer/differential.rb +15 -0
- 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 +43 -113
- data/lib/charming/presentation/components/empty_state.rb +13 -0
- data/lib/charming/presentation/components/form/builder.rb +14 -0
- data/lib/charming/presentation/components/form/confirm.rb +13 -0
- data/lib/charming/presentation/components/form/field.rb +25 -0
- data/lib/charming/presentation/components/form/input.rb +14 -0
- data/lib/charming/presentation/components/form/note.rb +9 -0
- data/lib/charming/presentation/components/form/select.rb +23 -0
- data/lib/charming/presentation/components/form/textarea.rb +16 -0
- data/lib/charming/presentation/components/form.rb +29 -0
- data/lib/charming/presentation/components/list.rb +28 -0
- data/lib/charming/presentation/components/markdown.rb +6 -0
- data/lib/charming/presentation/components/modal.rb +14 -0
- data/lib/charming/presentation/components/progressbar.rb +13 -0
- data/lib/charming/presentation/components/spinner.rb +10 -0
- data/lib/charming/presentation/components/table.rb +25 -0
- data/lib/charming/presentation/components/text_area.rb +48 -0
- data/lib/charming/presentation/components/text_input.rb +24 -0
- data/lib/charming/presentation/components/viewport.rb +52 -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/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 +45 -135
- data/lib/charming/presentation/markdown/syntax_highlighter.rb +16 -0
- data/lib/charming/presentation/markdown.rb +3 -0
- data/lib/charming/presentation/template_view.rb +7 -0
- data/lib/charming/presentation/templates.rb +17 -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_painter.rb +58 -0
- data/lib/charming/presentation/ui/canvas.rb +82 -0
- data/lib/charming/presentation/ui/style.rb +62 -95
- data/lib/charming/presentation/ui.rb +15 -156
- data/lib/charming/presentation/view.rb +17 -0
- data/lib/charming/runtime.rb +2 -0
- 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 +13 -0
- metadata +59 -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,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"
|
|
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
|
+
# Installs a SIGWINCH handler that sets the internal `@resized` flag, returning
|
|
55
|
+
# the previous handler so it can be restored on teardown.
|
|
49
56
|
def install_resize_handler
|
|
50
57
|
@previous_winch_handler = Signal.trap("WINCH") { @resized = true }
|
|
51
58
|
end
|
|
52
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.
|
|
53
62
|
def install_focus_handler
|
|
54
63
|
# Terminal focus change: some terminals send a special sequence
|
|
55
64
|
# when focus changes. We use this to throttle rendering.
|
|
56
65
|
@previous_focus_handler = Signal.trap("INFO") { @focused = true }
|
|
57
66
|
end
|
|
58
67
|
|
|
68
|
+
# Restores the previous SIGINFO handler.
|
|
59
69
|
def restore_focus_handler
|
|
60
70
|
Signal.trap("INFO", @previous_focus_handler) if @previous_focus_handler
|
|
61
71
|
@previous_focus_handler = nil
|
|
62
72
|
end
|
|
63
73
|
|
|
74
|
+
# Restores the previous SIGWINCH handler captured by `install_resize_handler`.
|
|
64
75
|
def restore_resize_handler
|
|
65
76
|
Signal.trap("WINCH", @previous_winch_handler) if @previous_winch_handler
|
|
66
77
|
@previous_winch_handler = nil
|
|
67
78
|
end
|
|
68
79
|
|
|
80
|
+
# Emits the ANSI sequences that enable terminal mouse reporting (press, motion, SGR).
|
|
81
|
+
# Idempotent: skipped when mouse tracking is already enabled.
|
|
69
82
|
def enable_mouse_tracking
|
|
70
83
|
return if @mouse_enabled
|
|
71
84
|
|
|
@@ -75,6 +88,7 @@ module Charming
|
|
|
75
88
|
@mouse_enabled = true
|
|
76
89
|
end
|
|
77
90
|
|
|
91
|
+
# Emits the ANSI sequences that disable terminal mouse reporting. Idempotent.
|
|
78
92
|
def disable_mouse_tracking
|
|
79
93
|
return unless @mouse_enabled
|
|
80
94
|
|
|
@@ -85,175 +99,91 @@ module Charming
|
|
|
85
99
|
@mouse_enabled = false
|
|
86
100
|
end
|
|
87
101
|
|
|
102
|
+
# Returns whether mouse tracking is currently enabled on this backend.
|
|
88
103
|
def mouse_enabled?
|
|
89
104
|
@mouse_enabled
|
|
90
105
|
end
|
|
91
106
|
|
|
107
|
+
# Manually flags the backend as resized (used by tests or external integrations).
|
|
92
108
|
def notify_resize
|
|
93
109
|
@resized = true
|
|
94
110
|
end
|
|
95
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.
|
|
96
114
|
def write_frame(frame)
|
|
97
115
|
without_auto_wrap do
|
|
98
116
|
write_positioned_lines(frame.to_s.lines(chomp: true))
|
|
99
117
|
end
|
|
100
118
|
end
|
|
101
119
|
|
|
120
|
+
# Writes a partial frame composed of [row, line] tuples (1-based rows).
|
|
102
121
|
def write_lines(line_changes, **)
|
|
103
122
|
without_auto_wrap do
|
|
104
123
|
write_control(line_changes.map { |row, line| "\e[#{row};1H\e[2K#{line}" }.join)
|
|
105
124
|
end
|
|
106
125
|
end
|
|
107
126
|
|
|
127
|
+
# Enters the alternate screen buffer.
|
|
108
128
|
def enter_alt_screen
|
|
109
129
|
write_control(ALT_SCREEN_ON)
|
|
110
130
|
end
|
|
111
131
|
|
|
132
|
+
# Leaves the alternate screen buffer.
|
|
112
133
|
def leave_alt_screen
|
|
113
134
|
write_control(ALT_SCREEN_OFF)
|
|
114
135
|
end
|
|
115
136
|
|
|
137
|
+
# Shows the terminal cursor.
|
|
116
138
|
def show_cursor
|
|
117
139
|
write_control(@cursor.show)
|
|
118
140
|
end
|
|
119
141
|
|
|
142
|
+
# Hides the terminal cursor.
|
|
120
143
|
def hide_cursor
|
|
121
144
|
write_control(@cursor.hide)
|
|
122
145
|
end
|
|
123
146
|
|
|
147
|
+
# Clears the terminal screen and moves the cursor to (1, 1).
|
|
124
148
|
def clear
|
|
125
149
|
write_control(@cursor.clear_screen)
|
|
126
150
|
end
|
|
127
151
|
|
|
152
|
+
# Moves the terminal cursor to the given 1-based (row, column).
|
|
128
153
|
def move_cursor(row, column)
|
|
129
154
|
write_control(@cursor.move_to(column - 1, row - 1))
|
|
130
155
|
end
|
|
131
156
|
|
|
157
|
+
# Returns the current terminal dimensions as [width, height] via TTY::Screen.
|
|
132
158
|
def size = [TTY::Screen.width, TTY::Screen.height]
|
|
133
159
|
|
|
134
160
|
private
|
|
135
161
|
|
|
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
|
-
|
|
162
|
+
# True when the SIGWINCH flag has been set since the last read_event.
|
|
185
163
|
def resized?
|
|
186
164
|
@resized
|
|
187
165
|
end
|
|
188
166
|
|
|
167
|
+
# Consumes the resize flag, measures the current terminal, and returns a ResizeEvent.
|
|
189
168
|
def resize_event
|
|
190
169
|
@resized = false
|
|
191
170
|
width, height = size
|
|
192
171
|
Events::ResizeEvent.new(width: width, height: height)
|
|
193
172
|
end
|
|
194
173
|
|
|
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
|
-
|
|
174
|
+
# Writes a raw escape *sequence* to the output stream and flushes.
|
|
248
175
|
def write_control(sequence)
|
|
249
176
|
@output.write(sequence)
|
|
250
177
|
@output.flush
|
|
251
178
|
end
|
|
252
179
|
|
|
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.
|
|
253
182
|
def write_positioned_lines(lines)
|
|
254
183
|
write_control(lines.each_with_index.map { |line, index| "\e[#{index + 1};1H\e[2K#{line}" }.join)
|
|
255
184
|
end
|
|
256
185
|
|
|
186
|
+
# Disables auto-wrap, yields, then re-enables it and flushes the output.
|
|
257
187
|
def without_auto_wrap
|
|
258
188
|
@output.write(AUTO_WRAP_OFF)
|
|
259
189
|
yield
|
|
@@ -3,7 +3,14 @@
|
|
|
3
3
|
module Charming
|
|
4
4
|
module Presentation
|
|
5
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.
|
|
6
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.
|
|
7
14
|
def initialize(message: "Nothing to show.", loading: false, loading_message: "Loading...", error: nil, error_message: nil, help: nil, theme: nil)
|
|
8
15
|
super(theme: theme)
|
|
9
16
|
@message = message
|
|
@@ -14,6 +21,8 @@ module Charming
|
|
|
14
21
|
@help = help
|
|
15
22
|
end
|
|
16
23
|
|
|
24
|
+
# Renders the appropriate state as styled text: loading → loading message, error →
|
|
25
|
+
# error message + help, otherwise the default message.
|
|
17
26
|
def render
|
|
18
27
|
return loading_state if @loading
|
|
19
28
|
return error_state if error?
|
|
@@ -23,10 +32,13 @@ module Charming
|
|
|
23
32
|
|
|
24
33
|
private
|
|
25
34
|
|
|
35
|
+
# Renders the loading state as a muted line.
|
|
26
36
|
def loading_state
|
|
27
37
|
text @loading_message, style: theme.muted
|
|
28
38
|
end
|
|
29
39
|
|
|
40
|
+
# Renders the error state: the error message styled with the theme's warn style,
|
|
41
|
+
# optionally followed by a muted help line.
|
|
30
42
|
def error_state
|
|
31
43
|
lines = [text(@error_message || @error.to_s, style: theme.warn)]
|
|
32
44
|
lines << text(@help, style: theme.muted) if @help.to_s.strip != ""
|
|
@@ -34,6 +46,7 @@ module Charming
|
|
|
34
46
|
column(*lines)
|
|
35
47
|
end
|
|
36
48
|
|
|
49
|
+
# True when either the *error* or *error_message* string is non-blank.
|
|
37
50
|
def error?
|
|
38
51
|
@error.to_s.strip != "" || @error_message.to_s.strip != ""
|
|
39
52
|
end
|
|
@@ -4,40 +4,54 @@ module Charming
|
|
|
4
4
|
module Presentation
|
|
5
5
|
module Components
|
|
6
6
|
class Form
|
|
7
|
+
# Builder collects form field declarations inside a `form(:name) { ... }` block and
|
|
8
|
+
# assembles them into a Form component when `build` is called. Each declaration method
|
|
9
|
+
# appends a Field subclass instance to the builder's *fields* list.
|
|
7
10
|
class Builder
|
|
11
|
+
# The accumulated field list and the theme applied to each declared field.
|
|
8
12
|
attr_reader :fields, :theme
|
|
9
13
|
|
|
14
|
+
# Initializes an empty builder. *theme* is forwarded to every declared field unless
|
|
15
|
+
# the field declaration explicitly overrides it.
|
|
10
16
|
def initialize(theme: nil)
|
|
11
17
|
@theme = theme
|
|
12
18
|
@fields = []
|
|
13
19
|
end
|
|
14
20
|
|
|
21
|
+
# Appends a single-line Input field. *options* are passed through to Input.
|
|
15
22
|
def input(name, **options)
|
|
16
23
|
fields << Input.new(name, **field_options(options))
|
|
17
24
|
end
|
|
18
25
|
|
|
26
|
+
# Appends a multi-line Textarea field.
|
|
19
27
|
def textarea(name, **options)
|
|
20
28
|
fields << Textarea.new(name, **field_options(options))
|
|
21
29
|
end
|
|
22
30
|
|
|
31
|
+
# Appends a Select field with the given *options* array.
|
|
23
32
|
def select(name, **options)
|
|
24
33
|
fields << Select.new(name, **field_options(options))
|
|
25
34
|
end
|
|
26
35
|
|
|
36
|
+
# Appends a Confirm (boolean) field.
|
|
27
37
|
def confirm(name, **options)
|
|
28
38
|
fields << Confirm.new(name, **field_options(options))
|
|
29
39
|
end
|
|
30
40
|
|
|
41
|
+
# Appends a static Note (non-focusable).
|
|
31
42
|
def note(text, **options)
|
|
32
43
|
fields << Note.new(text, **field_options(options))
|
|
33
44
|
end
|
|
34
45
|
|
|
46
|
+
# Assembles the collected fields into a Form component, bound to *state* and using
|
|
47
|
+
# the *theme* argument (falling back to the builder's theme).
|
|
35
48
|
def build(state:, theme: nil)
|
|
36
49
|
Components::Form.new(fields: fields, state: state, theme: theme || self.theme)
|
|
37
50
|
end
|
|
38
51
|
|
|
39
52
|
private
|
|
40
53
|
|
|
54
|
+
# Merges the builder's theme into the per-field *options* so each field receives it.
|
|
41
55
|
def field_options(options)
|
|
42
56
|
{theme: theme}.merge(options)
|
|
43
57
|
end
|
|
@@ -4,12 +4,19 @@ module Charming
|
|
|
4
4
|
module Presentation
|
|
5
5
|
module Components
|
|
6
6
|
class Form
|
|
7
|
+
# Confirm is a boolean Form field that renders a checkbox-style control. Space toggles
|
|
8
|
+
# the value; y/Right sets it to true; n/Left sets it to false. Required confirms must
|
|
9
|
+
# be accepted (value == true) to pass validation.
|
|
7
10
|
class Confirm < Field
|
|
11
|
+
# *value* is the initial boolean state (default: false). All other options are
|
|
12
|
+
# forwarded to Field.
|
|
8
13
|
def initialize(name, value: false, **options)
|
|
9
14
|
super(name, **options)
|
|
10
15
|
@initial_value = value
|
|
11
16
|
end
|
|
12
17
|
|
|
18
|
+
# Handles the standard confirm keys: space toggles, y/right sets to true, n/left
|
|
19
|
+
# sets to false, and a space character (when the event exposes `char`) also toggles.
|
|
13
20
|
def handle_key(event)
|
|
14
21
|
case Charming.key_of(event)
|
|
15
22
|
when :space
|
|
@@ -26,6 +33,8 @@ module Charming
|
|
|
26
33
|
:handled
|
|
27
34
|
end
|
|
28
35
|
|
|
36
|
+
# Returns ["must be accepted"] when required and the value is not true, otherwise
|
|
37
|
+
# the result of the base Field validation.
|
|
29
38
|
def validate
|
|
30
39
|
return ["must be accepted"] if required? && value != true
|
|
31
40
|
|
|
@@ -34,18 +43,22 @@ module Charming
|
|
|
34
43
|
|
|
35
44
|
private
|
|
36
45
|
|
|
46
|
+
# The default value for a freshly-bound field is the *value* passed at construction.
|
|
37
47
|
def default_value
|
|
38
48
|
@initial_value
|
|
39
49
|
end
|
|
40
50
|
|
|
51
|
+
# Renders "[x] Label" or "[ ] Label" depending on the current value.
|
|
41
52
|
def render_control
|
|
42
53
|
"#{checked_marker} #{label}"
|
|
43
54
|
end
|
|
44
55
|
|
|
56
|
+
# Returns the checkbox marker string.
|
|
45
57
|
def checked_marker
|
|
46
58
|
value ? "[x]" : "[ ]"
|
|
47
59
|
end
|
|
48
60
|
|
|
61
|
+
# Flips the current value (true ↔ false).
|
|
49
62
|
def toggle
|
|
50
63
|
state[:values][name] = !value
|
|
51
64
|
end
|