foruiman 0.1.2 → 0.3.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,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Foruiman::TUI
4
+ class InputLine
5
+ # Leave room for the newline in the child's canonical terminal buffer.
6
+ MAX_BYTES = 4095
7
+ HISTORY_SIZE = 100
8
+ SEQUENCES = {
9
+ "\e[A" => :history_previous, "\e[B" => :history_next,
10
+ "\e[C" => :right, "\e[D" => :left,
11
+ "\e[H" => :home, "\e[F" => :end,
12
+ "\e[1~" => :home, "\e[4~" => :end, "\e[3~" => :delete
13
+ }.freeze
14
+ CONTROLS = {
15
+ 1 => :home, 3 => :interrupt, 4 => :eof, 5 => :end, 8 => :backspace,
16
+ 10 => :submit, 13 => :submit, 18 => :history_previous, 21 => :clear, 23 => :delete_word,
17
+ 24 => :detach, 127 => :backspace
18
+ }.freeze
19
+
20
+ attr_reader :text, :cursor
21
+
22
+ def initialize
23
+ @text = +""
24
+ @cursor = 0
25
+ @bytes = +"".b
26
+ @history = []
27
+ @history_index = nil
28
+ @draft = nil
29
+ end
30
+
31
+ def feed(bytes)
32
+ bytes, detach, = bytes.b.partition("\x18")
33
+ @bytes << bytes
34
+ events = []
35
+ until @bytes.empty?
36
+ if @bytes.start_with?("\e")
37
+ if @bytes.bytesize > 64 && incomplete_escape?
38
+ @bytes.clear
39
+ break
40
+ end
41
+ break if incomplete_escape?
42
+
43
+ sequence = SEQUENCES.keys.find { |candidate| @bytes.start_with?(candidate) }
44
+ if sequence
45
+ edit(SEQUENCES.fetch(sequence))
46
+ @bytes.slice!(0, sequence.bytesize)
47
+ else
48
+ match = @bytes.match(%r{\A\e(?:\[[0-?]*[ -/]*[@-~]|O.|.)}m)
49
+ @bytes.slice!(0, match ? match[0].bytesize : 1)
50
+ end
51
+ next
52
+ end
53
+
54
+ byte = @bytes.getbyte(0)
55
+ if @after_cr && byte == 10
56
+ @bytes.slice!(0, 1)
57
+ @after_cr = false
58
+ next
59
+ end
60
+ @after_cr = byte == 13
61
+ if CONTROLS.key?(byte)
62
+ @bytes.slice!(0, 1)
63
+ event = edit(CONTROLS.fetch(byte))
64
+ events << event if event
65
+ elsif byte < 32
66
+ @bytes.slice!(0, 1)
67
+ else
68
+ character = next_character
69
+ break unless character
70
+
71
+ insert(character)
72
+ end
73
+ end
74
+ unless detach.empty?
75
+ @bytes.clear
76
+ clear
77
+ events << :detach
78
+ end
79
+ events
80
+ end
81
+
82
+ def clear
83
+ replace("")
84
+ @history_index = nil
85
+ @draft = nil
86
+ end
87
+
88
+ private
89
+
90
+ def edit(action)
91
+ case action
92
+ when :left then @cursor -= 1 if cursor.positive?
93
+ when :right then @cursor += 1 if cursor < graphemes.size
94
+ when :home then @cursor = 0
95
+ when :end then @cursor = graphemes.size
96
+ when :backspace then remove(cursor - 1) if cursor.positive?
97
+ when :delete then remove(cursor) if cursor < graphemes.size
98
+ when :delete_word then delete_word
99
+ when :clear then clear
100
+ when :history_previous then history_previous
101
+ when :history_next then history_next
102
+ when :submit then return submit
103
+ when :interrupt
104
+ clear
105
+ return :interrupt
106
+ when :eof
107
+ return :eof if text.empty?
108
+
109
+ remove(cursor) if cursor < graphemes.size
110
+ when :detach
111
+ clear
112
+ return :detach
113
+ end
114
+ nil
115
+ end
116
+
117
+ def submit
118
+ line = text.dup
119
+ @history << line.freeze unless line.empty? || @history.last == line
120
+ @history.shift if @history.size > HISTORY_SIZE
121
+ clear
122
+ [:submit, line]
123
+ end
124
+
125
+ def insert(character)
126
+ return if text.bytesize + character.bytesize > MAX_BYTES
127
+ return if character.match?(/[\u0080-\u009f]/)
128
+
129
+ if character.ascii_only? && cursor == text.length
130
+ @text << character
131
+ @cursor += 1
132
+ @history_index = nil
133
+ return
134
+ end
135
+
136
+ parts = graphemes
137
+ parts.insert(cursor, character)
138
+ @text = parts.join
139
+ @cursor = [cursor + 1, graphemes.size].min
140
+ @history_index = nil
141
+ end
142
+
143
+ def remove(index)
144
+ parts = graphemes
145
+ parts.delete_at(index)
146
+ @text = parts.join
147
+ @cursor -= 1 if index < cursor
148
+ @history_index = nil
149
+ end
150
+
151
+ def delete_word
152
+ parts = graphemes
153
+ index = cursor
154
+ index -= 1 while index.positive? && parts[index - 1].match?(/\s/)
155
+ index -= 1 while index.positive? && !parts[index - 1].match?(/\s/)
156
+ parts.slice!(index...cursor)
157
+ @text = parts.join
158
+ @cursor = index
159
+ @history_index = nil
160
+ end
161
+
162
+ def history_previous
163
+ return if @history.empty?
164
+
165
+ @draft = text.dup unless @history_index
166
+ @history_index = [(@history_index || @history.size) - 1, 0].max
167
+ replace(@history.fetch(@history_index))
168
+ end
169
+
170
+ def history_next
171
+ return unless @history_index
172
+
173
+ @history_index += 1
174
+ if @history_index >= @history.size
175
+ @history_index = nil
176
+ replace(@draft.to_s)
177
+ @draft = nil
178
+ else
179
+ replace(@history.fetch(@history_index))
180
+ end
181
+ end
182
+
183
+ def replace(value)
184
+ @text = +value
185
+ @cursor = graphemes.size
186
+ end
187
+
188
+ def graphemes
189
+ text.scan(/\X/)
190
+ end
191
+
192
+ def next_character
193
+ size = utf8_size(@bytes.getbyte(0))
194
+ return @bytes.slice!(0, 1).force_encoding(Encoding::UTF_8).scrub unless size
195
+ unless @bytes.byteslice(1, size - 1).bytes.all? { |byte| byte.between?(128, 191) }
196
+ return @bytes.slice!(0, 1).force_encoding(Encoding::UTF_8).scrub
197
+ end
198
+ return if @bytes.bytesize < size
199
+
200
+ @bytes.slice!(0, size).force_encoding(Encoding::UTF_8).scrub
201
+ end
202
+
203
+ def utf8_size(byte)
204
+ case byte
205
+ when 0..127 then 1
206
+ when 194..223 then 2
207
+ when 224..239 then 3
208
+ when 240..244 then 4
209
+ end
210
+ end
211
+
212
+ def incomplete_escape?
213
+ @bytes == "\e" || @bytes == "\e[" || @bytes == "\eO" ||
214
+ (@bytes.start_with?("\e[") && !@bytes.match?(%r{\A\e\[[0-?]*[ -/]*[@-~]}))
215
+ end
216
+ end
217
+ end
@@ -12,7 +12,7 @@ module Foruiman::TUI
12
12
  "\t" => :next, "h" => :previous, "l" => :next, "k" => :up, "j" => :down,
13
13
  "g" => :home, "G" => :end, "f" => :follow, " " => :toggle_follow,
14
14
  "r" => :restart, "R" => :restart_all, "s" => :stop, "S" => :stop_all,
15
- "?" => :help, "q" => :quit, "\x03" => :quit,
15
+ "i" => :input, "?" => :help, "q" => :quit, "\x03" => :quit,
16
16
  "\x15" => :page_up, "\x04" => :page_down
17
17
  }.freeze
18
18
 
@@ -30,6 +30,7 @@ module Foruiman::TUI
30
30
 
31
31
  def stream_style(stream)
32
32
  case stream
33
+ when :stdin then ["in ", :cyan]
33
34
  when :stderr then ["err", :red]
34
35
  when :lifecycle then ["sys", :amber]
35
36
  else ["out", :faint]
@@ -38,6 +39,7 @@ module Foruiman::TUI
38
39
 
39
40
  def body_color(stream)
40
41
  case stream
42
+ when :stdin then :cyan
41
43
  when :stderr then :red
42
44
  when :lifecycle then :muted
43
45
  else :text
@@ -16,7 +16,8 @@ module Foruiman::TUI
16
16
  [nil, "f / G / End", "Follow newest output", nil],
17
17
  [nil, "Space", "Pause / resume following", nil],
18
18
  ["⚙ PROCESSES", "r / R", "Restart selected / all", "r on all restarts all"],
19
- [nil, "s / S", "Stop selected / all", nil],
19
+ [nil, "s / S", "Start/stop selected / stop all", "s on all stops all"],
20
+ ["⌨ INPUT", "i", "Edit a command; Enter sends", "Ctrl-X returns to Foruiman"],
20
21
  ["◇ SESSION", "? / Escape", "Close help", nil],
21
22
  [nil, "q / Ctrl-C", "Stop processes and quit", nil]
22
23
  ].freeze
@@ -30,16 +31,16 @@ module Foruiman::TUI
30
31
  rows >= 14 && columns >= 60
31
32
  end
32
33
 
33
- def self.log_height(rows:, columns:)
34
+ def self.log_height(rows:, columns:, input: false)
34
35
  return 0 if rows < 6 || columns < 24
35
36
 
36
- rows - (expanded?(rows: rows, columns: columns) ? 7 : 5)
37
+ rows - (expanded?(rows: rows, columns: columns) ? 7 : 5) - (input && rows >= 8 ? 2 : 0)
37
38
  end
38
39
 
39
40
  def render(state, engine, rows:, columns:)
40
41
  @width = [columns - 1, 1].max
41
42
  @inside = [@width - 4, 0].max
42
- @height = self.class.log_height(rows: rows, columns: columns)
43
+ @height = self.class.log_height(rows: rows, columns: columns, input: state.input?)
43
44
  return tiny_frame(state, engine, rows) if @height.zero?
44
45
 
45
46
  lines = [header(engine)]
@@ -54,6 +55,7 @@ module Foruiman::TUI
54
55
  lines << log_header(state, engine)
55
56
  lines.concat(state.help ? help_rows : log_rows(state, engine))
56
57
  lines << log_footer(state, engine)
58
+ lines.concat(input_rows(state)) if state.input? && rows >= 8
57
59
  lines << controls(state, engine)
58
60
  frame(lines, rows)
59
61
  end
@@ -157,7 +159,9 @@ module Foruiman::TUI
157
159
  else
158
160
  engine.state(state.name).status == :running
159
161
  end
160
- mode = if !state.viewport.following
162
+ mode = if state.input?
163
+ @theme.paint(" ▶ INPUT ", :cyan)
164
+ elsif !state.viewport.following
161
165
  @theme.paint(" Ⅱ PAUSED ", :amber)
162
166
  elsif active
163
167
  @theme.paint(" ● LIVE ", :green)
@@ -244,28 +248,61 @@ module Foruiman::TUI
244
248
  end
245
249
 
246
250
  def controls(state, engine)
251
+ if state.input?
252
+ left = @theme.paint(" Enter send · ↑↓ history · Ctrl-C interrupt", :muted)
253
+ right = @theme.paint(" Ctrl-X back ", :amber, bold: true)
254
+ return distribute(left, right, @width)
255
+ end
256
+
247
257
  quit = @theme.paint(" q ", :accent, bold: true) + @theme.paint(" × quit ", :muted)
248
258
  left = if engine.shutting_down?
249
- @theme.paint(" ◌ Stopping process groups · TERM → KILL after 5s", :amber)
259
+ seconds = format("%g", engine.term_timeout)
260
+ @theme.paint(" ◌ Stopping process groups · TERM → KILL after #{seconds}s", :amber)
250
261
  elsif state.feedback
251
262
  @theme.paint(" #{state.feedback}", :amber)
252
263
  elsif state.help
253
264
  @theme.paint(" ? / Escape close help", :muted)
254
265
  else
255
- shortcuts(state)
266
+ shortcuts(state, engine)
256
267
  end
257
268
  distribute(left, quit, @width)
258
269
  end
259
270
 
260
- def shortcuts(state)
271
+ def shortcuts(state, engine)
261
272
  restart = ["r", state.name == "all" ? "↻ restart all" : "↻ restart"]
262
- pairs = [%w[Tab switch], ["↑↓", "scroll"], %w[f follow], restart, ["?", "help"]]
263
- pairs = [restart, ["?", "help"]] if @width < 65
273
+ toggle = if state.name == "all"
274
+ ["s", "■ stop all"]
275
+ elsif process_active?(engine.state(state.name))
276
+ ["s", "■ stop"]
277
+ else
278
+ ["s", "▶ start"]
279
+ end
280
+ pairs = [%w[Tab switch], ["↑↓", "scroll"], %w[f follow], restart,
281
+ toggle, %w[i input], ["?", "help"]]
282
+ pairs = [restart, toggle, %w[i input], ["?", "help"]] if @width < 85
264
283
  pairs.map do |key, label|
265
284
  @theme.paint(" #{key} ", :accent, bold: true) + @theme.paint(" #{label} ", :muted)
266
285
  end.join
267
286
  end
268
287
 
288
+ def process_active?(entry)
289
+ entry.pgid || %i[running restarting stopping].include?(entry.status)
290
+ end
291
+
292
+ def input_rows(state)
293
+ editor = state.input_line
294
+ parts = editor.text.scan(/\X/)
295
+ before = parts.take(editor.cursor)
296
+ current = parts[editor.cursor] || " "
297
+ after = parts.drop(editor.cursor + 1).join
298
+ available = [@inside - 5, 1].max
299
+ before.shift while !before.empty? && Text.width(before.join + current) > available
300
+ draft = @theme.paint("❯ ", :cyan, bold: true) + before.join +
301
+ @theme.paint(current,
302
+ selected: true) + Text.clip(after, [available - Text.width(before.join + current), 0].max)
303
+ [border(@theme.paint(" ⌨ Input to #{state.input_target} ", :cyan, bold: true)), panel(draft)]
304
+ end
305
+
269
306
  def border(left, right = "", bottom: false)
270
307
  corners = bottom ? %w[╰ ╯] : %w[╭ ╮]
271
308
  middle = distribute(left, right, @inside, fill: "─")
@@ -290,7 +327,7 @@ module Foruiman::TUI
290
327
  title = @theme.paint(" FORUIMAN", :accent, bold: true)
291
328
  message = engine.shutting_down? ? " Stopping…" : " #{state.name} · enlarge terminal"
292
329
  lines = [title, @theme.paint(message, :muted)]
293
- lines[rows - 1] = @theme.paint(" q quit", :accent) if rows > 2
330
+ lines[rows - 1] = @theme.paint(state.input? ? " Ctrl-X back" : " q quit", :accent) if rows > 2
294
331
  frame(lines, rows)
295
332
  end
296
333
 
@@ -1,11 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "viewport"
4
+ require_relative "input_line"
4
5
 
5
6
  module Foruiman::TUI
6
7
  class State
7
8
  attr_reader :tabs, :selected, :viewports
8
- attr_accessor :help, :feedback
9
+ attr_accessor :help, :feedback, :input_target
9
10
 
10
11
  def initialize(names)
11
12
  @tabs = [*names, "all"].freeze
@@ -13,6 +14,8 @@ module Foruiman::TUI
13
14
  @viewports = tabs.to_h { |name| [name, Viewport.new] }
14
15
  @help = false
15
16
  @feedback = nil
17
+ @input_target = nil
18
+ @input_lines = names.to_h { |name| [name, InputLine.new] }
16
19
  end
17
20
 
18
21
  def name
@@ -23,6 +26,14 @@ module Foruiman::TUI
23
26
  viewports.fetch(name)
24
27
  end
25
28
 
29
+ def input?
30
+ !input_target.nil?
31
+ end
32
+
33
+ def input_line
34
+ @input_lines.fetch(input_target || name)
35
+ end
36
+
26
37
  def select(index)
27
38
  @selected = index if index.between?(0, tabs.size - 1)
28
39
  end
@@ -16,8 +16,8 @@ module Foruiman::TUI
16
16
  stopped: :muted, exited: :muted, failed: :red
17
17
  }.freeze
18
18
  STATUS_MARKS = {
19
- pending: "○", running: "●", restarting: "↻", stopping: "◌",
20
- stopped: "■", exited: "✓", failed: "×"
19
+ pending: "○", running: "▶", restarting: "↻", stopping: "◼",
20
+ stopped: "■", exited: "✓", failed: "✕"
21
21
  }.freeze
22
22
 
23
23
  attr_reader :enabled
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Foruiman
4
- VERSION = "0.1.2"
4
+ VERSION = "0.3.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: foruiman
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Foruiman contributors
@@ -70,16 +70,19 @@ files:
70
70
  - lib/foruiman.rb
71
71
  - lib/foruiman/ansi.rb
72
72
  - lib/foruiman/cli.rb
73
+ - lib/foruiman/configuration.rb
73
74
  - lib/foruiman/diagnostics.rb
74
75
  - lib/foruiman/engine.rb
75
76
  - lib/foruiman/env.rb
76
77
  - lib/foruiman/log_store.rb
77
78
  - lib/foruiman/output.rb
79
+ - lib/foruiman/output_line.rb
78
80
  - lib/foruiman/plain.rb
79
81
  - lib/foruiman/process.rb
80
82
  - lib/foruiman/procfile.rb
81
83
  - lib/foruiman/ring.rb
82
84
  - lib/foruiman/tui/application.rb
85
+ - lib/foruiman/tui/input_line.rb
83
86
  - lib/foruiman/tui/keyboard.rb
84
87
  - lib/foruiman/tui/log_formatter.rb
85
88
  - lib/foruiman/tui/renderer.rb
@@ -110,7 +113,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
110
113
  - !ruby/object:Gem::Version
111
114
  version: '0'
112
115
  requirements: []
113
- rubygems_version: 4.0.16
116
+ rubygems_version: 4.0.20
114
117
  specification_version: 4
115
118
  summary: A Procfile supervisor with process tabs, bounded logs, and isolated restarts
116
119
  test_files: []