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,121 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require "bundler/setup"
|
5
|
+
require "milktea"
|
6
|
+
|
7
|
+
# Counter child component
|
8
|
+
class CounterModel < Milktea::Model
|
9
|
+
def view
|
10
|
+
"Counter: #{state[:count]}"
|
11
|
+
end
|
12
|
+
|
13
|
+
def update(message)
|
14
|
+
case message
|
15
|
+
when Milktea::Message::KeyPress
|
16
|
+
handle_keypress(message)
|
17
|
+
else
|
18
|
+
[self, Milktea::Message::None.new]
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
def default_state
|
25
|
+
{ count: 0 }
|
26
|
+
end
|
27
|
+
|
28
|
+
def handle_keypress(message)
|
29
|
+
case message.value
|
30
|
+
when "+"
|
31
|
+
[with(count: state[:count] + 1), Milktea::Message::None.new]
|
32
|
+
when "-"
|
33
|
+
[with(count: state[:count] - 1), Milktea::Message::None.new]
|
34
|
+
else
|
35
|
+
[self, Milktea::Message::None.new]
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
# Status bar child component
|
41
|
+
class StatusBarModel < Milktea::Model
|
42
|
+
def view
|
43
|
+
"Status: #{state[:message]}"
|
44
|
+
end
|
45
|
+
|
46
|
+
def update(_message)
|
47
|
+
[self, Milktea::Message::None.new]
|
48
|
+
end
|
49
|
+
|
50
|
+
private
|
51
|
+
|
52
|
+
def default_state
|
53
|
+
{ message: "Ready" }
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
# Dashboard parent component using nested models
|
58
|
+
class DashboardModel < Milktea::Model
|
59
|
+
child CounterModel, ->(state) { { count: state[:count] } }
|
60
|
+
child StatusBarModel, ->(state) { { message: state[:status_message] } }
|
61
|
+
|
62
|
+
def view
|
63
|
+
<<~VIEW
|
64
|
+
=== Dashboard v#{state[:app_version]} ===
|
65
|
+
|
66
|
+
#{children_views}
|
67
|
+
|
68
|
+
Controls:
|
69
|
+
- '+' / '-' to change counter
|
70
|
+
- 'r' to reset counter
|
71
|
+
- 's' to change status
|
72
|
+
- 'q' to quit
|
73
|
+
|
74
|
+
Ctrl+C to exit
|
75
|
+
VIEW
|
76
|
+
end
|
77
|
+
|
78
|
+
def update(message)
|
79
|
+
case message
|
80
|
+
when Milktea::Message::Exit
|
81
|
+
[self, message]
|
82
|
+
when Milktea::Message::KeyPress
|
83
|
+
handle_keypress(message)
|
84
|
+
else
|
85
|
+
[self, Milktea::Message::None.new]
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
private
|
90
|
+
|
91
|
+
def default_state
|
92
|
+
{
|
93
|
+
count: 0,
|
94
|
+
status_message: "Dashboard Ready",
|
95
|
+
app_version: "1.0"
|
96
|
+
}
|
97
|
+
end
|
98
|
+
|
99
|
+
def handle_keypress(message)
|
100
|
+
case message.value
|
101
|
+
when "+"
|
102
|
+
[with(count: state[:count] + 1), Milktea::Message::None.new]
|
103
|
+
when "-"
|
104
|
+
[with(count: state[:count] - 1), Milktea::Message::None.new]
|
105
|
+
when "r"
|
106
|
+
[with(count: 0, status_message: "Counter Reset!"), Milktea::Message::None.new]
|
107
|
+
when "s"
|
108
|
+
new_status = state[:status_message] == "Dashboard Ready" ? "Status Updated!" : "Dashboard Ready"
|
109
|
+
[with(status_message: new_status), Milktea::Message::None.new]
|
110
|
+
when "q"
|
111
|
+
[self, Milktea::Message::Exit.new]
|
112
|
+
else
|
113
|
+
[self, Milktea::Message::None.new]
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
# Create and run the dashboard program
|
119
|
+
model = DashboardModel.new
|
120
|
+
program = Milktea::Program.new(model)
|
121
|
+
program.run
|
@@ -0,0 +1,91 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Hot Reload Demo Model
|
4
|
+
#
|
5
|
+
# This model demonstrates hot reloading capabilities.
|
6
|
+
# While the program is running, try modifying this file:
|
7
|
+
# 1. Change the welcome message below
|
8
|
+
# 2. Modify the counter increment value
|
9
|
+
# 3. Add new key bindings
|
10
|
+
# 4. Change the view layout
|
11
|
+
#
|
12
|
+
# The changes should be reflected immediately in the running program!
|
13
|
+
|
14
|
+
class DemoModel < Milktea::Model
|
15
|
+
child StatusModel, ->(state) { { message: state[:status], timestamp: state[:last_update] } }
|
16
|
+
|
17
|
+
def view
|
18
|
+
<<~VIEW
|
19
|
+
=== Hot Reload Demo ===
|
20
|
+
|
21
|
+
Welcome! This demo shows hot reloading in action.
|
22
|
+
|
23
|
+
Counter: #{state[:count]} (try changing the increment value in demo_model.rb!)
|
24
|
+
|
25
|
+
#{children_views}
|
26
|
+
|
27
|
+
Instructions:
|
28
|
+
- '+' or 'k' to increment counter
|
29
|
+
- '-' or 'j' to decrement counter#{" "}
|
30
|
+
- 'r' to reset counter
|
31
|
+
- 'm' to change status message
|
32
|
+
- 'q' to quit
|
33
|
+
|
34
|
+
Try editing this file while the program runs!
|
35
|
+
(Change the welcome message or add new features)
|
36
|
+
|
37
|
+
Ctrl+C to exit
|
38
|
+
VIEW
|
39
|
+
end
|
40
|
+
|
41
|
+
def update(message)
|
42
|
+
case message
|
43
|
+
when Milktea::Message::Exit
|
44
|
+
[self, message]
|
45
|
+
when Milktea::Message::KeyPress
|
46
|
+
handle_keypress(message)
|
47
|
+
when Milktea::Message::Reload
|
48
|
+
# Hot reload detected - model will be automatically rebuilt
|
49
|
+
[with, Milktea::Message::None.new]
|
50
|
+
else
|
51
|
+
[self, Milktea::Message::None.new]
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
private
|
56
|
+
|
57
|
+
def default_state
|
58
|
+
{
|
59
|
+
count: 0,
|
60
|
+
status: "Ready for hot reloading!",
|
61
|
+
last_update: Time.now.strftime("%H:%M:%S")
|
62
|
+
}
|
63
|
+
end
|
64
|
+
|
65
|
+
def handle_keypress(message)
|
66
|
+
case message.value
|
67
|
+
when "+", "k"
|
68
|
+
# Try changing this increment value from 1 to 5 while running!
|
69
|
+
new_count = state[:count] + 1
|
70
|
+
[with(count: new_count, last_update: Time.now.strftime("%H:%M:%S")), Milktea::Message::None.new]
|
71
|
+
when "-", "j"
|
72
|
+
new_count = state[:count] - 1
|
73
|
+
[with(count: new_count, last_update: Time.now.strftime("%H:%M:%S")), Milktea::Message::None.new]
|
74
|
+
when "r"
|
75
|
+
[with(count: 0, status: "Counter reset!", last_update: Time.now.strftime("%H:%M:%S")), Milktea::Message::None.new]
|
76
|
+
when "m"
|
77
|
+
messages = [
|
78
|
+
"Hot reloading is awesome!",
|
79
|
+
"Change this file and see it update!",
|
80
|
+
"Ruby + TUI = Great experience",
|
81
|
+
"Milktea framework rocks!"
|
82
|
+
]
|
83
|
+
new_status = messages.sample
|
84
|
+
[with(status: new_status, last_update: Time.now.strftime("%H:%M:%S")), Milktea::Message::None.new]
|
85
|
+
when "q"
|
86
|
+
[self, Milktea::Message::Exit.new]
|
87
|
+
else
|
88
|
+
[self, Milktea::Message::None.new]
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Status Model - Child Component for Hot Reload Demo
|
4
|
+
#
|
5
|
+
# This demonstrates how child models are also reloaded automatically.
|
6
|
+
# Try modifying this component while the demo is running:
|
7
|
+
# 1. Change the status display format
|
8
|
+
# 2. Add additional information
|
9
|
+
# 3. Modify the styling
|
10
|
+
|
11
|
+
class StatusModel < Milktea::Model
|
12
|
+
def view
|
13
|
+
<<~VIEW
|
14
|
+
Status: #{state[:message]}
|
15
|
+
Last Updated: #{state[:timestamp]}
|
16
|
+
|
17
|
+
[Tip: Try editing status_model.rb to change this display!]
|
18
|
+
VIEW
|
19
|
+
end
|
20
|
+
|
21
|
+
def update(_message)
|
22
|
+
# Status model is read-only, managed by parent
|
23
|
+
[self, Milktea::Message::None.new]
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
def default_state
|
29
|
+
{
|
30
|
+
message: "Default status",
|
31
|
+
timestamp: Time.now.strftime("%H:%M:%S")
|
32
|
+
}
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require "bundler/setup"
|
5
|
+
require "milktea"
|
6
|
+
|
7
|
+
# Hot Reload Demo using Milktea::Application - Simplified setup
|
8
|
+
#
|
9
|
+
# This example shows how to use Milktea::Application for easier setup.
|
10
|
+
# The Application class encapsulates Loader and Program configuration.
|
11
|
+
#
|
12
|
+
# To test hot reloading:
|
13
|
+
# 1. Run this file: ruby examples/hot_reload_demo.rb
|
14
|
+
# 2. In another terminal, edit files in examples/hot_reload_demo/models/
|
15
|
+
# 3. Save the files and see changes reflected immediately!
|
16
|
+
#
|
17
|
+
# Features demonstrated:
|
18
|
+
# - Simplified Application setup
|
19
|
+
# - Automatic Loader and Program configuration
|
20
|
+
# - Hot reloading with minimal boilerplate
|
21
|
+
# - Clean Architecture with DDD principles
|
22
|
+
|
23
|
+
puts "=== Milktea Application Hot Reload Demo ==="
|
24
|
+
puts ""
|
25
|
+
puts "This demo shows the new Application class in action!"
|
26
|
+
puts "Try editing files in hot_reload_demo/models/ while this runs."
|
27
|
+
puts ""
|
28
|
+
|
29
|
+
# Check if Listen gem is available for optimal experience
|
30
|
+
begin
|
31
|
+
gem "listen"
|
32
|
+
puts "✓ Listen gem detected - hot reloading will work!"
|
33
|
+
rescue Gem::LoadError
|
34
|
+
puts "⚠ Listen gem not found - basic autoloading only"
|
35
|
+
puts " Install with: gem install listen"
|
36
|
+
end
|
37
|
+
|
38
|
+
puts ""
|
39
|
+
puts "Starting demo... (press any key to continue)"
|
40
|
+
gets
|
41
|
+
|
42
|
+
# Configure Milktea for hot reloading
|
43
|
+
Milktea.configure do |c|
|
44
|
+
# Point to our demo components directory
|
45
|
+
c.autoload_dirs = ["examples/hot_reload_demo/models"]
|
46
|
+
|
47
|
+
# Enable hot reloading explicitly
|
48
|
+
c.hot_reloading = true
|
49
|
+
end
|
50
|
+
|
51
|
+
# Define Application class
|
52
|
+
class HotReloadDemo < Milktea::Application
|
53
|
+
root "DemoModel"
|
54
|
+
end
|
55
|
+
|
56
|
+
puts "Hot reload demo starting..."
|
57
|
+
puts "Edit files in hot_reload_demo/models/ to see changes instantly!"
|
58
|
+
puts ""
|
59
|
+
|
60
|
+
begin
|
61
|
+
HotReloadDemo.boot
|
62
|
+
rescue Interrupt
|
63
|
+
puts "\nDemo ended. Thanks for trying the new Application class!"
|
64
|
+
end
|
data/examples/simple.rb
ADDED
@@ -0,0 +1,39 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require "bundler/setup"
|
5
|
+
require "milktea"
|
6
|
+
|
7
|
+
# Simple test model
|
8
|
+
class TestModel < Milktea::Model
|
9
|
+
def view
|
10
|
+
"Hello from Milktea!\nPress 'q' to quit or Ctrl+C to exit\n"
|
11
|
+
end
|
12
|
+
|
13
|
+
def update(message)
|
14
|
+
case message
|
15
|
+
when Milktea::Message::Exit
|
16
|
+
[self, message]
|
17
|
+
when Milktea::Message::KeyPress
|
18
|
+
handle_keypress(message)
|
19
|
+
else
|
20
|
+
[self, Milktea::Message::None.new]
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
def handle_keypress(message)
|
27
|
+
case message.value
|
28
|
+
when "q"
|
29
|
+
[self, Milktea::Message::Exit.new]
|
30
|
+
else
|
31
|
+
[self, Milktea::Message::None.new]
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
# Create and run a simple Milktea program
|
37
|
+
model = TestModel.new
|
38
|
+
program = Milktea::Program.new(model)
|
39
|
+
program.run
|
@@ -0,0 +1,64 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "zeitwerk"
|
4
|
+
|
5
|
+
module Milktea
|
6
|
+
# Application class provides a high-level interface for creating Milktea applications
|
7
|
+
# It encapsulates the Loader and Program setup, making it easier to create TUI applications
|
8
|
+
class Application
|
9
|
+
class << self
|
10
|
+
def inherited(subclass)
|
11
|
+
super
|
12
|
+
|
13
|
+
Milktea.app = subclass
|
14
|
+
end
|
15
|
+
|
16
|
+
def root(model_name = nil)
|
17
|
+
return @root_model_name if model_name.nil?
|
18
|
+
|
19
|
+
@root_model_name = model_name
|
20
|
+
end
|
21
|
+
|
22
|
+
def root_model_class
|
23
|
+
return unless @root_model_name
|
24
|
+
|
25
|
+
Kernel.const_get(@root_model_name)
|
26
|
+
end
|
27
|
+
|
28
|
+
def boot
|
29
|
+
return new.run if @root_model_name
|
30
|
+
|
31
|
+
raise Error, "No root model defined. Use 'root \"ModelName\"' in your Application class."
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
attr_reader :config, :loader, :program
|
36
|
+
|
37
|
+
def initialize(config: nil)
|
38
|
+
@config = config || Milktea.config
|
39
|
+
setup_loader
|
40
|
+
setup_program
|
41
|
+
end
|
42
|
+
|
43
|
+
def run
|
44
|
+
loader.hot_reload if config.hot_reloading?
|
45
|
+
program.run
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
def setup_loader
|
51
|
+
@loader = Loader.new(config)
|
52
|
+
loader.setup
|
53
|
+
end
|
54
|
+
|
55
|
+
def setup_program
|
56
|
+
unless self.class.root_model_class
|
57
|
+
raise Error,
|
58
|
+
"No root model defined. Use 'root \"ModelName\"' in your Application class."
|
59
|
+
end
|
60
|
+
|
61
|
+
@program = Program.new(self.class.root_model_class.new, config: config)
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,10 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Milktea
|
4
|
+
# Bounds represents the position and size of a UI element
|
5
|
+
Bounds = Data.define(:width, :height, :x, :y) do
|
6
|
+
def initialize(width: 0, height: 0, x: 0, y: 0) # rubocop:disable Naming/MethodParameterName
|
7
|
+
super
|
8
|
+
end
|
9
|
+
end
|
10
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Milktea
|
4
|
+
# Configuration class for Milktea applications
|
5
|
+
class Config
|
6
|
+
attr_accessor :autoload_dirs, :output
|
7
|
+
attr_writer :hot_reloading, :runtime, :renderer
|
8
|
+
|
9
|
+
def initialize
|
10
|
+
@autoload_dirs = ["app/models"]
|
11
|
+
@output = $stdout
|
12
|
+
@hot_reloading = nil # Will be set by lazy evaluation
|
13
|
+
@runtime = nil # Will be set by lazy evaluation
|
14
|
+
@renderer = nil # Will be set by lazy evaluation
|
15
|
+
|
16
|
+
yield(self) if block_given?
|
17
|
+
end
|
18
|
+
|
19
|
+
def hot_reloading?
|
20
|
+
@hot_reloading || (Milktea.env == :development)
|
21
|
+
end
|
22
|
+
|
23
|
+
def runtime
|
24
|
+
@runtime ||= Runtime.new
|
25
|
+
end
|
26
|
+
|
27
|
+
def renderer
|
28
|
+
@renderer ||= Renderer.new(@output)
|
29
|
+
end
|
30
|
+
|
31
|
+
def autoload_paths
|
32
|
+
@autoload_dirs.map { |dir| Milktea.root.join(dir) }
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,124 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Milktea
|
4
|
+
# Container model with layout capabilities using Flexbox
|
5
|
+
class Container < Model
|
6
|
+
attr_reader :bounds
|
7
|
+
|
8
|
+
class << self
|
9
|
+
# Define a child model with optional state mapping and flex properties
|
10
|
+
# @param klass [Class, Symbol] The child model class or method name
|
11
|
+
# @param mapper [Proc] Lambda to map parent state to child state
|
12
|
+
# @param flex [Integer] Flex grow factor for layout
|
13
|
+
def child(klass, mapper = nil, flex: 1)
|
14
|
+
@children ||= []
|
15
|
+
@children << {
|
16
|
+
class: klass,
|
17
|
+
mapper: mapper || ->(_state) { {} },
|
18
|
+
flex: flex
|
19
|
+
}
|
20
|
+
end
|
21
|
+
|
22
|
+
# Set the flex direction for the container
|
23
|
+
# @param dir [Symbol] The direction (:column or :row)
|
24
|
+
def direction(dir)
|
25
|
+
@direction = dir
|
26
|
+
end
|
27
|
+
|
28
|
+
# Get the flex direction (defaults to :column)
|
29
|
+
# @return [Symbol] The flex direction
|
30
|
+
def flex_direction
|
31
|
+
@direction || :column
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def initialize(state = {})
|
36
|
+
@bounds = extract_bounds(state)
|
37
|
+
# Remove bounds keys from state before passing to parent
|
38
|
+
super(state.except(:width, :height, :x, :y))
|
39
|
+
end
|
40
|
+
|
41
|
+
def view = children_views
|
42
|
+
|
43
|
+
private
|
44
|
+
|
45
|
+
def extract_bounds(state)
|
46
|
+
Bounds.new(
|
47
|
+
width: state[:width] || screen_width,
|
48
|
+
height: state[:height] || screen_height,
|
49
|
+
x: state[:x] || 0,
|
50
|
+
y: state[:y] || 0
|
51
|
+
)
|
52
|
+
end
|
53
|
+
|
54
|
+
# Override build_children to apply flexbox layout
|
55
|
+
def build_children(parent_state)
|
56
|
+
return [].freeze if self.class.children.empty?
|
57
|
+
|
58
|
+
layout_children(parent_state)
|
59
|
+
end
|
60
|
+
|
61
|
+
def layout_children(parent_state)
|
62
|
+
case self.class.flex_direction
|
63
|
+
when :row
|
64
|
+
layout_children_row(parent_state)
|
65
|
+
else
|
66
|
+
layout_children_column(parent_state)
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
def layout_children_column(parent_state)
|
71
|
+
total_flex = calculate_total_flex
|
72
|
+
current_y = bounds.y
|
73
|
+
|
74
|
+
self.class.children.map do |definition|
|
75
|
+
child_height = calculate_child_height(definition[:flex], total_flex)
|
76
|
+
child_state = build_child_state_column(definition, parent_state, current_y, child_height)
|
77
|
+
current_y += child_height
|
78
|
+
resolve_child(definition[:class], child_state)
|
79
|
+
end.freeze
|
80
|
+
end
|
81
|
+
|
82
|
+
def layout_children_row(parent_state)
|
83
|
+
total_flex = calculate_total_flex
|
84
|
+
current_x = bounds.x
|
85
|
+
|
86
|
+
self.class.children.map do |definition|
|
87
|
+
child_width = calculate_child_width(definition[:flex], total_flex)
|
88
|
+
child_state = build_child_state_row(definition, parent_state, current_x, child_width)
|
89
|
+
current_x += child_width
|
90
|
+
resolve_child(definition[:class], child_state)
|
91
|
+
end.freeze
|
92
|
+
end
|
93
|
+
|
94
|
+
def calculate_total_flex
|
95
|
+
self.class.children.sum { |definition| definition[:flex] }
|
96
|
+
end
|
97
|
+
|
98
|
+
def calculate_child_height(flex, total_flex)
|
99
|
+
(bounds.height * flex) / total_flex
|
100
|
+
end
|
101
|
+
|
102
|
+
def calculate_child_width(flex, total_flex)
|
103
|
+
(bounds.width * flex) / total_flex
|
104
|
+
end
|
105
|
+
|
106
|
+
def build_child_state_column(definition, parent_state, pos_y, child_height)
|
107
|
+
definition[:mapper].call(parent_state).merge(
|
108
|
+
width: bounds.width,
|
109
|
+
height: child_height,
|
110
|
+
x: bounds.x,
|
111
|
+
y: pos_y
|
112
|
+
)
|
113
|
+
end
|
114
|
+
|
115
|
+
def build_child_state_row(definition, parent_state, pos_x, child_width)
|
116
|
+
definition[:mapper].call(parent_state).merge(
|
117
|
+
width: child_width,
|
118
|
+
height: bounds.height,
|
119
|
+
x: pos_x,
|
120
|
+
y: bounds.y
|
121
|
+
)
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "zeitwerk"
|
4
|
+
|
5
|
+
module Milktea
|
6
|
+
# Auto loading and hot reloading implementation for Milktea applications
|
7
|
+
class Loader
|
8
|
+
def initialize(config = nil)
|
9
|
+
@config = config || Milktea.config
|
10
|
+
@autoload_paths = @config.autoload_paths
|
11
|
+
@runtime = @config.runtime
|
12
|
+
@loader = nil
|
13
|
+
@listeners = []
|
14
|
+
end
|
15
|
+
|
16
|
+
def setup
|
17
|
+
@loader = Zeitwerk::Loader.new
|
18
|
+
@autoload_paths.each { |path| @loader.push_dir(path) }
|
19
|
+
@loader.enable_reloading
|
20
|
+
@loader.setup
|
21
|
+
end
|
22
|
+
|
23
|
+
def hot_reload
|
24
|
+
gem "listen"
|
25
|
+
require "listen"
|
26
|
+
|
27
|
+
@listeners = @autoload_paths.map do |path|
|
28
|
+
Listen.to(path, only: /\.rb$/) do |modified, added, removed|
|
29
|
+
reload if modified.any? || added.any? || removed.any?
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
@listeners.each(&:start)
|
34
|
+
rescue Gem::LoadError
|
35
|
+
# Listen gem not available, skip file watching
|
36
|
+
end
|
37
|
+
|
38
|
+
def reload
|
39
|
+
return unless @loader
|
40
|
+
|
41
|
+
@loader.reload
|
42
|
+
@runtime.enqueue(Message::Reload.new)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Milktea
|
4
|
+
# Message definitions for events in the Milktea framework
|
5
|
+
module Message
|
6
|
+
# No operation message
|
7
|
+
None = Data.define
|
8
|
+
|
9
|
+
# Message to exit the program
|
10
|
+
Exit = Data.define
|
11
|
+
|
12
|
+
# Timer tick message
|
13
|
+
Tick = Data.define
|
14
|
+
|
15
|
+
# Keyboard event message
|
16
|
+
KeyPress = Data.define(:key, :value, :ctrl, :alt, :shift) do
|
17
|
+
def initialize(key:, value:, ctrl: false, alt: false, shift: false)
|
18
|
+
super
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
# Batch multiple messages
|
23
|
+
Batch = Data.define(:messages) do
|
24
|
+
def initialize(messages: [])
|
25
|
+
super
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
# Hot reload message
|
30
|
+
Reload = Data.define
|
31
|
+
|
32
|
+
# Terminal resize message
|
33
|
+
Resize = Data.define(:width, :height) do
|
34
|
+
def initialize(width: TTY::Screen.width, height: TTY::Screen.height)
|
35
|
+
super
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|