charming 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +38 -378
  3. data/lib/charming/application.rb +14 -3
  4. data/lib/charming/{application_model.rb → application_state.rb} +3 -3
  5. data/lib/charming/cli.rb +62 -3
  6. data/lib/charming/controller/class_methods.rb +115 -0
  7. data/lib/charming/controller/command_palette.rb +135 -0
  8. data/lib/charming/controller/component_dispatching.rb +81 -0
  9. data/lib/charming/controller/dispatching.rb +60 -0
  10. data/lib/charming/controller/focus_management.rb +30 -0
  11. data/lib/charming/controller/rendering.rb +127 -0
  12. data/lib/charming/controller/session_state.rb +41 -0
  13. data/lib/charming/controller/sidebar_navigation.rb +111 -0
  14. data/lib/charming/controller.rb +46 -448
  15. data/lib/charming/database_commands.rb +103 -0
  16. data/lib/charming/database_installer.rb +152 -0
  17. data/lib/charming/events/key_event.rb +15 -0
  18. data/lib/charming/events/mouse_event.rb +42 -0
  19. data/lib/charming/events/resize_event.rb +9 -0
  20. data/lib/charming/events/task_event.rb +19 -0
  21. data/lib/charming/events/timer_event.rb +9 -0
  22. data/lib/charming/focus.rb +58 -2
  23. data/lib/charming/generators/app_file_generator.rb +13 -0
  24. data/lib/charming/generators/app_generator.rb +147 -45
  25. data/lib/charming/generators/base.rb +26 -0
  26. data/lib/charming/generators/component_generator.rb +10 -10
  27. data/lib/charming/generators/controller_generator.rb +22 -14
  28. data/lib/charming/generators/model_generator.rb +128 -0
  29. data/lib/charming/generators/name.rb +10 -4
  30. data/lib/charming/generators/screen_generator.rb +84 -52
  31. data/lib/charming/generators/templates/app/Gemfile.template +5 -0
  32. data/lib/charming/generators/templates/app/README.md.template +9 -0
  33. data/lib/charming/generators/templates/app/Rakefile.template +3 -0
  34. data/lib/charming/generators/templates/app/application.template +13 -0
  35. data/lib/charming/generators/templates/app/application_controller.template +19 -0
  36. data/lib/charming/generators/templates/app/application_record.template +7 -0
  37. data/lib/charming/generators/templates/app/application_state.template +6 -0
  38. data/lib/charming/generators/templates/app/database_config.template +12 -0
  39. data/lib/charming/generators/templates/app/executable.template +7 -0
  40. data/lib/charming/generators/templates/app/gemspec.template +6 -0
  41. data/lib/charming/generators/templates/app/home_controller.template +6 -0
  42. data/lib/charming/generators/templates/app/home_state.template +7 -0
  43. data/lib/charming/generators/templates/app/keep.template +0 -0
  44. data/lib/charming/generators/templates/app/layout.template +113 -0
  45. data/lib/charming/generators/templates/app/root_file.template +20 -0
  46. data/lib/charming/generators/templates/app/routes.template +5 -0
  47. data/lib/charming/generators/templates/app/seeds.template +1 -0
  48. data/lib/charming/generators/templates/app/spec_controller.template +17 -0
  49. data/lib/charming/generators/templates/app/spec_helper.template +3 -0
  50. data/lib/charming/generators/templates/app/spec_state.template +17 -0
  51. data/lib/charming/generators/templates/app/spec_view.template +16 -0
  52. data/lib/charming/generators/templates/app/version.template +5 -0
  53. data/lib/charming/generators/templates/app/view.template +21 -0
  54. data/lib/charming/generators/templates/component/component.rb.template +9 -0
  55. data/lib/charming/generators/templates/controller/controller.rb.template +6 -0
  56. data/lib/charming/generators/templates/model/migration.rb.template +9 -0
  57. data/lib/charming/generators/templates/model/model.rb.template +6 -0
  58. data/lib/charming/generators/templates/model/spec.rb.template +9 -0
  59. data/lib/charming/generators/templates/screen/controller.rb.template +7 -0
  60. data/lib/charming/generators/templates/screen/spec_controller.rb.template +17 -0
  61. data/lib/charming/generators/templates/screen/spec_state.rb.template +17 -0
  62. data/lib/charming/generators/templates/screen/spec_view.rb.template +13 -0
  63. data/lib/charming/generators/templates/screen/state.rb.template +7 -0
  64. data/lib/charming/generators/templates/screen/view.rb.template +11 -0
  65. data/lib/charming/generators/templates/view/view.rb.template +11 -0
  66. data/lib/charming/generators/view_generator.rb +26 -13
  67. data/lib/charming/internal/renderer/differential.rb +17 -3
  68. data/lib/charming/internal/renderer/full_repaint.rb +6 -0
  69. data/lib/charming/internal/terminal/adapter.rb +29 -3
  70. data/lib/charming/internal/terminal/key_normalizer.rb +84 -0
  71. data/lib/charming/internal/terminal/memory_backend.rb +28 -1
  72. data/lib/charming/internal/terminal/mouse_parser.rb +81 -0
  73. data/lib/charming/internal/terminal/tty_backend.rb +62 -115
  74. data/lib/charming/presentation/component.rb +10 -0
  75. data/lib/charming/presentation/components/activity_indicator.rb +160 -0
  76. data/lib/charming/presentation/components/command_palette.rb +120 -0
  77. data/lib/charming/presentation/components/empty_state.rb +56 -0
  78. data/lib/charming/presentation/components/form/builder.rb +62 -0
  79. data/lib/charming/presentation/components/form/confirm.rb +69 -0
  80. data/lib/charming/presentation/components/form/field.rb +121 -0
  81. data/lib/charming/presentation/components/form/input.rb +71 -0
  82. data/lib/charming/presentation/components/form/note.rb +41 -0
  83. data/lib/charming/presentation/components/form/select.rb +112 -0
  84. data/lib/charming/presentation/components/form/textarea.rb +86 -0
  85. data/lib/charming/presentation/components/form.rb +156 -0
  86. data/lib/charming/presentation/components/keyboard_handler.rb +58 -0
  87. data/lib/charming/presentation/components/list.rb +132 -0
  88. data/lib/charming/presentation/components/markdown.rb +31 -0
  89. data/lib/charming/presentation/components/modal.rb +64 -0
  90. data/lib/charming/presentation/components/progressbar.rb +70 -0
  91. data/lib/charming/presentation/components/spinner.rb +49 -0
  92. data/lib/charming/presentation/components/table.rb +143 -0
  93. data/lib/charming/presentation/components/text_area.rb +267 -0
  94. data/lib/charming/presentation/components/text_input.rb +129 -0
  95. data/lib/charming/presentation/components/viewport.rb +272 -0
  96. data/lib/charming/presentation/layout/builder.rb +86 -0
  97. data/lib/charming/presentation/layout/overlay.rb +57 -0
  98. data/lib/charming/presentation/layout/pane.rb +145 -0
  99. data/lib/charming/presentation/layout/rect.rb +23 -0
  100. data/lib/charming/presentation/layout/screen_layout.rb +60 -0
  101. data/lib/charming/presentation/layout/split.rb +134 -0
  102. data/lib/charming/presentation/layout.rb +43 -0
  103. data/lib/charming/presentation/markdown/block_renderers.rb +120 -0
  104. data/lib/charming/presentation/markdown/inline_renderers.rb +68 -0
  105. data/lib/charming/presentation/markdown/render_context.rb +22 -0
  106. data/lib/charming/presentation/markdown/renderer.rb +113 -0
  107. data/lib/charming/presentation/markdown/syntax_highlighter.rb +79 -0
  108. data/lib/charming/presentation/markdown.rb +11 -0
  109. data/lib/charming/presentation/template_view.rb +34 -0
  110. data/lib/charming/presentation/templates/erb_handler.rb +15 -0
  111. data/lib/charming/presentation/templates.rb +68 -0
  112. data/lib/charming/presentation/ui/ansi_codes.rb +89 -0
  113. data/lib/charming/presentation/ui/ansi_slicer.rb +94 -0
  114. data/lib/charming/presentation/ui/border.rb +35 -0
  115. data/lib/charming/presentation/ui/border_painter.rb +58 -0
  116. data/lib/charming/presentation/ui/canvas.rb +82 -0
  117. data/lib/charming/presentation/ui/style.rb +213 -0
  118. data/lib/charming/presentation/ui/theme.rb +180 -0
  119. data/lib/charming/{ui → presentation/ui}/themes/phosphor.json +2 -2
  120. data/lib/charming/presentation/ui/width.rb +26 -0
  121. data/lib/charming/presentation/ui.rb +91 -0
  122. data/lib/charming/presentation/view.rb +135 -0
  123. data/lib/charming/runtime.rb +9 -7
  124. data/lib/charming/screen.rb +5 -1
  125. data/lib/charming/tasks/inline_executor.rb +37 -0
  126. data/lib/charming/tasks/task.rb +12 -0
  127. data/lib/charming/tasks/threaded_executor.rb +51 -0
  128. data/lib/charming/version.rb +1 -1
  129. data/lib/charming.rb +17 -0
  130. metadata +170 -36
  131. data/lib/charming/component.rb +0 -8
  132. data/lib/charming/components/activity_indicator.rb +0 -158
  133. data/lib/charming/components/command_palette.rb +0 -118
  134. data/lib/charming/components/keyboard_handler.rb +0 -22
  135. data/lib/charming/components/list.rb +0 -105
  136. data/lib/charming/components/modal.rb +0 -48
  137. data/lib/charming/components/progressbar.rb +0 -55
  138. data/lib/charming/components/spinner.rb +0 -37
  139. data/lib/charming/components/table.rb +0 -115
  140. data/lib/charming/components/text_input.rb +0 -103
  141. data/lib/charming/components/viewport.rb +0 -191
  142. data/lib/charming/generators/app_generator/app_spec_templates.rb +0 -86
  143. data/lib/charming/generators/app_generator/basic_templates.rb +0 -69
  144. data/lib/charming/generators/app_generator/component_templates.rb +0 -36
  145. data/lib/charming/generators/app_generator/controller_template.rb +0 -69
  146. data/lib/charming/generators/app_generator/layout_template.rb +0 -160
  147. data/lib/charming/generators/app_generator/model_templates.rb +0 -30
  148. data/lib/charming/generators/app_generator/screen_spec_templates.rb +0 -70
  149. data/lib/charming/generators/app_generator/view_template.rb +0 -90
  150. data/lib/charming/key_event.rb +0 -13
  151. data/lib/charming/mouse_event.rb +0 -40
  152. data/lib/charming/resize_event.rb +0 -7
  153. data/lib/charming/task.rb +0 -7
  154. data/lib/charming/task_event.rb +0 -17
  155. data/lib/charming/task_executor.rb +0 -62
  156. data/lib/charming/timer_event.rb +0 -7
  157. data/lib/charming/ui/border.rb +0 -33
  158. data/lib/charming/ui/style.rb +0 -244
  159. data/lib/charming/ui/theme.rb +0 -178
  160. data/lib/charming/ui/width.rb +0 -24
  161. data/lib/charming/ui.rb +0 -230
  162. data/lib/charming/view.rb +0 -116
  163. /data/lib/charming/{generators.rb → generators/error.rb} +0 -0
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "__APP_SNAKE__"
4
+
5
+ RSpec.describe __APP_CLASS__::__STATE_CLASS__ do
6
+ describe "#title" do
7
+ it "has the correct default string value" do
8
+ instance = described_class.new
9
+ expect(instance.title).to eq("__TITLE__")
10
+ end
11
+
12
+ it "accepts overridden title values" do
13
+ instance = described_class.new(title: "Alternative")
14
+ expect(instance.title).to eq("Alternative")
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "__APP_SNAKE__"
4
+
5
+ RSpec.describe __APP_CLASS__::__RESOURCE_MODULE__::ShowView do
6
+ describe "#render" do
7
+ it "renders the state title" do
8
+ view = described_class.new(__SCREEN_NAME__: double(title: "__TITLE__"))
9
+
10
+ expect(view.render).to eq("__TITLE__")
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module __APP_CLASS__
4
+ class __STATE_CLASS__ < ApplicationState
5
+ attribute :title, :string, default: "__TITLE__"
6
+ end
7
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module __APP_CLASS__
4
+ module __RESOURCE_MODULE__
5
+ class ShowView < Charming::Presentation::View
6
+ def render
7
+ __SCREEN_NAME__.title
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module __APP_CLASS__
4
+ module __RESOURCE_MODULE__
5
+ class __ACTION_VIEW_CLASS__View < Charming::Presentation::View
6
+ def render
7
+ "__RESOURCE_NAME__"
8
+ end
9
+ end
10
+ end
11
+ end
@@ -2,32 +2,45 @@
2
2
 
3
3
  module Charming
4
4
  module Generators
5
+ # ViewGenerator implements `charming generate view NAME [ACTION]`. Writes a single
6
+ # view class file at `app/views/<name>/<action>_view.rb`. The *action* defaults to `show`.
5
7
  class ViewGenerator < AppFileGenerator
8
+ # *name* is the resource name. *args* may contain a single action name (defaults to "show").
9
+ def initialize(name, args, out:, destination:, force: false)
10
+ super
11
+ raise Error, "Usage: charming generate view NAME [ACTION]" if args.length > 1
12
+
13
+ @action = args.fetch(0, "show")
14
+ end
15
+
16
+ # Writes the view file to `app/views/<name>/<action>_view.rb`.
6
17
  def generate
7
- create_file(app_path("app", "views"), view)
18
+ create_file(File.join("app", "views", name.snake_name, "#{action}_view.rb"), view)
8
19
  end
9
20
 
10
21
  private
11
22
 
23
+ # The action name (e.g., "show", "edit").
24
+ attr_reader :action
25
+
26
+ # The file-name suffix used by `app_path` (sets "view" so the file is
27
+ # `<name>_view.rb`). Not used directly by this generator but required by the parent.
12
28
  def suffix
13
29
  "view"
14
30
  end
15
31
 
32
+ # The full source of the generated view class.
16
33
  def view
17
- %(# frozen_string_literal: true
18
-
19
- module #{app_name.class_name}
20
- class #{name.view_class_name} < Charming::View
21
- #{view_body}
22
- end
23
- end
24
- )
34
+ render_template("view/view.rb.template",
35
+ app_class: app_name.class_name,
36
+ resource_module: name.class_name,
37
+ resource_name: name.class_name,
38
+ action_view_class: action_class_name)
25
39
  end
26
40
 
27
- def view_body
28
- %( def render
29
- "#{name.class_name}"
30
- end)
41
+ # CamelCase rendering of the action name (e.g., "user_settings" → "UserSettings").
42
+ def action_class_name
43
+ action.split("_").map(&:capitalize).join
31
44
  end
32
45
  end
33
46
  end
@@ -3,13 +3,22 @@
3
3
  module Charming
4
4
  module Internal
5
5
  module Renderer
6
+ # Differential renders frame updates by emitting only the lines that changed since
7
+ # the previous frame. On the first frame it falls back to a full repaint; when no
8
+ # lines changed it returns without writing anything.
6
9
  class Differential
10
+ # *output* is the terminal backend (must support `write_lines` for the differential
11
+ # path or `write_frame` for the fallback). *full_renderer* is the FullRepaint used
12
+ # for the initial frame and as a fallback when *output* doesn't support partial writes.
7
13
  def initialize(output, full_renderer: FullRepaint.new(output))
8
14
  @output = output
9
15
  @full_renderer = full_renderer
10
16
  @previous_frame = nil
11
17
  end
12
18
 
19
+ # Renders *frame*. The first call performs a full repaint and stores the frame.
20
+ # Subsequent calls compute the per-line diff and emit only changed rows. Returns nil
21
+ # when the frame is identical to the previous one.
13
22
  def render(frame)
14
23
  frame = frame.to_s
15
24
  return render_initial(frame) unless @previous_frame
@@ -20,11 +29,15 @@ module Charming
20
29
 
21
30
  private
22
31
 
32
+ # Performs the initial full repaint and records the first frame.
23
33
  def render_initial(frame)
24
34
  @full_renderer.render(frame)
25
35
  @previous_frame = frame
26
36
  end
27
37
 
38
+ # Computes the per-line diff against the previous frame, writes the changed lines,
39
+ # and records the new frame. Falls back to a full repaint when the output backend
40
+ # doesn't support partial writes.
28
41
  def render_changes(frame)
29
42
  changes = changed_lines(@previous_frame, frame)
30
43
  return @previous_frame = frame if changes.empty?
@@ -37,14 +50,15 @@ module Charming
37
50
  @previous_frame = frame
38
51
  end
39
52
 
53
+ # Returns an array of [1-based-row, line] tuples covering the larger of the two
54
+ # frames' line counts, with empty strings padding the shorter frame.
40
55
  def changed_lines(previous_frame, frame)
41
56
  previous_lines = previous_frame.lines(chomp: true)
42
57
  lines = frame.lines(chomp: true)
43
58
  line_count = [previous_lines.length, lines.length].max
44
59
 
45
- line_count.times.filter_map do |index|
46
- line = lines[index] || ""
47
- [index + 1, line] unless previous_lines[index] == line
60
+ line_count.times.map do |index|
61
+ [index + 1, lines[index] || ""]
48
62
  end
49
63
  end
50
64
  end
@@ -3,11 +3,17 @@
3
3
  module Charming
4
4
  module Internal
5
5
  module Renderer
6
+ # FullRepaint clears the screen and rewrites the entire frame on every render. It is
7
+ # used as the initial render path by Differential and as a fallback for backends that
8
+ # don't support partial line writes.
6
9
  class FullRepaint
10
+ # *output* is the terminal backend (must support `clear`, `move_cursor`, and
11
+ # `write_frame` per the Adapter contract).
7
12
  def initialize(output)
8
13
  @output = output
9
14
  end
10
15
 
16
+ # Clears the screen, homes the cursor, and writes the entire *frame* string.
11
17
  def render(frame)
12
18
  @output.clear
13
19
  @output.move_cursor(1, 1)
@@ -3,46 +3,72 @@
3
3
  module Charming
4
4
  module Internal
5
5
  module Terminal
6
- # Contract for terminal adapters used by Runtime and renderers.
7
- # Concrete adapters provide input events, terminal dimensions, and output
8
- # primitives for full or partial frame rendering.
6
+ # Adapter defines the duck-typed interface that terminal backends must implement.
7
+ # Concrete adapters (`TTYBackend`, `MemoryBackend`) mix this module in and provide
8
+ # the actual implementations; the methods below raise NotImplementedError to make
9
+ # missing implementations fail loudly.
10
+ #
11
+ # Input methods:
12
+ # - `read_event(timeout:)` returns the next event (KeyEvent, MouseEvent, or nil on timeout)
13
+ #
14
+ # Output methods:
15
+ # - `size` returns the [width, height] of the terminal
16
+ # - `enter_alt_screen` / `leave_alt_screen` switch to/from the alternate screen buffer
17
+ # - `hide_cursor` / `show_cursor` toggle the cursor
18
+ # - `clear` clears the screen
19
+ # - `move_cursor(row, column)` positions the cursor (1-based)
20
+ # - `write_frame(frame)` writes a full multi-line frame string
21
+ # - `write_lines(line_changes, frame: nil)` writes a partial frame of [row, line] changes
9
22
  module Adapter
23
+ # Reads the next event from the backend. Returns nil when no event is available
24
+ # within *timeout* seconds. Must be implemented by the including class.
10
25
  def read_event(timeout: nil)
11
26
  raise NotImplementedError, "#{self.class} must implement #read_event"
12
27
  end
13
28
 
29
+ # Returns the current terminal dimensions as [width, height] in cells.
14
30
  def size
15
31
  raise NotImplementedError, "#{self.class} must implement #size"
16
32
  end
17
33
 
34
+ # Switches the terminal into the alternate screen buffer (used to keep the host
35
+ # terminal scrollback untouched while a TUI app is running).
18
36
  def enter_alt_screen
19
37
  raise NotImplementedError, "#{self.class} must implement #enter_alt_screen"
20
38
  end
21
39
 
40
+ # Returns the terminal to the primary screen buffer (paired with `enter_alt_screen`).
22
41
  def leave_alt_screen
23
42
  raise NotImplementedError, "#{self.class} must implement #leave_alt_screen"
24
43
  end
25
44
 
45
+ # Hides the terminal cursor.
26
46
  def hide_cursor
27
47
  raise NotImplementedError, "#{self.class} must implement #hide_cursor"
28
48
  end
29
49
 
50
+ # Shows the terminal cursor.
30
51
  def show_cursor
31
52
  raise NotImplementedError, "#{self.class} must implement #show_cursor"
32
53
  end
33
54
 
55
+ # Clears the entire screen and homes the cursor.
34
56
  def clear
35
57
  raise NotImplementedError, "#{self.class} must implement #clear"
36
58
  end
37
59
 
60
+ # Moves the cursor to the given 1-based (row, column) position.
38
61
  def move_cursor(row, column)
39
62
  raise NotImplementedError, "#{self.class} must implement #move_cursor"
40
63
  end
41
64
 
65
+ # Writes a full multi-line frame string to the terminal in one operation.
42
66
  def write_frame(frame)
43
67
  raise NotImplementedError, "#{self.class} must implement #write_frame"
44
68
  end
45
69
 
70
+ # Writes a partial frame composed of [row, line] tuples. Optional *frame:* is the
71
+ # full frame string for backends that want to track it (e.g., the MemoryBackend).
46
72
  def write_lines(line_changes, frame: nil)
47
73
  raise NotImplementedError, "#{self.class} must implement #write_lines"
48
74
  end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Internal
5
+ module Terminal
6
+ # KeyNormalizer turns raw keypress strings (from tty-reader) into normalized
7
+ # KeyEvent objects with a semantic key symbol and the printable character (when
8
+ # applicable). Handles ctrl-modifier naming, special keys (return, tab, space),
9
+ # and the back-tab (Shift+Tab) variant.
10
+ class KeyNormalizer
11
+ # Matches key names like "ctrl_a" → captures "a" so the modifier can be split out.
12
+ CTRL_KEY_PATTERN = /\Actrl_(?<key>.+)\z/
13
+
14
+ # *reader* is a TTY::Reader used to look up canonical key names from raw keypresses.
15
+ def initialize(reader)
16
+ @reader = reader
17
+ end
18
+
19
+ # Converts a raw *keypress* string into a KeyEvent. Returns nil when *keypress* is nil.
20
+ def normalize(keypress)
21
+ return nil unless keypress
22
+
23
+ key_name = @reader.console.keys[keypress]
24
+ return character_event(keypress) unless key_name
25
+
26
+ named_event(key_name)
27
+ end
28
+
29
+ private
30
+
31
+ # Builds a KeyEvent for a raw character keypress (no semantic name was matched).
32
+ def character_event(keypress)
33
+ Events::KeyEvent.new(key: keypress.to_sym, char: keypress)
34
+ end
35
+
36
+ # Builds a KeyEvent for a named key, splitting out modifiers and the printable char.
37
+ def named_event(key_name)
38
+ normalized = normalize_key_name(key_name)
39
+ Events::KeyEvent.new(
40
+ key: normalized.fetch(:key),
41
+ char: normalized.fetch(:char, nil),
42
+ ctrl: normalized.fetch(:ctrl, false),
43
+ alt: normalized.fetch(:alt, false),
44
+ shift: normalized.fetch(:shift, false)
45
+ )
46
+ end
47
+
48
+ # Splits a key name into its semantic key, printable char, and modifier flags.
49
+ def normalize_key_name(key_name)
50
+ name = key_name.to_s
51
+ return ctrl_key(name) if name.match?(CTRL_KEY_PATTERN)
52
+ return {key: :tab, shift: true} if name == "back_tab"
53
+
54
+ {key: normalized_key(name), char: printable_char(name)}
55
+ end
56
+
57
+ # Returns the semantic key symbol for *name* (e.g., "return" → :enter).
58
+ def normalized_key(name)
59
+ return :enter if name == "return"
60
+
61
+ name.to_sym
62
+ end
63
+
64
+ # Returns a key/ctrl-modifier pair for a `ctrl_*` key name.
65
+ def ctrl_key(name)
66
+ match = name.match(CTRL_KEY_PATTERN)
67
+ {key: match[:key].to_sym, ctrl: true}
68
+ end
69
+
70
+ # Returns the printable character for *name* (e.g., "space" → " "), or nil when the
71
+ # name has no single-character printable form.
72
+ def printable_char(name)
73
+ case name
74
+ when "space" then " "
75
+ when "enter", "return" then "\n"
76
+ when "tab" then "\t"
77
+ else
78
+ name if name.length == 1 && !name.match?(/[[:cntrl:]]/)
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -3,11 +3,21 @@
3
3
  module Charming
4
4
  module Internal
5
5
  module Terminal
6
+ # MemoryBackend is an in-memory implementation of the terminal Adapter used by
7
+ # RSpec specs. It serves events from a fixed `events:` list and records every
8
+ # output operation in `frames` (rendered output) and `operations` (every method
9
+ # call with its arguments), so tests can assert against observed output.
6
10
  class MemoryBackend
7
11
  include Adapter
8
12
 
9
- attr_reader :frames, :operations
13
+ # The array of rendered frame strings (one per `write_frame` or `write_lines` call).
14
+ attr_reader :frames
10
15
 
16
+ # The array of recorded operation tuples: [:method_name, *args].
17
+ attr_reader :operations
18
+
19
+ # *events* is the queue of pre-seeded events to return from `read_event`.
20
+ # *width*/*height* set the initial terminal dimensions reported by `size`.
11
21
  def initialize(events: [], width: 80, height: 24)
12
22
  @events = events.dup
13
23
  @width = width
@@ -17,67 +27,84 @@ module Charming
17
27
  @mouse_enabled = false
18
28
  end
19
29
 
30
+ # Pops the next pre-seeded event from the queue. Returns nil when the queue is empty.
20
31
  def read_event(timeout: nil)
21
32
  @operations << [:read_event, timeout]
22
33
  @events.shift
23
34
  end
24
35
 
36
+ # Stores *frame* as the current frame and appends it to `frames`.
25
37
  def write_frame(frame)
26
38
  @current_frame = frame
27
39
  @frames << frame
28
40
  @operations << [:write_frame, frame]
29
41
  end
30
42
 
43
+ # Applies the [row, line] *line_changes* to the current frame, then stores and
44
+ # records the result. The full frame is taken from the optional *frame:* argument
45
+ # (when provided) or built by overlaying the changes on the previous frame.
31
46
  def write_lines(line_changes, frame: nil)
32
47
  @current_frame = frame || apply_line_changes(line_changes)
33
48
  @frames << @current_frame
34
49
  @operations << [:write_lines, line_changes]
35
50
  end
36
51
 
52
+ # Records an enter-alt-screen operation.
37
53
  def enter_alt_screen
38
54
  @operations << :enter_alt_screen
39
55
  end
40
56
 
57
+ # Records a leave-alt-screen operation.
41
58
  def leave_alt_screen
42
59
  @operations << :leave_alt_screen
43
60
  end
44
61
 
62
+ # Records a show-cursor operation.
45
63
  def show_cursor
46
64
  @operations << :show_cursor
47
65
  end
48
66
 
67
+ # Records a hide-cursor operation.
49
68
  def hide_cursor
50
69
  @operations << :hide_cursor
51
70
  end
52
71
 
72
+ # Records a clear-screen operation.
53
73
  def clear
54
74
  @operations << :clear
55
75
  end
56
76
 
77
+ # Records a move-cursor operation at the given (row, column) (1-based).
57
78
  def move_cursor(row, column)
58
79
  @operations << [:move_cursor, row, column]
59
80
  end
60
81
 
82
+ # Returns the configured terminal dimensions as [width, height].
61
83
  def size
62
84
  [@width, @height]
63
85
  end
64
86
 
87
+ # Marks the backend as having mouse tracking enabled and records the operation.
65
88
  def enable_mouse_tracking
66
89
  @mouse_enabled = true
67
90
  @operations << :enable_mouse_tracking
68
91
  end
69
92
 
93
+ # Marks the backend as having mouse tracking disabled and records the operation.
70
94
  def disable_mouse_tracking
71
95
  @mouse_enabled = false
72
96
  @operations << :disable_mouse_tracking
73
97
  end
74
98
 
99
+ # Returns whether mouse tracking is currently enabled.
75
100
  def mouse_enabled?
76
101
  @mouse_enabled
77
102
  end
78
103
 
79
104
  private
80
105
 
106
+ # Overlays each [row, line] from *line_changes* onto a copy of the current frame
107
+ # (1-based row indexing). Used when `write_lines` is called without a *frame:* argument.
81
108
  def apply_line_changes(line_changes)
82
109
  lines = @current_frame.to_s.lines(chomp: true)
83
110
  line_changes.each do |row, line|
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Internal
5
+ module Terminal
6
+ # MouseParser parses raw terminal escape sequences into MouseEvent objects.
7
+ # Supports both modern SGR sequences (the most common, used by current terminals)
8
+ # and the older 3-byte legacy sequences. The public API is class methods; no
9
+ # instance state is required.
10
+ class MouseParser
11
+ # Matches an SGR-encoded mouse sequence: "\e[<button;col;row[mode]M"
12
+ SGR_PATTERN = /\e\[<(\d+);(\d+);(\d+)([HmMhCc]?)(M|m)/
13
+
14
+ # Matches the legacy 3-byte mouse sequence: "\e[M" followed by 3 bytes.
15
+ LEGACY_PATTERN = /\e\[M(.{3})/
16
+
17
+ # Maps raw button codes to semantic symbols used by MouseEvent#button_name.
18
+ BUTTON_MAP = {
19
+ 0 => :left, 1 => :middle, 2 => :right, 3 => :release,
20
+ 64 => :scroll_up, 65 => :scroll_down,
21
+ 66 => :scroll_up, 67 => :scroll_down
22
+ }.freeze
23
+
24
+ # Returns true when *raw* looks like a recognizable mouse sequence (SGR or legacy).
25
+ # Lets the TTYBackend short-circuit and dispatch to MouseParser without allocation.
26
+ def self.sequence?(raw)
27
+ return false unless raw.is_a?(String)
28
+ return true if raw.match?(SGR_PATTERN)
29
+ return true if raw.start_with?("\e[M")
30
+
31
+ false
32
+ end
33
+
34
+ # Parses *raw* into a MouseEvent, or returns nil when the string is not a mouse
35
+ # sequence or cannot be decoded.
36
+ def self.parse(raw)
37
+ return nil unless raw.is_a?(String)
38
+ return parse_sgr(raw) if raw.match?(SGR_PATTERN)
39
+ return parse_legacy(raw) if raw.start_with?("\e[M")
40
+
41
+ nil
42
+ end
43
+
44
+ # Parses an SGR-format mouse sequence. Decodes button code, 1-based (col, row),
45
+ # the modifier "C" (ctrl) and "M" (shift) suffix, and the highlight alt (256-color)
46
+ # sequence as a heuristic for the alt modifier.
47
+ def self.parse_sgr(raw)
48
+ match = raw.match(SGR_PATTERN)
49
+ return nil unless match
50
+
51
+ button_code = match[1].to_i
52
+ col = match[2].to_i - 1
53
+ row = match[3].to_i - 1
54
+ mode = match[4]
55
+
56
+ ctrl = mode == "C"
57
+ alt = raw.include?("\e[38;5;")
58
+ shift = mode == "M"
59
+
60
+ Events::MouseEvent.new(button: button_code, x: col, y: row, ctrl: ctrl, alt: alt, shift: shift)
61
+ end
62
+
63
+ # Parses a legacy 3-byte mouse sequence. Each of the 3 bytes has 32 subtracted
64
+ # to recover the (button, col, row) values.
65
+ def self.parse_legacy(raw)
66
+ match = raw.match(LEGACY_PATTERN)
67
+ return nil unless match
68
+
69
+ bytes = match[1].bytes
70
+ return nil unless bytes.length == 3
71
+
72
+ button_code = bytes[0] - 32
73
+ col = bytes[1] - 32
74
+ row = bytes[2] - 32
75
+
76
+ Events::MouseEvent.new(button: button_code, x: col, y: row)
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end