tui_tui 0.1.0 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +54 -0
- data/README.md +6 -10
- data/examples/file_browser.rb +51 -8
- data/examples/paint.rb +3 -2
- data/examples/widgets.rb +3 -2
- data/lib/tui_tui/box_chrome.rb +54 -0
- data/lib/tui_tui/box_prober.rb +60 -0
- data/lib/tui_tui/canvas.rb +12 -9
- data/lib/tui_tui/clock.rb +11 -0
- data/lib/tui_tui/command_palette.rb +190 -0
- data/lib/tui_tui/key_code.rb +3 -0
- data/lib/tui_tui/line.rb +11 -0
- data/lib/tui_tui/list.rb +17 -5
- data/lib/tui_tui/modal_host.rb +61 -0
- data/lib/tui_tui/pager.rb +15 -3
- data/lib/tui_tui/prompt.rb +2 -5
- data/lib/tui_tui/rect.rb +8 -0
- data/lib/tui_tui/render_context.rb +14 -0
- data/lib/tui_tui/runtime.rb +20 -4
- data/lib/tui_tui/screen.rb +29 -5
- data/lib/tui_tui/scrollbar.rb +2 -1
- data/lib/tui_tui/select.rb +3 -4
- data/lib/tui_tui/span.rb +5 -0
- data/lib/tui_tui/terminal_session.rb +16 -1
- data/lib/tui_tui/text_sanitizer.rb +10 -2
- data/lib/tui_tui/text_view.rb +7 -14
- data/lib/tui_tui/theme.rb +32 -4
- data/lib/tui_tui/toast.rb +2 -1
- data/lib/tui_tui/version.rb +1 -1
- data/lib/tui_tui.rb +6 -0
- metadata +10 -2
data/lib/tui_tui/list.rb
CHANGED
|
@@ -12,20 +12,34 @@ module TuiTui
|
|
|
12
12
|
@scroll = scroll
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
def draw(canvas, rect, highlight: nil, scrollbar: nil)
|
|
16
|
-
|
|
15
|
+
def draw(canvas, rect, highlight: nil, scrollbar: nil, auto: false)
|
|
16
|
+
# With auto:, reserve the gutter only when the content overflows the rect.
|
|
17
|
+
show_bar = scrollbar && !(auto && @scroll.count <= rect.rows)
|
|
18
|
+
body, gutter = show_bar ? rect.split_gutter : [rect, nil]
|
|
17
19
|
@scroll.ensure_visible(body.rows)
|
|
18
20
|
@scroll.each_visible(body.rows) do |index, offset|
|
|
19
21
|
row = body.row + offset
|
|
20
22
|
selected = index == @scroll.cursor
|
|
21
23
|
canvas.fill(Rect.new(row: row, col: body.col, rows: 1, cols: body.cols), highlight) if highlight && selected
|
|
22
|
-
canvas.line(row, body.col,
|
|
24
|
+
canvas.line(row, body.col, Line.coerce(yield(index, selected)).truncate(body.cols))
|
|
23
25
|
end
|
|
24
26
|
|
|
25
27
|
draw_scrollbar(canvas, gutter, scrollbar) if gutter
|
|
26
28
|
canvas
|
|
27
29
|
end
|
|
28
30
|
|
|
31
|
+
# Map a MouseEvent to the list index under it, or nil. Pass the same `rect`
|
|
32
|
+
# and `scrollbar:` used for `draw` so the gutter column is excluded and the
|
|
33
|
+
# scroll offset matches what was rendered. Returns nil for clicks outside the
|
|
34
|
+
# body or below the last item.
|
|
35
|
+
def index_at(rect, event, scrollbar: nil)
|
|
36
|
+
body = scrollbar ? rect.split_gutter.first : rect
|
|
37
|
+
return nil unless body.hit?(event)
|
|
38
|
+
|
|
39
|
+
index = @scroll.top + (event.row - body.row)
|
|
40
|
+
index < @scroll.count ? index : nil
|
|
41
|
+
end
|
|
42
|
+
|
|
29
43
|
private
|
|
30
44
|
|
|
31
45
|
def draw_scrollbar(canvas, gutter, theme)
|
|
@@ -39,7 +53,5 @@ module TuiTui
|
|
|
39
53
|
thumb_style: theme.scroll_thumb
|
|
40
54
|
)
|
|
41
55
|
end
|
|
42
|
-
|
|
43
|
-
def as_line(content) = content.is_a?(Line) ? content : Line.new(Array(content))
|
|
44
56
|
end
|
|
45
57
|
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "event"
|
|
4
|
+
|
|
5
|
+
module TuiTui
|
|
6
|
+
# Host-side helper for the app that *owns* the current modal widget.
|
|
7
|
+
#
|
|
8
|
+
# Centralizes the open + dispatch loop every app with modals otherwise hand
|
|
9
|
+
# writes: it routes MouseEvents to #handle_mouse and other events to #handle,
|
|
10
|
+
# honors the "resolved value, or nil to stay open" widget contract, and runs
|
|
11
|
+
# the caller's on_result callback when the modal resolves.
|
|
12
|
+
#
|
|
13
|
+
# host = TuiTui::ModalHost.new
|
|
14
|
+
# host.open(TuiTui::Confirm.new("Quit?")) { |r| :quit if r == :ok }
|
|
15
|
+
# # in update(event):
|
|
16
|
+
# if host.open?
|
|
17
|
+
# outcome = host.handle(event) # nil while open; on_result value once resolved
|
|
18
|
+
# return outcome == :quit ? :quit : self
|
|
19
|
+
# end
|
|
20
|
+
# # in view(size): host.draw(canvas, size) if host.open?
|
|
21
|
+
class ModalHost
|
|
22
|
+
def open(widget, &on_result)
|
|
23
|
+
@widget = widget
|
|
24
|
+
@on_result = on_result || ->(result) { result }
|
|
25
|
+
self
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def open? = !@widget.nil?
|
|
29
|
+
|
|
30
|
+
def close
|
|
31
|
+
@widget = nil
|
|
32
|
+
@on_result = nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def draw(canvas, size) = @widget&.draw(canvas, size)
|
|
36
|
+
|
|
37
|
+
# Route one event to the modal. Returns nil while the modal stays open
|
|
38
|
+
# (the event was consumed), or the on_result callback's value once the
|
|
39
|
+
# widget resolves (e.g. :quit, or a new app model).
|
|
40
|
+
def handle(event)
|
|
41
|
+
return nil unless open?
|
|
42
|
+
|
|
43
|
+
result = dispatch(event)
|
|
44
|
+
return nil if result.nil?
|
|
45
|
+
|
|
46
|
+
callback = @on_result
|
|
47
|
+
close
|
|
48
|
+
callback.call(result)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def dispatch(event)
|
|
54
|
+
case event
|
|
55
|
+
when MouseEvent then @widget.handle_mouse(event)
|
|
56
|
+
when KeyEvent then @widget.handle(event.key)
|
|
57
|
+
else @widget.handle(event)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
data/lib/tui_tui/pager.rb
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "display_text"
|
|
4
4
|
require_relative "style"
|
|
5
|
+
require_relative "span"
|
|
6
|
+
require_relative "line"
|
|
5
7
|
require_relative "rect"
|
|
6
8
|
require_relative "modal"
|
|
7
9
|
require_relative "key_intent"
|
|
@@ -12,13 +14,16 @@ module TuiTui
|
|
|
12
14
|
MARGIN = 2
|
|
13
15
|
WHEEL = 3
|
|
14
16
|
|
|
17
|
+
# Each line may be a plain String, a `Line`, or an array of `Span`s, so a
|
|
18
|
+
# log/diff/error view can color whole lines or runs within them. Spans
|
|
19
|
+
# without a style fall back to theme.muted.
|
|
15
20
|
def initialize(title, lines, start: 0, close_keys: [], theme: Theme::DEFAULT)
|
|
16
21
|
@title = title
|
|
17
|
-
@
|
|
22
|
+
@theme = theme
|
|
23
|
+
@lines = lines.map { |line| normalize_line(line) }
|
|
18
24
|
@top = start
|
|
19
25
|
@page = 1
|
|
20
26
|
@close_keys = close_keys
|
|
21
|
-
@theme = theme
|
|
22
27
|
end
|
|
23
28
|
|
|
24
29
|
def handle(key)
|
|
@@ -60,7 +65,7 @@ module TuiTui
|
|
|
60
65
|
line = @lines[@top + offset]
|
|
61
66
|
next if line.nil?
|
|
62
67
|
|
|
63
|
-
canvas.
|
|
68
|
+
canvas.line(rect.row + 3 + offset, rect.col + 2, line.truncate(inner))
|
|
64
69
|
end
|
|
65
70
|
|
|
66
71
|
canvas
|
|
@@ -68,6 +73,13 @@ module TuiTui
|
|
|
68
73
|
|
|
69
74
|
private
|
|
70
75
|
|
|
76
|
+
# -> Line. Accepts a String, a Line, or a Span array; spans left unstyled
|
|
77
|
+
# default to theme.muted so a bare line still reads as muted body text.
|
|
78
|
+
def normalize_line(line)
|
|
79
|
+
spans = Line.coerce(line).spans.map { |span| Span[span.text, span.style || theme.muted] }
|
|
80
|
+
Line.new(spans)
|
|
81
|
+
end
|
|
82
|
+
|
|
71
83
|
def paginate(key)
|
|
72
84
|
case key
|
|
73
85
|
when " ", :pgdn
|
data/lib/tui_tui/prompt.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "display_text"
|
|
4
|
+
require_relative "text_sanitizer"
|
|
4
5
|
require_relative "style"
|
|
5
6
|
require_relative "modal"
|
|
6
7
|
require_relative "key_code"
|
|
@@ -38,7 +39,7 @@ module TuiTui
|
|
|
38
39
|
when :end
|
|
39
40
|
edit { @pos = @graphemes.length }
|
|
40
41
|
when String
|
|
41
|
-
edit { insert(key) if printable?(key) }
|
|
42
|
+
edit { insert(key) if TextSanitizer.printable?(key) }
|
|
42
43
|
end
|
|
43
44
|
end
|
|
44
45
|
|
|
@@ -103,9 +104,5 @@ module TuiTui
|
|
|
103
104
|
def delete_forward
|
|
104
105
|
@graphemes.delete_at(@pos) if @pos < @graphemes.length
|
|
105
106
|
end
|
|
106
|
-
|
|
107
|
-
def printable?(string)
|
|
108
|
-
string.bytes.all? { |byte| byte >= 0x20 && byte != 0x7F }
|
|
109
|
-
end
|
|
110
107
|
end
|
|
111
108
|
end
|
data/lib/tui_tui/rect.rb
CHANGED
|
@@ -37,6 +37,14 @@ module TuiTui
|
|
|
37
37
|
Rect.new(row: row, col: col + by, rows: rows, cols: cols - by)
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
# Whether a 1-origin cell (row, col) falls inside this rectangle.
|
|
41
|
+
def include?(r, c)
|
|
42
|
+
r.between?(row, row + rows - 1) && c.between?(col, col + cols - 1)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Whether a MouseEvent's cell falls inside this rectangle.
|
|
46
|
+
def hit?(mouse) = include?(mouse.row, mouse.col)
|
|
47
|
+
|
|
40
48
|
# Carve `width` columns off the right edge for a scrollbar gutter. Returns
|
|
41
49
|
# [body, gutter]; gutter is nil when the rect is too narrow to spare them.
|
|
42
50
|
def split_gutter(width = 1)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "canvas"
|
|
4
|
+
|
|
5
|
+
module TuiTui
|
|
6
|
+
# What an app's `view` receives: the terminal size plus the resolved chrome
|
|
7
|
+
RenderContext = Data.define(:size, :chrome) do
|
|
8
|
+
def rows = size.rows
|
|
9
|
+
def cols = size.cols
|
|
10
|
+
|
|
11
|
+
# A blank canvas already carrying the resolved chrome.
|
|
12
|
+
def canvas = Canvas.blank(size, chrome: chrome)
|
|
13
|
+
end
|
|
14
|
+
end
|
data/lib/tui_tui/runtime.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "screen"
|
|
4
|
+
require_relative "render_context"
|
|
4
5
|
|
|
5
6
|
module TuiTui
|
|
6
7
|
# Small Elm-style loop: render, read one event, fold it through the app, repeat.
|
|
@@ -9,11 +10,11 @@ module TuiTui
|
|
|
9
10
|
@app = app
|
|
10
11
|
end
|
|
11
12
|
|
|
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|
|
|
13
|
+
def run(input: $stdin, output: $stdout, depth: ColorDepth.detect, tick: 0.1, mouse: Screen.mouse_default, box: ENV["TUITUI_BOX"])
|
|
14
|
+
Screen.run(input: input, output: output, depth: depth, mouse: mouse, box: box) do |screen|
|
|
14
15
|
raise "tui_tui: not a terminal" if screen.nil?
|
|
15
16
|
|
|
16
|
-
screen.render(
|
|
17
|
+
screen.render(view(screen))
|
|
17
18
|
loop do
|
|
18
19
|
event = screen.events.next_event(tick: tick)
|
|
19
20
|
break if event.is_a?(EofEvent)
|
|
@@ -25,13 +26,20 @@ module TuiTui
|
|
|
25
26
|
|
|
26
27
|
@app = result
|
|
27
28
|
flush_clipboard(screen)
|
|
28
|
-
|
|
29
|
+
flush_mouse(screen)
|
|
30
|
+
screen.render(view(screen))
|
|
29
31
|
end
|
|
30
32
|
end
|
|
31
33
|
end
|
|
32
34
|
|
|
33
35
|
private
|
|
34
36
|
|
|
37
|
+
# Pass a RenderContext (size + resolved chrome + canvas factory). It is
|
|
38
|
+
# Size-compatible, so legacy `view(size)` apps keep working (ASCII).
|
|
39
|
+
def view(screen)
|
|
40
|
+
@app.view(RenderContext.new(size: screen.size, chrome: screen.chrome))
|
|
41
|
+
end
|
|
42
|
+
|
|
35
43
|
def flush_clipboard(screen)
|
|
36
44
|
# Clipboard writes stay an effect of the loop, requested by the app.
|
|
37
45
|
return unless @app.respond_to?(:take_clipboard)
|
|
@@ -40,6 +48,14 @@ module TuiTui
|
|
|
40
48
|
screen.copy(text) if text
|
|
41
49
|
end
|
|
42
50
|
|
|
51
|
+
# Apps may release/recapture the mouse per frame (e.g. to allow a native
|
|
52
|
+
# terminal selection while a read-only pane is open).
|
|
53
|
+
def flush_mouse(screen)
|
|
54
|
+
return unless @app.respond_to?(:wants_mouse?)
|
|
55
|
+
|
|
56
|
+
screen.mouse = @app.wants_mouse?
|
|
57
|
+
end
|
|
58
|
+
|
|
43
59
|
def wants_redraw?(event)
|
|
44
60
|
@app.respond_to?(:redraw?) && @app.redraw?(event)
|
|
45
61
|
end
|
data/lib/tui_tui/screen.rb
CHANGED
|
@@ -5,6 +5,8 @@ require "io/console"
|
|
|
5
5
|
require_relative "ansi"
|
|
6
6
|
require_relative "size"
|
|
7
7
|
require_relative "color_depth"
|
|
8
|
+
require_relative "box_chrome"
|
|
9
|
+
require_relative "box_prober"
|
|
8
10
|
require_relative "canvas_compositor"
|
|
9
11
|
require_relative "terminal_size"
|
|
10
12
|
require_relative "event_stream"
|
|
@@ -15,12 +17,12 @@ module TuiTui
|
|
|
15
17
|
class Screen
|
|
16
18
|
DEFAULT_SIZE = Size.new(rows: 24, cols: 80)
|
|
17
19
|
|
|
18
|
-
def self.run(input: $stdin, output: $stdout, depth: ColorDepth.detect, mouse: mouse_default)
|
|
20
|
+
def self.run(input: $stdin, output: $stdout, depth: ColorDepth.detect, mouse: mouse_default, box: ENV["TUITUI_BOX"])
|
|
19
21
|
console = IO.console
|
|
20
22
|
# Let callers provide a non-interactive fallback for piped output.
|
|
21
23
|
return yield(nil) if console.nil? || !output.tty?
|
|
22
24
|
|
|
23
|
-
screen = new(console, input, output, depth, mouse: mouse)
|
|
25
|
+
screen = new(console, input, output, depth, mouse: mouse, box: box)
|
|
24
26
|
screen.start
|
|
25
27
|
begin
|
|
26
28
|
yield screen
|
|
@@ -33,8 +35,12 @@ module TuiTui
|
|
|
33
35
|
!%w[0 off false].include?(ENV["TUITUI_MOUSE"])
|
|
34
36
|
end
|
|
35
37
|
|
|
36
|
-
def initialize(console, input, output, depth, mouse: true)
|
|
38
|
+
def initialize(console, input, output, depth, mouse: true, box: nil)
|
|
39
|
+
@input = input
|
|
37
40
|
@output = output
|
|
41
|
+
@box_override = box
|
|
42
|
+
# ASCII until start probes a real TTY; safe for non-TTY/StringIO callers.
|
|
43
|
+
@chrome = BoxChrome::ASCII
|
|
38
44
|
@compositor = CanvasCompositor.new(depth: depth)
|
|
39
45
|
@term_size = TerminalSize.new(console, default: DEFAULT_SIZE)
|
|
40
46
|
@events = EventStream.new(input: input, size: @term_size)
|
|
@@ -44,9 +50,27 @@ module TuiTui
|
|
|
44
50
|
@cursor = nil
|
|
45
51
|
end
|
|
46
52
|
|
|
47
|
-
attr_reader :events
|
|
53
|
+
attr_reader :events, :chrome
|
|
48
54
|
|
|
49
|
-
|
|
55
|
+
# Toggle mouse reporting mid-session (so an app can release the mouse for a
|
|
56
|
+
# native terminal selection while a read-only pane is open).
|
|
57
|
+
def mouse=(enabled)
|
|
58
|
+
@session.mouse = enabled
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def mouse = @session.mouse
|
|
62
|
+
|
|
63
|
+
def start
|
|
64
|
+
@session.start
|
|
65
|
+
# Probe box-drawing support once, after raw mode + alt screen, before the
|
|
66
|
+
# first render/next_event so the DSR reply never reaches the key reader.
|
|
67
|
+
@chrome = BoxChrome.resolve(
|
|
68
|
+
input: @input,
|
|
69
|
+
output: @output,
|
|
70
|
+
term_cols: size.cols,
|
|
71
|
+
env: {"TUITUI_BOX" => @box_override}
|
|
72
|
+
)
|
|
73
|
+
end
|
|
50
74
|
|
|
51
75
|
def size = @term_size.size
|
|
52
76
|
|
data/lib/tui_tui/scrollbar.rb
CHANGED
|
@@ -13,9 +13,10 @@ module TuiTui
|
|
|
13
13
|
TRACK = Theme::DEFAULT.scroll_track
|
|
14
14
|
THUMB = Theme::DEFAULT.scroll_thumb
|
|
15
15
|
|
|
16
|
-
def draw(canvas, rect, top:, visible:, total:, track:
|
|
16
|
+
def draw(canvas, rect, top:, visible:, total:, track: nil, thumb: " ", track_style: TRACK, thumb_style: THUMB)
|
|
17
17
|
return canvas if rect.rows <= 0
|
|
18
18
|
|
|
19
|
+
track ||= canvas.chrome.track
|
|
19
20
|
length, offset = geometry(rect.rows, top, visible, total)
|
|
20
21
|
rect.rows.times do |i|
|
|
21
22
|
in_thumb = i >= offset && i < offset + length
|
data/lib/tui_tui/select.rb
CHANGED
|
@@ -91,11 +91,10 @@ module TuiTui
|
|
|
91
91
|
|
|
92
92
|
# The item under a click, picked, or nil if the click missed the list.
|
|
93
93
|
def click(event)
|
|
94
|
-
|
|
95
|
-
return nil unless rect && event.col.between?(rect.col, rect.col + rect.cols - 1)
|
|
94
|
+
return nil unless @items_rect
|
|
96
95
|
|
|
97
|
-
index = @list.
|
|
98
|
-
return nil
|
|
96
|
+
index = List.new(@list).index_at(@items_rect, event)
|
|
97
|
+
return nil if index.nil?
|
|
99
98
|
|
|
100
99
|
@list.go_to(index)
|
|
101
100
|
@list.cursor
|
data/lib/tui_tui/span.rb
CHANGED
|
@@ -6,6 +6,11 @@ module TuiTui
|
|
|
6
6
|
# A run of text sharing one Style.
|
|
7
7
|
# Width is terminal-column aware via DisplayText.
|
|
8
8
|
Span = Data.define(:text, :style) do
|
|
9
|
+
# Data.define auto-generates Span.[] as an alias of .new. Remove it before
|
|
10
|
+
# redefining so our convenience form (style optional, text coerced) does not
|
|
11
|
+
# print a "method redefined" warning under -w.
|
|
12
|
+
singleton_class.send(:remove_method, :[])
|
|
13
|
+
|
|
9
14
|
# Convenience constructor: Span["hi", style] (style optional).
|
|
10
15
|
def self.[](text, style = nil) = new(text: text.to_s, style: style)
|
|
11
16
|
|
|
@@ -16,7 +16,8 @@ module TuiTui
|
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
def start
|
|
19
|
-
|
|
19
|
+
# `intr: true` keeps the interrupt/quit/suspend characters live in raw mode, so Ctrl-C raises SIGINT.
|
|
20
|
+
@console.raw!(intr: true)
|
|
20
21
|
@output.write(Ansi::ALT_ON + Ansi::HIDE + Ansi::CLEAR + (@mouse ? Ansi::MOUSE_ON : ""))
|
|
21
22
|
@output.flush
|
|
22
23
|
@prev_winch = trap("WINCH") { @events.resized! }
|
|
@@ -24,6 +25,20 @@ module TuiTui
|
|
|
24
25
|
at_exit { close }
|
|
25
26
|
end
|
|
26
27
|
|
|
28
|
+
attr_reader :mouse
|
|
29
|
+
|
|
30
|
+
# Toggle mouse reporting mid-session. Releasing it (false) lets the user
|
|
31
|
+
# make a native terminal selection (drag to select / copy) over the alternate
|
|
32
|
+
# screen; re-enabling restores in-app mouse events. No-op if unchanged.
|
|
33
|
+
def mouse=(enabled)
|
|
34
|
+
enabled = !!enabled
|
|
35
|
+
return if @closed || enabled == @mouse
|
|
36
|
+
|
|
37
|
+
@output.write(enabled ? Ansi::MOUSE_ON : Ansi::MOUSE_OFF)
|
|
38
|
+
@output.flush
|
|
39
|
+
@mouse = enabled
|
|
40
|
+
end
|
|
41
|
+
|
|
27
42
|
def close
|
|
28
43
|
# Close is called from ensure, at_exit, and signal traps.
|
|
29
44
|
return if @closed
|
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module TuiTui
|
|
4
|
-
#
|
|
5
|
-
# safely
|
|
4
|
+
# Character-level text hygiene: keeps malformed or control bytes out of the
|
|
5
|
+
# render/input pipeline so they are displayed safely (or rejected as input)
|
|
6
|
+
# instead of raising encoding errors or emitting raw control codes.
|
|
6
7
|
module TextSanitizer
|
|
7
8
|
module_function
|
|
8
9
|
|
|
9
10
|
def sanitize(string)
|
|
10
11
|
string.valid_encoding? ? string : string.scrub("?")
|
|
11
12
|
end
|
|
13
|
+
|
|
14
|
+
# Whether `string` is safe to insert as literal text: every byte is a
|
|
15
|
+
# printable character (no C0 controls and no DEL). Multibyte UTF-8 passes,
|
|
16
|
+
# since its bytes are all >= 0x80.
|
|
17
|
+
def printable?(string)
|
|
18
|
+
string.bytes.all? { |byte| byte >= 0x20 && byte != 0x7F }
|
|
19
|
+
end
|
|
12
20
|
end
|
|
13
21
|
end
|
data/lib/tui_tui/text_view.rb
CHANGED
|
@@ -10,14 +10,18 @@ module TuiTui
|
|
|
10
10
|
module TextView
|
|
11
11
|
module_function
|
|
12
12
|
|
|
13
|
-
def draw(canvas, rect, lines = nil, top: 0, style: nil, scrollbar: nil, total: nil)
|
|
14
|
-
|
|
13
|
+
def draw(canvas, rect, lines = nil, top: 0, style: nil, scrollbar: nil, total: nil, auto: false)
|
|
14
|
+
# With auto:, reserve the gutter only when the content overflows the rect.
|
|
15
|
+
# When the total is unknown (lazy block, no total:), the bar is kept.
|
|
16
|
+
count = total || lines&.length
|
|
17
|
+
show_bar = scrollbar && !(auto && count && count <= rect.rows)
|
|
18
|
+
body, gutter = show_bar ? rect.split_gutter : [rect, nil]
|
|
15
19
|
body.rows.times do |offset|
|
|
16
20
|
index = top + offset
|
|
17
21
|
content = lines ? lines[index] : yield(index)
|
|
18
22
|
next if content.nil?
|
|
19
23
|
|
|
20
|
-
canvas.line(body.row + offset, body.col,
|
|
24
|
+
canvas.line(body.row + offset, body.col, Line.coerce(content, style).truncate(body.cols))
|
|
21
25
|
end
|
|
22
26
|
|
|
23
27
|
draw_scrollbar(canvas, gutter, top, total || lines&.length, body.rows, scrollbar) if gutter
|
|
@@ -37,16 +41,5 @@ module TuiTui
|
|
|
37
41
|
thumb_style: theme.scroll_thumb
|
|
38
42
|
)
|
|
39
43
|
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
44
|
end
|
|
52
45
|
end
|
data/lib/tui_tui/theme.rb
CHANGED
|
@@ -22,23 +22,47 @@ module TuiTui
|
|
|
22
22
|
:bar,
|
|
23
23
|
:cursor,
|
|
24
24
|
:scroll_track,
|
|
25
|
-
:scroll_thumb
|
|
25
|
+
:scroll_thumb,
|
|
26
|
+
# semantic status roles (hue-independent, background-aware)
|
|
27
|
+
:success,
|
|
28
|
+
:warning,
|
|
29
|
+
:danger,
|
|
30
|
+
:info
|
|
26
31
|
)
|
|
27
32
|
|
|
28
33
|
class Theme
|
|
34
|
+
# Map a symbolic status to its semantic role Style (with common aliases).
|
|
35
|
+
def status(kind)
|
|
36
|
+
case kind
|
|
37
|
+
when :ok, :success then success
|
|
38
|
+
when :warn, :warning then warning
|
|
39
|
+
when :error, :danger then danger
|
|
40
|
+
when :info then info
|
|
41
|
+
else text
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
29
45
|
# Background-dependent neutral roles.
|
|
30
46
|
SURFACES = {
|
|
31
47
|
dark: {
|
|
32
48
|
text: Style.new,
|
|
33
49
|
muted: Style.new(fg: 245),
|
|
34
50
|
bar: Style.new(fg: 252, bg: 238),
|
|
35
|
-
selection_dim: Style.new(fg: 247, bg: 238)
|
|
51
|
+
selection_dim: Style.new(fg: 247, bg: 238),
|
|
52
|
+
success: Style.new(fg: 71),
|
|
53
|
+
warning: Style.new(fg: 179),
|
|
54
|
+
danger: Style.new(fg: 167),
|
|
55
|
+
info: Style.new(fg: 110)
|
|
36
56
|
},
|
|
37
57
|
light: {
|
|
38
58
|
text: Style.new,
|
|
39
59
|
muted: Style.new(fg: 240),
|
|
40
60
|
bar: Style.new(fg: 16, bg: 252),
|
|
41
|
-
selection_dim: Style.new(fg: 240, bg: 252)
|
|
61
|
+
selection_dim: Style.new(fg: 240, bg: 252),
|
|
62
|
+
success: Style.new(fg: 28),
|
|
63
|
+
warning: Style.new(fg: 130),
|
|
64
|
+
danger: Style.new(fg: 124),
|
|
65
|
+
info: Style.new(fg: 25)
|
|
42
66
|
}
|
|
43
67
|
}.freeze
|
|
44
68
|
|
|
@@ -79,7 +103,11 @@ module TuiTui
|
|
|
79
103
|
bar: surface[:bar],
|
|
80
104
|
cursor: selection,
|
|
81
105
|
scroll_track: Style.new(fg: a[:line]),
|
|
82
|
-
scroll_thumb: Style.new(bg: a[:sel][1])
|
|
106
|
+
scroll_thumb: Style.new(bg: a[:sel][1]),
|
|
107
|
+
success: surface[:success],
|
|
108
|
+
warning: surface[:warning],
|
|
109
|
+
danger: surface[:danger],
|
|
110
|
+
info: surface[:info]
|
|
83
111
|
)
|
|
84
112
|
end
|
|
85
113
|
|
data/lib/tui_tui/toast.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "display_text"
|
|
4
4
|
require_relative "style"
|
|
5
|
+
require_relative "clock"
|
|
5
6
|
|
|
6
7
|
module TuiTui
|
|
7
8
|
# A transient notification overlay.
|
|
@@ -22,7 +23,7 @@ module TuiTui
|
|
|
22
23
|
bottom_right: [:bottom, :right],
|
|
23
24
|
center: [:middle, :center]
|
|
24
25
|
}.freeze
|
|
25
|
-
MONOTONIC = -> {
|
|
26
|
+
MONOTONIC = -> { Clock.monotonic }
|
|
26
27
|
|
|
27
28
|
def initialize(message, seconds: DEFAULT_SECONDS, position: DEFAULT_POSITION, clock: MONOTONIC)
|
|
28
29
|
@message = DisplayText.new(message)
|
data/lib/tui_tui/version.rb
CHANGED
data/lib/tui_tui.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "tui_tui/version"
|
|
4
4
|
|
|
5
|
+
require_relative "tui_tui/clock"
|
|
5
6
|
require_relative "tui_tui/width"
|
|
6
7
|
require_relative "tui_tui/text_sanitizer"
|
|
7
8
|
require_relative "tui_tui/display_text"
|
|
@@ -9,6 +10,8 @@ require_relative "tui_tui/span"
|
|
|
9
10
|
require_relative "tui_tui/line"
|
|
10
11
|
require_relative "tui_tui/ansi"
|
|
11
12
|
require_relative "tui_tui/color_depth"
|
|
13
|
+
require_relative "tui_tui/box_chrome"
|
|
14
|
+
require_relative "tui_tui/box_prober"
|
|
12
15
|
require_relative "tui_tui/palette"
|
|
13
16
|
require_relative "tui_tui/style"
|
|
14
17
|
require_relative "tui_tui/theme"
|
|
@@ -16,6 +19,7 @@ require_relative "tui_tui/size"
|
|
|
16
19
|
require_relative "tui_tui/rect"
|
|
17
20
|
require_relative "tui_tui/cell"
|
|
18
21
|
require_relative "tui_tui/canvas"
|
|
22
|
+
require_relative "tui_tui/render_context"
|
|
19
23
|
require_relative "tui_tui/canvas_compositor"
|
|
20
24
|
require_relative "tui_tui/event"
|
|
21
25
|
require_relative "tui_tui/key_code"
|
|
@@ -32,8 +36,10 @@ require_relative "tui_tui/toast"
|
|
|
32
36
|
require_relative "tui_tui/focus_ring"
|
|
33
37
|
require_relative "tui_tui/fuzzy"
|
|
34
38
|
require_relative "tui_tui/modal"
|
|
39
|
+
require_relative "tui_tui/modal_host"
|
|
35
40
|
require_relative "tui_tui/confirm"
|
|
36
41
|
require_relative "tui_tui/select"
|
|
42
|
+
require_relative "tui_tui/command_palette"
|
|
37
43
|
require_relative "tui_tui/help"
|
|
38
44
|
require_relative "tui_tui/prompt"
|
|
39
45
|
require_relative "tui_tui/pager"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: tui_tui
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- takahashim
|
|
@@ -19,6 +19,7 @@ extensions: []
|
|
|
19
19
|
extra_rdoc_files: []
|
|
20
20
|
files:
|
|
21
21
|
- ".github/workflows/ci.yml"
|
|
22
|
+
- CHANGELOG.md
|
|
22
23
|
- LICENSE.txt
|
|
23
24
|
- README.md
|
|
24
25
|
- Rakefile
|
|
@@ -33,10 +34,14 @@ files:
|
|
|
33
34
|
- examples/widgets.rb
|
|
34
35
|
- lib/tui_tui.rb
|
|
35
36
|
- lib/tui_tui/ansi.rb
|
|
37
|
+
- lib/tui_tui/box_chrome.rb
|
|
38
|
+
- lib/tui_tui/box_prober.rb
|
|
36
39
|
- lib/tui_tui/canvas.rb
|
|
37
40
|
- lib/tui_tui/canvas_compositor.rb
|
|
38
41
|
- lib/tui_tui/cell.rb
|
|
42
|
+
- lib/tui_tui/clock.rb
|
|
39
43
|
- lib/tui_tui/color_depth.rb
|
|
44
|
+
- lib/tui_tui/command_palette.rb
|
|
40
45
|
- lib/tui_tui/confirm.rb
|
|
41
46
|
- lib/tui_tui/display_text.rb
|
|
42
47
|
- lib/tui_tui/event.rb
|
|
@@ -50,10 +55,12 @@ files:
|
|
|
50
55
|
- lib/tui_tui/line.rb
|
|
51
56
|
- lib/tui_tui/list.rb
|
|
52
57
|
- lib/tui_tui/modal.rb
|
|
58
|
+
- lib/tui_tui/modal_host.rb
|
|
53
59
|
- lib/tui_tui/pager.rb
|
|
54
60
|
- lib/tui_tui/palette.rb
|
|
55
61
|
- lib/tui_tui/prompt.rb
|
|
56
62
|
- lib/tui_tui/rect.rb
|
|
63
|
+
- lib/tui_tui/render_context.rb
|
|
57
64
|
- lib/tui_tui/runtime.rb
|
|
58
65
|
- lib/tui_tui/screen.rb
|
|
59
66
|
- lib/tui_tui/scroll_list.rb
|
|
@@ -78,6 +85,7 @@ metadata:
|
|
|
78
85
|
allowed_push_host: https://rubygems.org
|
|
79
86
|
homepage_uri: https://github.com/takahashim/tui_tui
|
|
80
87
|
source_code_uri: https://github.com/takahashim/tui_tui
|
|
88
|
+
changelog_uri: https://github.com/takahashim/tui_tui/blob/main/CHANGELOG.md
|
|
81
89
|
rdoc_options: []
|
|
82
90
|
require_paths:
|
|
83
91
|
- lib
|
|
@@ -92,7 +100,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
92
100
|
- !ruby/object:Gem::Version
|
|
93
101
|
version: '0'
|
|
94
102
|
requirements: []
|
|
95
|
-
rubygems_version:
|
|
103
|
+
rubygems_version: 3.6.9
|
|
96
104
|
specification_version: 4
|
|
97
105
|
summary: A tiny, dependency-free TUI runtime for modern terminals.
|
|
98
106
|
test_files: []
|