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.
Files changed (55) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +20 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +132 -0
  5. data/Rakefile +8 -0
  6. data/examples/clock.rb +112 -0
  7. data/examples/counter.rb +48 -0
  8. data/examples/csv_viewer.rb +233 -0
  9. data/examples/file_browser.rb +665 -0
  10. data/examples/form.rb +633 -0
  11. data/examples/life.rb +144 -0
  12. data/examples/paint.rb +246 -0
  13. data/examples/todo.rb +250 -0
  14. data/examples/widgets.rb +101 -0
  15. data/lib/tui_tui/ansi.rb +34 -0
  16. data/lib/tui_tui/canvas.rb +187 -0
  17. data/lib/tui_tui/canvas_compositor.rb +45 -0
  18. data/lib/tui_tui/cell.rb +11 -0
  19. data/lib/tui_tui/color_depth.rb +39 -0
  20. data/lib/tui_tui/confirm.rb +74 -0
  21. data/lib/tui_tui/display_text.rb +73 -0
  22. data/lib/tui_tui/event.rb +10 -0
  23. data/lib/tui_tui/event_stream.rb +39 -0
  24. data/lib/tui_tui/focus_ring.rb +25 -0
  25. data/lib/tui_tui/fuzzy.rb +56 -0
  26. data/lib/tui_tui/help.rb +44 -0
  27. data/lib/tui_tui/key_code.rb +9 -0
  28. data/lib/tui_tui/key_intent.rb +29 -0
  29. data/lib/tui_tui/key_reader.rb +175 -0
  30. data/lib/tui_tui/line.rb +59 -0
  31. data/lib/tui_tui/list.rb +45 -0
  32. data/lib/tui_tui/modal.rb +30 -0
  33. data/lib/tui_tui/pager.rb +94 -0
  34. data/lib/tui_tui/palette.rb +49 -0
  35. data/lib/tui_tui/prompt.rb +111 -0
  36. data/lib/tui_tui/rect.rb +48 -0
  37. data/lib/tui_tui/runtime.rb +53 -0
  38. data/lib/tui_tui/screen.rb +85 -0
  39. data/lib/tui_tui/scroll_list.rb +57 -0
  40. data/lib/tui_tui/scrollbar.rb +40 -0
  41. data/lib/tui_tui/select.rb +104 -0
  42. data/lib/tui_tui/size.rb +5 -0
  43. data/lib/tui_tui/span.rb +14 -0
  44. data/lib/tui_tui/status_bar.rb +23 -0
  45. data/lib/tui_tui/style.rb +101 -0
  46. data/lib/tui_tui/terminal_session.rb +65 -0
  47. data/lib/tui_tui/terminal_size.rb +24 -0
  48. data/lib/tui_tui/text_sanitizer.rb +13 -0
  49. data/lib/tui_tui/text_view.rb +52 -0
  50. data/lib/tui_tui/theme.rb +127 -0
  51. data/lib/tui_tui/toast.rb +82 -0
  52. data/lib/tui_tui/version.rb +5 -0
  53. data/lib/tui_tui/width.rb +101 -0
  54. data/lib/tui_tui.rb +51 -0
  55. 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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TuiTui
4
+ Size = Data.define(:rows, :cols)
5
+ end
@@ -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