tui_tui 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/.github/workflows/ci.yml +20 -0
- data/LICENSE.txt +21 -0
- data/README.md +132 -0
- data/Rakefile +8 -0
- data/examples/clock.rb +112 -0
- data/examples/counter.rb +48 -0
- data/examples/csv_viewer.rb +233 -0
- data/examples/file_browser.rb +665 -0
- data/examples/form.rb +633 -0
- data/examples/life.rb +144 -0
- data/examples/paint.rb +246 -0
- data/examples/todo.rb +250 -0
- data/examples/widgets.rb +101 -0
- data/lib/tui_tui/ansi.rb +34 -0
- data/lib/tui_tui/canvas.rb +187 -0
- data/lib/tui_tui/canvas_compositor.rb +45 -0
- data/lib/tui_tui/cell.rb +11 -0
- data/lib/tui_tui/color_depth.rb +39 -0
- data/lib/tui_tui/confirm.rb +74 -0
- data/lib/tui_tui/display_text.rb +73 -0
- data/lib/tui_tui/event.rb +10 -0
- data/lib/tui_tui/event_stream.rb +39 -0
- data/lib/tui_tui/focus_ring.rb +25 -0
- data/lib/tui_tui/fuzzy.rb +56 -0
- data/lib/tui_tui/help.rb +44 -0
- data/lib/tui_tui/key_code.rb +9 -0
- data/lib/tui_tui/key_intent.rb +29 -0
- data/lib/tui_tui/key_reader.rb +175 -0
- data/lib/tui_tui/line.rb +59 -0
- data/lib/tui_tui/list.rb +45 -0
- data/lib/tui_tui/modal.rb +30 -0
- data/lib/tui_tui/pager.rb +94 -0
- data/lib/tui_tui/palette.rb +49 -0
- data/lib/tui_tui/prompt.rb +111 -0
- data/lib/tui_tui/rect.rb +48 -0
- data/lib/tui_tui/runtime.rb +53 -0
- data/lib/tui_tui/screen.rb +85 -0
- data/lib/tui_tui/scroll_list.rb +57 -0
- data/lib/tui_tui/scrollbar.rb +40 -0
- data/lib/tui_tui/select.rb +104 -0
- data/lib/tui_tui/size.rb +5 -0
- data/lib/tui_tui/span.rb +14 -0
- data/lib/tui_tui/status_bar.rb +23 -0
- data/lib/tui_tui/style.rb +101 -0
- data/lib/tui_tui/terminal_session.rb +65 -0
- data/lib/tui_tui/terminal_size.rb +24 -0
- data/lib/tui_tui/text_sanitizer.rb +13 -0
- data/lib/tui_tui/text_view.rb +52 -0
- data/lib/tui_tui/theme.rb +127 -0
- data/lib/tui_tui/toast.rb +82 -0
- data/lib/tui_tui/version.rb +5 -0
- data/lib/tui_tui/width.rb +101 -0
- data/lib/tui_tui.rb +51 -0
- metadata +98 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "screen"
|
|
4
|
+
|
|
5
|
+
module TuiTui
|
|
6
|
+
# Small Elm-style loop: render, read one event, fold it through the app, repeat.
|
|
7
|
+
class Runtime
|
|
8
|
+
def initialize(app)
|
|
9
|
+
@app = app
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def run(input: $stdin, output: $stdout, depth: ColorDepth.detect, tick: 0.1, mouse: Screen.mouse_default)
|
|
13
|
+
Screen.run(input: input, output: output, depth: depth, mouse: mouse) do |screen|
|
|
14
|
+
raise "tui_tui: not a terminal" if screen.nil?
|
|
15
|
+
|
|
16
|
+
screen.render(@app.view(screen.size))
|
|
17
|
+
loop do
|
|
18
|
+
event = screen.events.next_event(tick: tick)
|
|
19
|
+
break if event.is_a?(EofEvent)
|
|
20
|
+
next if inert_tick?(event)
|
|
21
|
+
|
|
22
|
+
screen.invalidate if wants_redraw?(event)
|
|
23
|
+
result = @app.update(event)
|
|
24
|
+
break if result == :quit || result.nil?
|
|
25
|
+
|
|
26
|
+
@app = result
|
|
27
|
+
flush_clipboard(screen)
|
|
28
|
+
screen.render(@app.view(screen.size))
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def flush_clipboard(screen)
|
|
36
|
+
# Clipboard writes stay an effect of the loop, requested by the app.
|
|
37
|
+
return unless @app.respond_to?(:take_clipboard)
|
|
38
|
+
|
|
39
|
+
text = @app.take_clipboard
|
|
40
|
+
screen.copy(text) if text
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def wants_redraw?(event)
|
|
44
|
+
@app.respond_to?(:redraw?) && @app.redraw?(event)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def inert_tick?(event)
|
|
48
|
+
# Ticks are opt-in: a TickEvent is inert unless the app explicitly wants it
|
|
49
|
+
# (so a plain app like counter.rb never redraws on the timer).
|
|
50
|
+
event.is_a?(TickEvent) && !(@app.respond_to?(:wants_tick?) && @app.wants_tick?)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "io/console"
|
|
4
|
+
|
|
5
|
+
require_relative "ansi"
|
|
6
|
+
require_relative "size"
|
|
7
|
+
require_relative "color_depth"
|
|
8
|
+
require_relative "canvas_compositor"
|
|
9
|
+
require_relative "terminal_size"
|
|
10
|
+
require_relative "event_stream"
|
|
11
|
+
require_relative "terminal_session"
|
|
12
|
+
|
|
13
|
+
module TuiTui
|
|
14
|
+
# Terminal-facing screen owner: session lifecycle, event stream, and rendering.
|
|
15
|
+
class Screen
|
|
16
|
+
DEFAULT_SIZE = Size.new(rows: 24, cols: 80)
|
|
17
|
+
|
|
18
|
+
def self.run(input: $stdin, output: $stdout, depth: ColorDepth.detect, mouse: mouse_default)
|
|
19
|
+
console = IO.console
|
|
20
|
+
# Let callers provide a non-interactive fallback for piped output.
|
|
21
|
+
return yield(nil) if console.nil? || !output.tty?
|
|
22
|
+
|
|
23
|
+
screen = new(console, input, output, depth, mouse: mouse)
|
|
24
|
+
screen.start
|
|
25
|
+
begin
|
|
26
|
+
yield screen
|
|
27
|
+
ensure
|
|
28
|
+
screen.close
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.mouse_default
|
|
33
|
+
!%w[0 off false].include?(ENV["TUITUI_MOUSE"])
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def initialize(console, input, output, depth, mouse: true)
|
|
37
|
+
@output = output
|
|
38
|
+
@compositor = CanvasCompositor.new(depth: depth)
|
|
39
|
+
@term_size = TerminalSize.new(console, default: DEFAULT_SIZE)
|
|
40
|
+
@events = EventStream.new(input: input, size: @term_size)
|
|
41
|
+
@session = TerminalSession.new(console: console, output: output, events: @events, mouse: mouse)
|
|
42
|
+
@previous = nil
|
|
43
|
+
# the cursor position last written (the session starts it hidden)
|
|
44
|
+
@cursor = nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
attr_reader :events
|
|
48
|
+
|
|
49
|
+
def start = @session.start
|
|
50
|
+
|
|
51
|
+
def size = @term_size.size
|
|
52
|
+
|
|
53
|
+
# Render `canvas`: the compositor computes the (full or per-row diff) escape
|
|
54
|
+
# string, then the cursor is repositioned (or hidden). The cursor directive
|
|
55
|
+
# is appended only on a full repaint or when the cursor actually moved, so an
|
|
56
|
+
# idle identical re-render still writes nothing.
|
|
57
|
+
def render(canvas)
|
|
58
|
+
full = @previous.nil? || !@previous.same_size?(canvas)
|
|
59
|
+
out = @compositor.render(@previous, canvas)
|
|
60
|
+
out += cursor_directive(canvas) if full || canvas.cursor != @cursor
|
|
61
|
+
@output.write(out)
|
|
62
|
+
@output.flush
|
|
63
|
+
@previous = canvas
|
|
64
|
+
@cursor = canvas.cursor
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def invalidate
|
|
68
|
+
@previous = nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def copy(text)
|
|
72
|
+
@output.write(Ansi.clipboard(text))
|
|
73
|
+
@output.flush
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def close = @session.close
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def cursor_directive(canvas)
|
|
81
|
+
pos = canvas.cursor
|
|
82
|
+
pos ? Ansi.move(pos[0], pos[1]) + Ansi::SHOW : Ansi::HIDE
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TuiTui
|
|
4
|
+
# Cursor and viewport arithmetic shared by list-like widgets.
|
|
5
|
+
class ScrollList
|
|
6
|
+
attr_reader :cursor, :top
|
|
7
|
+
|
|
8
|
+
def initialize(count = 0)
|
|
9
|
+
@count = count
|
|
10
|
+
@cursor = 0
|
|
11
|
+
@top = 0
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def count=(value)
|
|
15
|
+
@count = [value, 0].max
|
|
16
|
+
@cursor = @cursor.clamp(0, last)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
attr_reader :count
|
|
20
|
+
|
|
21
|
+
def empty? = @count.zero?
|
|
22
|
+
def last = [@count - 1, 0].max
|
|
23
|
+
def at_end? = @cursor == last
|
|
24
|
+
|
|
25
|
+
def move(delta) = go_to(@cursor + delta)
|
|
26
|
+
def page(height) = move(height)
|
|
27
|
+
def to_top = go_to(0)
|
|
28
|
+
def to_end = go_to(last)
|
|
29
|
+
|
|
30
|
+
def go_to(index)
|
|
31
|
+
@cursor = index.clamp(0, last)
|
|
32
|
+
self
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def ensure_visible(height)
|
|
36
|
+
return self if height <= 0
|
|
37
|
+
|
|
38
|
+
@top = @cursor if @cursor < @top
|
|
39
|
+
@top = @cursor - height + 1 if @cursor >= @top + height
|
|
40
|
+
@top = 0 if @top.negative?
|
|
41
|
+
self
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def each_visible(height)
|
|
45
|
+
return enum_for(:each_visible, height) unless block_given?
|
|
46
|
+
|
|
47
|
+
height.times do |offset|
|
|
48
|
+
index = @top + offset
|
|
49
|
+
break if index >= @count
|
|
50
|
+
|
|
51
|
+
yield index, offset
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
self
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "rect"
|
|
4
|
+
require_relative "style"
|
|
5
|
+
require_relative "theme"
|
|
6
|
+
|
|
7
|
+
module TuiTui
|
|
8
|
+
# A vertical scroll indicator for a 1-column gutter.
|
|
9
|
+
# It sizes a thumb from top/visible/total and draws ASCII-only chrome.
|
|
10
|
+
module Scrollbar
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
TRACK = Theme::DEFAULT.scroll_track
|
|
14
|
+
THUMB = Theme::DEFAULT.scroll_thumb
|
|
15
|
+
|
|
16
|
+
def draw(canvas, rect, top:, visible:, total:, track: "|", thumb: " ", track_style: TRACK, thumb_style: THUMB)
|
|
17
|
+
return canvas if rect.rows <= 0
|
|
18
|
+
|
|
19
|
+
length, offset = geometry(rect.rows, top, visible, total)
|
|
20
|
+
rect.rows.times do |i|
|
|
21
|
+
in_thumb = i >= offset && i < offset + length
|
|
22
|
+
canvas.text(rect.row + i, rect.col, in_thumb ? thumb : track, in_thumb ? thumb_style : track_style)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
canvas
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The thumb's [length, offset] in rows for a `height`-row track. Returns
|
|
29
|
+
# [0, 0] (no thumb, track only) when everything fits — nothing to scroll.
|
|
30
|
+
def geometry(height, top, visible, total)
|
|
31
|
+
visible = [visible, 1].max
|
|
32
|
+
total = [total, visible].max
|
|
33
|
+
return [0, 0] if total <= visible
|
|
34
|
+
|
|
35
|
+
length = [(height * visible / total.to_f).round, 1].max.clamp(1, height)
|
|
36
|
+
offset = ((height - length) * top.to_f / (total - visible)).round
|
|
37
|
+
[length, offset.clamp(0, height - length)]
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "display_text"
|
|
4
|
+
require_relative "style"
|
|
5
|
+
require_relative "scroll_list"
|
|
6
|
+
require_relative "list"
|
|
7
|
+
require_relative "line"
|
|
8
|
+
require_relative "span"
|
|
9
|
+
require_relative "rect"
|
|
10
|
+
require_relative "modal"
|
|
11
|
+
require_relative "key_intent"
|
|
12
|
+
|
|
13
|
+
module TuiTui
|
|
14
|
+
# Scrollable list picker modal.
|
|
15
|
+
class Select < Modal
|
|
16
|
+
MAX_ROWS = 12
|
|
17
|
+
MIN_INNER = 16
|
|
18
|
+
WHEEL = 3
|
|
19
|
+
|
|
20
|
+
def initialize(title, items, default: 0, theme: Theme::DEFAULT)
|
|
21
|
+
@title = DisplayText.new(title)
|
|
22
|
+
@items = items.map { |item| DisplayText.new(item) }
|
|
23
|
+
@list = ScrollList.new(@items.size)
|
|
24
|
+
@list.go_to(default)
|
|
25
|
+
@theme = theme
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def cursor = @list.cursor
|
|
29
|
+
|
|
30
|
+
def handle(key)
|
|
31
|
+
case KeyIntent.for(key)
|
|
32
|
+
when :up
|
|
33
|
+
nudge(-1)
|
|
34
|
+
when :down
|
|
35
|
+
nudge(1)
|
|
36
|
+
when :top
|
|
37
|
+
nudge_to(0)
|
|
38
|
+
when :bottom
|
|
39
|
+
nudge_to(@list.last)
|
|
40
|
+
when :cancel
|
|
41
|
+
:cancel
|
|
42
|
+
else
|
|
43
|
+
@list.cursor if ["\r", " "].include?(key)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Wheel moves the highlighted item; a click on an item picks it. Returns the
|
|
48
|
+
# chosen index on a click, otherwise nil (stay open).
|
|
49
|
+
def handle_mouse(event)
|
|
50
|
+
case event.action
|
|
51
|
+
when :wheel
|
|
52
|
+
nudge(event.button == :wheel_up ? -WHEEL : WHEEL)
|
|
53
|
+
when :press
|
|
54
|
+
click(event)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def draw(canvas, size)
|
|
59
|
+
rows = visible_rows(size)
|
|
60
|
+
inner = [MIN_INNER, @title.width, *@items.map(&:width)].max
|
|
61
|
+
rect, text_col = panel(canvas, inner: inner, body_rows: rows + 2)
|
|
62
|
+
|
|
63
|
+
canvas.text(rect.row + 1, text_col, @title.truncate(inner), theme.title)
|
|
64
|
+
draw_items(canvas, rect.row + 3, text_col, inner, rows)
|
|
65
|
+
canvas
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def nudge(delta)
|
|
71
|
+
@list.move(delta)
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def nudge_to(index)
|
|
76
|
+
@list.go_to(index)
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def visible_rows(size)
|
|
81
|
+
room = [size.rows - 4, 1].max
|
|
82
|
+
[@items.size, MAX_ROWS, room].min
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def draw_items(canvas, row, col, inner, rows)
|
|
86
|
+
@items_rect = Rect.new(row: row, col: col, rows: rows, cols: inner)
|
|
87
|
+
List.new(@list).draw(canvas, @items_rect) do |index, focused|
|
|
88
|
+
Line[Span[@items[index].to_s, focused ? theme.selection : theme.text]]
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The item under a click, picked, or nil if the click missed the list.
|
|
93
|
+
def click(event)
|
|
94
|
+
rect = @items_rect
|
|
95
|
+
return nil unless rect && event.col.between?(rect.col, rect.col + rect.cols - 1)
|
|
96
|
+
|
|
97
|
+
index = @list.top + (event.row - rect.row)
|
|
98
|
+
return nil unless (event.row - rect.row).between?(0, rect.rows - 1) && !@list.empty? && index <= @list.last
|
|
99
|
+
|
|
100
|
+
@list.go_to(index)
|
|
101
|
+
@list.cursor
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
data/lib/tui_tui/size.rb
ADDED
data/lib/tui_tui/span.rb
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "display_text"
|
|
4
|
+
|
|
5
|
+
module TuiTui
|
|
6
|
+
# A run of text sharing one Style.
|
|
7
|
+
# Width is terminal-column aware via DisplayText.
|
|
8
|
+
Span = Data.define(:text, :style) do
|
|
9
|
+
# Convenience constructor: Span["hi", style] (style optional).
|
|
10
|
+
def self.[](text, style = nil) = new(text: text.to_s, style: style)
|
|
11
|
+
|
|
12
|
+
def width = DisplayText.new(text).width
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "display_text"
|
|
4
|
+
|
|
5
|
+
module TuiTui
|
|
6
|
+
# A one-row status/footer bar.
|
|
7
|
+
# It draws left text from the start and optional right text flush right.
|
|
8
|
+
module StatusBar
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def draw(canvas, rect, left: "", right: nil, style: nil)
|
|
12
|
+
canvas.fill(rect, style)
|
|
13
|
+
|
|
14
|
+
right_width = right ? DisplayText.new(right).width : 0
|
|
15
|
+
fits_right = right && right_width < rect.cols
|
|
16
|
+
left_max = fits_right ? rect.cols - right_width : rect.cols
|
|
17
|
+
|
|
18
|
+
canvas.text(rect.row, rect.col, DisplayText.new(left).truncate(left_max), style)
|
|
19
|
+
canvas.text(rect.row, rect.col + rect.cols - right_width, right, style) if fits_right
|
|
20
|
+
canvas
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "palette"
|
|
4
|
+
|
|
5
|
+
module TuiTui
|
|
6
|
+
# Value object for SGR styling. It downgrades richer colors to the selected
|
|
7
|
+
# terminal depth instead of emitting unsupported escape sequences.
|
|
8
|
+
class Style
|
|
9
|
+
NAMED = {
|
|
10
|
+
black: 30,
|
|
11
|
+
red: 31,
|
|
12
|
+
green: 32,
|
|
13
|
+
yellow: 33,
|
|
14
|
+
blue: 34,
|
|
15
|
+
magenta: 35,
|
|
16
|
+
cyan: 36,
|
|
17
|
+
white: 37,
|
|
18
|
+
bright_black: 90,
|
|
19
|
+
bright_red: 91,
|
|
20
|
+
bright_green: 92,
|
|
21
|
+
bright_yellow: 93,
|
|
22
|
+
bright_blue: 94,
|
|
23
|
+
bright_magenta: 95,
|
|
24
|
+
bright_cyan: 96,
|
|
25
|
+
bright_white: 97
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
ATTRS = {bold: 1, dim: 2, italic: 3, underline: 4, reverse: 7}.freeze
|
|
29
|
+
|
|
30
|
+
attr_reader :fg, :bg, :attrs
|
|
31
|
+
|
|
32
|
+
def initialize(fg: nil, bg: nil, attrs: [])
|
|
33
|
+
@fg = fg
|
|
34
|
+
@bg = bg
|
|
35
|
+
@attrs = attrs
|
|
36
|
+
@palette = Palette.new
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def with(fg: @fg, bg: @bg, attrs: @attrs)
|
|
40
|
+
self.class.new(fg: fg, bg: bg, attrs: attrs)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def ==(other)
|
|
44
|
+
# Canvas diffing relies on equivalent styles comparing equal.
|
|
45
|
+
other.is_a?(Style) && fg == other.fg && bg == other.bg && attrs == other.attrs
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
alias_method :eql?, :==
|
|
49
|
+
|
|
50
|
+
def hash = [fg, bg, attrs].hash
|
|
51
|
+
|
|
52
|
+
def paint(text, depth: :ansi256, enabled: true)
|
|
53
|
+
return text if !enabled || depth == :none
|
|
54
|
+
|
|
55
|
+
codes = sgr_codes(depth)
|
|
56
|
+
return text if codes.empty?
|
|
57
|
+
|
|
58
|
+
"\e[#{codes.join(";")}m#{text}\e[0m"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def sgr_codes(depth)
|
|
62
|
+
codes = @attrs.map { |attr| ATTRS.fetch(attr) }
|
|
63
|
+
codes.concat(color_codes(@fg, depth, ground: :fg))
|
|
64
|
+
codes.concat(color_codes(@bg, depth, ground: :bg))
|
|
65
|
+
codes
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def color_codes(color, depth, ground:)
|
|
71
|
+
case color
|
|
72
|
+
when nil
|
|
73
|
+
[]
|
|
74
|
+
when Symbol
|
|
75
|
+
[ground_offset(NAMED.fetch(color), ground)]
|
|
76
|
+
when Integer
|
|
77
|
+
integer_codes(color, depth, ground)
|
|
78
|
+
when Array
|
|
79
|
+
array_codes(color, depth, ground)
|
|
80
|
+
else
|
|
81
|
+
[]
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def integer_codes(index, depth, ground)
|
|
86
|
+
return [ground_offset(@palette.nearest_code(@palette.rgb_from_256(index)), ground)] if depth == :basic16
|
|
87
|
+
|
|
88
|
+
[ground == :bg ? 48 : 38, 5, index]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def array_codes(rgb, depth, ground)
|
|
92
|
+
return [ground_offset(@palette.nearest_code(rgb), ground)] if depth == :basic16
|
|
93
|
+
# RGB has no honest representation below truecolor except the basic16 path.
|
|
94
|
+
return [] unless depth == :truecolor
|
|
95
|
+
|
|
96
|
+
[ground == :bg ? 48 : 38, 2, *rgb]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def ground_offset(base, ground) = ground == :bg ? base + 10 : base
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ansi"
|
|
4
|
+
|
|
5
|
+
module TuiTui
|
|
6
|
+
# Owns raw mode, alternate screen, and restoration traps for one TUI session.
|
|
7
|
+
class TerminalSession
|
|
8
|
+
RESTORE_SIGNALS = %w[TERM HUP INT].freeze
|
|
9
|
+
|
|
10
|
+
def initialize(console:, output:, events:, mouse:)
|
|
11
|
+
@console = console
|
|
12
|
+
@output = output
|
|
13
|
+
@events = events
|
|
14
|
+
@mouse = mouse
|
|
15
|
+
@closed = false
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def start
|
|
19
|
+
@console.raw!
|
|
20
|
+
@output.write(Ansi::ALT_ON + Ansi::HIDE + Ansi::CLEAR + (@mouse ? Ansi::MOUSE_ON : ""))
|
|
21
|
+
@output.flush
|
|
22
|
+
@prev_winch = trap("WINCH") { @events.resized! }
|
|
23
|
+
install_restore_traps
|
|
24
|
+
at_exit { close }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def close
|
|
28
|
+
# Close is called from ensure, at_exit, and signal traps.
|
|
29
|
+
return if @closed
|
|
30
|
+
|
|
31
|
+
@closed = true
|
|
32
|
+
@output.write((@mouse ? Ansi::MOUSE_OFF : "") + Ansi::SHOW + Ansi::ALT_OFF)
|
|
33
|
+
@output.flush
|
|
34
|
+
begin
|
|
35
|
+
@console.cooked!
|
|
36
|
+
rescue StandardError
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
trap("WINCH", @prev_winch || "DEFAULT") if defined?(@prev_winch)
|
|
41
|
+
restore_signal_traps
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def install_restore_traps
|
|
47
|
+
@prev_signals = {}
|
|
48
|
+
RESTORE_SIGNALS.each do |signal|
|
|
49
|
+
@prev_signals[signal] = trap(signal) do
|
|
50
|
+
# Restore the terminal, then preserve the signal's normal process effect.
|
|
51
|
+
close
|
|
52
|
+
trap(signal, "DEFAULT")
|
|
53
|
+
Process.kill(signal, Process.pid)
|
|
54
|
+
end
|
|
55
|
+
rescue ArgumentError
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def restore_signal_traps
|
|
60
|
+
@prev_signals&.each { |signal, handler| trap(signal, handler || "DEFAULT") }
|
|
61
|
+
rescue ArgumentError
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "size"
|
|
4
|
+
|
|
5
|
+
module TuiTui
|
|
6
|
+
# Reads winsize with a fallback that keeps layout deterministic in tests and PTYs.
|
|
7
|
+
class TerminalSize
|
|
8
|
+
DEFAULT = Size.new(rows: 24, cols: 80)
|
|
9
|
+
|
|
10
|
+
def initialize(console, default: DEFAULT)
|
|
11
|
+
@console = console
|
|
12
|
+
@default = default
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def size
|
|
16
|
+
rows, cols = @console.winsize
|
|
17
|
+
return @default if rows.to_i.zero? || cols.to_i.zero?
|
|
18
|
+
|
|
19
|
+
Size.new(rows: rows, cols: cols)
|
|
20
|
+
rescue StandardError
|
|
21
|
+
@default
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TuiTui
|
|
4
|
+
# Normalizes text before rendering so malformed input bytes are displayed
|
|
5
|
+
# safely instead of raising encoding errors.
|
|
6
|
+
module TextSanitizer
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def sanitize(string)
|
|
10
|
+
string.valid_encoding? ? string : string.scrub("?")
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "line"
|
|
4
|
+
require_relative "span"
|
|
5
|
+
require_relative "scrollbar"
|
|
6
|
+
|
|
7
|
+
module TuiTui
|
|
8
|
+
# A scrolled read-only text window.
|
|
9
|
+
# Lines may be Strings, Lines, or Span arrays, supplied eagerly or lazily.
|
|
10
|
+
module TextView
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def draw(canvas, rect, lines = nil, top: 0, style: nil, scrollbar: nil, total: nil)
|
|
14
|
+
body, gutter = scrollbar ? rect.split_gutter : [rect, nil]
|
|
15
|
+
body.rows.times do |offset|
|
|
16
|
+
index = top + offset
|
|
17
|
+
content = lines ? lines[index] : yield(index)
|
|
18
|
+
next if content.nil?
|
|
19
|
+
|
|
20
|
+
canvas.line(body.row + offset, body.col, as_line(content, style).truncate(body.cols))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
draw_scrollbar(canvas, gutter, top, total || lines&.length, body.rows, scrollbar) if gutter
|
|
24
|
+
canvas
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def draw_scrollbar(canvas, gutter, top, total, visible, theme)
|
|
28
|
+
return unless total
|
|
29
|
+
|
|
30
|
+
Scrollbar.draw(
|
|
31
|
+
canvas,
|
|
32
|
+
gutter,
|
|
33
|
+
top: top,
|
|
34
|
+
visible: visible,
|
|
35
|
+
total: total,
|
|
36
|
+
track_style: theme.scroll_track,
|
|
37
|
+
thumb_style: theme.scroll_thumb
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def as_line(content, style)
|
|
42
|
+
case content
|
|
43
|
+
when Line
|
|
44
|
+
content
|
|
45
|
+
when Array
|
|
46
|
+
Line.new(content)
|
|
47
|
+
else
|
|
48
|
+
Line[Span[content.to_s, style]]
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|