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.
- checksums.yaml +7 -0
- data/.claude/commands/devlog.md +103 -0
- data/.claude/settings.json +29 -0
- data/.rspec +3 -0
- data/.rubocop.yml +17 -0
- data/ARCHITECTURE.md +621 -0
- data/CLAUDE.md +537 -0
- data/LICENSE.txt +21 -0
- data/README.md +382 -0
- data/Rakefile +12 -0
- data/docs/devlog/20250703.md +119 -0
- data/docs/devlog/20250704-2.md +129 -0
- data/docs/devlog/20250704.md +90 -0
- data/docs/devlog/20250705.md +81 -0
- data/examples/container_layout.rb +288 -0
- data/examples/container_simple.rb +66 -0
- data/examples/counter.rb +60 -0
- data/examples/dashboard.rb +121 -0
- data/examples/hot_reload_demo/models/demo_model.rb +91 -0
- data/examples/hot_reload_demo/models/status_model.rb +34 -0
- data/examples/hot_reload_demo.rb +64 -0
- data/examples/simple.rb +39 -0
- data/lib/milktea/application.rb +64 -0
- data/lib/milktea/bounds.rb +10 -0
- data/lib/milktea/config.rb +35 -0
- data/lib/milktea/container.rb +124 -0
- data/lib/milktea/loader.rb +45 -0
- data/lib/milktea/message.rb +39 -0
- data/lib/milktea/model.rb +112 -0
- data/lib/milktea/program.rb +81 -0
- data/lib/milktea/renderer.rb +44 -0
- data/lib/milktea/runtime.rb +71 -0
- data/lib/milktea/version.rb +5 -0
- data/lib/milktea.rb +69 -0
- data/sig/milktea.rbs +4 -0
- metadata +151 -0
@@ -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
|
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
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: []
|