charming 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +16 -3
- data/lib/charming/application.rb +48 -0
- data/lib/charming/cli.rb +16 -4
- data/lib/charming/controller/key_dispatch.rb +113 -0
- data/lib/charming/controller/session_state.rb +11 -0
- data/lib/charming/controller/terminal.rb +33 -0
- data/lib/charming/controller.rb +13 -40
- data/lib/charming/escape.rb +81 -0
- data/lib/charming/events/mouse_event.rb +22 -9
- data/lib/charming/generators/app_file_generator.rb +29 -0
- data/lib/charming/generators/app_generator.rb +13 -25
- data/lib/charming/generators/base.rb +5 -0
- data/lib/charming/generators/layout_generator.rb +155 -0
- data/lib/charming/generators/screen_generator.rb +0 -29
- data/lib/charming/generators/templates/app/Gemfile.template +6 -0
- data/lib/charming/generators/templates/app/README.md.template +15 -1
- data/lib/charming/generators/templates/app/application.template +0 -6
- data/lib/charming/generators/templates/app/application_controller.template +0 -10
- data/lib/charming/generators/templates/app/dot_rspec.template +2 -0
- data/lib/charming/generators/templates/app/layout.template +4 -93
- data/lib/charming/generators/templates/app/routes.template +3 -1
- data/lib/charming/generators/templates/layout/sidebar/application_layout.rb.template +110 -0
- data/lib/charming/image/protocol/kitty.rb +133 -0
- data/lib/charming/image/protocol.rb +18 -0
- data/lib/charming/image/source.rb +95 -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 +21 -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 +79 -2
- data/lib/charming/presentation/components/activity_indicator.rb +2 -19
- data/lib/charming/presentation/components/autocomplete.rb +7 -0
- data/lib/charming/presentation/components/chart.rb +80 -0
- data/lib/charming/presentation/components/error_screen.rb +1 -1
- 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/field.rb +5 -0
- data/lib/charming/presentation/components/form/input.rb +14 -4
- data/lib/charming/presentation/components/form/multiselect.rb +105 -0
- data/lib/charming/presentation/components/form/textarea.rb +18 -8
- data/lib/charming/presentation/components/form.rb +6 -0
- data/lib/charming/presentation/components/image.rb +38 -0
- data/lib/charming/presentation/components/list.rb +22 -4
- data/lib/charming/presentation/components/modal.rb +4 -4
- data/lib/charming/presentation/components/paginator.rb +54 -0
- data/lib/charming/presentation/components/progressbar.rb +26 -4
- data/lib/charming/presentation/components/sparkline.rb +38 -0
- data/lib/charming/presentation/components/spinner.rb +22 -3
- data/lib/charming/presentation/components/stopwatch.rb +55 -0
- data/lib/charming/presentation/components/table.rb +42 -1
- data/lib/charming/presentation/components/text_area.rb +3 -4
- data/lib/charming/presentation/components/text_input.rb +11 -6
- 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 +4 -3
- data/lib/charming/presentation/components/viewport/content_lines.rb +1 -1
- data/lib/charming/presentation/components/viewport/line_window.rb +1 -1
- data/lib/charming/presentation/components/viewport.rb +11 -1
- data/lib/charming/presentation/markdown/renderer.rb +1 -1
- data/lib/charming/presentation/markdown/style_config.rb +1 -0
- data/lib/charming/presentation/markdown/table_renderer.rb +2 -3
- data/lib/charming/presentation/ui/adaptive_color.rb +20 -0
- data/lib/charming/presentation/ui/ansi_codes.rb +5 -2
- 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/gradient.rb +47 -0
- data/lib/charming/presentation/ui/style.rb +119 -19
- data/lib/charming/presentation/{markdown → ui}/text_wrapper.rb +4 -4
- data/lib/charming/presentation/ui/truncate.rb +29 -0
- data/lib/charming/presentation/ui/width.rb +11 -0
- 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/runtime.rb +167 -90
- data/lib/charming/version.rb +1 -1
- data/lib/charming/welcome/controller.rb +23 -0
- data/lib/charming/welcome/show_view.rb +113 -0
- data/lib/charming/welcome.rb +13 -0
- metadata +34 -9
- data/lib/charming/generators/templates/app/home_controller.template +0 -6
- data/lib/charming/generators/templates/app/home_state.template +0 -7
- data/lib/charming/generators/templates/app/spec_controller.template +0 -17
- data/lib/charming/generators/templates/app/spec_state.template +0 -17
- data/lib/charming/generators/templates/app/spec_view.template +0 -16
- data/lib/charming/generators/templates/app/view.template +0 -21
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Image
|
|
5
|
+
module Protocol
|
|
6
|
+
# Kitty encodes images for the Kitty graphics protocol using *Unicode placeholders* (virtual
|
|
7
|
+
# placement) — the technique that lets a cell-based TUI display images without the raw image
|
|
8
|
+
# escapes ever entering the width-measured, line-diffed frame.
|
|
9
|
+
#
|
|
10
|
+
# Two halves:
|
|
11
|
+
#
|
|
12
|
+
# * {transmit} builds the out-of-band escape (APC `\e_G…\e\`) that ships the PNG bytes and
|
|
13
|
+
# creates a virtual placement sized to rows×cols. It is base64-encoded and chunked to
|
|
14
|
+
# ≤4096 bytes per the protocol, and uses `q=2` to suppress terminal responses (which would
|
|
15
|
+
# otherwise leak into the input stream). This must NOT go through the frame pipeline — the
|
|
16
|
+
# width layer's Fe-escape rule strips the `\e_` introducer and would corrupt it.
|
|
17
|
+
#
|
|
18
|
+
# * {placeholder_block} builds the in-frame cells: each is the placeholder code point
|
|
19
|
+
# {PLACEHOLDER} plus a row and a column diacritic ({DIACRITICS}); the image id is carried in
|
|
20
|
+
# the cells' foreground colour. The colour is a hand-built, *exact* truecolour SGR — it must
|
|
21
|
+
# never be routed through {Charming::UI} styling, whose colour downconversion would mangle
|
|
22
|
+
# the id on non-truecolour terminals.
|
|
23
|
+
module Kitty
|
|
24
|
+
# The Kitty image placeholder code point. Renders as a width-1 cell that the terminal
|
|
25
|
+
# replaces with image pixels; combining diacritics on it encode the cell's row/column.
|
|
26
|
+
PLACEHOLDER = [0x10EEEE].pack("U")
|
|
27
|
+
|
|
28
|
+
# Maximum base64 payload bytes per APC chunk, per the Kitty graphics protocol.
|
|
29
|
+
CHUNK_SIZE = 4096
|
|
30
|
+
|
|
31
|
+
# The ordered combining diacritics that encode a cell's row/column index (the value at
|
|
32
|
+
# index N marks position N). Sourced verbatim from Kitty's `rowcolumn-diacritics.txt`.
|
|
33
|
+
DIACRITIC_CODEPOINTS = [
|
|
34
|
+
0x0305, 0x030D, 0x030E, 0x0310, 0x0312, 0x033D, 0x033E, 0x033F, 0x0346, 0x034A, 0x034B, 0x034C,
|
|
35
|
+
0x0350, 0x0351, 0x0352, 0x0357, 0x035B, 0x0363, 0x0364, 0x0365, 0x0366, 0x0367, 0x0368, 0x0369,
|
|
36
|
+
0x036A, 0x036B, 0x036C, 0x036D, 0x036E, 0x036F, 0x0483, 0x0484, 0x0485, 0x0486, 0x0487, 0x0592,
|
|
37
|
+
0x0593, 0x0594, 0x0595, 0x0597, 0x0598, 0x0599, 0x059C, 0x059D, 0x059E, 0x059F, 0x05A0, 0x05A1,
|
|
38
|
+
0x05A8, 0x05A9, 0x05AB, 0x05AC, 0x05AF, 0x05C4, 0x0610, 0x0611, 0x0612, 0x0613, 0x0614, 0x0615,
|
|
39
|
+
0x0616, 0x0617, 0x0657, 0x0658, 0x0659, 0x065A, 0x065B, 0x065D, 0x065E, 0x06D6, 0x06D7, 0x06D8,
|
|
40
|
+
0x06D9, 0x06DA, 0x06DB, 0x06DC, 0x06DF, 0x06E0, 0x06E1, 0x06E2, 0x06E4, 0x06E7, 0x06E8, 0x06EB,
|
|
41
|
+
0x06EC, 0x0730, 0x0732, 0x0733, 0x0735, 0x0736, 0x073A, 0x073D, 0x073F, 0x0740, 0x0741, 0x0743,
|
|
42
|
+
0x0745, 0x0747, 0x0749, 0x074A, 0x07EB, 0x07EC, 0x07ED, 0x07EE, 0x07EF, 0x07F0, 0x07F1, 0x07F3,
|
|
43
|
+
0x0816, 0x0817, 0x0818, 0x0819, 0x081B, 0x081C, 0x081D, 0x081E, 0x081F, 0x0820, 0x0821, 0x0822,
|
|
44
|
+
0x0823, 0x0825, 0x0826, 0x0827, 0x0829, 0x082A, 0x082B, 0x082C, 0x082D, 0x0951, 0x0953, 0x0954,
|
|
45
|
+
0x0F82, 0x0F83, 0x0F86, 0x0F87, 0x135D, 0x135E, 0x135F, 0x17DD, 0x193A, 0x1A17, 0x1A75, 0x1A76,
|
|
46
|
+
0x1A77, 0x1A78, 0x1A79, 0x1A7A, 0x1A7B, 0x1A7C, 0x1B6B, 0x1B6D, 0x1B6E, 0x1B6F, 0x1B70, 0x1B71,
|
|
47
|
+
0x1B72, 0x1B73, 0x1CD0, 0x1CD1, 0x1CD2, 0x1CDA, 0x1CDB, 0x1CE0, 0x1DC0, 0x1DC1, 0x1DC3, 0x1DC4,
|
|
48
|
+
0x1DC5, 0x1DC6, 0x1DC7, 0x1DC8, 0x1DC9, 0x1DCB, 0x1DCC, 0x1DD1, 0x1DD2, 0x1DD3, 0x1DD4, 0x1DD5,
|
|
49
|
+
0x1DD6, 0x1DD7, 0x1DD8, 0x1DD9, 0x1DDA, 0x1DDB, 0x1DDC, 0x1DDD, 0x1DDE, 0x1DDF, 0x1DE0, 0x1DE1,
|
|
50
|
+
0x1DE2, 0x1DE3, 0x1DE4, 0x1DE5, 0x1DE6, 0x1DFE, 0x20D0, 0x20D1, 0x20D4, 0x20D5, 0x20D6, 0x20D7,
|
|
51
|
+
0x20DB, 0x20DC, 0x20E1, 0x20E7, 0x20E9, 0x20F0, 0x2CEF, 0x2CF0, 0x2CF1, 0x2DE0, 0x2DE1, 0x2DE2,
|
|
52
|
+
0x2DE3, 0x2DE4, 0x2DE5, 0x2DE6, 0x2DE7, 0x2DE8, 0x2DE9, 0x2DEA, 0x2DEB, 0x2DEC, 0x2DED, 0x2DEE,
|
|
53
|
+
0x2DEF, 0x2DF0, 0x2DF1, 0x2DF2, 0x2DF3, 0x2DF4, 0x2DF5, 0x2DF6, 0x2DF7, 0x2DF8, 0x2DF9, 0x2DFA,
|
|
54
|
+
0x2DFB, 0x2DFC, 0x2DFD, 0x2DFE, 0x2DFF, 0xA66F, 0xA67C, 0xA67D, 0xA6F0, 0xA6F1, 0xA8E0, 0xA8E1,
|
|
55
|
+
0xA8E2, 0xA8E3, 0xA8E4, 0xA8E5, 0xA8E6, 0xA8E7, 0xA8E8, 0xA8E9, 0xA8EA, 0xA8EB, 0xA8EC, 0xA8ED,
|
|
56
|
+
0xA8EE, 0xA8EF, 0xA8F0, 0xA8F1, 0xAAB0, 0xAAB2, 0xAAB3, 0xAAB7, 0xAAB8, 0xAABE, 0xAABF, 0xAAC1,
|
|
57
|
+
0xFE20, 0xFE21, 0xFE22, 0xFE23, 0xFE24, 0xFE25, 0xFE26, 0x10A0F, 0x10A38, 0x1D185, 0x1D186, 0x1D187,
|
|
58
|
+
0x1D188, 0x1D189, 0x1D1AA, 0x1D1AB, 0x1D1AC, 0x1D1AD, 0x1D242, 0x1D243, 0x1D244
|
|
59
|
+
].freeze
|
|
60
|
+
|
|
61
|
+
# The diacritics as encoded UTF-8 strings, indexed by row/column number.
|
|
62
|
+
DIACRITICS = DIACRITIC_CODEPOINTS.map { |cp| [cp].pack("U") }.freeze
|
|
63
|
+
|
|
64
|
+
module_function
|
|
65
|
+
|
|
66
|
+
# Builds the out-of-band transmit payload for *png_bytes* under *image_id*, creating a virtual
|
|
67
|
+
# placement sized *rows*×*cols* cells. Returns a single string: a chunked `a=t` transmit
|
|
68
|
+
# followed by an `a=p,U=1` virtual placement, ready to write straight to the terminal.
|
|
69
|
+
def transmit(image_id:, png_bytes:, rows:, cols:)
|
|
70
|
+
transmit_image(image_id, png_bytes) + create_placement(image_id, rows: rows, cols: cols)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Builds the in-frame placeholder block: *rows* lines of *cols* cells each, every cell a
|
|
74
|
+
# {PLACEHOLDER} plus its row/column diacritics, with the *image_id* carried in an exact
|
|
75
|
+
# truecolour foreground SGR. Each line measures exactly *cols* display columns.
|
|
76
|
+
def placeholder_block(image_id:, rows:, cols:)
|
|
77
|
+
ensure_in_range!(rows, cols)
|
|
78
|
+
prefix = foreground(image_id)
|
|
79
|
+
Array.new(rows) do |row|
|
|
80
|
+
cells = Array.new(cols) { |col| PLACEHOLDER + DIACRITICS[row] + DIACRITICS[col] }.join
|
|
81
|
+
"#{prefix}#{cells}\e[0m"
|
|
82
|
+
end.join("\n")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Builds the `a=d,d=I` escape that deletes *image_id*'s placements AND frees its data from
|
|
86
|
+
# terminal memory (lowercase `d=i` would keep the data resident). Long-lived apps that cycle
|
|
87
|
+
# many images must emit this when evicting, or the terminal's image storage grows unbounded.
|
|
88
|
+
def delete(image_id:)
|
|
89
|
+
"\e_Ga=d,d=I,i=#{image_id},q=2\e\\"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The chunked `a=t` (transmit) escapes carrying the base64 PNG data directly (`f=100,t=d`).
|
|
93
|
+
# Control keys ride only the first chunk; `m=1` flags more-to-come, `m=0` the last.
|
|
94
|
+
def transmit_image(image_id, png_bytes)
|
|
95
|
+
chunks = chunk(encode(png_bytes))
|
|
96
|
+
chunks.each_with_index.map do |chunk, index|
|
|
97
|
+
last = index == chunks.length - 1
|
|
98
|
+
control = (index == 0) ? "a=t,f=100,t=d,i=#{image_id},q=2," : ""
|
|
99
|
+
"\e_G#{control}m=#{last ? 0 : 1};#{chunk}\e\\"
|
|
100
|
+
end.join
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# The `a=p,U=1` escape creating a virtual placement for *image_id* sized *cols*×*rows* cells.
|
|
104
|
+
def create_placement(image_id, rows:, cols:)
|
|
105
|
+
"\e_Ga=p,U=1,i=#{image_id},c=#{cols},r=#{rows},q=2\e\\"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# The exact truecolour foreground SGR encoding *image_id*'s low 24 bits (no downconversion).
|
|
109
|
+
def foreground(image_id)
|
|
110
|
+
"\e[38;2;#{(image_id >> 16) & 0xFF};#{(image_id >> 8) & 0xFF};#{image_id & 0xFF}m"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Strict base64 (no newlines) of *bytes*, via String#pack so no library require is needed.
|
|
114
|
+
def encode(bytes)
|
|
115
|
+
[bytes].pack("m0")
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Splits *data* into ≤{CHUNK_SIZE}-byte chunks (always at least one, even when empty).
|
|
119
|
+
def chunk(data)
|
|
120
|
+
data.scan(/.{1,#{CHUNK_SIZE}}/mo).then { |parts| parts.empty? ? [""] : parts }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Raises when *rows*/*cols* exceed the available diacritics (the encodable cell range).
|
|
124
|
+
def ensure_in_range!(rows, cols)
|
|
125
|
+
max = DIACRITICS.length
|
|
126
|
+
return if rows <= max && cols <= max
|
|
127
|
+
|
|
128
|
+
raise ArgumentError, "image is at most #{max} cells per dimension (got #{rows}x#{cols})"
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Image
|
|
5
|
+
# Protocol namespaces the terminal-graphics encoders and dispatches to one by name. Each encoder
|
|
6
|
+
# (e.g. {Protocol::Kitty}) exposes the same surface — `transmit(...)` for the out-of-band payload
|
|
7
|
+
# and `placeholder_block(...)` for the in-frame cells — so {Source} stays protocol-agnostic.
|
|
8
|
+
module Protocol
|
|
9
|
+
# Returns the encoder module for *name* (a {Terminal#protocol} symbol), or nil when no encoder
|
|
10
|
+
# applies (e.g. `:none`).
|
|
11
|
+
def self.for(name)
|
|
12
|
+
case name
|
|
13
|
+
when :kitty then Kitty
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Charming
|
|
6
|
+
module Image
|
|
7
|
+
# Source owns an image's bytes, its stable id, and its transmit state — the "engine" half of the
|
|
8
|
+
# image feature, analogous to {Charming::Audio::Player}. Keep it in `session` (built once) rather
|
|
9
|
+
# than rebuilt per render, so {transmitted?} reliably gates the one-time transmission.
|
|
10
|
+
#
|
|
11
|
+
# It dispatches to the active {Protocol} (chosen by its {Terminal}) to build the out-of-band
|
|
12
|
+
# {Transmit} and the in-frame placeholder block, and is a no-op on terminals without graphics
|
|
13
|
+
# support, letting {Charming::Components::Image} fall back gracefully.
|
|
14
|
+
class Source
|
|
15
|
+
# *path* or *data* supplies the (PNG) bytes — exactly one is required. *id* overrides the
|
|
16
|
+
# derived image id. *terminal* is the protocol-detection seam (injectable for specs).
|
|
17
|
+
def initialize(path: nil, data: nil, id: nil, terminal: Terminal.new)
|
|
18
|
+
raise ArgumentError, "provide either path: or data:" unless path || data
|
|
19
|
+
|
|
20
|
+
@path = path
|
|
21
|
+
@data = data
|
|
22
|
+
@id = id
|
|
23
|
+
@terminal = terminal
|
|
24
|
+
@transmitted = false
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The protocol-detection seam, exposed so the component can ask {supports_graphics?}.
|
|
28
|
+
attr_reader :terminal
|
|
29
|
+
|
|
30
|
+
# The stable 32-bit image id (kept within 24 bits so the placeholder foreground colour fully
|
|
31
|
+
# encodes it). Derived from a digest of the bytes unless an explicit id was given.
|
|
32
|
+
def image_id
|
|
33
|
+
@image_id ||= @id || derive_id
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# True when the host terminal supports a graphics protocol.
|
|
37
|
+
def supports_graphics?
|
|
38
|
+
@terminal.supports_graphics?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Builds the out-of-band {Transmit} for a *rows*×*cols* placement, or nil when graphics are
|
|
42
|
+
# unsupported. The {Runtime} flushes it to the backend; {Charming::Escape.register} collects it.
|
|
43
|
+
def transmit(rows:, cols:)
|
|
44
|
+
return unless protocol
|
|
45
|
+
|
|
46
|
+
Transmit.new(image_id: image_id, payload: protocol.transmit(image_id: image_id, png_bytes: bytes, rows: rows, cols: cols))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The in-frame placeholder block sized *rows*×*cols*, or "" when graphics are unsupported.
|
|
50
|
+
def placement(rows:, cols:)
|
|
51
|
+
return "" unless protocol
|
|
52
|
+
|
|
53
|
+
protocol.placeholder_block(image_id: image_id, rows: rows, cols: cols)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Builds the out-of-band {Transmit} that frees the image from terminal memory, or nil when
|
|
57
|
+
# graphics are unsupported. Re-arms {transmitted?} so a later render retransmits. Callers
|
|
58
|
+
# evicting an image register this the same way as {transmit}.
|
|
59
|
+
def release
|
|
60
|
+
return unless protocol
|
|
61
|
+
|
|
62
|
+
@transmitted = false
|
|
63
|
+
Transmit.new(image_id: image_id, payload: protocol.delete(image_id: image_id))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# True once {mark_transmitted} has recorded that the image was sent to the terminal.
|
|
67
|
+
def transmitted?
|
|
68
|
+
@transmitted
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Records that the image has been transmitted, so it is not re-sent on later renders.
|
|
72
|
+
def mark_transmitted
|
|
73
|
+
@transmitted = true
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
# The encoder module for the detected protocol, or nil when none applies.
|
|
79
|
+
def protocol
|
|
80
|
+
@protocol = Protocol.for(@terminal.protocol) unless defined?(@protocol)
|
|
81
|
+
@protocol
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The raw image bytes, read once from *path* (binary) or taken from *data*.
|
|
85
|
+
def bytes
|
|
86
|
+
@bytes ||= @data || File.binread(@path)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# A stable, nonzero 24-bit id derived from the image bytes.
|
|
90
|
+
def derive_id
|
|
91
|
+
(Digest::SHA256.digest(bytes).unpack1("N") % 0xFFFFFE) + 1
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Image
|
|
5
|
+
# Terminal detects which terminal graphics protocol the host emulator supports, from
|
|
6
|
+
# environment variables. It is the single detection seam: the rest of the image engine
|
|
7
|
+
# dispatches on {protocol}. Injectable (`env:`) so specs never depend on the real terminal,
|
|
8
|
+
# mirroring {Charming::Audio::System}.
|
|
9
|
+
#
|
|
10
|
+
# Phase 1 detects only the Kitty graphics protocol (Ghostty + Kitty). Other terminals report
|
|
11
|
+
# `:none`, letting {Charming::Components::Image} fall back gracefully. Future protocols
|
|
12
|
+
# (iTerm2, Sixel, half-block) slot in here without touching {Source} or the component.
|
|
13
|
+
class Terminal
|
|
14
|
+
# *env* is the environment hash probed for terminal identity (defaults to the process env).
|
|
15
|
+
def initialize(env: ENV)
|
|
16
|
+
@env = env
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# The supported graphics protocol as a symbol: `:kitty` for Kitty/Ghostty, else `:none`.
|
|
20
|
+
def protocol
|
|
21
|
+
return :kitty if kitty?
|
|
22
|
+
|
|
23
|
+
:none
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# True when a real graphics protocol is available (i.e. {protocol} is not `:none`).
|
|
27
|
+
def supports_graphics?
|
|
28
|
+
protocol != :none
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
# True when the host terminal speaks the Kitty graphics protocol (Kitty or Ghostty).
|
|
34
|
+
def kitty?
|
|
35
|
+
present?("KITTY_WINDOW_ID") ||
|
|
36
|
+
term.match?(/kitty|ghostty/i) ||
|
|
37
|
+
@env["TERM_PROGRAM"].to_s.casecmp?("ghostty") ||
|
|
38
|
+
present?("GHOSTTY_RESOURCES_DIR")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The `TERM` value as a string (never nil).
|
|
42
|
+
def term
|
|
43
|
+
@env["TERM"].to_s
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# True when *key* is set to a non-empty value in the environment.
|
|
47
|
+
def present?(key)
|
|
48
|
+
!@env[key].to_s.empty?
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Image
|
|
5
|
+
# Transmit is a single out-of-band terminal graphics payload: the escape-sequence string that
|
|
6
|
+
# transmits an image (and creates its virtual placement) for a given *image_id*. It travels on
|
|
7
|
+
# {Response}#graphics and is written verbatim to the backend by the {Runtime}, bypassing the
|
|
8
|
+
# line-based frame pipeline. *image_id* is retained for deduplication and spec assertions.
|
|
9
|
+
Transmit = Data.define(:image_id, :payload)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
# Image provides terminal image display. Unlike {Charming::Audio} (which spawns an out-of-band
|
|
5
|
+
# process and never touches the TTY), images must be written *into* the terminal — but the
|
|
6
|
+
# framework's renderer is line-based and width-measured, and would shred raw image escape
|
|
7
|
+
# sequences. So images use the Kitty graphics protocol's *Unicode placeholders*: the image bytes
|
|
8
|
+
# are transmitted once out-of-band (see {Charming::Image::Transmit} and the {Runtime}'s graphics
|
|
9
|
+
# flush), then placed by printing ordinary width-1 placeholder cells that ride the normal frame
|
|
10
|
+
# pipeline (see {Charming::Image::Protocol::Kitty}).
|
|
11
|
+
#
|
|
12
|
+
# The engine here ({Source}, {Terminal}, {Protocol}) mirrors {Charming::Audio}'s split; the view
|
|
13
|
+
# is {Charming::Components::Image}. Terminal support is detected via {Terminal}; Phase 1 targets
|
|
14
|
+
# Ghostty/Kitty and degrades to a fallback string elsewhere.
|
|
15
|
+
#
|
|
16
|
+
# Image transmissions ride the shared out-of-band {Charming::Escape} channel: {Transmit} responds
|
|
17
|
+
# to `#payload`, so the component registers it via `Charming::Escape.register` and the {Runtime}
|
|
18
|
+
# flushes it before the frame.
|
|
19
|
+
module Image
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Internal
|
|
5
|
+
# EventLoop pumps events for a Charming runtime: it delivers the next due
|
|
6
|
+
# event — completed background tasks first, then due timers, then terminal
|
|
7
|
+
# input (with optional held-key coalescing) — to the block passed to {#run},
|
|
8
|
+
# until the block returns :quit, the interrupt check trips, or a test
|
|
9
|
+
# backend runs out of events. It knows nothing about controllers, routes,
|
|
10
|
+
# or rendering; deciding what an event *means* belongs to its caller.
|
|
11
|
+
class EventLoop
|
|
12
|
+
DEFAULT_READ_TIMEOUT = 0.05
|
|
13
|
+
|
|
14
|
+
# *timer_bindings* is the list of controller timer bindings (each
|
|
15
|
+
# responding to `name` and `interval`) to schedule. *interrupted* is a
|
|
16
|
+
# callable checked every iteration so a signal handler can stop the loop.
|
|
17
|
+
def initialize(backend:, clock:, task_queue:, timer_bindings: [], coalesce_input: false, interrupted: -> { false })
|
|
18
|
+
@backend = backend
|
|
19
|
+
@clock = clock
|
|
20
|
+
@task_queue = task_queue
|
|
21
|
+
@coalesce_input = coalesce_input
|
|
22
|
+
@interrupted = interrupted
|
|
23
|
+
@timers = build_timers(timer_bindings)
|
|
24
|
+
@pending_event = nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Pumps events, yielding each to the caller together with a flag saying
|
|
28
|
+
# whether another event is already due (letting the caller coalesce a
|
|
29
|
+
# burst into one repaint). Stops when the block returns :quit, when the
|
|
30
|
+
# interrupt check returns true, or when a test backend is exhausted.
|
|
31
|
+
# Closes the task queue on the way out so producer threads can't block on
|
|
32
|
+
# a loop that is no longer draining them.
|
|
33
|
+
def run
|
|
34
|
+
loop do
|
|
35
|
+
break if @interrupted.call
|
|
36
|
+
|
|
37
|
+
event = next_event
|
|
38
|
+
unless event
|
|
39
|
+
break if backend_exhausted?
|
|
40
|
+
next
|
|
41
|
+
end
|
|
42
|
+
break if yield(event, more_ready?) == :quit
|
|
43
|
+
end
|
|
44
|
+
ensure
|
|
45
|
+
@task_queue.close
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Replaces the scheduled timers — called when navigation lands on a
|
|
49
|
+
# controller with different timer bindings.
|
|
50
|
+
def reset_timers(timer_bindings)
|
|
51
|
+
@timers = build_timers(timer_bindings)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# The next due event in priority order: task results, then timers, then input.
|
|
57
|
+
def next_event
|
|
58
|
+
next_task_event || next_timer_event || next_input_event
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# True when another task result is already queued. Only the task queue is
|
|
62
|
+
# considered: timer ticks drive animations and must each paint, and key
|
|
63
|
+
# floods are handled separately by input coalescing.
|
|
64
|
+
def more_ready?
|
|
65
|
+
!@task_queue.empty?
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Pops a task event from the thread-safe queue if one is available.
|
|
69
|
+
# Non-blocking — returns nil immediately when the queue is empty.
|
|
70
|
+
def next_task_event
|
|
71
|
+
@task_queue.pop(true)
|
|
72
|
+
rescue ThreadError
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Returns a TimerEvent for the first due timer and advances its next fire
|
|
77
|
+
# time. Returns nil if no timers are ready or registered.
|
|
78
|
+
def next_timer_event
|
|
79
|
+
timer = due_timer
|
|
80
|
+
return unless timer
|
|
81
|
+
|
|
82
|
+
now = clock_now
|
|
83
|
+
timer[:next_at] = now + timer.fetch(:binding).interval
|
|
84
|
+
Events::TimerEvent.new(name: timer.fetch(:binding).name, now: now)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Reads the next input event, consuming a stashed event first, then
|
|
88
|
+
# collapsing any auto-repeat burst behind it.
|
|
89
|
+
def next_input_event
|
|
90
|
+
event = @pending_event
|
|
91
|
+
@pending_event = nil
|
|
92
|
+
event ||= @backend.read_event(timeout: read_timeout)
|
|
93
|
+
return event unless event && @coalesce_input
|
|
94
|
+
|
|
95
|
+
coalesce(event)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Collapses a run of identical key events — the flood the terminal emits while
|
|
99
|
+
# a key is held down — into a single delivered event, so holding a key can't
|
|
100
|
+
# queue a backlog that keeps acting after release. The first non-matching event
|
|
101
|
+
# encountered is stashed for the next loop iteration, so distinct keys and
|
|
102
|
+
# non-key events (resize/paste/mouse) are never lost. Only KeyEvents are
|
|
103
|
+
# coalesced; everything else passes straight through.
|
|
104
|
+
def coalesce(event)
|
|
105
|
+
return event unless event.is_a?(Events::KeyEvent)
|
|
106
|
+
|
|
107
|
+
# Only read while input is *immediately* available, so the drain never blocks
|
|
108
|
+
# on an empty buffer (input_pending? is a true 0s check).
|
|
109
|
+
while @backend.input_pending?
|
|
110
|
+
nxt = @backend.read_event(timeout: 0)
|
|
111
|
+
break unless nxt
|
|
112
|
+
next if nxt == event # identical auto-repeat — discard the older one, keep draining
|
|
113
|
+
|
|
114
|
+
@pending_event = nxt
|
|
115
|
+
break
|
|
116
|
+
end
|
|
117
|
+
event
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Computes how long to block waiting for input based on when the next timer
|
|
121
|
+
# is due, clamped between 0 and DEFAULT_READ_TIMEOUT. Returns DEFAULT when
|
|
122
|
+
# no timers exist.
|
|
123
|
+
def read_timeout
|
|
124
|
+
return DEFAULT_READ_TIMEOUT if @timers.empty?
|
|
125
|
+
|
|
126
|
+
next_at = @timers.map { |timer| timer.fetch(:next_at) }.min
|
|
127
|
+
(next_at - clock_now).clamp(0, DEFAULT_READ_TIMEOUT)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Returns the timer due at or before now with the earliest fire time.
|
|
131
|
+
def due_timer
|
|
132
|
+
now = clock_now
|
|
133
|
+
@timers.select { |timer| timer.fetch(:next_at) <= now }.min_by { |timer| timer.fetch(:next_at) }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Builds timer states from bindings, scheduling each one interval from now.
|
|
137
|
+
def build_timers(timer_bindings)
|
|
138
|
+
now = clock_now
|
|
139
|
+
timer_bindings.map { |binding| {binding: binding, next_at: now + binding.interval} }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# True when the backend reports it has no more events to deliver (test
|
|
143
|
+
# backends only — the TTY backend never exhausts). Prevents the loop from
|
|
144
|
+
# spinning forever in tests that forget a trailing quit event.
|
|
145
|
+
def backend_exhausted?
|
|
146
|
+
@backend.respond_to?(:exhausted?) && @backend.exhausted?
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Returns current clock time.
|
|
150
|
+
def clock_now
|
|
151
|
+
@clock.call
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -26,6 +26,14 @@ module Charming
|
|
|
26
26
|
raise NotImplementedError, "#{self.class} must implement #read_event"
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
+
# True when at least one input event is immediately available to read without blocking.
|
|
30
|
+
# The runtime uses this to drain held-key auto-repeat without stalling on the final,
|
|
31
|
+
# empty read. Defaults to false so a backend that can't answer simply opts out of
|
|
32
|
+
# coalescing (the runtime then dispatches each event individually).
|
|
33
|
+
def input_pending?
|
|
34
|
+
false
|
|
35
|
+
end
|
|
36
|
+
|
|
29
37
|
# Returns the current terminal dimensions as [width, height] in cells.
|
|
30
38
|
def size
|
|
31
39
|
raise NotImplementedError, "#{self.class} must implement #size"
|
|
@@ -72,6 +80,12 @@ module Charming
|
|
|
72
80
|
def write_lines(line_changes, frame: nil)
|
|
73
81
|
raise NotImplementedError, "#{self.class} must implement #write_lines"
|
|
74
82
|
end
|
|
83
|
+
|
|
84
|
+
# Writes an out-of-band escape sequence (image transmission, clipboard write, notification,
|
|
85
|
+
# window title) straight to the terminal, bypassing the line-based frame pipeline. *sequence*
|
|
86
|
+
# responds to `payload` (the escape-sequence string). Defaults to a no-op so backends opt in.
|
|
87
|
+
def write_escape(sequence)
|
|
88
|
+
end
|
|
75
89
|
end
|
|
76
90
|
end
|
|
77
91
|
end
|
|
@@ -17,9 +17,17 @@ module Charming
|
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
# Converts a raw *keypress* string into a KeyEvent. Returns nil when *keypress* is nil.
|
|
20
|
+
# Modified CSI sequences (shift/alt/ctrl arrows, function keys) and ESC-prefixed alt
|
|
21
|
+
# chords are decoded first; everything else goes through tty-reader's key table.
|
|
20
22
|
def normalize(keypress)
|
|
21
23
|
return nil unless keypress
|
|
22
24
|
|
|
25
|
+
modified = ModifiedKeyParser.parse(keypress)
|
|
26
|
+
return modified if modified
|
|
27
|
+
|
|
28
|
+
alt = alt_prefixed_event(keypress)
|
|
29
|
+
return alt if alt
|
|
30
|
+
|
|
23
31
|
key_name = @reader.console.keys[keypress]
|
|
24
32
|
return character_event(keypress) unless key_name
|
|
25
33
|
|
|
@@ -28,6 +36,18 @@ module Charming
|
|
|
28
36
|
|
|
29
37
|
private
|
|
30
38
|
|
|
39
|
+
# Decodes "\e<key>" (Alt held while pressing <key>) by normalizing the inner
|
|
40
|
+
# key and flagging alt. Bare escapes and CSI/SS3 sequences are left alone.
|
|
41
|
+
def alt_prefixed_event(keypress)
|
|
42
|
+
return nil unless keypress.start_with?("\e") && keypress.length >= 2
|
|
43
|
+
return nil if ["[", "O", "\e"].include?(keypress[1])
|
|
44
|
+
|
|
45
|
+
inner = normalize(keypress[1..])
|
|
46
|
+
return nil unless inner
|
|
47
|
+
|
|
48
|
+
Events::KeyEvent.new(key: inner.key, char: inner.char, ctrl: inner.ctrl, alt: true, shift: inner.shift)
|
|
49
|
+
end
|
|
50
|
+
|
|
31
51
|
# Builds a KeyEvent for a raw character keypress (no semantic name was matched).
|
|
32
52
|
def character_event(keypress)
|
|
33
53
|
Events::KeyEvent.new(key: keypress.to_sym, char: keypress)
|
|
@@ -16,6 +16,9 @@ module Charming
|
|
|
16
16
|
# The array of recorded operation tuples: [:method_name, *args].
|
|
17
17
|
attr_reader :operations
|
|
18
18
|
|
|
19
|
+
# The array of out-of-band escape sequences written via `write_escape`.
|
|
20
|
+
attr_reader :escapes
|
|
21
|
+
|
|
19
22
|
# *events* is the queue of pre-seeded events to return from `read_event`.
|
|
20
23
|
# *width*/*height* set the initial terminal dimensions reported by `size`.
|
|
21
24
|
def initialize(events: [], width: 80, height: 24)
|
|
@@ -24,6 +27,7 @@ module Charming
|
|
|
24
27
|
@height = height
|
|
25
28
|
@frames = []
|
|
26
29
|
@operations = []
|
|
30
|
+
@escapes = []
|
|
27
31
|
@mouse_enabled = false
|
|
28
32
|
end
|
|
29
33
|
|
|
@@ -39,6 +43,12 @@ module Charming
|
|
|
39
43
|
@events.empty?
|
|
40
44
|
end
|
|
41
45
|
|
|
46
|
+
# True when a pre-seeded event is still queued (mirrors a TTY with bytes ready). Lets
|
|
47
|
+
# the runtime's held-key coalescing drain seeded repeats in specs.
|
|
48
|
+
def input_pending?
|
|
49
|
+
!@events.empty?
|
|
50
|
+
end
|
|
51
|
+
|
|
42
52
|
# Stores *frame* as the current frame and appends it to `frames`.
|
|
43
53
|
def write_frame(frame)
|
|
44
54
|
@current_frame = frame
|
|
@@ -55,6 +65,13 @@ module Charming
|
|
|
55
65
|
@operations << [:write_lines, line_changes]
|
|
56
66
|
end
|
|
57
67
|
|
|
68
|
+
# Records an out-of-band escape sequence, appending it to `escapes` and recording the
|
|
69
|
+
# operation (so specs can assert both its content and its ordering relative to frames).
|
|
70
|
+
def write_escape(sequence)
|
|
71
|
+
@escapes << sequence
|
|
72
|
+
@operations << [:write_escape, sequence]
|
|
73
|
+
end
|
|
74
|
+
|
|
58
75
|
# Records an enter-alt-screen operation.
|
|
59
76
|
def enter_alt_screen
|
|
60
77
|
@operations << :enter_alt_screen
|
|
@@ -90,10 +107,11 @@ module Charming
|
|
|
90
107
|
[@width, @height]
|
|
91
108
|
end
|
|
92
109
|
|
|
93
|
-
# Marks the backend as having mouse tracking enabled and records the operation
|
|
94
|
-
|
|
110
|
+
# Marks the backend as having mouse tracking enabled and records the operation
|
|
111
|
+
# with the requested motion mode (:drag or :all).
|
|
112
|
+
def enable_mouse_tracking(motion: :drag)
|
|
95
113
|
@mouse_enabled = true
|
|
96
|
-
@operations << :enable_mouse_tracking
|
|
114
|
+
@operations << [:enable_mouse_tracking, motion]
|
|
97
115
|
end
|
|
98
116
|
|
|
99
117
|
# Marks the backend as having mouse tracking disabled and records the operation.
|
|
@@ -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
|