charming 0.2.0 → 0.2.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 +144 -9
- data/lib/charming/audio/player.rb +104 -0
- data/lib/charming/audio/system.rb +69 -0
- data/lib/charming/cli.rb +63 -7
- data/lib/charming/controller/action_hooks.rb +124 -0
- data/lib/charming/controller/class_methods.rb +15 -1
- data/lib/charming/controller/dispatching.rb +31 -5
- data/lib/charming/controller/focus.rb +9 -0
- data/lib/charming/controller/focus_management.rb +0 -7
- data/lib/charming/controller/key_dispatch.rb +113 -0
- data/lib/charming/controller/session_state.rb +27 -1
- data/lib/charming/controller/sidebar_navigation.rb +63 -28
- data/lib/charming/controller/terminal.rb +33 -0
- data/lib/charming/controller.rb +43 -18
- data/lib/charming/database/commands.rb +123 -11
- data/lib/charming/escape.rb +81 -0
- data/lib/charming/events/focus_event.rb +12 -0
- data/lib/charming/events/mouse_event.rb +22 -9
- data/lib/charming/events/paste_event.rb +11 -0
- data/lib/charming/events/task_progress_event.rb +21 -0
- data/lib/charming/generators/app_generator.rb +41 -3
- data/lib/charming/generators/database_installer.rb +4 -15
- data/lib/charming/generators/migration_generator.rb +116 -0
- data/lib/charming/generators/migration_timestamp.rb +29 -0
- data/lib/charming/generators/model_generator.rb +4 -2
- data/lib/charming/generators/templates/app/Gemfile.template +6 -0
- data/lib/charming/generators/templates/app/application_controller.template +1 -1
- data/lib/charming/generators/templates/app/database_config.template +3 -1
- data/lib/charming/generators/templates/app/dot_rspec.template +2 -0
- data/lib/charming/generators/templates/app/layout.template +1 -1
- data/lib/charming/generators/templates/app/spec_helper.template +2 -1
- data/lib/charming/generators/templates/app/view.template +1 -1
- data/lib/charming/image/protocol/kitty.rb +126 -0
- data/lib/charming/image/protocol.rb +18 -0
- data/lib/charming/image/source.rb +85 -0
- data/lib/charming/image/terminal.rb +52 -0
- data/lib/charming/image/transmit.rb +11 -0
- data/lib/charming/image.rb +21 -0
- data/lib/charming/internal/event_loop.rb +155 -0
- data/lib/charming/internal/terminal/adapter.rb +14 -0
- data/lib/charming/internal/terminal/key_normalizer.rb +20 -0
- data/lib/charming/internal/terminal/memory_backend.rb +27 -3
- data/lib/charming/internal/terminal/modified_key_parser.rb +63 -0
- data/lib/charming/internal/terminal/mouse_parser.rb +32 -27
- data/lib/charming/internal/terminal/tty_backend.rb +143 -4
- data/lib/charming/presentation/component.rb +7 -0
- data/lib/charming/presentation/components/activity_indicator.rb +2 -19
- data/lib/charming/presentation/components/audio.rb +31 -0
- data/lib/charming/presentation/components/autocomplete.rb +108 -0
- data/lib/charming/presentation/components/badge.rb +31 -0
- data/lib/charming/presentation/components/breadcrumbs.rb +29 -0
- data/lib/charming/presentation/components/chart.rb +80 -0
- data/lib/charming/presentation/components/command_palette.rb +8 -5
- data/lib/charming/presentation/components/error_screen.rb +72 -0
- data/lib/charming/presentation/components/filepicker.rb +101 -0
- data/lib/charming/presentation/components/form/builder.rb +5 -0
- data/lib/charming/presentation/components/form/multiselect.rb +105 -0
- data/lib/charming/presentation/components/form.rb +9 -0
- data/lib/charming/presentation/components/fuzzy_matcher.rb +83 -0
- data/lib/charming/presentation/components/help_overlay.rb +65 -0
- data/lib/charming/presentation/components/image.rb +38 -0
- data/lib/charming/presentation/components/list.rb +22 -4
- data/lib/charming/presentation/components/markdown.rb +6 -2
- data/lib/charming/presentation/components/modal.rb +45 -5
- data/lib/charming/presentation/components/multi_select_list.rb +85 -0
- data/lib/charming/presentation/components/paginator.rb +54 -0
- data/lib/charming/presentation/components/progressbar.rb +26 -5
- data/lib/charming/presentation/components/sparkline.rb +38 -0
- data/lib/charming/presentation/components/spinner.rb +22 -3
- data/lib/charming/presentation/components/status_bar.rb +75 -0
- data/lib/charming/presentation/components/stopwatch.rb +55 -0
- data/lib/charming/presentation/components/tab_bar.rb +103 -0
- data/lib/charming/presentation/components/table.rb +82 -10
- data/lib/charming/presentation/components/text_area.rb +48 -12
- data/lib/charming/presentation/components/text_input.rb +85 -5
- data/lib/charming/presentation/components/time_display.rb +20 -0
- data/lib/charming/presentation/components/timer.rb +43 -0
- data/lib/charming/presentation/components/toast.rb +51 -0
- data/lib/charming/presentation/components/tree.rb +176 -0
- data/lib/charming/presentation/components/viewport/content_lines.rb +55 -0
- data/lib/charming/presentation/components/viewport/line_window.rb +71 -0
- data/lib/charming/presentation/components/viewport/position.rb +67 -0
- data/lib/charming/presentation/components/viewport.rb +37 -122
- data/lib/charming/presentation/layout/builder.rb +4 -1
- data/lib/charming/presentation/layout/overlay.rb +6 -4
- data/lib/charming/presentation/layout/pane.rb +2 -1
- data/lib/charming/presentation/layout/pane_geometry.rb +16 -8
- data/lib/charming/presentation/layout/screen_layout.rb +12 -3
- data/lib/charming/presentation/layout/split.rb +37 -3
- data/lib/charming/presentation/markdown/renderer.rb +99 -63
- data/lib/charming/presentation/markdown/style_config.rb +11 -5
- data/lib/charming/presentation/markdown/syntax_highlighter.rb +11 -1
- data/lib/charming/presentation/markdown/table_renderer.rb +59 -0
- data/lib/charming/presentation/markdown/url_resolver.rb +27 -0
- data/lib/charming/presentation/templates/erb_handler.rb +35 -2
- data/lib/charming/presentation/ui/adaptive_color.rb +20 -0
- data/lib/charming/presentation/ui/ansi_codes.rb +15 -1
- data/lib/charming/presentation/ui/ansi_slicer.rb +20 -13
- data/lib/charming/presentation/ui/background.rb +58 -0
- data/lib/charming/presentation/ui/border.rb +14 -1
- data/lib/charming/presentation/ui/border_painter.rb +23 -11
- data/lib/charming/presentation/ui/braille_canvas.rb +80 -0
- data/lib/charming/presentation/ui/canvas.rb +1 -0
- data/lib/charming/presentation/ui/color_support.rb +129 -0
- data/lib/charming/presentation/ui/gradient.rb +47 -0
- data/lib/charming/presentation/ui/style.rb +119 -19
- data/lib/charming/presentation/ui/text_wrapper.rb +40 -0
- data/lib/charming/presentation/ui/theme.rb +7 -0
- data/lib/charming/presentation/ui/themes/catppuccin-latte.json +35 -0
- data/lib/charming/presentation/ui/themes/catppuccin-mocha.json +35 -0
- data/lib/charming/presentation/ui/themes/gruvbox-dark.json +33 -0
- data/lib/charming/presentation/ui/themes/nord.json +32 -0
- data/lib/charming/presentation/ui/themes/tokyonight.json +34 -0
- data/lib/charming/presentation/ui/truncate.rb +29 -0
- data/lib/charming/presentation/ui/width.rb +38 -2
- data/lib/charming/presentation/ui.rb +52 -11
- data/lib/charming/presentation/view.rb +8 -6
- data/lib/charming/response.rb +11 -6
- data/lib/charming/router.rb +1 -1
- data/lib/charming/runtime.rb +236 -63
- data/lib/charming/tasks/cancelled.rb +11 -0
- data/lib/charming/tasks/inline_executor.rb +10 -4
- data/lib/charming/tasks/progress.rb +30 -0
- data/lib/charming/tasks/task.rb +24 -4
- data/lib/charming/tasks/threaded_executor.rb +35 -11
- data/lib/charming/test_helper.rb +120 -0
- data/lib/charming/version.rb +1 -1
- data/lib/charming.rb +43 -1
- metadata +63 -50
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Internal
|
|
5
|
+
module Terminal
|
|
6
|
+
# ModifiedKeyParser decodes xterm CSI sequences that carry a modifier
|
|
7
|
+
# parameter — "\e[1;2A" (shift+up), "\e[3;5~" (ctrl+delete) — which
|
|
8
|
+
# tty-reader's key table does not cover. The modifier parameter encodes
|
|
9
|
+
# 1 + (1 shift | 2 alt | 4 ctrl | 8 meta); meta is treated as alt.
|
|
10
|
+
class ModifiedKeyParser
|
|
11
|
+
# "\e[1;<mod><final>" — arrows, home/end, and the SS3-style F1-F4 finals.
|
|
12
|
+
LETTER_PATTERN = /\A\e\[1;(\d+)([A-DFHPQRS])\z/
|
|
13
|
+
|
|
14
|
+
# "\e[<code>;<mod>~" — insert/delete, page keys, home/end, F5-F12.
|
|
15
|
+
TILDE_PATTERN = /\A\e\[(\d+);(\d+)~\z/
|
|
16
|
+
|
|
17
|
+
LETTER_KEYS = {
|
|
18
|
+
"A" => :up, "B" => :down, "C" => :right, "D" => :left,
|
|
19
|
+
"F" => :end, "H" => :home,
|
|
20
|
+
"P" => :f1, "Q" => :f2, "R" => :f3, "S" => :f4
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
TILDE_KEYS = {
|
|
24
|
+
1 => :home, 2 => :insert, 3 => :delete, 4 => :end,
|
|
25
|
+
5 => :page_up, 6 => :page_down, 7 => :home, 8 => :end,
|
|
26
|
+
15 => :f5, 17 => :f6, 18 => :f7, 19 => :f8,
|
|
27
|
+
20 => :f9, 21 => :f10, 23 => :f11, 24 => :f12
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
SHIFT_BIT = 1
|
|
31
|
+
ALT_BIT = 2
|
|
32
|
+
CTRL_BIT = 4
|
|
33
|
+
META_BIT = 8
|
|
34
|
+
|
|
35
|
+
# Parses *raw* into a KeyEvent with modifier flags, or nil when it is not
|
|
36
|
+
# a modified CSI sequence.
|
|
37
|
+
def self.parse(raw)
|
|
38
|
+
return nil unless raw.is_a?(String)
|
|
39
|
+
|
|
40
|
+
if (match = raw.match(LETTER_PATTERN))
|
|
41
|
+
build_event(LETTER_KEYS[match[2]], match[1].to_i)
|
|
42
|
+
elsif (match = raw.match(TILDE_PATTERN))
|
|
43
|
+
build_event(TILDE_KEYS[match[1].to_i], match[2].to_i)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.build_event(key, modifier_param)
|
|
48
|
+
return nil unless key
|
|
49
|
+
|
|
50
|
+
bits = modifier_param - 1
|
|
51
|
+
Events::KeyEvent.new(
|
|
52
|
+
key: key,
|
|
53
|
+
shift: bits.anybits?(SHIFT_BIT),
|
|
54
|
+
alt: bits.anybits?(ALT_BIT | META_BIT),
|
|
55
|
+
ctrl: bits.anybits?(CTRL_BIT)
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private_class_method :build_event
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -5,21 +5,23 @@ module Charming
|
|
|
5
5
|
module Terminal
|
|
6
6
|
# MouseParser parses raw terminal escape sequences into MouseEvent objects.
|
|
7
7
|
# Supports both modern SGR sequences (the most common, used by current terminals)
|
|
8
|
-
# and the older 3-byte legacy sequences.
|
|
9
|
-
#
|
|
8
|
+
# and the older 3-byte legacy sequences. Both encodings pack modifiers and motion
|
|
9
|
+
# into the button code: bit 2 shift, bit 3 alt/meta, bit 4 ctrl, bit 5 motion.
|
|
10
|
+
# SGR signals release with a final "m" instead of "M". The public API is class
|
|
11
|
+
# methods; no instance state is required.
|
|
10
12
|
class MouseParser
|
|
11
|
-
# Matches an SGR-encoded mouse sequence: "\e[<button;col;row
|
|
12
|
-
SGR_PATTERN = /\e\[<(\d+);(\d+);(\d+)(
|
|
13
|
+
# Matches an SGR-encoded mouse sequence: "\e[<button;col;row" + "M" (press) or "m" (release).
|
|
14
|
+
SGR_PATTERN = /\e\[<(\d+);(\d+);(\d+)(M|m)/
|
|
13
15
|
|
|
14
16
|
# Matches the legacy 3-byte mouse sequence: "\e[M" followed by 3 bytes.
|
|
15
17
|
LEGACY_PATTERN = /\e\[M(.{3})/
|
|
16
18
|
|
|
17
|
-
#
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
# Button-code bits for modifier keys and motion (shared by SGR and legacy).
|
|
20
|
+
SHIFT_BIT = 4
|
|
21
|
+
ALT_BIT = 8
|
|
22
|
+
CTRL_BIT = 16
|
|
23
|
+
MOTION_BIT = 32
|
|
24
|
+
FLAG_BITS = SHIFT_BIT | ALT_BIT | CTRL_BIT | MOTION_BIT
|
|
23
25
|
|
|
24
26
|
# Returns true when *raw* looks like a recognizable mouse sequence (SGR or legacy).
|
|
25
27
|
# Lets the TTYBackend short-circuit and dispatch to MouseParser without allocation.
|
|
@@ -41,23 +43,13 @@ module Charming
|
|
|
41
43
|
nil
|
|
42
44
|
end
|
|
43
45
|
|
|
44
|
-
# Parses an SGR-format mouse sequence
|
|
45
|
-
#
|
|
46
|
-
# sequence as a heuristic for the alt modifier.
|
|
46
|
+
# Parses an SGR-format mouse sequence: button code with flag bits, 1-based
|
|
47
|
+
# (col, row), and the press/release final byte.
|
|
47
48
|
def self.parse_sgr(raw)
|
|
48
49
|
match = raw.match(SGR_PATTERN)
|
|
49
50
|
return nil unless match
|
|
50
51
|
|
|
51
|
-
|
|
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)
|
|
52
|
+
build_event(match[1].to_i, match[2].to_i - 1, match[3].to_i - 1, release: match[4] == "m")
|
|
61
53
|
end
|
|
62
54
|
|
|
63
55
|
# Parses a legacy 3-byte mouse sequence. Each of the 3 bytes has 32 subtracted
|
|
@@ -69,12 +61,25 @@ module Charming
|
|
|
69
61
|
bytes = match[1].bytes
|
|
70
62
|
return nil unless bytes.length == 3
|
|
71
63
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
row = bytes[2] - 32
|
|
64
|
+
build_event(bytes[0] - 32, bytes[1] - 32, bytes[2] - 32, release: false)
|
|
65
|
+
end
|
|
75
66
|
|
|
76
|
-
|
|
67
|
+
# Builds a MouseEvent from a raw button *code*, splitting out the modifier
|
|
68
|
+
# and motion flag bits so `button` carries only the button/wheel identity.
|
|
69
|
+
def self.build_event(code, x, y, release:)
|
|
70
|
+
Events::MouseEvent.new(
|
|
71
|
+
button: code & ~FLAG_BITS,
|
|
72
|
+
x: x,
|
|
73
|
+
y: y,
|
|
74
|
+
shift: code.anybits?(SHIFT_BIT),
|
|
75
|
+
alt: code.anybits?(ALT_BIT),
|
|
76
|
+
ctrl: code.anybits?(CTRL_BIT),
|
|
77
|
+
motion: code.anybits?(MOTION_BIT),
|
|
78
|
+
release: release
|
|
79
|
+
)
|
|
77
80
|
end
|
|
81
|
+
|
|
82
|
+
private_class_method :build_event
|
|
78
83
|
end
|
|
79
84
|
end
|
|
80
85
|
end
|
|
@@ -22,6 +22,19 @@ module Charming
|
|
|
22
22
|
AUTO_WRAP_OFF = "\e[?7l"
|
|
23
23
|
AUTO_WRAP_ON = "\e[?7h"
|
|
24
24
|
|
|
25
|
+
# Escape sequences for enabling/disabling bracketed-paste mode, and the markers
|
|
26
|
+
# the terminal wraps around pasted text.
|
|
27
|
+
BRACKETED_PASTE_ON = "\e[?2004h"
|
|
28
|
+
BRACKETED_PASTE_OFF = "\e[?2004l"
|
|
29
|
+
PASTE_START = "\e[200~"
|
|
30
|
+
PASTE_END = "\e[201~"
|
|
31
|
+
|
|
32
|
+
# Escape sequences for terminal focus reporting and the focus-in/out markers.
|
|
33
|
+
FOCUS_REPORTING_ON = "\e[?1004h"
|
|
34
|
+
FOCUS_REPORTING_OFF = "\e[?1004l"
|
|
35
|
+
FOCUS_IN = "\e[I"
|
|
36
|
+
FOCUS_OUT = "\e[O"
|
|
37
|
+
|
|
25
38
|
# *input* and *output* default to `$stdin`/`$stdout` for normal terminal use;
|
|
26
39
|
# tests can inject IO objects. *reader* is a TTY::Reader instance (created from
|
|
27
40
|
# *input*/*output* when nil). *cursor* is the TTY::Cursor class used for cursor control.
|
|
@@ -37,13 +50,17 @@ module Charming
|
|
|
37
50
|
end
|
|
38
51
|
|
|
39
52
|
# Reads the next event. If a SIGWINCH was received, returns a ResizeEvent with the
|
|
40
|
-
# current terminal dimensions.
|
|
41
|
-
# other input is normalized via KeyNormalizer.
|
|
53
|
+
# current terminal dimensions. Bracketed pastes return a PasteEvent; mouse escape
|
|
54
|
+
# sequences are parsed by MouseParser; other input is normalized via KeyNormalizer.
|
|
55
|
+
# Returns nil on timeout.
|
|
42
56
|
def read_event(timeout: nil)
|
|
43
57
|
return resize_event if resized?
|
|
44
58
|
|
|
45
59
|
raw = @reader.read_keypress(echo: false, raw: true, nonblock: timeout)
|
|
46
60
|
return nil unless raw
|
|
61
|
+
return Events::FocusEvent.new(focused: true) if raw == FOCUS_IN
|
|
62
|
+
return Events::FocusEvent.new(focused: false) if raw == FOCUS_OUT
|
|
63
|
+
return paste_event(raw) if raw.start_with?(PASTE_START)
|
|
47
64
|
return MouseParser.parse(raw) if MouseParser.sequence?(raw)
|
|
48
65
|
|
|
49
66
|
@key_normalizer.normalize(raw)
|
|
@@ -51,6 +68,87 @@ module Charming
|
|
|
51
68
|
nil
|
|
52
69
|
end
|
|
53
70
|
|
|
71
|
+
# True when stdin has bytes ready to read right now — a genuine nonblocking check (0s
|
|
72
|
+
# wait), unlike read_event whose tty-reader nonblock path waits up to 0.1s. Lets the
|
|
73
|
+
# runtime drain buffered key auto-repeat and stop the instant the buffer empties.
|
|
74
|
+
def input_pending?
|
|
75
|
+
resized? || !@input.wait_readable(0).nil?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Emits the ANSI sequence enabling terminal focus reporting. Idempotent.
|
|
79
|
+
def enable_focus_reporting
|
|
80
|
+
return if @focus_reporting
|
|
81
|
+
|
|
82
|
+
write_control(FOCUS_REPORTING_ON)
|
|
83
|
+
@focus_reporting = true
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Emits the ANSI sequence disabling terminal focus reporting. Idempotent.
|
|
87
|
+
def disable_focus_reporting
|
|
88
|
+
return unless @focus_reporting
|
|
89
|
+
|
|
90
|
+
write_control(FOCUS_REPORTING_OFF)
|
|
91
|
+
@focus_reporting = false
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Emits the ANSI sequence enabling bracketed-paste mode. Idempotent.
|
|
95
|
+
def enable_bracketed_paste
|
|
96
|
+
return if @bracketed_paste
|
|
97
|
+
|
|
98
|
+
write_control(BRACKETED_PASTE_ON)
|
|
99
|
+
@bracketed_paste = true
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Emits the ANSI sequence disabling bracketed-paste mode. Idempotent.
|
|
103
|
+
def disable_bracketed_paste
|
|
104
|
+
return unless @bracketed_paste
|
|
105
|
+
|
|
106
|
+
write_control(BRACKETED_PASTE_OFF)
|
|
107
|
+
@bracketed_paste = false
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Returns the terminal to its normal state for a shell suspend (Ctrl+Z):
|
|
111
|
+
# reporting modes off, cursor visible, primary screen, cooked input.
|
|
112
|
+
def suspend
|
|
113
|
+
@mouse_was_enabled = @mouse_enabled
|
|
114
|
+
disable_mouse_tracking
|
|
115
|
+
disable_bracketed_paste
|
|
116
|
+
disable_focus_reporting
|
|
117
|
+
show_cursor
|
|
118
|
+
leave_alt_screen
|
|
119
|
+
@input.cooked! if @input.respond_to?(:cooked!)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Re-enters the TUI after a resume (SIGCONT): raw/no-echo input, alt
|
|
123
|
+
# screen, hidden cursor, and the reporting modes that were active before
|
|
124
|
+
# the suspend. The caller is responsible for triggering a repaint.
|
|
125
|
+
def resume
|
|
126
|
+
@input.raw! if @input.respond_to?(:raw!)
|
|
127
|
+
@input.echo = false if @input.respond_to?(:echo=)
|
|
128
|
+
enter_alt_screen
|
|
129
|
+
hide_cursor
|
|
130
|
+
enable_mouse_tracking(motion: @mouse_motion || :drag) if @mouse_was_enabled
|
|
131
|
+
enable_bracketed_paste
|
|
132
|
+
enable_focus_reporting
|
|
133
|
+
clear
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# The OSC 11 background-color query and the terminators a reply may end with.
|
|
137
|
+
BACKGROUND_QUERY = "\e]11;?\e\\"
|
|
138
|
+
OSC_TERMINATORS = ["\a", "\e\\"].freeze
|
|
139
|
+
|
|
140
|
+
# Queries the terminal's background color via OSC 11 and classifies the
|
|
141
|
+
# reply as :dark or :light. Returns nil when the input cannot be polled,
|
|
142
|
+
# the terminal stays silent past *timeout*, or the reply is unparseable.
|
|
143
|
+
# Call before the event loop starts — a reply arriving later would be
|
|
144
|
+
# read as keyboard input.
|
|
145
|
+
def query_background_color(timeout: 0.15)
|
|
146
|
+
return nil unless @input.respond_to?(:wait_readable)
|
|
147
|
+
|
|
148
|
+
write_control(BACKGROUND_QUERY)
|
|
149
|
+
UI::Background.parse_osc11(read_reply(timeout))
|
|
150
|
+
end
|
|
151
|
+
|
|
54
152
|
# Keeps terminal input in raw/no-echo mode for the duration of a TUI run. Reading a
|
|
55
153
|
# single keypress in raw mode is not enough: keys pressed while rendering or dispatching
|
|
56
154
|
# events can otherwise be echoed into the alternate screen before the next read.
|
|
@@ -93,13 +191,15 @@ module Charming
|
|
|
93
191
|
|
|
94
192
|
# Emits the ANSI sequences that enable terminal mouse reporting (press, motion, SGR).
|
|
95
193
|
# Idempotent: skipped when mouse tracking is already enabled.
|
|
96
|
-
def enable_mouse_tracking
|
|
194
|
+
def enable_mouse_tracking(motion: :drag)
|
|
97
195
|
return if @mouse_enabled
|
|
98
196
|
|
|
99
197
|
write_control("\e[?1000h")
|
|
100
198
|
write_control("\e[?1002h")
|
|
199
|
+
write_control("\e[?1003h") if motion == :all
|
|
101
200
|
write_control("\e[?1006h")
|
|
102
201
|
@mouse_enabled = true
|
|
202
|
+
@mouse_motion = motion
|
|
103
203
|
end
|
|
104
204
|
|
|
105
205
|
# Emits the ANSI sequences that disable terminal mouse reporting. Idempotent.
|
|
@@ -108,7 +208,7 @@ module Charming
|
|
|
108
208
|
|
|
109
209
|
write_control("\e[?1000l")
|
|
110
210
|
write_control("\e[?1002l")
|
|
111
|
-
write_control("\e[?1003l")
|
|
211
|
+
write_control("\e[?1003l") if @mouse_motion == :all
|
|
112
212
|
write_control("\e[?1006l")
|
|
113
213
|
@mouse_enabled = false
|
|
114
214
|
end
|
|
@@ -138,6 +238,12 @@ module Charming
|
|
|
138
238
|
end
|
|
139
239
|
end
|
|
140
240
|
|
|
241
|
+
# Writes an out-of-band escape *sequence* (image transmission, clipboard, notification, title)
|
|
242
|
+
# raw — no cursor positioning, line-clearing, or auto-wrap handling that would corrupt it.
|
|
243
|
+
def write_escape(sequence)
|
|
244
|
+
write_control(sequence.payload)
|
|
245
|
+
end
|
|
246
|
+
|
|
141
247
|
# Enters the alternate screen buffer.
|
|
142
248
|
def enter_alt_screen
|
|
143
249
|
write_control(ALT_SCREEN_ON)
|
|
@@ -173,6 +279,26 @@ module Charming
|
|
|
173
279
|
|
|
174
280
|
private
|
|
175
281
|
|
|
282
|
+
# Accumulates an OSC reply from the input until a terminator arrives or
|
|
283
|
+
# *timeout* elapses. Returns whatever was read (possibly "").
|
|
284
|
+
def read_reply(timeout)
|
|
285
|
+
reply = +""
|
|
286
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
287
|
+
while (remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)) > 0
|
|
288
|
+
break unless @input.wait_readable(remaining)
|
|
289
|
+
break if append_reply_chunk(reply).nil?
|
|
290
|
+
break if OSC_TERMINATORS.any? { |terminator| reply.include?(terminator) }
|
|
291
|
+
end
|
|
292
|
+
reply
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Reads one available chunk into *reply*; nil at EOF or read error.
|
|
296
|
+
def append_reply_chunk(reply)
|
|
297
|
+
reply << @input.readpartial(64)
|
|
298
|
+
rescue IOError, SystemCallError
|
|
299
|
+
nil
|
|
300
|
+
end
|
|
301
|
+
|
|
176
302
|
# True when the SIGWINCH flag has been set since the last read_event.
|
|
177
303
|
def resized?
|
|
178
304
|
@resized
|
|
@@ -185,6 +311,19 @@ module Charming
|
|
|
185
311
|
Events::ResizeEvent.new(width: width, height: height)
|
|
186
312
|
end
|
|
187
313
|
|
|
314
|
+
# Builds a PasteEvent from a bracketed-paste chunk, reading further keypresses
|
|
315
|
+
# until the paste-end marker arrives (large pastes can span multiple reads).
|
|
316
|
+
def paste_event(raw)
|
|
317
|
+
buffer = raw.delete_prefix(PASTE_START)
|
|
318
|
+
until buffer.include?(PASTE_END)
|
|
319
|
+
chunk = @reader.read_keypress(echo: false, raw: true, nonblock: 0.2)
|
|
320
|
+
break unless chunk
|
|
321
|
+
|
|
322
|
+
buffer << chunk
|
|
323
|
+
end
|
|
324
|
+
Events::PasteEvent.new(text: buffer.sub(/#{Regexp.escape(PASTE_END)}.*\z/mo, ""))
|
|
325
|
+
end
|
|
326
|
+
|
|
188
327
|
# Writes a raw escape *sequence* to the output stream and flushes.
|
|
189
328
|
def write_control(sequence)
|
|
190
329
|
@output.write(sequence)
|
|
@@ -4,5 +4,12 @@ module Charming
|
|
|
4
4
|
# Component is the base class for all reusable terminal widgets. It inherits from View to gain assigns,
|
|
5
5
|
# helper methods (text, box, row, column, etc.), and rendering via render.
|
|
6
6
|
class Component < View
|
|
7
|
+
# True for components that accept free-typed text (TextInput, TextArea, Form, …).
|
|
8
|
+
# While such a component is focused, the controller routes printable characters to
|
|
9
|
+
# it BEFORE global/content key bindings — so typing "q" or "?" into a field inserts
|
|
10
|
+
# the character instead of triggering an app shortcut.
|
|
11
|
+
def captures_text?
|
|
12
|
+
false
|
|
13
|
+
end
|
|
7
14
|
end
|
|
8
15
|
end
|
|
@@ -154,7 +154,7 @@ module Charming
|
|
|
154
154
|
end
|
|
155
155
|
|
|
156
156
|
def widest_ellipsis_width
|
|
157
|
-
@widest_ellipsis_width ||=
|
|
157
|
+
@widest_ellipsis_width ||= UI::Width.widest(ELLIPSIS_FRAMES)
|
|
158
158
|
end
|
|
159
159
|
|
|
160
160
|
# Interpolates between gradient[0] and gradient[1] at the fractional +position+ (0.0 to 1.0).
|
|
@@ -162,24 +162,7 @@ module Charming
|
|
|
162
162
|
def color_at(position)
|
|
163
163
|
return gradient.first unless indicator_width > 1
|
|
164
164
|
|
|
165
|
-
blend(gradient.first, gradient.last, position / (indicator_width - 1).to_f)
|
|
166
|
-
end
|
|
167
|
-
|
|
168
|
-
# Blends two hex colors by interpolating their red/green/blue components at fractional +amount+.
|
|
169
|
-
# Accepts strings like "#ff0000" and produces a new "#rrggbb" string.
|
|
170
|
-
def blend(start_hex, end_hex, amount)
|
|
171
|
-
start_rgb = rgb(start_hex)
|
|
172
|
-
end_rgb = rgb(end_hex)
|
|
173
|
-
mixed = start_rgb.zip(end_rgb).map { |from, to| (from + ((to - from) * amount)).round }
|
|
174
|
-
"#%02x%02x%02x" % mixed
|
|
175
|
-
end
|
|
176
|
-
|
|
177
|
-
# Decomposes a hex color string ("#rrggbb") into an array of three integers [r, g, b].
|
|
178
|
-
def rgb(hex)
|
|
179
|
-
value = hex.to_s.delete_prefix("#")
|
|
180
|
-
raise ArgumentError, "gradient colors must be #rrggbb" unless value.match?(/\A[0-9a-fA-F]{6}\z/)
|
|
181
|
-
|
|
182
|
-
[value[0..1], value[2..3], value[4..5]].map { |part| part.to_i(16) }
|
|
165
|
+
UI::Gradient.blend(gradient.first, gradient.last, position / (indicator_width - 1).to_f)
|
|
183
166
|
end
|
|
184
167
|
|
|
185
168
|
# Advances the animation frame counter, wrapping around after +FRAME_COUNT+ (10) steps.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Components
|
|
5
|
+
# Audio is a one-line playback-status indicator for a {Charming::Audio::Player}. It
|
|
6
|
+
# reads the player's `playing?` state and renders a play/stop glyph with an optional
|
|
7
|
+
# *label*; pair it with a controller timer (or `on_task` re-render) to keep it live.
|
|
8
|
+
#
|
|
9
|
+
# Note: this view component (`Charming::Components::Audio`) is distinct from the
|
|
10
|
+
# playback engine namespace (`Charming::Audio`) — the component only displays state,
|
|
11
|
+
# the engine spawns the sound.
|
|
12
|
+
class Audio < Component
|
|
13
|
+
# *player* is the {Charming::Audio::Player} whose state is shown. *label* is an
|
|
14
|
+
# optional suffix (e.g. the track name) appended after the glyph. *theme* is the
|
|
15
|
+
# active theme, forwarded to the view layer.
|
|
16
|
+
def initialize(player:, label: nil, theme: nil)
|
|
17
|
+
super(theme: theme)
|
|
18
|
+
@player = player
|
|
19
|
+
@label = label
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Renders `▶`/`■` for playing/stopped, followed by the label when present.
|
|
23
|
+
def render
|
|
24
|
+
glyph = @player.playing? ? "▶" : "■"
|
|
25
|
+
return glyph unless @label
|
|
26
|
+
|
|
27
|
+
"#{glyph} #{@label}"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Components
|
|
5
|
+
# Autocomplete is a combobox: a TextInput with a suggestion list beneath it,
|
|
6
|
+
# filtered live against the typed value. Up/down move through suggestions,
|
|
7
|
+
# Enter submits the highlighted suggestion (or the free text when nothing
|
|
8
|
+
# matches), Escape cancels.
|
|
9
|
+
#
|
|
10
|
+
# Autocomplete.new(suggestions: ["ruby", "rails", "rspec"], value: "r")
|
|
11
|
+
#
|
|
12
|
+
# `handle_key` returns `[:submitted, value]` on Enter, `:cancelled` on Escape,
|
|
13
|
+
# `:handled` for consumed keys, nil otherwise.
|
|
14
|
+
class Autocomplete < Component
|
|
15
|
+
DEFAULT_MAX_SUGGESTIONS = 6
|
|
16
|
+
|
|
17
|
+
# The current typed value and the highlighted suggestion index.
|
|
18
|
+
attr_reader :selected_index
|
|
19
|
+
|
|
20
|
+
# *suggestions* is the full candidate list. *value*/*cursor* seed the inner
|
|
21
|
+
# TextInput. *max_suggestions* caps the visible dropdown rows.
|
|
22
|
+
def initialize(suggestions:, value: "", cursor: nil, placeholder: "", selected_index: 0,
|
|
23
|
+
max_suggestions: DEFAULT_MAX_SUGGESTIONS, theme: nil)
|
|
24
|
+
super(theme: theme)
|
|
25
|
+
@suggestions = Array(suggestions).map(&:to_s)
|
|
26
|
+
@input = TextInput.new(value: value, cursor: cursor, placeholder: placeholder)
|
|
27
|
+
@selected_index = selected_index
|
|
28
|
+
@max_suggestions = max_suggestions
|
|
29
|
+
clamp_selection
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The typed text.
|
|
33
|
+
def value
|
|
34
|
+
@input.value
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The inner input's cursor offset.
|
|
38
|
+
def cursor
|
|
39
|
+
@input.cursor
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The suggestions matching the current value (case-insensitive substring),
|
|
43
|
+
# capped at max_suggestions. All suggestions when the value is empty.
|
|
44
|
+
def filtered_suggestions
|
|
45
|
+
query = value.downcase
|
|
46
|
+
matches = query.empty? ? @suggestions : @suggestions.select { |s| s.downcase.include?(query) }
|
|
47
|
+
matches.first(@max_suggestions)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Free-typed characters belong to this component while it is focused.
|
|
51
|
+
def captures_text?
|
|
52
|
+
true
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Enter submits, Escape cancels, up/down move the highlight, everything else
|
|
56
|
+
# edits the text (resetting the highlight).
|
|
57
|
+
def handle_key(event)
|
|
58
|
+
case Charming.key_of(event)
|
|
59
|
+
when :escape then :cancelled
|
|
60
|
+
when :enter then [:submitted, submission_value]
|
|
61
|
+
when :up then move_selection(-1)
|
|
62
|
+
when :down then move_selection(+1)
|
|
63
|
+
else
|
|
64
|
+
result = @input.handle_key(event)
|
|
65
|
+
clamp_selection if result
|
|
66
|
+
result
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Renders the input row followed by the suggestion dropdown.
|
|
71
|
+
def render
|
|
72
|
+
[input_line, *suggestion_lines].join("\n")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
# The highlighted suggestion, or the raw text when none match.
|
|
78
|
+
def submission_value
|
|
79
|
+
filtered_suggestions[selected_index] || value
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def input_line
|
|
83
|
+
@input.render
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# One row per suggestion; the highlighted row in the selected style.
|
|
87
|
+
def suggestion_lines
|
|
88
|
+
filtered_suggestions.each_with_index.map do |suggestion, index|
|
|
89
|
+
line = " #{suggestion}"
|
|
90
|
+
(index == selected_index) ? theme.selected.render(line) : theme.muted.render(line)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def move_selection(delta)
|
|
95
|
+
count = filtered_suggestions.length
|
|
96
|
+
return :handled if count.zero?
|
|
97
|
+
|
|
98
|
+
@selected_index = (selected_index + delta).clamp(0, count - 1)
|
|
99
|
+
:handled
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def clamp_selection
|
|
103
|
+
max = [filtered_suggestions.length - 1, 0].max
|
|
104
|
+
@selected_index = selected_index.clamp(0, max)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Components
|
|
5
|
+
# Badge is a small inline label rendered as a styled pill — useful for versions,
|
|
6
|
+
# counts, and statuses inside status bars, lists, and headers.
|
|
7
|
+
#
|
|
8
|
+
# Badge.new("v1.2").render # themed default
|
|
9
|
+
# Badge.new("3 errors", style: theme.warn).render
|
|
10
|
+
class Badge < Component
|
|
11
|
+
# *label* is the badge text. *style* overrides the default themed style.
|
|
12
|
+
def initialize(label, style: nil, theme: nil)
|
|
13
|
+
super(theme: theme)
|
|
14
|
+
@label = label.to_s
|
|
15
|
+
@badge_style = style
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Renders the pill: a space-padded label with the badge style applied.
|
|
19
|
+
def render
|
|
20
|
+
resolved_style.render(" #{@label} ")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
# The user style or the theme's selected style (which guarantees contrast).
|
|
26
|
+
def resolved_style
|
|
27
|
+
@badge_style || theme.selected
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Components
|
|
5
|
+
# Breadcrumbs renders a navigation trail like `Home › Projects › My App`, with the
|
|
6
|
+
# final (current) item highlighted and ancestors muted.
|
|
7
|
+
class Breadcrumbs < Component
|
|
8
|
+
DEFAULT_SEPARATOR = " › "
|
|
9
|
+
|
|
10
|
+
# *items* is the trail (strings or anything responding to to_s), first-to-last.
|
|
11
|
+
# *separator* joins the items.
|
|
12
|
+
def initialize(items:, separator: DEFAULT_SEPARATOR, theme: nil)
|
|
13
|
+
super(theme: theme)
|
|
14
|
+
@items = Array(items).map(&:to_s)
|
|
15
|
+
@separator = separator
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Renders the trail; ancestors muted, current item in the title style.
|
|
19
|
+
def render
|
|
20
|
+
return "" if @items.empty?
|
|
21
|
+
|
|
22
|
+
*ancestors, current = @items
|
|
23
|
+
parts = ancestors.map { |item| theme.muted.render(item) }
|
|
24
|
+
parts << theme.title.render(current)
|
|
25
|
+
parts.join(theme.muted.render(@separator))
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|