milktea 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,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty-screen"
4
+
5
+ module Milktea
6
+ # Base model class for creating TUI components following the Elm Architecture
7
+ class Model
8
+ attr_reader :state, :children
9
+
10
+ class << self
11
+ # Define a child model with optional state mapping
12
+ # @param klass [Class, Symbol] The child model class or method name
13
+ # @param mapper [Proc] Lambda to map parent state to child state
14
+ def child(klass, mapper = nil)
15
+ @children ||= []
16
+ @children << {
17
+ class: klass,
18
+ mapper: mapper || ->(_state) { {} }
19
+ }
20
+ end
21
+
22
+ # Get all child definitions for this model
23
+ # @return [Array<Hash>] Array of child definitions
24
+ def children
25
+ @children ||= []
26
+ end
27
+ end
28
+
29
+ def initialize(state = {})
30
+ @state = default_state.merge(state).freeze
31
+ @children = build_children(@state)
32
+ end
33
+
34
+ # Render the model to a string representation
35
+ # @return [String] The rendered output
36
+ def view
37
+ raise NotImplementedError, "#{self.class} must implement #view"
38
+ end
39
+
40
+ # Update the model based on a message
41
+ # @param message [Object] The message to process
42
+ # @return [Array(Model, Message)] New model and side effect message
43
+ def update(message)
44
+ raise NotImplementedError, "#{self.class} must implement #update"
45
+ end
46
+
47
+ # Create a new instance with updated state
48
+ # @param new_state [Hash] State updates to merge
49
+ # @return [Model] New model instance with updated state
50
+ def with(new_state = {})
51
+ merged_state = @state.merge(new_state)
52
+ return Kernel.const_get(self.class.name).new(merged_state) if self.class.name
53
+
54
+ self.class.new(merged_state)
55
+ end
56
+
57
+ # Combine all children views into a single string
58
+ # @return [String] Combined views of all children
59
+ def children_views
60
+ @children.map(&:view).join
61
+ end
62
+
63
+ # Get the current screen width
64
+ # @return [Integer] Screen width in characters
65
+ def screen_width
66
+ TTY::Screen.width
67
+ end
68
+
69
+ # Get the current screen height
70
+ # @return [Integer] Screen height in characters
71
+ def screen_height
72
+ TTY::Screen.height
73
+ end
74
+
75
+ # Get the current screen size
76
+ # @return [Array<Integer>] [width, height] in characters
77
+ def screen_size
78
+ TTY::Screen.size
79
+ end
80
+
81
+ private
82
+
83
+ # Build child model instances based on class definitions
84
+ # @param parent_state [Hash] The parent model's state
85
+ # @return [Array<Model>] Array of child model instances
86
+ def build_children(parent_state)
87
+ self.class.children.map do |definition|
88
+ state = definition[:mapper].call(parent_state)
89
+ resolve_child(definition[:class], state)
90
+ end.freeze
91
+ end
92
+
93
+ # Resolve child class and create instance
94
+ # @param klass [Class, Symbol] The child model class or method name
95
+ # @param state [Hash] The state to pass to the child
96
+ # @return [Model] Child model instance
97
+ def resolve_child(klass, state)
98
+ klass = send(klass) if klass.is_a?(Symbol)
99
+ raise ArgumentError, "Child must be a Model class, got #{klass.class}" unless klass.is_a?(Class) && klass <= Model
100
+
101
+ klass.new(state)
102
+ rescue NoMethodError
103
+ raise ArgumentError, "Method #{klass} not found for dynamic child resolution"
104
+ end
105
+
106
+ # Override in subclasses to provide default state
107
+ # @return [Hash] Default state for the model
108
+ def default_state
109
+ {}
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timers"
4
+ require "tty-reader"
5
+ require "forwardable"
6
+
7
+ module Milktea
8
+ # Main program class for running Milktea TUI applications
9
+ class Program
10
+ extend Forwardable
11
+ FPS = 60
12
+ REFRESH_INTERVAL = 1.0 / FPS
13
+
14
+ # Delegate config accessors
15
+ def_delegators :@config, :runtime, :renderer
16
+
17
+ # Delegate to runtime and renderer
18
+ delegate %i[start stop running? tick render? enqueue] => :runtime
19
+ delegate %i[setup_screen restore_screen render] => :renderer
20
+
21
+ def initialize(model, config: nil)
22
+ @model = model
23
+ @config = config || Milktea.config
24
+ @timers = Timers::Group.new
25
+ @reader = TTY::Reader.new(interrupt: :error)
26
+ end
27
+
28
+ def run
29
+ start
30
+ setup_screen
31
+ render(@model)
32
+ setup_timers
33
+ @timers.wait while running?
34
+ ensure
35
+ restore_screen
36
+ end
37
+
38
+ private
39
+
40
+ def process_messages
41
+ check_resize
42
+ @model = tick(@model)
43
+ render(@model) if render?
44
+ end
45
+
46
+ def setup_timers
47
+ # Main event loop
48
+ @timers.now_and_every(REFRESH_INTERVAL) do
49
+ read_keyboard_input
50
+ process_messages
51
+ end
52
+ end
53
+
54
+ def read_keyboard_input
55
+ key = @reader.read_keypress(nonblock: true)
56
+ return if key.nil?
57
+
58
+ enqueue_key_message(key)
59
+ rescue TTY::Reader::InputInterrupt
60
+ enqueue(Message::Exit.new)
61
+ end
62
+
63
+ def enqueue_key_message(key)
64
+ key_message = Message::KeyPress.new(
65
+ key: key,
66
+ value: key,
67
+ ctrl: key == "\u0003", # Ctrl+C
68
+ alt: false,
69
+ shift: false
70
+ )
71
+ enqueue(key_message)
72
+ end
73
+
74
+ def check_resize
75
+ return unless renderer.resize?
76
+
77
+ resize_message = Message::Resize.new
78
+ enqueue(resize_message)
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty-cursor"
4
+ require "tty-screen"
5
+
6
+ module Milktea
7
+ # Renderer handles TUI rendering and screen management
8
+ class Renderer
9
+ def initialize(output = $stdout)
10
+ @output = output
11
+ @cursor = TTY::Cursor
12
+ @last_screen_size = TTY::Screen.size
13
+ end
14
+
15
+ def render(model)
16
+ @output.print @cursor.clear_screen
17
+ @output.print @cursor.move_to(0, 0)
18
+ content = model.view
19
+ @output.print content
20
+ @output.flush
21
+ end
22
+
23
+ def setup_screen
24
+ @output.print @cursor.hide
25
+ @output.print @cursor.clear_screen
26
+ @output.print @cursor.move_to(0, 0)
27
+ @output.flush
28
+ end
29
+
30
+ def restore_screen
31
+ @output.print @cursor.clear_screen
32
+ @output.print @cursor.show
33
+ @output.flush
34
+ end
35
+
36
+ def resize?
37
+ current_size = TTY::Screen.size
38
+ return false if current_size == @last_screen_size
39
+
40
+ @last_screen_size = current_size
41
+ true
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Milktea
4
+ # Runtime manages message processing and execution state for Milktea applications
5
+ class Runtime
6
+ def initialize(queue: Queue.new)
7
+ @queue = queue
8
+ @running = false
9
+ @should_render = false
10
+ end
11
+
12
+ def tick(model)
13
+ has_render_messages = false
14
+
15
+ until @queue.empty?
16
+ message = @queue.pop(true) # non-blocking pop
17
+ model, side_effect = model.update(message)
18
+ execute_side_effect(side_effect)
19
+
20
+ # Only Message::None instances should not trigger render
21
+ has_render_messages = true unless message.is_a?(Message::None)
22
+ end
23
+
24
+ @should_render = has_render_messages
25
+ model
26
+ end
27
+
28
+ def render?
29
+ @should_render
30
+ end
31
+
32
+ def stop?
33
+ !@running
34
+ end
35
+
36
+ def running?
37
+ @running
38
+ end
39
+
40
+ def start
41
+ @running = true
42
+ end
43
+
44
+ def stop
45
+ @running = false
46
+ end
47
+
48
+ def enqueue(message)
49
+ @queue << message
50
+ end
51
+
52
+ private
53
+
54
+ def execute_side_effect(side_effect)
55
+ case side_effect
56
+ when Message::None
57
+ # Do nothing
58
+ when Message::Exit
59
+ stop
60
+ when Message::Batch
61
+ side_effect.messages.each { |msg| enqueue(msg) }
62
+ when Message::Reload
63
+ # Hot reload handled automatically by Zeitwerk
64
+ # No additional action needed
65
+ when Message::Resize
66
+ # Terminal resize detected
67
+ # No additional action needed at this level
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Milktea
4
+ VERSION = "0.1.0"
5
+ end
data/lib/milktea.rb ADDED
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zeitwerk"
4
+ require "pathname"
5
+
6
+ loader = Zeitwerk::Loader.for_gem
7
+ loader.setup
8
+
9
+ # The Milktea TUI framework for Ruby
10
+ module Milktea
11
+ class Error < StandardError; end
12
+
13
+ MUTEX = Mutex.new
14
+
15
+ class << self
16
+ def app
17
+ MUTEX.synchronize { @app }
18
+ end
19
+
20
+ def app=(app)
21
+ MUTEX.synchronize { @app = app }
22
+ end
23
+
24
+ def root
25
+ @root ||= find_root
26
+ end
27
+
28
+ def env
29
+ (ENV.fetch("MILKTEA_ENV", nil) || ENV.fetch("APP_ENV", "production")).to_sym
30
+ end
31
+
32
+ def config
33
+ MUTEX.synchronize do
34
+ @config ||= Config.new
35
+ end
36
+ end
37
+
38
+ def configure(&block)
39
+ MUTEX.synchronize do
40
+ @config = if block_given?
41
+ Config.new(&block)
42
+ else
43
+ Config.new
44
+ end
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def find_root
51
+ return Pathname.new(Bundler.root) if defined?(Bundler) && Bundler.respond_to?(:root)
52
+
53
+ # Find root by looking for common project files
54
+ current_dir = Pathname.new(Dir.pwd)
55
+ while current_dir.parent != current_dir
56
+ return current_dir if project_root?(current_dir)
57
+
58
+ current_dir = current_dir.parent
59
+ end
60
+
61
+ # Default to current directory if no project root found
62
+ Pathname.new(Dir.pwd)
63
+ end
64
+
65
+ def project_root?(dir)
66
+ %w[Gemfile Gemfile.lock .git].any? { |file| dir.join(file).exist? }
67
+ end
68
+ end
69
+ end
data/sig/milktea.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Milktea
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,151 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: milktea
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Aotokitsuruya
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2025-07-05 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: timers
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '4.3'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '4.3'
27
+ - !ruby/object:Gem::Dependency
28
+ name: tty-cursor
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '0.7'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '0.7'
41
+ - !ruby/object:Gem::Dependency
42
+ name: tty-reader
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '0.9'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '0.9'
55
+ - !ruby/object:Gem::Dependency
56
+ name: tty-screen
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '0.8'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '0.8'
69
+ - !ruby/object:Gem::Dependency
70
+ name: zeitwerk
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '2.7'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '2.7'
83
+ description: The TUI framework for Ruby, inspired by the bubbletea framework for Go.
84
+ email:
85
+ - contact@aotoki.me
86
+ executables: []
87
+ extensions: []
88
+ extra_rdoc_files: []
89
+ files:
90
+ - ".claude/commands/devlog.md"
91
+ - ".claude/settings.json"
92
+ - ".rspec"
93
+ - ".rubocop.yml"
94
+ - ARCHITECTURE.md
95
+ - CLAUDE.md
96
+ - LICENSE.txt
97
+ - README.md
98
+ - Rakefile
99
+ - docs/devlog/20250703.md
100
+ - docs/devlog/20250704-2.md
101
+ - docs/devlog/20250704.md
102
+ - docs/devlog/20250705.md
103
+ - examples/container_layout.rb
104
+ - examples/container_simple.rb
105
+ - examples/counter.rb
106
+ - examples/dashboard.rb
107
+ - examples/hot_reload_demo.rb
108
+ - examples/hot_reload_demo/models/demo_model.rb
109
+ - examples/hot_reload_demo/models/status_model.rb
110
+ - examples/simple.rb
111
+ - lib/milktea.rb
112
+ - lib/milktea/application.rb
113
+ - lib/milktea/bounds.rb
114
+ - lib/milktea/config.rb
115
+ - lib/milktea/container.rb
116
+ - lib/milktea/loader.rb
117
+ - lib/milktea/message.rb
118
+ - lib/milktea/model.rb
119
+ - lib/milktea/program.rb
120
+ - lib/milktea/renderer.rb
121
+ - lib/milktea/runtime.rb
122
+ - lib/milktea/version.rb
123
+ - sig/milktea.rbs
124
+ homepage: https://github.com/elct9620/milktea
125
+ licenses:
126
+ - MIT
127
+ metadata:
128
+ homepage_uri: https://github.com/elct9620/milktea
129
+ source_code_uri: https://github.com/elct9620/milktea
130
+ changelog_uri: https://github.com/elct9620/milktea/blob/main/CHANGELOG.md
131
+ rubygems_mfa_required: 'true'
132
+ post_install_message:
133
+ rdoc_options: []
134
+ require_paths:
135
+ - lib
136
+ required_ruby_version: !ruby/object:Gem::Requirement
137
+ requirements:
138
+ - - ">="
139
+ - !ruby/object:Gem::Version
140
+ version: 3.1.0
141
+ required_rubygems_version: !ruby/object:Gem::Requirement
142
+ requirements:
143
+ - - ">="
144
+ - !ruby/object:Gem::Version
145
+ version: '0'
146
+ requirements: []
147
+ rubygems_version: 3.5.22
148
+ signing_key:
149
+ specification_version: 4
150
+ summary: The TUI framework for Ruby, inspired by the bubbletea framework for Go.
151
+ test_files: []