bubbletea 0.0.1 → 0.1.0

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.
@@ -0,0 +1,376 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bubbletea
4
+ # Runner manages the event loop and coordinates between the model and the terminal
5
+ class Runner
6
+ attr_reader :options
7
+
8
+ DEFAULT_OPTIONS = {
9
+ alt_screen: false,
10
+ mouse_cell_motion: false,
11
+ mouse_all_motion: false,
12
+ bracketed_paste: false,
13
+ report_focus: false,
14
+ fps: 60,
15
+ input_timeout: 10,
16
+ without_renderer: false,
17
+ }.freeze
18
+
19
+ def initialize(model, **options)
20
+ @model = model
21
+ @options = DEFAULT_OPTIONS.merge(options)
22
+ @program = Program.new
23
+ @renderer_id = nil
24
+ @running = false
25
+ @pending_ticks = []
26
+ @width = 80
27
+ @height = 24
28
+ @resize_pending = false
29
+ @previous_winch_handler = nil
30
+ @in_alt_screen = false
31
+ end
32
+
33
+ def run
34
+ setup_terminal
35
+ @renderer_id = @program.create_renderer unless @options[:without_renderer]
36
+
37
+ update_terminal_size
38
+ @running = true
39
+
40
+ new_model, command = @model.init
41
+ @model = new_model if new_model
42
+ process_command(command)
43
+
44
+ return unless @running
45
+
46
+ handle_message(WindowSizeMessage.new(width: @width, height: @height))
47
+
48
+ render
49
+ run_loop
50
+ render
51
+ ensure
52
+ cleanup_terminal
53
+ end
54
+
55
+ def send(message)
56
+ @pending_messages ||= []
57
+ @pending_messages << message
58
+ end
59
+
60
+ private
61
+
62
+ def setup_terminal
63
+ @program.enter_raw_mode
64
+ @program.hide_cursor
65
+ @program.start_input_reader
66
+
67
+ if @options[:alt_screen]
68
+ @program.enter_alt_screen
69
+ @program.renderer_set_alt_screen(@renderer_id, true) if @renderer_id
70
+ @in_alt_screen = true
71
+ end
72
+
73
+ @program.enable_mouse_cell_motion if @options[:mouse_cell_motion]
74
+ @program.enable_mouse_all_motion if @options[:mouse_all_motion]
75
+ @program.enable_bracketed_paste if @options[:bracketed_paste]
76
+ @program.enable_report_focus if @options[:report_focus]
77
+
78
+ setup_resize_handler
79
+ end
80
+
81
+ def cleanup_terminal
82
+ restore_resize_handler
83
+
84
+ @program.disable_mouse if @options[:mouse_cell_motion] || @options[:mouse_all_motion]
85
+ @program.disable_bracketed_paste if @options[:bracketed_paste]
86
+ @program.disable_report_focus if @options[:report_focus]
87
+
88
+ if @in_alt_screen
89
+ @program.exit_alt_screen
90
+ else
91
+ print "\r\n"
92
+ end
93
+
94
+ @program.show_cursor
95
+ @program.stop_input_reader
96
+ @program.exit_raw_mode
97
+ end
98
+
99
+ def setup_resize_handler
100
+ @previous_winch_handler = Signal.trap("WINCH") do
101
+ @resize_pending = true
102
+ @previous_winch_handler.call if @previous_winch_handler.is_a?(Proc)
103
+ end
104
+ rescue ArgumentError
105
+ # SIGWINCH not supported on this platform
106
+ @previous_winch_handler = nil
107
+ end
108
+
109
+ def restore_resize_handler
110
+ if @previous_winch_handler
111
+ Signal.trap("WINCH", @previous_winch_handler)
112
+ else
113
+ Signal.trap("WINCH", "DEFAULT")
114
+ end
115
+ rescue ArgumentError
116
+ # SIGWINCH not supported on this platform
117
+ end
118
+
119
+ def update_terminal_size
120
+ size = @program.terminal_size
121
+ return unless size
122
+
123
+ @width, @height = size
124
+ @program.renderer_set_size(@renderer_id, @width, @height) if @renderer_id
125
+ end
126
+
127
+ def run_loop
128
+ frame_duration = 1.0 / @options[:fps]
129
+ last_frame = Time.now
130
+
131
+ while @running
132
+ check_resize
133
+ process_pending_messages
134
+
135
+ event = @program.poll_event(@options[:input_timeout])
136
+
137
+ if event
138
+ message = Bubbletea.parse_event(event)
139
+ handle_message(message) if message
140
+ end
141
+
142
+ process_ticks
143
+
144
+ now = Time.now
145
+
146
+ if now - last_frame >= frame_duration
147
+ render
148
+ last_frame = now
149
+ end
150
+ end
151
+ end
152
+
153
+ def check_resize
154
+ return unless @resize_pending
155
+
156
+ @resize_pending = false
157
+
158
+ size = @program.terminal_size
159
+ return unless size
160
+
161
+ new_width, new_height = size
162
+
163
+ return if new_width == @width && new_height == @height
164
+
165
+ @width = new_width
166
+ @height = new_height
167
+
168
+ @program.renderer_set_size(@renderer_id, @width, @height) if @renderer_id
169
+
170
+ handle_message(WindowSizeMessage.new(width: @width, height: @height))
171
+ end
172
+
173
+ def process_pending_messages
174
+ return unless @pending_messages&.any?
175
+
176
+ messages = @pending_messages
177
+ @pending_messages = []
178
+
179
+ messages.each { |message| handle_message(message) }
180
+ end
181
+
182
+ def handle_message(message)
183
+ return unless @running
184
+
185
+ new_model, command = @model.update(message)
186
+ @model = new_model if new_model
187
+ process_command(command)
188
+ end
189
+
190
+ def process_command(command)
191
+ return if command.nil?
192
+
193
+ case command
194
+ when QuitCommand
195
+ @running = false
196
+
197
+ when BatchCommand
198
+ Thread.new do
199
+ execute_batch_sync(command.commands)
200
+ end
201
+
202
+ when SequenceCommand
203
+ Thread.new do
204
+ execute_sequence_sync(command.commands)
205
+ end
206
+
207
+ when TickCommand
208
+ schedule_tick(command)
209
+
210
+ when SendMessage
211
+ if command.delay.positive?
212
+ schedule_delayed_message(command)
213
+ else
214
+ handle_message(command.message)
215
+ end
216
+
217
+ when EnterAltScreenCommand
218
+ @program.enter_alt_screen
219
+ @program.renderer_set_alt_screen(@renderer_id, true) if @renderer_id
220
+ @in_alt_screen = true
221
+
222
+ when ExitAltScreenCommand
223
+ @program.exit_alt_screen
224
+ @program.renderer_set_alt_screen(@renderer_id, false) if @renderer_id
225
+ @in_alt_screen = false
226
+
227
+ when SetWindowTitleCommand
228
+ Bubbletea._set_window_title(command.title)
229
+
230
+ when PutsCommand
231
+ warn "\r#{command.text}\r"
232
+
233
+ when SuspendCommand
234
+ suspend_process
235
+
236
+ when Proc
237
+ Thread.new do
238
+ result = command.call
239
+ next unless result
240
+
241
+ if result.is_a?(Message)
242
+ send(result)
243
+ else
244
+ process_command(result)
245
+ end
246
+ end
247
+ end
248
+ end
249
+
250
+ def execute_command_sync(command)
251
+ return if command.nil?
252
+
253
+ case command
254
+ when QuitCommand
255
+ @running = false
256
+
257
+ when BatchCommand
258
+ execute_batch_sync(command.commands)
259
+
260
+ when SequenceCommand
261
+ execute_sequence_sync(command.commands)
262
+
263
+ when TickCommand
264
+ schedule_tick(command)
265
+
266
+ when SendMessage
267
+ sleep(command.delay) if command.delay.positive?
268
+ handle_message(command.message)
269
+
270
+ when EnterAltScreenCommand
271
+ @program.enter_alt_screen
272
+ @program.renderer_set_alt_screen(@renderer_id, true) if @renderer_id
273
+ @in_alt_screen = true
274
+
275
+ when ExitAltScreenCommand
276
+ @program.exit_alt_screen
277
+ @program.renderer_set_alt_screen(@renderer_id, false) if @renderer_id
278
+ @in_alt_screen = false
279
+
280
+ when SetWindowTitleCommand
281
+ Bubbletea._set_window_title(command.title)
282
+
283
+ when PutsCommand
284
+ warn "\r#{command.text}\r"
285
+
286
+ when SuspendCommand
287
+ suspend_process
288
+
289
+ when Proc
290
+ result = command.call
291
+ return unless result
292
+
293
+ if result.is_a?(Message)
294
+ send(result)
295
+ else
296
+ execute_command_sync(result)
297
+ end
298
+ end
299
+ end
300
+
301
+ def execute_sequence_sync(commands)
302
+ commands.each do |cmd|
303
+ break unless @running
304
+
305
+ execute_command_sync(cmd)
306
+ end
307
+ end
308
+
309
+ def execute_batch_sync(commands)
310
+ threads = commands.map do |cmd|
311
+ Thread.new { execute_command_sync(cmd) }
312
+ end
313
+
314
+ threads.each(&:join)
315
+ end
316
+
317
+ def schedule_tick(tick_command)
318
+ @pending_ticks << {
319
+ at: Time.now + tick_command.duration,
320
+ callback: tick_command.callback,
321
+ }
322
+ end
323
+
324
+ def schedule_delayed_message(send_command)
325
+ @pending_ticks << {
326
+ at: Time.now + send_command.delay,
327
+ message: send_command.message,
328
+ }
329
+ end
330
+
331
+ def process_ticks
332
+ now = Time.now
333
+ ready, @pending_ticks = @pending_ticks.partition { |tick| tick[:at] <= now }
334
+
335
+ ready.each do |tick|
336
+ if tick[:callback]
337
+ result = tick[:callback].call
338
+ handle_message(result) if result
339
+ elsif tick[:message]
340
+ handle_message(tick[:message])
341
+ end
342
+ end
343
+ end
344
+
345
+ def suspend_process
346
+ @program.disable_mouse if @options[:mouse_cell_motion] || @options[:mouse_all_motion]
347
+ @program.show_cursor
348
+ @program.stop_input_reader
349
+ @program.exit_raw_mode
350
+
351
+ Process.kill("TSTP", Process.pid)
352
+
353
+ # When we get here, we've been resumed (SIGCONT was received)
354
+ @program.enter_raw_mode
355
+ @program.hide_cursor
356
+ @program.start_input_reader
357
+ @program.enable_mouse_cell_motion if @options[:mouse_cell_motion]
358
+ @program.enable_mouse_all_motion if @options[:mouse_all_motion]
359
+
360
+ handle_message(ResumeMessage.new)
361
+ end
362
+
363
+ def render
364
+ return if @options[:without_renderer]
365
+ return unless @renderer_id
366
+
367
+ view = @model.view
368
+ @program.render(@renderer_id, view)
369
+ end
370
+ end
371
+
372
+ def self.run(model, **options)
373
+ runner = Runner.new(model, **options)
374
+ runner.run
375
+ end
376
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bubbletea
4
- VERSION = "0.0.1"
4
+ VERSION = "0.1.0"
5
5
  end
data/lib/bubbletea.rb CHANGED
@@ -2,7 +2,18 @@
2
2
 
3
3
  require_relative "bubbletea/version"
4
4
 
5
+ begin
6
+ major, minor, _patch = RUBY_VERSION.split(".") #: [String, String, String]
7
+ require_relative "bubbletea/#{major}.#{minor}/bubbletea"
8
+ rescue LoadError
9
+ require_relative "bubbletea/bubbletea"
10
+ end
11
+
12
+ require_relative "bubbletea/messages"
13
+ require_relative "bubbletea/commands"
14
+ require_relative "bubbletea/model"
15
+ require_relative "bubbletea/runner"
16
+
5
17
  module Bubbletea
6
18
  class Error < StandardError; end
7
- # Your code goes here...
8
19
  end
metadata CHANGED
@@ -1,36 +1,64 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bubbletea
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Marco Roth
8
8
  bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
- dependencies: []
12
- description: A powerful little TUI framework. Integrate Charm's Bubble Tea with the
13
- RubyGems infrastructure.
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: lipgloss
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.1'
26
+ description: Build beautiful, interactive terminal applications using the Elm Architecture
27
+ in Ruby.
14
28
  email:
15
29
  - marco.roth@intergga.ch
16
30
  executables: []
17
- extensions: []
31
+ extensions:
32
+ - ext/bubbletea/extconf.rb
18
33
  extra_rdoc_files: []
19
34
  files:
20
- - CHANGELOG.md
21
- - CODE_OF_CONDUCT.md
35
+ - LICENSE.txt
22
36
  - README.md
23
- - Rakefile
37
+ - bubbletea.gemspec
38
+ - ext/bubbletea/extconf.rb
39
+ - ext/bubbletea/extension.c
40
+ - ext/bubbletea/extension.h
41
+ - ext/bubbletea/program.c
42
+ - go/bubbletea.go
43
+ - go/go.mod
44
+ - go/go.sum
45
+ - go/input.go
46
+ - go/keys.go
47
+ - go/renderer.go
48
+ - go/terminal.go
24
49
  - lib/bubbletea.rb
50
+ - lib/bubbletea/commands.rb
51
+ - lib/bubbletea/messages.rb
52
+ - lib/bubbletea/model.rb
53
+ - lib/bubbletea/runner.rb
25
54
  - lib/bubbletea/version.rb
26
- - sig/bubbletea.rbs
27
- homepage: https://github.com/marcoroth/bubbletea
55
+ homepage: https://github.com/marcoroth/bubbletea-ruby
28
56
  licenses:
29
57
  - MIT
30
58
  metadata:
31
- homepage_uri: https://github.com/marcoroth/bubbletea
32
- source_code_uri: https://github.com/marcoroth/bubbletea
33
- changelog_uri: https://github.com/marcoroth/bubbletea/blob/main/CHANGELOG.md
59
+ homepage_uri: https://github.com/marcoroth/bubbletea-ruby
60
+ source_code_uri: https://github.com/marcoroth/bubbletea-ruby
61
+ changelog_uri: https://github.com/marcoroth/bubbletea-ruby/releases
34
62
  rubygems_mfa_required: 'true'
35
63
  rdoc_options: []
36
64
  require_paths:
@@ -46,7 +74,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
46
74
  - !ruby/object:Gem::Version
47
75
  version: '0'
48
76
  requirements: []
49
- rubygems_version: 3.6.9
77
+ rubygems_version: 4.0.3
50
78
  specification_version: 4
51
- summary: Ruby wrapper for Charm's bubbletea CLI tool.
79
+ summary: Ruby wrapper for Charm's bubbletea. A powerful TUI framework.
52
80
  test_files: []
data/CHANGELOG.md DELETED
@@ -1,5 +0,0 @@
1
- ## [Unreleased]
2
-
3
- ## [0.1.0] - 2025-12-07
4
-
5
- - Initial release
data/CODE_OF_CONDUCT.md DELETED
@@ -1,132 +0,0 @@
1
- # Contributor Covenant Code of Conduct
2
-
3
- ## Our Pledge
4
-
5
- We as members, contributors, and leaders pledge to make participation in our
6
- community a harassment-free experience for everyone, regardless of age, body
7
- size, visible or invisible disability, ethnicity, sex characteristics, gender
8
- identity and expression, level of experience, education, socio-economic status,
9
- nationality, personal appearance, race, caste, color, religion, or sexual
10
- identity and orientation.
11
-
12
- We pledge to act and interact in ways that contribute to an open, welcoming,
13
- diverse, inclusive, and healthy community.
14
-
15
- ## Our Standards
16
-
17
- Examples of behavior that contributes to a positive environment for our
18
- community include:
19
-
20
- * Demonstrating empathy and kindness toward other people
21
- * Being respectful of differing opinions, viewpoints, and experiences
22
- * Giving and gracefully accepting constructive feedback
23
- * Accepting responsibility and apologizing to those affected by our mistakes,
24
- and learning from the experience
25
- * Focusing on what is best not just for us as individuals, but for the overall
26
- community
27
-
28
- Examples of unacceptable behavior include:
29
-
30
- * The use of sexualized language or imagery, and sexual attention or advances of
31
- any kind
32
- * Trolling, insulting or derogatory comments, and personal or political attacks
33
- * Public or private harassment
34
- * Publishing others' private information, such as a physical or email address,
35
- without their explicit permission
36
- * Other conduct which could reasonably be considered inappropriate in a
37
- professional setting
38
-
39
- ## Enforcement Responsibilities
40
-
41
- Community leaders are responsible for clarifying and enforcing our standards of
42
- acceptable behavior and will take appropriate and fair corrective action in
43
- response to any behavior that they deem inappropriate, threatening, offensive,
44
- or harmful.
45
-
46
- Community leaders have the right and responsibility to remove, edit, or reject
47
- comments, commits, code, wiki edits, issues, and other contributions that are
48
- not aligned to this Code of Conduct, and will communicate reasons for moderation
49
- decisions when appropriate.
50
-
51
- ## Scope
52
-
53
- This Code of Conduct applies within all community spaces, and also applies when
54
- an individual is officially representing the community in public spaces.
55
- Examples of representing our community include using an official email address,
56
- posting via an official social media account, or acting as an appointed
57
- representative at an online or offline event.
58
-
59
- ## Enforcement
60
-
61
- Instances of abusive, harassing, or otherwise unacceptable behavior may be
62
- reported to the community leaders responsible for enforcement at
63
- [INSERT CONTACT METHOD].
64
- All complaints will be reviewed and investigated promptly and fairly.
65
-
66
- All community leaders are obligated to respect the privacy and security of the
67
- reporter of any incident.
68
-
69
- ## Enforcement Guidelines
70
-
71
- Community leaders will follow these Community Impact Guidelines in determining
72
- the consequences for any action they deem in violation of this Code of Conduct:
73
-
74
- ### 1. Correction
75
-
76
- **Community Impact**: Use of inappropriate language or other behavior deemed
77
- unprofessional or unwelcome in the community.
78
-
79
- **Consequence**: A private, written warning from community leaders, providing
80
- clarity around the nature of the violation and an explanation of why the
81
- behavior was inappropriate. A public apology may be requested.
82
-
83
- ### 2. Warning
84
-
85
- **Community Impact**: A violation through a single incident or series of
86
- actions.
87
-
88
- **Consequence**: A warning with consequences for continued behavior. No
89
- interaction with the people involved, including unsolicited interaction with
90
- those enforcing the Code of Conduct, for a specified period of time. This
91
- includes avoiding interactions in community spaces as well as external channels
92
- like social media. Violating these terms may lead to a temporary or permanent
93
- ban.
94
-
95
- ### 3. Temporary Ban
96
-
97
- **Community Impact**: A serious violation of community standards, including
98
- sustained inappropriate behavior.
99
-
100
- **Consequence**: A temporary ban from any sort of interaction or public
101
- communication with the community for a specified period of time. No public or
102
- private interaction with the people involved, including unsolicited interaction
103
- with those enforcing the Code of Conduct, is allowed during this period.
104
- Violating these terms may lead to a permanent ban.
105
-
106
- ### 4. Permanent Ban
107
-
108
- **Community Impact**: Demonstrating a pattern of violation of community
109
- standards, including sustained inappropriate behavior, harassment of an
110
- individual, or aggression toward or disparagement of classes of individuals.
111
-
112
- **Consequence**: A permanent ban from any sort of public interaction within the
113
- community.
114
-
115
- ## Attribution
116
-
117
- This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118
- version 2.1, available at
119
- [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
120
-
121
- Community Impact Guidelines were inspired by
122
- [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
123
-
124
- For answers to common questions about this code of conduct, see the FAQ at
125
- [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
126
- [https://www.contributor-covenant.org/translations][translations].
127
-
128
- [homepage]: https://www.contributor-covenant.org
129
- [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
130
- [Mozilla CoC]: https://github.com/mozilla/diversity
131
- [FAQ]: https://www.contributor-covenant.org/faq
132
- [translations]: https://www.contributor-covenant.org/translations
data/Rakefile DELETED
@@ -1,12 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "bundler/gem_tasks"
4
- require "minitest/test_task"
5
-
6
- Minitest::TestTask.create
7
-
8
- require "rubocop/rake_task"
9
-
10
- RuboCop::RakeTask.new
11
-
12
- task default: %i[test rubocop]
data/sig/bubbletea.rbs DELETED
@@ -1,4 +0,0 @@
1
- module Bubbletea
2
- VERSION: String
3
- # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
- end