thor-interactive 0.1.0.pre.4 → 0.1.0.pre.6

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,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Thor
4
+ module Interactive
5
+ module TUI
6
+ # Configurable status bar with left/center/right sections.
7
+ # App authors provide lambdas that receive the Thor instance.
8
+ class StatusBar
9
+ attr_accessor :left, :center, :right
10
+
11
+ def initialize(thor_class, thor_instance, options = {})
12
+ @thor_class = thor_class
13
+ @thor_instance = thor_instance
14
+
15
+ config = options[:status_bar] || {}
16
+
17
+ @left = config[:left] || ->(instance) { " #{instance.class.name}" }
18
+ @center = config[:center] || ->(instance) { "" }
19
+ @right = config[:right] || ->(instance) { " ready " }
20
+ end
21
+
22
+ def render_text(width, override_center: nil, override_right: nil)
23
+ left_text = evaluate_section(@left)
24
+ center_text = override_center || evaluate_section(@center)
25
+ right_text = override_right || evaluate_section(@right)
26
+
27
+ # Calculate spacing
28
+ if center_text.empty?
29
+ padding = width - left_text.length - right_text.length
30
+ padding = 1 if padding < 1
31
+ left_text + (" " * padding) + right_text
32
+ else
33
+ # Three-section layout
34
+ left_space = width - left_text.length - center_text.length - right_text.length
35
+ left_pad = [left_space / 2, 1].max
36
+ right_pad = [left_space - left_pad, 1].max
37
+ left_text + (" " * left_pad) + center_text + (" " * right_pad) + right_text
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def evaluate_section(section)
44
+ case section
45
+ when Proc, Method
46
+ section.arity == 0 ? section.call.to_s : section.call(@thor_instance).to_s
47
+ when String
48
+ section
49
+ else
50
+ section.to_s
51
+ end
52
+ rescue => e
53
+ "(error)"
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Thor
4
+ module Interactive
5
+ module TUI
6
+ # Multi-line text buffer with cursor tracking.
7
+ # Used as the data model for the input area in the TUI shell.
8
+ class TextInput
9
+ attr_reader :cursor_row, :cursor_col
10
+
11
+ def initialize
12
+ @lines = [+""]
13
+ @cursor_row = 0
14
+ @cursor_col = 0
15
+ @history = []
16
+ @history_index = nil
17
+ @saved_input = nil
18
+ end
19
+
20
+ def content
21
+ @lines.join("\n")
22
+ end
23
+
24
+ def empty?
25
+ @lines.length == 1 && @lines[0].empty?
26
+ end
27
+
28
+ def lines
29
+ @lines.dup
30
+ end
31
+
32
+ def line_count
33
+ @lines.length
34
+ end
35
+
36
+ def current_line
37
+ @lines[@cursor_row] || ""
38
+ end
39
+
40
+ def insert_char(ch)
41
+ @lines[@cursor_row].insert(@cursor_col, ch)
42
+ @cursor_col += ch.length
43
+ end
44
+
45
+ def insert_text(text)
46
+ text_lines = text.split("\n", -1)
47
+ if text_lines.length == 1
48
+ insert_char(text_lines[0])
49
+ else
50
+ # Split current line at cursor
51
+ before = @lines[@cursor_row][0...@cursor_col]
52
+ after = @lines[@cursor_row][@cursor_col..]
53
+
54
+ # First fragment joins with text before cursor
55
+ @lines[@cursor_row] = before + text_lines[0]
56
+
57
+ # Middle lines insert after current row
58
+ text_lines[1...-1].each_with_index do |line, i|
59
+ @lines.insert(@cursor_row + 1 + i, line)
60
+ end
61
+
62
+ # Last fragment joins with text after cursor
63
+ last_line = text_lines.last + after.to_s
64
+ @lines.insert(@cursor_row + text_lines.length - 1, last_line)
65
+
66
+ @cursor_row += text_lines.length - 1
67
+ @cursor_col = text_lines.last.length
68
+ end
69
+ end
70
+
71
+ def newline
72
+ # Split current line at cursor
73
+ before = @lines[@cursor_row][0...@cursor_col]
74
+ after = @lines[@cursor_row][@cursor_col..]
75
+
76
+ @lines[@cursor_row] = before
77
+ @lines.insert(@cursor_row + 1, after.to_s)
78
+ @cursor_row += 1
79
+ @cursor_col = 0
80
+ end
81
+
82
+ def backspace
83
+ if @cursor_col > 0
84
+ @lines[@cursor_row] = @lines[@cursor_row][0...@cursor_col - 1] + @lines[@cursor_row][@cursor_col..]
85
+ @cursor_col -= 1
86
+ elsif @cursor_row > 0
87
+ # Join with previous line
88
+ prev_col = @lines[@cursor_row - 1].length
89
+ @lines[@cursor_row - 1] += @lines[@cursor_row]
90
+ @lines.delete_at(@cursor_row)
91
+ @cursor_row -= 1
92
+ @cursor_col = prev_col
93
+ end
94
+ end
95
+
96
+ def delete_char
97
+ if @cursor_col < @lines[@cursor_row].length
98
+ @lines[@cursor_row] = @lines[@cursor_row][0...@cursor_col] + @lines[@cursor_row][@cursor_col + 1..]
99
+ elsif @cursor_row < @lines.length - 1
100
+ # Join with next line
101
+ @lines[@cursor_row] += @lines[@cursor_row + 1]
102
+ @lines.delete_at(@cursor_row + 1)
103
+ end
104
+ end
105
+
106
+ def move_left
107
+ if @cursor_col > 0
108
+ @cursor_col -= 1
109
+ elsif @cursor_row > 0
110
+ @cursor_row -= 1
111
+ @cursor_col = @lines[@cursor_row].length
112
+ end
113
+ end
114
+
115
+ def move_right
116
+ if @cursor_col < @lines[@cursor_row].length
117
+ @cursor_col += 1
118
+ elsif @cursor_row < @lines.length - 1
119
+ @cursor_row += 1
120
+ @cursor_col = 0
121
+ end
122
+ end
123
+
124
+ def move_up
125
+ if @cursor_row > 0
126
+ @cursor_row -= 1
127
+ @cursor_col = [@cursor_col, @lines[@cursor_row].length].min
128
+ end
129
+ end
130
+
131
+ def move_down
132
+ if @cursor_row < @lines.length - 1
133
+ @cursor_row += 1
134
+ @cursor_col = [@cursor_col, @lines[@cursor_row].length].min
135
+ end
136
+ end
137
+
138
+ def move_home
139
+ @cursor_col = 0
140
+ end
141
+
142
+ def move_end
143
+ @cursor_col = @lines[@cursor_row].length
144
+ end
145
+
146
+ def clear
147
+ @lines = [+""]
148
+ @cursor_row = 0
149
+ @cursor_col = 0
150
+ end
151
+
152
+ # Submit the current content and add to history.
153
+ # Returns the content and clears the input.
154
+ def submit
155
+ text = content
156
+ add_to_history(text) unless text.strip.empty?
157
+ clear
158
+ @history_index = nil
159
+ @saved_input = nil
160
+ text
161
+ end
162
+
163
+ def add_to_history(text)
164
+ # Don't add duplicates of the most recent entry
165
+ @history.push(text) unless @history.last == text
166
+ end
167
+
168
+ def history_back
169
+ return false if @history.empty?
170
+
171
+ if @history_index.nil?
172
+ @saved_input = content
173
+ @history_index = @history.length - 1
174
+ elsif @history_index > 0
175
+ @history_index -= 1
176
+ else
177
+ return false
178
+ end
179
+
180
+ load_from_string(@history[@history_index])
181
+ true
182
+ end
183
+
184
+ def history_forward
185
+ return false if @history_index.nil?
186
+
187
+ if @history_index < @history.length - 1
188
+ @history_index += 1
189
+ load_from_string(@history[@history_index])
190
+ else
191
+ @history_index = nil
192
+ load_from_string(@saved_input || "")
193
+ @saved_input = nil
194
+ end
195
+ true
196
+ end
197
+
198
+ # Load history entries from an array of strings
199
+ def load_history(entries)
200
+ @history = entries.dup
201
+ end
202
+
203
+ def history_entries
204
+ @history.dup
205
+ end
206
+
207
+ private
208
+
209
+ def load_from_string(str)
210
+ @lines = str.split("\n", -1).map { |s| +s }
211
+ @lines = [+""] if @lines.empty?
212
+ @cursor_row = @lines.length - 1
213
+ @cursor_col = @lines[@cursor_row].length
214
+ end
215
+ end
216
+ end
217
+ end
218
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Thor
4
+ module Interactive
5
+ module TUI
6
+ # Theming system for the TUI shell.
7
+ # Predefined themes and custom color configuration.
8
+ class Theme
9
+ THEMES = {
10
+ default: {
11
+ output_fg: nil,
12
+ output_border: :dark_gray,
13
+ error_fg: :red,
14
+ command_echo_fg: :dark_gray,
15
+ system_fg: :cyan,
16
+ input_fg: nil,
17
+ input_border: :green,
18
+ input_title_fg: :green,
19
+ cursor_fg: :black,
20
+ cursor_bg: :white,
21
+ status_bar_fg: :white,
22
+ status_bar_bg: :blue,
23
+ completion_fg: :white,
24
+ completion_bg: :dark_gray,
25
+ completion_selected_fg: :black,
26
+ completion_selected_bg: :cyan
27
+ },
28
+ dark: {
29
+ output_fg: :gray,
30
+ output_border: :dark_gray,
31
+ error_fg: :light_red,
32
+ command_echo_fg: :dark_gray,
33
+ system_fg: :light_cyan,
34
+ input_fg: :white,
35
+ input_border: :light_green,
36
+ input_title_fg: :light_green,
37
+ cursor_fg: :black,
38
+ cursor_bg: :light_yellow,
39
+ status_bar_fg: :white,
40
+ status_bar_bg: :dark_gray,
41
+ completion_fg: :white,
42
+ completion_bg: :dark_gray,
43
+ completion_selected_fg: :black,
44
+ completion_selected_bg: :light_cyan
45
+ },
46
+ light: {
47
+ output_fg: :black,
48
+ output_border: :gray,
49
+ error_fg: :red,
50
+ command_echo_fg: :gray,
51
+ system_fg: :blue,
52
+ input_fg: :black,
53
+ input_border: :green,
54
+ input_title_fg: :green,
55
+ cursor_fg: :white,
56
+ cursor_bg: :black,
57
+ status_bar_fg: :white,
58
+ status_bar_bg: :blue,
59
+ completion_fg: :black,
60
+ completion_bg: :gray,
61
+ completion_selected_fg: :white,
62
+ completion_selected_bg: :blue
63
+ },
64
+ minimal: {
65
+ output_fg: nil,
66
+ output_border: :dark_gray,
67
+ error_fg: :red,
68
+ command_echo_fg: :dark_gray,
69
+ system_fg: :yellow,
70
+ input_fg: nil,
71
+ input_border: :dark_gray,
72
+ input_title_fg: :white,
73
+ cursor_fg: :black,
74
+ cursor_bg: :white,
75
+ status_bar_fg: :black,
76
+ status_bar_bg: :white,
77
+ completion_fg: :white,
78
+ completion_bg: :dark_gray,
79
+ completion_selected_fg: :black,
80
+ completion_selected_bg: :white
81
+ }
82
+ }.freeze
83
+
84
+ attr_reader :colors
85
+
86
+ def initialize(theme = :default)
87
+ @colors = if theme.is_a?(Hash)
88
+ THEMES[:default].merge(theme)
89
+ else
90
+ THEMES.fetch(theme, THEMES[:default]).dup
91
+ end
92
+ end
93
+
94
+ def [](key)
95
+ @colors[key]
96
+ end
97
+
98
+ def style(key)
99
+ color = @colors[key]
100
+ color ? RatatuiRuby::Style::Style.new(fg: color) : nil
101
+ end
102
+
103
+ def output_style
104
+ fg = @colors[:output_fg]
105
+ fg ? RatatuiRuby::Style::Style.new(fg: fg) : nil
106
+ end
107
+
108
+ def error_style
109
+ RatatuiRuby::Style::Style.new(fg: @colors[:error_fg])
110
+ end
111
+
112
+ def command_echo_style
113
+ RatatuiRuby::Style::Style.new(fg: @colors[:command_echo_fg])
114
+ end
115
+
116
+ def system_style
117
+ RatatuiRuby::Style::Style.new(fg: @colors[:system_fg])
118
+ end
119
+
120
+ def input_border_style
121
+ RatatuiRuby::Style::Style.new(fg: @colors[:input_border])
122
+ end
123
+
124
+ def input_title_style
125
+ RatatuiRuby::Style::Style.new(fg: @colors[:input_title_fg], modifiers: [:bold])
126
+ end
127
+
128
+ def cursor_style
129
+ RatatuiRuby::Style::Style.new(fg: @colors[:cursor_fg], bg: @colors[:cursor_bg], modifiers: [:bold])
130
+ end
131
+
132
+ def output_border_style
133
+ RatatuiRuby::Style::Style.new(fg: @colors[:output_border])
134
+ end
135
+
136
+ def status_bar_style
137
+ RatatuiRuby::Style::Style.new(fg: @colors[:status_bar_fg], bg: @colors[:status_bar_bg])
138
+ end
139
+
140
+ def completion_style
141
+ RatatuiRuby::Style::Style.new(fg: @colors[:completion_fg])
142
+ end
143
+
144
+ def completion_bg_style
145
+ RatatuiRuby::Style::Style.new(bg: @colors[:completion_bg])
146
+ end
147
+
148
+ def completion_selected_style
149
+ RatatuiRuby::Style::Style.new(
150
+ fg: @colors[:completion_selected_fg],
151
+ bg: @colors[:completion_selected_bg],
152
+ modifiers: [:bold]
153
+ )
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Thor
4
+ module Interactive
5
+ module TUI
6
+ def self.available?
7
+ require "ratatui_ruby"
8
+ true
9
+ rescue LoadError
10
+ false
11
+ end
12
+ end
13
+ end
14
+ end
@@ -4,5 +4,5 @@
4
4
  # This file is separate to avoid circular dependencies during gem installation
5
5
 
6
6
  module ThorInteractive
7
- VERSION = "0.1.0.pre.4"
7
+ VERSION = "0.1.0.pre.6"
8
8
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: thor-interactive
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0.pre.4
4
+ version: 0.1.0.pre.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chris Petersen
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-09-08 00:00:00.000000000 Z
11
+ date: 2026-03-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: thor
@@ -53,7 +53,10 @@ files:
53
53
  - LICENSE.txt
54
54
  - README.md
55
55
  - Rakefile
56
+ - docs/assets/thor-interactive-wide.png
57
+ - docs/assets/thor-interactive.png
56
58
  - examples/README.md
59
+ - examples/completion_demo.rb
57
60
  - examples/demo_session.rb
58
61
  - examples/edge_case_test.rb
59
62
  - examples/nested_example.rb
@@ -61,9 +64,18 @@ files:
61
64
  - examples/sample_app.rb
62
65
  - examples/signal_demo.rb
63
66
  - examples/test_interactive.rb
67
+ - examples/tui_demo.rb
64
68
  - lib/thor/interactive.rb
65
69
  - lib/thor/interactive/command.rb
70
+ - lib/thor/interactive/command_dispatch.rb
66
71
  - lib/thor/interactive/shell.rb
72
+ - lib/thor/interactive/tui.rb
73
+ - lib/thor/interactive/tui/output_buffer.rb
74
+ - lib/thor/interactive/tui/ratatui_shell.rb
75
+ - lib/thor/interactive/tui/spinner.rb
76
+ - lib/thor/interactive/tui/status_bar.rb
77
+ - lib/thor/interactive/tui/text_input.rb
78
+ - lib/thor/interactive/tui/theme.rb
67
79
  - lib/thor/interactive/version.rb
68
80
  - lib/thor/interactive/version_constant.rb
69
81
  - sig/thor/interactive.rbs
@@ -90,7 +102,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
90
102
  - !ruby/object:Gem::Version
91
103
  version: '0'
92
104
  requirements: []
93
- rubygems_version: 3.5.3
105
+ rubygems_version: 3.5.22
94
106
  signing_key:
95
107
  specification_version: 4
96
108
  summary: Turn any Thor CLI into an interactive REPL with persistent state and auto-completion