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,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
@@ -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