charming 0.1.1 → 0.1.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 (132) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/lib/charming/application.rb +14 -3
  4. data/lib/charming/cli.rb +23 -0
  5. data/lib/charming/controller/class_methods.rb +115 -0
  6. data/lib/charming/controller/command_palette.rb +135 -0
  7. data/lib/charming/controller/component_dispatching.rb +81 -0
  8. data/lib/charming/controller/dispatching.rb +60 -0
  9. data/lib/charming/controller/focus_management.rb +30 -0
  10. data/lib/charming/controller/rendering.rb +127 -0
  11. data/lib/charming/controller/session_state.rb +41 -0
  12. data/lib/charming/controller/sidebar_navigation.rb +111 -0
  13. data/lib/charming/controller.rb +35 -559
  14. data/lib/charming/database_commands.rb +16 -0
  15. data/lib/charming/database_installer.rb +27 -0
  16. data/lib/charming/focus.rb +58 -2
  17. data/lib/charming/generators/app_file_generator.rb +13 -0
  18. data/lib/charming/generators/app_generator.rb +123 -47
  19. data/lib/charming/generators/base.rb +26 -0
  20. data/lib/charming/generators/component_generator.rb +10 -10
  21. data/lib/charming/generators/controller_generator.rb +22 -11
  22. data/lib/charming/generators/model_generator.rb +38 -29
  23. data/lib/charming/generators/name.rb +10 -0
  24. data/lib/charming/generators/screen_generator.rb +78 -32
  25. data/lib/charming/generators/templates/app/Gemfile.template +5 -0
  26. data/lib/charming/generators/templates/app/README.md.template +9 -0
  27. data/lib/charming/generators/templates/app/Rakefile.template +3 -0
  28. data/lib/charming/generators/templates/app/application.template +13 -0
  29. data/lib/charming/generators/templates/app/application_controller.template +19 -0
  30. data/lib/charming/generators/templates/app/application_record.template +7 -0
  31. data/lib/charming/generators/templates/app/application_state.template +6 -0
  32. data/lib/charming/generators/templates/app/database_config.template +12 -0
  33. data/lib/charming/generators/templates/app/executable.template +7 -0
  34. data/lib/charming/generators/templates/app/gemspec.template +6 -0
  35. data/lib/charming/generators/templates/app/home_controller.template +6 -0
  36. data/lib/charming/generators/templates/app/home_state.template +7 -0
  37. data/lib/charming/generators/templates/app/keep.template +0 -0
  38. data/lib/charming/generators/templates/app/layout.template +110 -0
  39. data/lib/charming/generators/templates/app/root_file.template +20 -0
  40. data/lib/charming/generators/templates/app/routes.template +5 -0
  41. data/lib/charming/generators/templates/app/seeds.template +1 -0
  42. data/lib/charming/generators/templates/app/spec_controller.template +17 -0
  43. data/lib/charming/generators/templates/app/spec_helper.template +3 -0
  44. data/lib/charming/generators/templates/app/spec_state.template +17 -0
  45. data/lib/charming/generators/templates/app/spec_view.template +16 -0
  46. data/lib/charming/generators/templates/app/version.template +5 -0
  47. data/lib/charming/generators/templates/app/view.template +21 -0
  48. data/lib/charming/generators/templates/component/component.rb.template +9 -0
  49. data/lib/charming/generators/templates/controller/controller.rb.template +6 -0
  50. data/lib/charming/generators/templates/model/migration.rb.template +9 -0
  51. data/lib/charming/generators/templates/model/model.rb.template +6 -0
  52. data/lib/charming/generators/templates/model/spec.rb.template +9 -0
  53. data/lib/charming/generators/templates/screen/controller.rb.template +7 -0
  54. data/lib/charming/generators/templates/screen/spec_controller.rb.template +17 -0
  55. data/lib/charming/generators/templates/screen/spec_state.rb.template +17 -0
  56. data/lib/charming/generators/templates/screen/spec_view.rb.template +13 -0
  57. data/lib/charming/generators/templates/screen/state.rb.template +7 -0
  58. data/lib/charming/generators/templates/screen/view.rb.template +11 -0
  59. data/lib/charming/generators/templates/view/view.rb.template +11 -0
  60. data/lib/charming/generators/view_generator.rb +19 -3
  61. data/lib/charming/internal/renderer/differential.rb +25 -2
  62. data/lib/charming/internal/renderer/full_repaint.rb +6 -0
  63. data/lib/charming/internal/terminal/adapter.rb +29 -3
  64. data/lib/charming/internal/terminal/key_normalizer.rb +84 -0
  65. data/lib/charming/internal/terminal/memory_backend.rb +28 -1
  66. data/lib/charming/internal/terminal/mouse_parser.rb +81 -0
  67. data/lib/charming/internal/terminal/tty_backend.rb +65 -115
  68. data/lib/charming/presentation/component.rb +3 -5
  69. data/lib/charming/presentation/components/activity_indicator.rb +173 -134
  70. data/lib/charming/presentation/components/command_palette.rb +94 -96
  71. data/lib/charming/presentation/components/command_palette_modal.rb +33 -0
  72. data/lib/charming/presentation/components/empty_state.rb +47 -36
  73. data/lib/charming/presentation/components/form/builder.rb +52 -40
  74. data/lib/charming/presentation/components/form/confirm.rb +50 -39
  75. data/lib/charming/presentation/components/form/field.rb +94 -71
  76. data/lib/charming/presentation/components/form/input.rb +61 -49
  77. data/lib/charming/presentation/components/form/note.rb +27 -20
  78. data/lib/charming/presentation/components/form/select.rb +84 -63
  79. data/lib/charming/presentation/components/form/textarea.rb +67 -53
  80. data/lib/charming/presentation/components/form.rb +120 -93
  81. data/lib/charming/presentation/components/keyboard_handler.rb +41 -43
  82. data/lib/charming/presentation/components/list.rb +123 -97
  83. data/lib/charming/presentation/components/markdown.rb +21 -17
  84. data/lib/charming/presentation/components/modal.rb +55 -43
  85. data/lib/charming/presentation/components/progressbar.rb +61 -50
  86. data/lib/charming/presentation/components/spinner.rb +35 -27
  87. data/lib/charming/presentation/components/table.rb +108 -85
  88. data/lib/charming/presentation/components/text_area.rb +219 -173
  89. data/lib/charming/presentation/components/text_input.rb +120 -98
  90. data/lib/charming/presentation/components/viewport.rb +218 -168
  91. data/lib/charming/presentation/layout/builder.rb +84 -0
  92. data/lib/charming/presentation/layout/overlay.rb +55 -0
  93. data/lib/charming/presentation/layout/pane.rb +149 -0
  94. data/lib/charming/presentation/layout/rect.rb +21 -0
  95. data/lib/charming/presentation/layout/screen_layout.rb +58 -0
  96. data/lib/charming/presentation/layout/split.rb +132 -0
  97. data/lib/charming/presentation/layout.rb +28 -30
  98. data/lib/charming/presentation/markdown/block_renderers.rb +118 -0
  99. data/lib/charming/presentation/markdown/inline_renderers.rb +66 -0
  100. data/lib/charming/presentation/markdown/render_context.rb +20 -0
  101. data/lib/charming/presentation/markdown/renderer.rb +82 -174
  102. data/lib/charming/presentation/markdown/syntax_highlighter.rb +58 -44
  103. data/lib/charming/presentation/markdown.rb +4 -3
  104. data/lib/charming/presentation/template_view.rb +22 -17
  105. data/lib/charming/presentation/templates/erb_handler.rb +4 -6
  106. data/lib/charming/presentation/templates.rb +47 -32
  107. data/lib/charming/presentation/ui/ansi_codes.rb +87 -0
  108. data/lib/charming/presentation/ui/ansi_slicer.rb +92 -0
  109. data/lib/charming/presentation/ui/border.rb +24 -26
  110. data/lib/charming/presentation/ui/border_painter.rb +56 -0
  111. data/lib/charming/presentation/ui/canvas.rb +80 -0
  112. data/lib/charming/presentation/ui/style.rb +170 -205
  113. data/lib/charming/presentation/ui/theme.rb +133 -135
  114. data/lib/charming/presentation/ui/width.rb +12 -14
  115. data/lib/charming/presentation/ui.rb +70 -213
  116. data/lib/charming/presentation/view.rb +107 -92
  117. data/lib/charming/runtime.rb +25 -10
  118. data/lib/charming/tasks/inline_executor.rb +9 -0
  119. data/lib/charming/tasks/task.rb +3 -0
  120. data/lib/charming/tasks/threaded_executor.rb +12 -0
  121. data/lib/charming/version.rb +1 -1
  122. data/lib/charming.rb +16 -2
  123. metadata +60 -10
  124. data/lib/charming/generators/app_generator/app_spec_templates.rb +0 -90
  125. data/lib/charming/generators/app_generator/basic_templates.rb +0 -81
  126. data/lib/charming/generators/app_generator/component_templates.rb +0 -36
  127. data/lib/charming/generators/app_generator/controller_template.rb +0 -60
  128. data/lib/charming/generators/app_generator/database_templates.rb +0 -45
  129. data/lib/charming/generators/app_generator/layout_template.rb +0 -66
  130. data/lib/charming/generators/app_generator/screen_spec_templates.rb +0 -69
  131. data/lib/charming/generators/app_generator/state_templates.rb +0 -30
  132. data/lib/charming/generators/app_generator/view_template.rb +0 -84
@@ -3,11 +3,21 @@
3
3
  module Charming
4
4
  module Internal
5
5
  module Terminal
6
+ # MemoryBackend is an in-memory implementation of the terminal Adapter used by
7
+ # RSpec specs. It serves events from a fixed `events:` list and records every
8
+ # output operation in `frames` (rendered output) and `operations` (every method
9
+ # call with its arguments), so tests can assert against observed output.
6
10
  class MemoryBackend
7
11
  include Adapter
8
12
 
9
- 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
@@ -7,65 +7,92 @@ require "tty-screen"
7
7
  module Charming
8
8
  module Internal
9
9
  module Terminal
10
+ # TTYBackend is the production terminal backend. It reads key and mouse events from
11
+ # a TTY::Reader, normalizes them via KeyNormalizer and MouseParser, and writes output
12
+ # frames using TTY::Cursor and TTY::Screen. It also installs SIGWINCH and SIGINFO
13
+ # handlers so the runtime can react to terminal resize and focus changes.
10
14
  class TTYBackend
11
15
  include Adapter
12
16
 
17
+ # Escape sequences for entering/leaving the alternate screen buffer.
13
18
  ALT_SCREEN_ON = "\e[?1049h"
14
19
  ALT_SCREEN_OFF = "\e[?1049l"
20
+
21
+ # Escape sequences for disabling/enabling automatic line wrapping during frame writes.
15
22
  AUTO_WRAP_OFF = "\e[?7l"
16
23
  AUTO_WRAP_ON = "\e[?7h"
17
- CTRL_KEY_PATTERN = /\Actrl_(?<key>.+)\z/
18
- MOUSE_SGR_PATTERN = /\e\[<(\d+);(\d+);(\d+)([HmMhCc]?)(M|m)/
19
- MOUSE_LEGACY_PATTERN = /\e\[M(.{3})/
20
- MOUSE_BUTTON_MAP = {
21
- 0 => :left, 1 => :middle, 2 => :right, 3 => :release,
22
- 64 => :scroll_up, 65 => :scroll_down,
23
- 66 => :scroll_up, 67 => :scroll_down
24
- }.freeze
25
24
 
25
+ # *input* and *output* default to `$stdin`/`$stdout` for normal terminal use;
26
+ # tests can inject IO objects. *reader* is a TTY::Reader instance (created from
27
+ # *input*/*output* when nil). *cursor* is the TTY::Cursor class used for cursor control.
26
28
  def initialize(input: $stdin, output: $stdout, reader: nil, cursor: TTY::Cursor)
27
29
  @input = input
28
30
  @output = output
29
31
  @reader = reader || TTY::Reader.new(input: input, output: output)
30
32
  @cursor = cursor
33
+ @key_normalizer = KeyNormalizer.new(@reader)
31
34
  @resized = false
32
35
  @previous_winch_handler = nil
33
36
  @mouse_enabled = false
34
37
  end
35
38
 
39
+ # Reads the next event. If a SIGWINCH was received, returns a ResizeEvent with the
40
+ # current terminal dimensions. Mouse escape sequences are parsed by MouseParser;
41
+ # other input is normalized via KeyNormalizer. Returns nil on timeout.
36
42
  def read_event(timeout: nil)
37
43
  return resize_event if resized?
38
44
 
39
45
  raw = @reader.read_keypress(echo: false, raw: true, nonblock: timeout)
40
46
  return nil unless raw
47
+ return MouseParser.parse(raw) if MouseParser.sequence?(raw)
41
48
 
42
- return mouse_event(raw) if mouse_sequence?(raw)
43
-
44
- normalize_keypress(raw)
49
+ @key_normalizer.normalize(raw)
45
50
  rescue Errno::EAGAIN, IO::WaitReadable
46
51
  nil
47
52
  end
48
53
 
54
+ # Keeps terminal input in raw/no-echo mode for the duration of a TUI run. Reading a
55
+ # single keypress in raw mode is not enough: keys pressed while rendering or dispatching
56
+ # events can otherwise be echoed into the alternate screen before the next read.
57
+ def with_raw_input
58
+ return yield unless @input.respond_to?(:tty?) && @input.tty?
59
+ return yield unless @input.respond_to?(:raw) && @input.respond_to?(:noecho)
60
+
61
+ @input.raw do
62
+ @input.noecho do
63
+ yield
64
+ end
65
+ end
66
+ end
67
+
68
+ # Installs a SIGWINCH handler that sets the internal `@resized` flag, returning
69
+ # the previous handler so it can be restored on teardown.
49
70
  def install_resize_handler
50
71
  @previous_winch_handler = Signal.trap("WINCH") { @resized = true }
51
72
  end
52
73
 
74
+ # Installs a SIGINFO handler that marks the terminal as having received focus.
75
+ # SIGINFO is sent by some terminals (notably macOS Terminal.app) on focus changes.
53
76
  def install_focus_handler
54
77
  # Terminal focus change: some terminals send a special sequence
55
78
  # when focus changes. We use this to throttle rendering.
56
79
  @previous_focus_handler = Signal.trap("INFO") { @focused = true }
57
80
  end
58
81
 
82
+ # Restores the previous SIGINFO handler.
59
83
  def restore_focus_handler
60
84
  Signal.trap("INFO", @previous_focus_handler) if @previous_focus_handler
61
85
  @previous_focus_handler = nil
62
86
  end
63
87
 
88
+ # Restores the previous SIGWINCH handler captured by `install_resize_handler`.
64
89
  def restore_resize_handler
65
90
  Signal.trap("WINCH", @previous_winch_handler) if @previous_winch_handler
66
91
  @previous_winch_handler = nil
67
92
  end
68
93
 
94
+ # Emits the ANSI sequences that enable terminal mouse reporting (press, motion, SGR).
95
+ # Idempotent: skipped when mouse tracking is already enabled.
69
96
  def enable_mouse_tracking
70
97
  return if @mouse_enabled
71
98
 
@@ -75,6 +102,7 @@ module Charming
75
102
  @mouse_enabled = true
76
103
  end
77
104
 
105
+ # Emits the ANSI sequences that disable terminal mouse reporting. Idempotent.
78
106
  def disable_mouse_tracking
79
107
  return unless @mouse_enabled
80
108
 
@@ -85,175 +113,97 @@ module Charming
85
113
  @mouse_enabled = false
86
114
  end
87
115
 
116
+ # Returns whether mouse tracking is currently enabled on this backend.
88
117
  def mouse_enabled?
89
118
  @mouse_enabled
90
119
  end
91
120
 
121
+ # Manually flags the backend as resized (used by tests or external integrations).
92
122
  def notify_resize
93
123
  @resized = true
94
124
  end
95
125
 
126
+ # Writes a full multi-line *frame* to the terminal, disabling auto-wrap during
127
+ # the write so overlong lines don't disturb the screen layout.
96
128
  def write_frame(frame)
97
129
  without_auto_wrap do
98
130
  write_positioned_lines(frame.to_s.lines(chomp: true))
99
131
  end
100
132
  end
101
133
 
134
+ # Writes a partial frame composed of [row, line] tuples (1-based rows).
102
135
  def write_lines(line_changes, **)
103
136
  without_auto_wrap do
104
- write_control(line_changes.map { |row, line| "\e[#{row};1H\e[2K#{line}" }.join)
137
+ write_control(line_changes.map { |row, line| positioned_line(row, line) }.join)
105
138
  end
106
139
  end
107
140
 
141
+ # Enters the alternate screen buffer.
108
142
  def enter_alt_screen
109
143
  write_control(ALT_SCREEN_ON)
110
144
  end
111
145
 
146
+ # Leaves the alternate screen buffer.
112
147
  def leave_alt_screen
113
148
  write_control(ALT_SCREEN_OFF)
114
149
  end
115
150
 
151
+ # Shows the terminal cursor.
116
152
  def show_cursor
117
153
  write_control(@cursor.show)
118
154
  end
119
155
 
156
+ # Hides the terminal cursor.
120
157
  def hide_cursor
121
158
  write_control(@cursor.hide)
122
159
  end
123
160
 
161
+ # Clears the terminal screen and moves the cursor to (1, 1).
124
162
  def clear
125
163
  write_control(@cursor.clear_screen)
126
164
  end
127
165
 
166
+ # Moves the terminal cursor to the given 1-based (row, column).
128
167
  def move_cursor(row, column)
129
168
  write_control(@cursor.move_to(column - 1, row - 1))
130
169
  end
131
170
 
171
+ # Returns the current terminal dimensions as [width, height] via TTY::Screen.
132
172
  def size = [TTY::Screen.width, TTY::Screen.height]
133
173
 
134
174
  private
135
175
 
136
- def mouse_sequence?(raw)
137
- return false unless raw.is_a?(String)
138
- return true if raw.match?(MOUSE_SGR_PATTERN)
139
- return true if raw.start_with?("\e[M")
140
-
141
- false
142
- end
143
-
144
- def mouse_event(raw)
145
- if raw.match?(MOUSE_SGR_PATTERN)
146
- parse_sgr_mouse(raw)
147
- else
148
- parse_legacy_mouse(raw)
149
- end
150
- end
151
-
152
- def parse_sgr_mouse(raw)
153
- match = raw.match(MOUSE_SGR_PATTERN)
154
- return nil unless match
155
-
156
- # \e[<button>;<col>;<row><mode>M
157
- button_code = match[1].to_i
158
- col = match[2].to_i - 1
159
- row = match[3].to_i - 1
160
- mode = match[4]
161
-
162
- ctrl = mode == "C"
163
- alt = raw.include?("\e[38;5;")
164
- shift = mode == "M"
165
-
166
- Events::MouseEvent.new(button: button_code, x: col, y: row, ctrl: ctrl, alt: alt, shift: shift)
167
- end
168
-
169
- def parse_legacy_mouse(raw)
170
- # Legacy format: \e[M + 3 bytes (button, col, row)
171
- # Each byte is 32 + value (space offset)
172
- match = raw.match(MOUSE_LEGACY_PATTERN)
173
- return nil unless match
174
-
175
- bytes = match[1].bytes
176
- return nil unless bytes.length == 3
177
-
178
- button_code = bytes[0] - 32
179
- col = bytes[1] - 32
180
- row = bytes[2] - 32
181
-
182
- Events::MouseEvent.new(button: button_code, x: col, y: row)
183
- end
184
-
176
+ # True when the SIGWINCH flag has been set since the last read_event.
185
177
  def resized?
186
178
  @resized
187
179
  end
188
180
 
181
+ # Consumes the resize flag, measures the current terminal, and returns a ResizeEvent.
189
182
  def resize_event
190
183
  @resized = false
191
184
  width, height = size
192
185
  Events::ResizeEvent.new(width: width, height: height)
193
186
  end
194
187
 
195
- def normalize_keypress(keypress)
196
- return nil unless keypress
197
-
198
- key_name = @reader.console.keys[keypress]
199
- return character_event(keypress) unless key_name
200
-
201
- named_event(key_name)
202
- end
203
-
204
- def character_event(keypress)
205
- Events::KeyEvent.new(key: keypress.to_sym, char: keypress)
206
- end
207
-
208
- def named_event(key_name)
209
- normalized = normalize_key_name(key_name)
210
- Events::KeyEvent.new(
211
- key: normalized.fetch(:key),
212
- char: normalized.fetch(:char, nil),
213
- ctrl: normalized.fetch(:ctrl, false),
214
- alt: normalized.fetch(:alt, false),
215
- shift: normalized.fetch(:shift, false)
216
- )
217
- end
218
-
219
- def normalize_key_name(key_name)
220
- name = key_name.to_s
221
- return ctrl_key(name) if name.match?(CTRL_KEY_PATTERN)
222
- return {key: :tab, shift: true} if name == "back_tab"
223
-
224
- {key: normalized_key(name), char: printable_char(name)}
225
- end
226
-
227
- def normalized_key(name)
228
- return :enter if name == "return"
229
-
230
- name.to_sym
231
- end
232
-
233
- def ctrl_key(name)
234
- match = name.match(CTRL_KEY_PATTERN)
235
- {key: match[:key].to_sym, ctrl: true}
236
- end
237
-
238
- def printable_char(name)
239
- case name
240
- when "space" then " "
241
- when "enter", "return" then "\n"
242
- when "tab" then "\t"
243
- else
244
- name if name.length == 1 && !name.match?(/[[:cntrl:]]/)
245
- end
246
- end
247
-
188
+ # Writes a raw escape *sequence* to the output stream and flushes.
248
189
  def write_control(sequence)
249
190
  @output.write(sequence)
250
191
  @output.flush
251
192
  end
252
193
 
194
+ # Writes *lines* one row at a time, with each line preceded by an ANSI cursor
195
+ # position and a clear-to-end-of-line sequence.
253
196
  def write_positioned_lines(lines)
254
- write_control(lines.each_with_index.map { |line, index| "\e[#{index + 1};1H\e[2K#{line}" }.join)
197
+ write_control(lines.each_with_index.map { |line, index| positioned_line(index + 1, line) }.join)
198
+ end
199
+
200
+ # Resets SGR before and after each row so partial repaint rows cannot inherit
201
+ # colors/backgrounds from the previous physical terminal line.
202
+ def positioned_line(row, line)
203
+ "\e[0m\e[#{row};1H\e[2K#{line}\e[0m"
255
204
  end
256
205
 
206
+ # Disables auto-wrap, yields, then re-enables it and flushes the output.
257
207
  def without_auto_wrap
258
208
  @output.write(AUTO_WRAP_OFF)
259
209
  yield
@@ -1,10 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Charming
4
- module Presentation
5
- # Component is the base class for all reusable terminal widgets. It inherits from View to gain assigns,
6
- # helper methods (text, box, row, column, etc.), and rendering via render.
7
- class Component < View
8
- end
4
+ # Component is the base class for all reusable terminal widgets. It inherits from View to gain assigns,
5
+ # helper methods (text, box, row, column, etc.), and rendering via render.
6
+ class Component < View
9
7
  end
10
8
  end