charming 0.1.1 → 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 (122) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/lib/charming/application.rb +11 -0
  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 +113 -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 +15 -0
  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 +43 -113
  68. data/lib/charming/presentation/components/empty_state.rb +13 -0
  69. data/lib/charming/presentation/components/form/builder.rb +14 -0
  70. data/lib/charming/presentation/components/form/confirm.rb +13 -0
  71. data/lib/charming/presentation/components/form/field.rb +25 -0
  72. data/lib/charming/presentation/components/form/input.rb +14 -0
  73. data/lib/charming/presentation/components/form/note.rb +9 -0
  74. data/lib/charming/presentation/components/form/select.rb +23 -0
  75. data/lib/charming/presentation/components/form/textarea.rb +16 -0
  76. data/lib/charming/presentation/components/form.rb +29 -0
  77. data/lib/charming/presentation/components/list.rb +28 -0
  78. data/lib/charming/presentation/components/markdown.rb +6 -0
  79. data/lib/charming/presentation/components/modal.rb +14 -0
  80. data/lib/charming/presentation/components/progressbar.rb +13 -0
  81. data/lib/charming/presentation/components/spinner.rb +10 -0
  82. data/lib/charming/presentation/components/table.rb +25 -0
  83. data/lib/charming/presentation/components/text_area.rb +48 -0
  84. data/lib/charming/presentation/components/text_input.rb +24 -0
  85. data/lib/charming/presentation/components/viewport.rb +52 -0
  86. data/lib/charming/presentation/layout/builder.rb +86 -0
  87. data/lib/charming/presentation/layout/overlay.rb +57 -0
  88. data/lib/charming/presentation/layout/pane.rb +145 -0
  89. data/lib/charming/presentation/layout/rect.rb +23 -0
  90. data/lib/charming/presentation/layout/screen_layout.rb +60 -0
  91. data/lib/charming/presentation/layout/split.rb +134 -0
  92. data/lib/charming/presentation/markdown/block_renderers.rb +120 -0
  93. data/lib/charming/presentation/markdown/inline_renderers.rb +68 -0
  94. data/lib/charming/presentation/markdown/render_context.rb +22 -0
  95. data/lib/charming/presentation/markdown/renderer.rb +45 -135
  96. data/lib/charming/presentation/markdown/syntax_highlighter.rb +16 -0
  97. data/lib/charming/presentation/markdown.rb +3 -0
  98. data/lib/charming/presentation/template_view.rb +7 -0
  99. data/lib/charming/presentation/templates.rb +17 -0
  100. data/lib/charming/presentation/ui/ansi_codes.rb +89 -0
  101. data/lib/charming/presentation/ui/ansi_slicer.rb +94 -0
  102. data/lib/charming/presentation/ui/border_painter.rb +58 -0
  103. data/lib/charming/presentation/ui/canvas.rb +82 -0
  104. data/lib/charming/presentation/ui/style.rb +62 -95
  105. data/lib/charming/presentation/ui.rb +15 -156
  106. data/lib/charming/presentation/view.rb +17 -0
  107. data/lib/charming/runtime.rb +2 -0
  108. data/lib/charming/tasks/inline_executor.rb +9 -0
  109. data/lib/charming/tasks/task.rb +3 -0
  110. data/lib/charming/tasks/threaded_executor.rb +12 -0
  111. data/lib/charming/version.rb +1 -1
  112. data/lib/charming.rb +13 -0
  113. metadata +59 -10
  114. data/lib/charming/generators/app_generator/app_spec_templates.rb +0 -90
  115. data/lib/charming/generators/app_generator/basic_templates.rb +0 -81
  116. data/lib/charming/generators/app_generator/component_templates.rb +0 -36
  117. data/lib/charming/generators/app_generator/controller_template.rb +0 -60
  118. data/lib/charming/generators/app_generator/database_templates.rb +0 -45
  119. data/lib/charming/generators/app_generator/layout_template.rb +0 -66
  120. data/lib/charming/generators/app_generator/screen_spec_templates.rb +0 -69
  121. data/lib/charming/generators/app_generator/state_templates.rb +0 -30
  122. 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,78 @@ 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
+ # Installs a SIGWINCH handler that sets the internal `@resized` flag, returning
55
+ # the previous handler so it can be restored on teardown.
49
56
  def install_resize_handler
50
57
  @previous_winch_handler = Signal.trap("WINCH") { @resized = true }
51
58
  end
52
59
 
60
+ # Installs a SIGINFO handler that marks the terminal as having received focus.
61
+ # SIGINFO is sent by some terminals (notably macOS Terminal.app) on focus changes.
53
62
  def install_focus_handler
54
63
  # Terminal focus change: some terminals send a special sequence
55
64
  # when focus changes. We use this to throttle rendering.
56
65
  @previous_focus_handler = Signal.trap("INFO") { @focused = true }
57
66
  end
58
67
 
68
+ # Restores the previous SIGINFO handler.
59
69
  def restore_focus_handler
60
70
  Signal.trap("INFO", @previous_focus_handler) if @previous_focus_handler
61
71
  @previous_focus_handler = nil
62
72
  end
63
73
 
74
+ # Restores the previous SIGWINCH handler captured by `install_resize_handler`.
64
75
  def restore_resize_handler
65
76
  Signal.trap("WINCH", @previous_winch_handler) if @previous_winch_handler
66
77
  @previous_winch_handler = nil
67
78
  end
68
79
 
80
+ # Emits the ANSI sequences that enable terminal mouse reporting (press, motion, SGR).
81
+ # Idempotent: skipped when mouse tracking is already enabled.
69
82
  def enable_mouse_tracking
70
83
  return if @mouse_enabled
71
84
 
@@ -75,6 +88,7 @@ module Charming
75
88
  @mouse_enabled = true
76
89
  end
77
90
 
91
+ # Emits the ANSI sequences that disable terminal mouse reporting. Idempotent.
78
92
  def disable_mouse_tracking
79
93
  return unless @mouse_enabled
80
94
 
@@ -85,175 +99,91 @@ module Charming
85
99
  @mouse_enabled = false
86
100
  end
87
101
 
102
+ # Returns whether mouse tracking is currently enabled on this backend.
88
103
  def mouse_enabled?
89
104
  @mouse_enabled
90
105
  end
91
106
 
107
+ # Manually flags the backend as resized (used by tests or external integrations).
92
108
  def notify_resize
93
109
  @resized = true
94
110
  end
95
111
 
112
+ # Writes a full multi-line *frame* to the terminal, disabling auto-wrap during
113
+ # the write so overlong lines don't disturb the screen layout.
96
114
  def write_frame(frame)
97
115
  without_auto_wrap do
98
116
  write_positioned_lines(frame.to_s.lines(chomp: true))
99
117
  end
100
118
  end
101
119
 
120
+ # Writes a partial frame composed of [row, line] tuples (1-based rows).
102
121
  def write_lines(line_changes, **)
103
122
  without_auto_wrap do
104
123
  write_control(line_changes.map { |row, line| "\e[#{row};1H\e[2K#{line}" }.join)
105
124
  end
106
125
  end
107
126
 
127
+ # Enters the alternate screen buffer.
108
128
  def enter_alt_screen
109
129
  write_control(ALT_SCREEN_ON)
110
130
  end
111
131
 
132
+ # Leaves the alternate screen buffer.
112
133
  def leave_alt_screen
113
134
  write_control(ALT_SCREEN_OFF)
114
135
  end
115
136
 
137
+ # Shows the terminal cursor.
116
138
  def show_cursor
117
139
  write_control(@cursor.show)
118
140
  end
119
141
 
142
+ # Hides the terminal cursor.
120
143
  def hide_cursor
121
144
  write_control(@cursor.hide)
122
145
  end
123
146
 
147
+ # Clears the terminal screen and moves the cursor to (1, 1).
124
148
  def clear
125
149
  write_control(@cursor.clear_screen)
126
150
  end
127
151
 
152
+ # Moves the terminal cursor to the given 1-based (row, column).
128
153
  def move_cursor(row, column)
129
154
  write_control(@cursor.move_to(column - 1, row - 1))
130
155
  end
131
156
 
157
+ # Returns the current terminal dimensions as [width, height] via TTY::Screen.
132
158
  def size = [TTY::Screen.width, TTY::Screen.height]
133
159
 
134
160
  private
135
161
 
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
-
162
+ # True when the SIGWINCH flag has been set since the last read_event.
185
163
  def resized?
186
164
  @resized
187
165
  end
188
166
 
167
+ # Consumes the resize flag, measures the current terminal, and returns a ResizeEvent.
189
168
  def resize_event
190
169
  @resized = false
191
170
  width, height = size
192
171
  Events::ResizeEvent.new(width: width, height: height)
193
172
  end
194
173
 
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
-
174
+ # Writes a raw escape *sequence* to the output stream and flushes.
248
175
  def write_control(sequence)
249
176
  @output.write(sequence)
250
177
  @output.flush
251
178
  end
252
179
 
180
+ # Writes *lines* one row at a time, with each line preceded by an ANSI cursor
181
+ # position and a clear-to-end-of-line sequence.
253
182
  def write_positioned_lines(lines)
254
183
  write_control(lines.each_with_index.map { |line, index| "\e[#{index + 1};1H\e[2K#{line}" }.join)
255
184
  end
256
185
 
186
+ # Disables auto-wrap, yields, then re-enables it and flushes the output.
257
187
  def without_auto_wrap
258
188
  @output.write(AUTO_WRAP_OFF)
259
189
  yield
@@ -3,7 +3,14 @@
3
3
  module Charming
4
4
  module Presentation
5
5
  module Components
6
+ # EmptyState is a placeholder component for screens with no content. Renders one of three
7
+ # states: a default "nothing to show" message, a "loading…" message, or an error message
8
+ # with optional help text.
6
9
  class EmptyState < Component
10
+ # *message* is shown in the default state. *loading* switches to the loading message
11
+ # (overrides *message*). *loading_message* is the string rendered in the loading state.
12
+ # *error* and *error_message* switch to the error state (the string form takes precedence).
13
+ # *help* is an optional muted line shown below the error message.
7
14
  def initialize(message: "Nothing to show.", loading: false, loading_message: "Loading...", error: nil, error_message: nil, help: nil, theme: nil)
8
15
  super(theme: theme)
9
16
  @message = message
@@ -14,6 +21,8 @@ module Charming
14
21
  @help = help
15
22
  end
16
23
 
24
+ # Renders the appropriate state as styled text: loading → loading message, error →
25
+ # error message + help, otherwise the default message.
17
26
  def render
18
27
  return loading_state if @loading
19
28
  return error_state if error?
@@ -23,10 +32,13 @@ module Charming
23
32
 
24
33
  private
25
34
 
35
+ # Renders the loading state as a muted line.
26
36
  def loading_state
27
37
  text @loading_message, style: theme.muted
28
38
  end
29
39
 
40
+ # Renders the error state: the error message styled with the theme's warn style,
41
+ # optionally followed by a muted help line.
30
42
  def error_state
31
43
  lines = [text(@error_message || @error.to_s, style: theme.warn)]
32
44
  lines << text(@help, style: theme.muted) if @help.to_s.strip != ""
@@ -34,6 +46,7 @@ module Charming
34
46
  column(*lines)
35
47
  end
36
48
 
49
+ # True when either the *error* or *error_message* string is non-blank.
37
50
  def error?
38
51
  @error.to_s.strip != "" || @error_message.to_s.strip != ""
39
52
  end
@@ -4,40 +4,54 @@ module Charming
4
4
  module Presentation
5
5
  module Components
6
6
  class Form
7
+ # Builder collects form field declarations inside a `form(:name) { ... }` block and
8
+ # assembles them into a Form component when `build` is called. Each declaration method
9
+ # appends a Field subclass instance to the builder's *fields* list.
7
10
  class Builder
11
+ # The accumulated field list and the theme applied to each declared field.
8
12
  attr_reader :fields, :theme
9
13
 
14
+ # Initializes an empty builder. *theme* is forwarded to every declared field unless
15
+ # the field declaration explicitly overrides it.
10
16
  def initialize(theme: nil)
11
17
  @theme = theme
12
18
  @fields = []
13
19
  end
14
20
 
21
+ # Appends a single-line Input field. *options* are passed through to Input.
15
22
  def input(name, **options)
16
23
  fields << Input.new(name, **field_options(options))
17
24
  end
18
25
 
26
+ # Appends a multi-line Textarea field.
19
27
  def textarea(name, **options)
20
28
  fields << Textarea.new(name, **field_options(options))
21
29
  end
22
30
 
31
+ # Appends a Select field with the given *options* array.
23
32
  def select(name, **options)
24
33
  fields << Select.new(name, **field_options(options))
25
34
  end
26
35
 
36
+ # Appends a Confirm (boolean) field.
27
37
  def confirm(name, **options)
28
38
  fields << Confirm.new(name, **field_options(options))
29
39
  end
30
40
 
41
+ # Appends a static Note (non-focusable).
31
42
  def note(text, **options)
32
43
  fields << Note.new(text, **field_options(options))
33
44
  end
34
45
 
46
+ # Assembles the collected fields into a Form component, bound to *state* and using
47
+ # the *theme* argument (falling back to the builder's theme).
35
48
  def build(state:, theme: nil)
36
49
  Components::Form.new(fields: fields, state: state, theme: theme || self.theme)
37
50
  end
38
51
 
39
52
  private
40
53
 
54
+ # Merges the builder's theme into the per-field *options* so each field receives it.
41
55
  def field_options(options)
42
56
  {theme: theme}.merge(options)
43
57
  end
@@ -4,12 +4,19 @@ module Charming
4
4
  module Presentation
5
5
  module Components
6
6
  class Form
7
+ # Confirm is a boolean Form field that renders a checkbox-style control. Space toggles
8
+ # the value; y/Right sets it to true; n/Left sets it to false. Required confirms must
9
+ # be accepted (value == true) to pass validation.
7
10
  class Confirm < Field
11
+ # *value* is the initial boolean state (default: false). All other options are
12
+ # forwarded to Field.
8
13
  def initialize(name, value: false, **options)
9
14
  super(name, **options)
10
15
  @initial_value = value
11
16
  end
12
17
 
18
+ # Handles the standard confirm keys: space toggles, y/right sets to true, n/left
19
+ # sets to false, and a space character (when the event exposes `char`) also toggles.
13
20
  def handle_key(event)
14
21
  case Charming.key_of(event)
15
22
  when :space
@@ -26,6 +33,8 @@ module Charming
26
33
  :handled
27
34
  end
28
35
 
36
+ # Returns ["must be accepted"] when required and the value is not true, otherwise
37
+ # the result of the base Field validation.
29
38
  def validate
30
39
  return ["must be accepted"] if required? && value != true
31
40
 
@@ -34,18 +43,22 @@ module Charming
34
43
 
35
44
  private
36
45
 
46
+ # The default value for a freshly-bound field is the *value* passed at construction.
37
47
  def default_value
38
48
  @initial_value
39
49
  end
40
50
 
51
+ # Renders "[x] Label" or "[ ] Label" depending on the current value.
41
52
  def render_control
42
53
  "#{checked_marker} #{label}"
43
54
  end
44
55
 
56
+ # Returns the checkbox marker string.
45
57
  def checked_marker
46
58
  value ? "[x]" : "[ ]"
47
59
  end
48
60
 
61
+ # Flips the current value (true ↔ false).
49
62
  def toggle
50
63
  state[:values][name] = !value
51
64
  end