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.
Files changed (94) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +16 -3
  3. data/lib/charming/application.rb +48 -0
  4. data/lib/charming/cli.rb +16 -4
  5. data/lib/charming/controller/key_dispatch.rb +113 -0
  6. data/lib/charming/controller/session_state.rb +11 -0
  7. data/lib/charming/controller/terminal.rb +33 -0
  8. data/lib/charming/controller.rb +13 -40
  9. data/lib/charming/escape.rb +81 -0
  10. data/lib/charming/events/mouse_event.rb +22 -9
  11. data/lib/charming/generators/app_file_generator.rb +29 -0
  12. data/lib/charming/generators/app_generator.rb +13 -25
  13. data/lib/charming/generators/base.rb +5 -0
  14. data/lib/charming/generators/layout_generator.rb +155 -0
  15. data/lib/charming/generators/screen_generator.rb +0 -29
  16. data/lib/charming/generators/templates/app/Gemfile.template +6 -0
  17. data/lib/charming/generators/templates/app/README.md.template +15 -1
  18. data/lib/charming/generators/templates/app/application.template +0 -6
  19. data/lib/charming/generators/templates/app/application_controller.template +0 -10
  20. data/lib/charming/generators/templates/app/dot_rspec.template +2 -0
  21. data/lib/charming/generators/templates/app/layout.template +4 -93
  22. data/lib/charming/generators/templates/app/routes.template +3 -1
  23. data/lib/charming/generators/templates/layout/sidebar/application_layout.rb.template +110 -0
  24. data/lib/charming/image/protocol/kitty.rb +133 -0
  25. data/lib/charming/image/protocol.rb +18 -0
  26. data/lib/charming/image/source.rb +95 -0
  27. data/lib/charming/image/terminal.rb +52 -0
  28. data/lib/charming/image/transmit.rb +11 -0
  29. data/lib/charming/image.rb +21 -0
  30. data/lib/charming/internal/event_loop.rb +155 -0
  31. data/lib/charming/internal/terminal/adapter.rb +14 -0
  32. data/lib/charming/internal/terminal/key_normalizer.rb +20 -0
  33. data/lib/charming/internal/terminal/memory_backend.rb +21 -3
  34. data/lib/charming/internal/terminal/modified_key_parser.rb +63 -0
  35. data/lib/charming/internal/terminal/mouse_parser.rb +32 -27
  36. data/lib/charming/internal/terminal/tty_backend.rb +79 -2
  37. data/lib/charming/presentation/components/activity_indicator.rb +2 -19
  38. data/lib/charming/presentation/components/autocomplete.rb +7 -0
  39. data/lib/charming/presentation/components/chart.rb +80 -0
  40. data/lib/charming/presentation/components/error_screen.rb +1 -1
  41. data/lib/charming/presentation/components/filepicker.rb +101 -0
  42. data/lib/charming/presentation/components/form/builder.rb +5 -0
  43. data/lib/charming/presentation/components/form/field.rb +5 -0
  44. data/lib/charming/presentation/components/form/input.rb +14 -4
  45. data/lib/charming/presentation/components/form/multiselect.rb +105 -0
  46. data/lib/charming/presentation/components/form/textarea.rb +18 -8
  47. data/lib/charming/presentation/components/form.rb +6 -0
  48. data/lib/charming/presentation/components/image.rb +38 -0
  49. data/lib/charming/presentation/components/list.rb +22 -4
  50. data/lib/charming/presentation/components/modal.rb +4 -4
  51. data/lib/charming/presentation/components/paginator.rb +54 -0
  52. data/lib/charming/presentation/components/progressbar.rb +26 -4
  53. data/lib/charming/presentation/components/sparkline.rb +38 -0
  54. data/lib/charming/presentation/components/spinner.rb +22 -3
  55. data/lib/charming/presentation/components/stopwatch.rb +55 -0
  56. data/lib/charming/presentation/components/table.rb +42 -1
  57. data/lib/charming/presentation/components/text_area.rb +3 -4
  58. data/lib/charming/presentation/components/text_input.rb +11 -6
  59. data/lib/charming/presentation/components/time_display.rb +20 -0
  60. data/lib/charming/presentation/components/timer.rb +43 -0
  61. data/lib/charming/presentation/components/toast.rb +4 -3
  62. data/lib/charming/presentation/components/viewport/content_lines.rb +1 -1
  63. data/lib/charming/presentation/components/viewport/line_window.rb +1 -1
  64. data/lib/charming/presentation/components/viewport.rb +11 -1
  65. data/lib/charming/presentation/markdown/renderer.rb +1 -1
  66. data/lib/charming/presentation/markdown/style_config.rb +1 -0
  67. data/lib/charming/presentation/markdown/table_renderer.rb +2 -3
  68. data/lib/charming/presentation/ui/adaptive_color.rb +20 -0
  69. data/lib/charming/presentation/ui/ansi_codes.rb +5 -2
  70. data/lib/charming/presentation/ui/background.rb +58 -0
  71. data/lib/charming/presentation/ui/border.rb +14 -1
  72. data/lib/charming/presentation/ui/border_painter.rb +23 -11
  73. data/lib/charming/presentation/ui/braille_canvas.rb +80 -0
  74. data/lib/charming/presentation/ui/canvas.rb +1 -0
  75. data/lib/charming/presentation/ui/gradient.rb +47 -0
  76. data/lib/charming/presentation/ui/style.rb +119 -19
  77. data/lib/charming/presentation/{markdown → ui}/text_wrapper.rb +4 -4
  78. data/lib/charming/presentation/ui/truncate.rb +29 -0
  79. data/lib/charming/presentation/ui/width.rb +11 -0
  80. data/lib/charming/presentation/ui.rb +52 -11
  81. data/lib/charming/presentation/view.rb +8 -6
  82. data/lib/charming/response.rb +11 -6
  83. data/lib/charming/runtime.rb +167 -90
  84. data/lib/charming/version.rb +1 -1
  85. data/lib/charming/welcome/controller.rb +23 -0
  86. data/lib/charming/welcome/show_view.rb +113 -0
  87. data/lib/charming/welcome.rb +13 -0
  88. metadata +34 -9
  89. data/lib/charming/generators/templates/app/home_controller.template +0 -6
  90. data/lib/charming/generators/templates/app/home_state.template +0 -7
  91. data/lib/charming/generators/templates/app/spec_controller.template +0 -17
  92. data/lib/charming/generators/templates/app/spec_state.template +0 -17
  93. data/lib/charming/generators/templates/app/spec_view.template +0 -16
  94. 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
- def enable_mouse_tracking
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