foruiman 0.1.2

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,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Foruiman::Ring
4
+ include Enumerable
5
+
6
+ attr_reader :capacity, :size
7
+
8
+ def initialize(capacity)
9
+ raise Foruiman::Error, "log-lines must be a positive integer" unless capacity.is_a?(Integer) && capacity.positive?
10
+
11
+ @capacity = capacity
12
+ @slots = []
13
+ @positions = {}
14
+ @head = 0
15
+ @size = 0
16
+ end
17
+
18
+ def append(record)
19
+ index = (@head + @size) % capacity
20
+ if size == capacity
21
+ @positions.delete(@slots[index].sequence)
22
+ @head = (@head + 1) % capacity
23
+ else
24
+ @size += 1
25
+ end
26
+ @slots[index] = record
27
+ @positions[record.sequence] = index
28
+ record
29
+ end
30
+
31
+ def replace(record)
32
+ index = @positions[record.sequence]
33
+ @slots[index] = record if index
34
+ record
35
+ end
36
+
37
+ def [](index)
38
+ return unless index >= 0 && index < size
39
+
40
+ @slots[(@head + index) % capacity]
41
+ end
42
+
43
+ def index_of(sequence)
44
+ position = @positions[sequence]
45
+ (position - @head) % capacity if position
46
+ end
47
+
48
+ def each
49
+ return enum_for(:each) unless block_given?
50
+
51
+ size.times { |index| yield self[index] }
52
+ end
53
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "terminal"
4
+ require_relative "state"
5
+ require_relative "keyboard"
6
+ require_relative "renderer"
7
+
8
+ module Foruiman::TUI
9
+ class Application
10
+ attr_reader :state
11
+
12
+ def initialize(engine, terminal: Terminal.new, renderer: Renderer.new)
13
+ @engine = engine
14
+ @terminal = terminal
15
+ @renderer = renderer
16
+ @keyboard = Keyboard.new
17
+ @state = State.new(engine.processes.map(&:name))
18
+ @last_frame = nil
19
+ @next_frame_at = 0
20
+ @was_shutting_down = false
21
+ @feedback_until = 0
22
+ end
23
+
24
+ def run
25
+ @terminal.session do
26
+ @engine.run(keep_open: true) do
27
+ rows, columns = @terminal.size
28
+ input = @terminal.read
29
+ @engine.shutdown if input.nil?
30
+ height = [Renderer.log_height(rows: rows, columns: columns), 1].max
31
+ @keyboard.feed(input.is_a?(String) ? input : "").each { |key| handle(key, height) }
32
+ state.feedback = nil if monotonic >= @feedback_until
33
+ next if monotonic < @next_frame_at && @was_shutting_down == @engine.shutting_down?
34
+
35
+ @was_shutting_down = @engine.shutting_down?
36
+
37
+ @next_frame_at = monotonic + (1.0 / 30)
38
+ frame = @renderer.render(state, @engine, rows: rows, columns: columns)
39
+ if frame != @last_frame
40
+ @terminal.draw(frame)
41
+ @last_frame = frame
42
+ end
43
+ end
44
+ end
45
+ ensure
46
+ @engine.close
47
+ end
48
+
49
+ def handle(key, height)
50
+ return if @engine.shutting_down?
51
+
52
+ if key.is_a?(Integer)
53
+ state.select(key.zero? ? state.tabs.size - 1 : key - 1)
54
+ return
55
+ end
56
+ buffer = @engine.logs[state.name]
57
+ case key
58
+ when :next then state.move(1)
59
+ when :previous then state.move(-1)
60
+ when :up then state.viewport.scroll(-1, buffer, height)
61
+ when :down then state.viewport.scroll(1, buffer, height)
62
+ when :page_up then state.viewport.scroll(-height, buffer, height)
63
+ when :page_down then state.viewport.scroll(height, buffer, height)
64
+ when :home then state.viewport.home(buffer)
65
+ when :end, :follow then state.viewport.follow
66
+ when :toggle_follow then state.viewport.toggle(buffer, height)
67
+ when :help then state.help = !state.help
68
+ when :escape then state.help = false
69
+ when :restart, :stop then control(key)
70
+ when :restart_all, :stop_all
71
+ control_all(key == :restart_all ? :restart : :stop)
72
+ when :quit then @engine.shutdown
73
+ end
74
+ end
75
+
76
+ private
77
+
78
+ def control(action)
79
+ if state.name == "all"
80
+ if action == :restart
81
+ control_all(action)
82
+ else
83
+ feedback("Select a process first; S stops all processes")
84
+ end
85
+ else
86
+ @engine.public_send(action, state.name)
87
+ feedback("#{action == :restart ? 'Restarting' : 'Stopping'} #{state.name}")
88
+ end
89
+ end
90
+
91
+ def control_all(action)
92
+ @engine.processes.each { |entry| @engine.public_send(action, entry.name) }
93
+ feedback("#{action == :restart ? 'Restarting' : 'Stopping'} all processes")
94
+ end
95
+
96
+ def feedback(message)
97
+ state.feedback = message
98
+ @feedback_until = monotonic + 2
99
+ end
100
+
101
+ def monotonic
102
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Foruiman::TUI
4
+ class Keyboard
5
+ SEQUENCES = {
6
+ "\e[A" => :up, "\e[B" => :down, "\e[C" => :next, "\e[D" => :previous,
7
+ "\e[5~" => :page_up, "\e[6~" => :page_down, "\e[H" => :home, "\e[F" => :end,
8
+ "\e[1~" => :home, "\e[4~" => :end, "\eOH" => :home, "\eOF" => :end,
9
+ "\e[Z" => :previous
10
+ }.freeze
11
+ KEYS = {
12
+ "\t" => :next, "h" => :previous, "l" => :next, "k" => :up, "j" => :down,
13
+ "g" => :home, "G" => :end, "f" => :follow, " " => :toggle_follow,
14
+ "r" => :restart, "R" => :restart_all, "s" => :stop, "S" => :stop_all,
15
+ "?" => :help, "q" => :quit, "\x03" => :quit,
16
+ "\x15" => :page_up, "\x04" => :page_down
17
+ }.freeze
18
+
19
+ def initialize
20
+ @buffer = +"".b
21
+ @escape_at = nil
22
+ end
23
+
24
+ def feed(bytes, now: monotonic)
25
+ @buffer << bytes
26
+ actions = []
27
+ until @buffer.empty?
28
+ if @buffer.start_with?("\e")
29
+ sequence = SEQUENCES.keys.find { |key| @buffer.start_with?(key) }
30
+ if sequence
31
+ actions << SEQUENCES.fetch(sequence)
32
+ @buffer.slice!(0, sequence.bytesize)
33
+ elsif incomplete_escape?
34
+ @escape_at ||= now
35
+ break if now - @escape_at < 0.1 && @buffer.bytesize < 64
36
+
37
+ actions << :escape if @buffer == "\e"
38
+ @buffer.clear
39
+ else
40
+ # Ignore an unknown complete escape as a unit; its final byte must
41
+ # never accidentally become a destructive shortcut such as R.
42
+ match = @buffer.match(%r{\A\e(?:\[[0-?]*[ -/]*[@-~]|O.|.)}m)
43
+ @buffer.slice!(0, match ? match[0].bytesize : 1)
44
+ end
45
+ else
46
+ key = @buffer.slice!(0, 1)
47
+ actions << (key.match?(/[0-9]/) ? key.to_i : KEYS[key])
48
+ end
49
+ @escape_at = nil
50
+ end
51
+ actions.compact
52
+ end
53
+
54
+ private
55
+
56
+ def incomplete_escape?
57
+ return true if @buffer == "\e" || @buffer == "\eO"
58
+
59
+ @buffer.start_with?("\e[") && !@buffer.match?(%r{\A\e\[[0-?]*[ -/]*[@-~]})
60
+ end
61
+
62
+ def monotonic
63
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "text"
4
+ require_relative "theme"
5
+
6
+ module Foruiman::TUI
7
+ class LogFormatter
8
+ def initialize(theme, names)
9
+ @theme = theme
10
+ @colors = names.each_with_index.to_h { |name, index| [name, theme.process(index)] }
11
+ @name_width = names.map { |name| Text.width(name) }.max.to_i.clamp(3, 14)
12
+ end
13
+
14
+ def row(record, aggregate:, width:)
15
+ prefix = +""
16
+ prefix << @theme.paint("#{record.time.strftime('%H:%M:%S')} ", :faint) if width >= 55
17
+ if aggregate
18
+ name_width = [@name_width, width / 5].min
19
+ name = Text.pad(Text.clip(record.name, name_width, ellipsis: true), name_width)
20
+ prefix << @theme.paint(name, @colors.fetch(record.name, :cyan)) << " "
21
+ end
22
+ marker, color = stream_style(record.stream)
23
+ prefix << @theme.paint(marker, color) << @theme.paint(" │ ", :border)
24
+ content = record.stream == :lifecycle ? record.text.delete_prefix("--- ").delete_suffix(" ---") : record.text
25
+ content = Text.clip(content, [width - Text.width(prefix), 0].max, ellipsis: true)
26
+ prefix + @theme.log(content, color: body_color(record.stream))
27
+ end
28
+
29
+ private
30
+
31
+ def stream_style(stream)
32
+ case stream
33
+ when :stderr then ["err", :red]
34
+ when :lifecycle then ["sys", :amber]
35
+ else ["out", :faint]
36
+ end
37
+ end
38
+
39
+ def body_color(stream)
40
+ case stream
41
+ when :stderr then :red
42
+ when :lifecycle then :muted
43
+ else :text
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,306 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "text"
4
+ require_relative "theme"
5
+ require_relative "log_formatter"
6
+ require "pathname"
7
+
8
+ module Foruiman::TUI
9
+ class Renderer
10
+ HELP = [
11
+ ["↔ NAVIGATE", "Tab / Shift-Tab", "Change tab", "← → or h l"],
12
+ [nil, "1–9 / 0", "Select a process / all logs", nil],
13
+ ["≡ READ LOGS", "↑ ↓ or k j", "Scroll a line", nil],
14
+ [nil, "PgUp / PgDn", "Scroll a page", "Ctrl-U / Ctrl-D"],
15
+ [nil, "g / Home", "Oldest retained line", nil],
16
+ [nil, "f / G / End", "Follow newest output", nil],
17
+ [nil, "Space", "Pause / resume following", nil],
18
+ ["⚙ PROCESSES", "r / R", "Restart selected / all", "r on all restarts all"],
19
+ [nil, "s / S", "Stop selected / all", nil],
20
+ ["◇ SESSION", "? / Escape", "Close help", nil],
21
+ [nil, "q / Ctrl-C", "Stop processes and quit", nil]
22
+ ].freeze
23
+
24
+ def initialize(theme: Theme.new)
25
+ @theme = theme
26
+ @first_tab = 0
27
+ end
28
+
29
+ def self.expanded?(rows:, columns:)
30
+ rows >= 14 && columns >= 60
31
+ end
32
+
33
+ def self.log_height(rows:, columns:)
34
+ return 0 if rows < 6 || columns < 24
35
+
36
+ rows - (expanded?(rows: rows, columns: columns) ? 7 : 5)
37
+ end
38
+
39
+ def render(state, engine, rows:, columns:)
40
+ @width = [columns - 1, 1].max
41
+ @inside = [@width - 4, 0].max
42
+ @height = self.class.log_height(rows: rows, columns: columns)
43
+ return tiny_frame(state, engine, rows) if @height.zero?
44
+
45
+ lines = [header(engine)]
46
+ if self.class.expanded?(rows: rows, columns: columns)
47
+ hint = @inside >= 70 ? " Tab switch · 1–9 / 0 select " : ""
48
+ lines << border(@theme.paint(" processes ", :muted, bold: true), @theme.paint(hint, :faint))
49
+ lines << panel(tab_row(state, engine, @inside - 2))
50
+ lines << border("", "", bottom: true)
51
+ else
52
+ lines << " #{@theme.paint('│', :border)} #{tab_row(state, engine, @inside - 2)}"
53
+ end
54
+ lines << log_header(state, engine)
55
+ lines.concat(state.help ? help_rows : log_rows(state, engine))
56
+ lines << log_footer(state, engine)
57
+ lines << controls(state, engine)
58
+ frame(lines, rows)
59
+ end
60
+
61
+ def self.width(text)
62
+ Text.width(text)
63
+ end
64
+
65
+ def self.truncate(text, columns)
66
+ Text.clip(text, columns) + Foruiman::ANSI::RESET
67
+ end
68
+
69
+ private
70
+
71
+ def header(engine)
72
+ brand = @width >= 45 ? "▰ FORUIMAN" : "▰"
73
+ left = " #{@theme.paint(brand, :accent, bold: true)}"
74
+ right = if engine.shutting_down?
75
+ @theme.paint("◌ shutting down", :amber)
76
+ elsif @width >= 60
77
+ summary(engine)
78
+ else
79
+ ""
80
+ end
81
+ source = if engine.procfile_path
82
+ Text.clean(Pathname.new(engine.procfile_path).relative_path_from(Pathname.new(engine.root)).to_s)
83
+ end
84
+ project = @theme.paint(" / #{Text.clean(File.basename(engine.root))}", :muted)
85
+ left += project if @width >= 70 && Text.width(left + project + source.to_s + right) + 5 <= @width
86
+ if source
87
+ available = [@width - Text.width(left + right) - 4, 0].max
88
+ source = "…/#{File.basename(source)}" if Text.width(source) > available && source.include?("/")
89
+ left += @theme.paint(" #{Text.clip(source, available, ellipsis: true)}")
90
+ end
91
+ distribute(left, "#{right} ", @width)
92
+ end
93
+
94
+ def summary(engine)
95
+ running = engine.processes.count { |entry| entry.status == :running }
96
+ failed = engine.processes.count { |entry| entry.status == :failed }
97
+ summary = @theme.paint("● #{running} running", running.positive? ? :green : :muted)
98
+ summary += @theme.paint(" × #{failed} failed", :red) if failed.positive? && @width >= 55
99
+ summary
100
+ end
101
+
102
+ def tab_row(state, engine, width)
103
+ @first_tab = state.selected if state.selected < @first_tab
104
+ labels = state.tabs.each_with_index.map { |name, index| tab(name, index, state, engine, width) }
105
+ @first_tab += 1 while @first_tab < state.selected && tab_span(labels, @first_tab, state.selected) > width
106
+ output = +(@first_tab.positive? ? @theme.paint("‹ ", :muted) : "")
107
+ index = @first_tab
108
+ while index < labels.size
109
+ separator = index == @first_tab ? "" : " "
110
+ more = index < labels.size - 1 ? 2 : 0
111
+ available = width - Text.width(output) - more - separator.size
112
+ break if Text.width(labels[index]) > available && index > @first_tab
113
+
114
+ output << separator << Text.clip(labels[index], [available, 0].max) << @theme.reset
115
+ index += 1
116
+ end
117
+ output << @theme.paint(" ›", :muted) if index < labels.size
118
+ Text.clip(output, width)
119
+ end
120
+
121
+ def tab_span(labels, first, last)
122
+ Text.width(labels[first..last].join(" ")) + (first.positive? ? 2 : 0) + (last < labels.size - 1 ? 2 : 0)
123
+ end
124
+
125
+ def tab(name, index, state, engine, width)
126
+ selected = index == state.selected
127
+ entry = name == "all" ? nil : engine.processes.find { |process| process.name == name }
128
+ mark = name == "all" ? "≡" : Theme::STATUS_MARKS.fetch(entry&.status, "○")
129
+ number = if name == "all"
130
+ "0"
131
+ else
132
+ (index < 9 ? (index + 1).to_s : "·")
133
+ end
134
+ name = Text.clip(name, [width - 12, 6].max.clamp(6, 22), ellipsis: true)
135
+ color = name == "all" ? :accent : @theme.process(index)
136
+ mark_color = entry ? Theme::STATUS_COLORS.fetch(entry.status, :muted) : :muted
137
+ @theme.paint(" #{number} ", selected ? :muted : :faint, selected: selected) +
138
+ @theme.paint("#{mark} ", mark_color, selected: selected) +
139
+ @theme.paint("#{name} ", color, selected: selected, bold: selected)
140
+ end
141
+
142
+ def log_header(state, engine)
143
+ if state.help
144
+ return border(@theme.paint(" ⌨ keyboard shortcuts ", :text, bold: true),
145
+ @theme.paint(" ? / Escape close help ", :muted))
146
+ end
147
+
148
+ title = state.name == "all" ? " ≡ all logs " : " › #{state.name} "
149
+ detail = if state.name == "all"
150
+ "#{engine.processes.size} processes"
151
+ else
152
+ entry = engine.state(state.name)
153
+ process_detail(entry)
154
+ end
155
+ active = if state.name == "all"
156
+ engine.processes.any? { |entry| entry.status == :running }
157
+ else
158
+ engine.state(state.name).status == :running
159
+ end
160
+ mode = if !state.viewport.following
161
+ @theme.paint(" Ⅱ PAUSED ", :amber)
162
+ elsif active
163
+ @theme.paint(" ● LIVE ", :green)
164
+ else
165
+ @theme.paint(" ↓ FOLLOW ", :muted)
166
+ end
167
+ title = @theme.paint(title, :text, bold: true)
168
+ if @inside >= 45
169
+ title += @theme.paint(" #{detail} ", :muted)
170
+ elsif state.name != "all" && @inside >= 28
171
+ entry = engine.state(state.name)
172
+ title += @theme.paint(" #{entry.status} ", Theme::STATUS_COLORS.fetch(entry.status, :muted))
173
+ end
174
+ border(title, mode)
175
+ end
176
+
177
+ def process_detail(entry)
178
+ detail = "#{entry.status} · PID #{entry.pid || '-'}"
179
+ if entry.exit_status
180
+ code = entry.exit_status.exitstatus || "signal #{entry.exit_status.termsig}"
181
+ detail += " · exit #{code}"
182
+ elsif entry.port && @inside >= 75
183
+ detail += " · PORT #{entry.port}"
184
+ end
185
+ detail
186
+ end
187
+
188
+ def log_rows(state, engine)
189
+ @formatter ||= LogFormatter.new(@theme, engine.process_names)
190
+ buffer = engine.logs[state.name]
191
+ records = state.viewport.rows(buffer, @height)
192
+ @visible = records
193
+ first = records.empty? ? 0 : buffer.index_of(records.first.sequence).to_i
194
+ thumb_size = buffer.none? ? @height : [(@height * @height / buffer.size), 1].max.clamp(1, @height)
195
+ travel = @height - thumb_size
196
+ thumb_start = buffer.size <= @height ? 0 : (first * travel / (buffer.size - @height))
197
+ Array.new(@height) do |index|
198
+ content = if records[index]
199
+ @formatter.row(records[index], aggregate: state.name == "all", width: @inside - 2)
200
+ elsif records.empty? && index == @height / 2
201
+ @theme.paint("Waiting for output…", :faint)
202
+ else
203
+ ""
204
+ end
205
+ thumb = buffer.size > @height && index.between?(thumb_start, thumb_start + thumb_size - 1)
206
+ panel(content, scroll: if thumb
207
+ state.viewport.following ? :green : :amber
208
+ end)
209
+ end
210
+ end
211
+
212
+ def log_footer(state, engine)
213
+ return border("", "", bottom: true) if state.help
214
+
215
+ buffer = engine.logs[state.name]
216
+ first = @visible&.first
217
+ last = @visible&.last
218
+ range = first ? "#{buffer.index_of(first.sequence) + 1}–#{buffer.index_of(last.sequence) + 1}" : "0"
219
+ count = " #{range} / #{buffer.size} lines "
220
+ location = if state.viewport.following
221
+ " following "
222
+ else
223
+ " f resume "
224
+ end
225
+ location = " PID #{engine.state(state.name).pid || '-'} " if state.name != "all" && @inside < 45
226
+ border(@theme.paint(count, :faint), @theme.paint(location, state.viewport.following ? :faint : :amber),
227
+ bottom: true)
228
+ end
229
+
230
+ def help_rows
231
+ rows = []
232
+ HELP.each do |section, key, description, alias_keys|
233
+ if section && @height >= 15
234
+ rows << panel("") if !rows.empty? && @height >= 18
235
+ rows << panel(@theme.paint(section, :accent, bold: true))
236
+ end
237
+ line = @theme.paint(Text.pad(key, 18), :amber, bold: true) + @theme.paint(description)
238
+ line += @theme.paint(" #{alias_keys}", :faint) if alias_keys && @inside >= 75
239
+ rows << panel(line)
240
+ end
241
+ rows = rows.first(@height)
242
+ rows << panel("") while rows.size < @height
243
+ rows
244
+ end
245
+
246
+ def controls(state, engine)
247
+ quit = @theme.paint(" q ", :accent, bold: true) + @theme.paint(" × quit ", :muted)
248
+ left = if engine.shutting_down?
249
+ @theme.paint(" ◌ Stopping process groups · TERM → KILL after 5s", :amber)
250
+ elsif state.feedback
251
+ @theme.paint(" #{state.feedback}", :amber)
252
+ elsif state.help
253
+ @theme.paint(" ? / Escape close help", :muted)
254
+ else
255
+ shortcuts(state)
256
+ end
257
+ distribute(left, quit, @width)
258
+ end
259
+
260
+ def shortcuts(state)
261
+ 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
264
+ pairs.map do |key, label|
265
+ @theme.paint(" #{key} ", :accent, bold: true) + @theme.paint(" #{label} ", :muted)
266
+ end.join
267
+ end
268
+
269
+ def border(left, right = "", bottom: false)
270
+ corners = bottom ? %w[╰ ╯] : %w[╭ ╮]
271
+ middle = distribute(left, right, @inside, fill: "─")
272
+ " #{@theme.paint(corners.first, :border)}#{@theme.paint(middle, :border)}#{@theme.paint(corners.last, :border)} "
273
+ end
274
+
275
+ def panel(content, scroll: nil)
276
+ inside = Text.pad(Text.clip(content, @inside - 2), @inside - 2)
277
+ edge = @theme.paint(scroll ? "┃" : "│", scroll || :border)
278
+ " #{@theme.paint('│', :border)} #{inside}#{@theme.reset} #{edge} "
279
+ end
280
+
281
+ def distribute(left, right, width, fill: " ")
282
+ right = Text.clip(right, [width / 2, Text.width(right)].min)
283
+ available = [width - Text.width(right), 0].max
284
+ left = Text.clip(left, available, ellipsis: true)
285
+ gap = [width - Text.width(left) - Text.width(right), 0].max
286
+ left + @theme.reset + @theme.paint(fill * gap, :border) + right + @theme.reset
287
+ end
288
+
289
+ def tiny_frame(state, engine, rows)
290
+ title = @theme.paint(" FORUIMAN", :accent, bold: true)
291
+ message = engine.shutting_down? ? " Stopping…" : " #{state.name} · enlarge terminal"
292
+ lines = [title, @theme.paint(message, :muted)]
293
+ lines[rows - 1] = @theme.paint(" q quit", :accent) if rows > 2
294
+ frame(lines, rows)
295
+ end
296
+
297
+ def frame(lines, rows)
298
+ rendered = Array.new(rows) do |index|
299
+ # Erase before drawing, then restore the theme defaults. Reserve the last
300
+ # cell to avoid terminal autowrap.
301
+ "\e[2K#{@theme.base}#{self.class.truncate(lines[index].to_s, @width)}"
302
+ end.join("\r\n")
303
+ "\e[H#{rendered}"
304
+ end
305
+ end
306
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "viewport"
4
+
5
+ module Foruiman::TUI
6
+ class State
7
+ attr_reader :tabs, :selected, :viewports
8
+ attr_accessor :help, :feedback
9
+
10
+ def initialize(names)
11
+ @tabs = [*names, "all"].freeze
12
+ @selected = tabs.size - 1
13
+ @viewports = tabs.to_h { |name| [name, Viewport.new] }
14
+ @help = false
15
+ @feedback = nil
16
+ end
17
+
18
+ def name
19
+ tabs[selected]
20
+ end
21
+
22
+ def viewport
23
+ viewports.fetch(name)
24
+ end
25
+
26
+ def select(index)
27
+ @selected = index if index.between?(0, tabs.size - 1)
28
+ end
29
+
30
+ def move(delta)
31
+ @selected = (selected + delta) % tabs.size
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/console"
4
+
5
+ module Foruiman::TUI
6
+ class Terminal
7
+ ENTER = "\e[?1049h\e[?25l\e[0m"
8
+ LEAVE = "\e[0m\e[?25h\e[?1049l"
9
+
10
+ def initialize(input: $stdin, output: $stdout)
11
+ @input = input
12
+ @output = output
13
+ end
14
+
15
+ def session(&)
16
+ @output.write(ENTER)
17
+ @output.flush
18
+ @input.raw(intr: false, &)
19
+ ensure
20
+ begin
21
+ @output.write(LEAVE)
22
+ @output.flush
23
+ rescue IOError, SystemCallError
24
+ # Input's raw block has already restored its exact prior terminal mode.
25
+ end
26
+ end
27
+
28
+ def size
29
+ rows, columns = @output.winsize
30
+ [[rows, 1].max, [columns, 1].max]
31
+ rescue IOError, SystemCallError
32
+ [24, 80]
33
+ end
34
+
35
+ def read
36
+ @input.read_nonblock(4096, exception: false)
37
+ end
38
+
39
+ def draw(frame)
40
+ @output.write(frame)
41
+ @output.flush
42
+ end
43
+ end
44
+ end