text-ui 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 412150d9f77ef6b3363eaae39bcc64a4ddc73a5a9ed3fa76a54841eca070549a
4
+ data.tar.gz: 9ae1904192edd13412b87667a9b0bf62c1ee601facc498feba703f5e85750d9a
5
+ SHA512:
6
+ metadata.gz: 05ded2430b82482f2e7d3d4074159a9bec9684304bc4db69913ff3f9d0562664641d31c593a22b661c5cb30c9b91111c53c41c9bd93578c921cb00c036a15b1d
7
+ data.tar.gz: d46560ec1bdd4cfaa563b6f9b978b3276565f721d451ae5fce5ac302805ac028708fb2f6486e4d551b52ca4ff9b4e7e241a9e635bdaf34f8069a42c3e76e2af6
data/README.md ADDED
@@ -0,0 +1,33 @@
1
+ # Description
2
+
3
+ A gem to build Text User Interface of nested boxes for console with custom dynamic content.
4
+
5
+ It allows to positions and align nested boxes through notions of rows and columns to structure data.
6
+
7
+ ## Usage
8
+
9
+ The gem expects you to have the main application that does its job separately.
10
+
11
+ In a meanwhile, TUI's thread would re-draw layout with you custom content on console.
12
+
13
+ See [example](./bin/example.rb) for some usage.
14
+
15
+ ## Development
16
+
17
+ After checking out the repo, run `bundle install` to install dependencies. Then, run `ruby -Ilib:test test/test_tui.rb` to run the tests.
18
+
19
+ ## Contributing
20
+
21
+ Feel free to submit bug reports and merge requests.
22
+
23
+ # Docs
24
+
25
+ `yard doc` generates some documentation.
26
+
27
+ `yard server --reload` starts a server with documentation available at http://localhost:8808
28
+
29
+ # TODOs
30
+
31
+ - resurrect logging
32
+ - handle Ctrl+C
33
+ - provide interface to control data fetching: frequency, caching, make it parallel
data/lib/tui/assets.rb ADDED
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Static assets to draw boxes.
4
+ # Are used mainly in {Format#box!}
5
+ module Tui::Assets
6
+ CORNERS = {
7
+ sharp: '┏┓┗┛',
8
+ round: '╭╮╰╯'
9
+ }.freeze
10
+ LINES = {
11
+ single: '│─'
12
+ }.freeze
13
+ end
data/lib/tui/block.rb ADDED
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'format'
4
+
5
+ module Tui
6
+ # Basic TUI building block.
7
+ #
8
+ # In a nutshell, all blocks are columns ({Block#column}) with {::String}s in it printed vertically one by one (see {#to_s}).
9
+ # So a row is a way to compose multiple columns to a single "column" (see {Block#row}), which is an {::Array} of lines anyway.
10
+ class Block
11
+
12
+ attr_reader :width
13
+ attr_reader :array
14
+
15
+ # Mix-in formatting methods.
16
+ # The {Blocks} class has only methods to make nested blocks
17
+ include Format
18
+
19
+ # Make a column with several rows in it. Rows could be other {Block}s or just {::String}s.
20
+ #
21
+ # The method actually transform array or "rows" to a single column right away.
22
+ # @example
23
+ # Block.column "some", "other" # "some" and "other will be centered in the column by default
24
+ # Block.column "some", "other", align: :left # "some" and "other" will be aligned to the left
25
+ # Block.column
26
+ # Block.row("one", "two"),
27
+ # "three"
28
+ # ]} # "one" and "two" will be printed in the first row, "three" will be below them centered
29
+ # Block.column("some", "other") { |el| el.box! } # box both string then place boxes to a column
30
+ #
31
+ # @param rows array of rows ({Block}s / {::String}s)
32
+ # @param align how to align blocks between each other: :center (default), :right, :left
33
+ # @param block individual row processor, the block is supplied with {Block}s
34
+ def self.column *rows, align: :center, &block
35
+ # row could be a String, make an array of horizontal lines from it
36
+ rows.collect! { |col| col.is_a?(Block) ? col : Block.new(col) }
37
+ rows.collect!(&block) if block_given? # pre-process "rows"
38
+ max_row_width = rows.collect(&:width).max
39
+ Block.new rows.collect! { |blk|
40
+ extra_columns = max_row_width - blk.width
41
+ case align
42
+ when :left then blk.collect! { |line| line + ' ' * extra_columns }
43
+ when :right then blk.collect! { |line| ' ' * extra_columns + line }
44
+ else
45
+ blk.h_pad!(extra_columns / 2)
46
+ extra_columns.odd? ? blk.collect! { |line| line + ' ' } : blk
47
+ end
48
+ blk.array # get the array to join using builtin flatten
49
+ }.flatten!
50
+ end
51
+
52
+ # Compose a row of other {Block}s or {::String}s.
53
+ #
54
+ # The method actually squashes a row of columns (blocks) to a single block (column)
55
+ # @example
56
+ # Block.row "some", "other" # => "someother"
57
+ # Block.row "some", ["other", "foo"], aligh: bottom # "some" will be shifted down by 1 line
58
+ # Block.row("some", "other") { |blk| blk.box! } # both columns will be enclosed in a box
59
+ #
60
+ # @param cols array of columns ({Block}s / {::String}s) to squash
61
+ # @param align how to align blocks in the row: :center, :top, :bottom
62
+ # @param block individual column processor
63
+ def self.row *cols, align: :center, &block
64
+ cols.collect! { |col| col.is_a?(Block) ? col : Block.new(col) }
65
+ cols.collect!(&block) if block_given? # pre-process columns
66
+ max_col_height = cols.collect(&:height).max
67
+ Block.new cols.collect! { |col|
68
+ extra_lines = max_col_height - col.height
69
+ case align
70
+ when :top then col << Array.new(extra_lines, '')
71
+ when :bottom then col >> Array.new(extra_lines, '')
72
+ else
73
+ col.v_pad!(extra_lines / 2)
74
+ col << '' if extra_lines.odd?
75
+ end
76
+ col.v_align! # is needed due to transpose call below
77
+ col.array # get the array to process using builtin methods
78
+ }.transpose.collect(&:join)
79
+ end
80
+
81
+ # "Render" the Block to print to the console.
82
+ # As each block and operation just transforms a list of Strings,
83
+ # the whole "rendering" is as simple as ...
84
+ def to_s
85
+ @array.join "\n"
86
+ end
87
+
88
+ # Add extra lines from the supplied array to the block;
89
+ # no auto-alignment is performed, see {#v_align!} to make width even
90
+ # @param other either {::Array} or {::String} to push back
91
+ def << other
92
+ other.is_a?(Array) ? @array += other : @array << other
93
+ @width = @array.collect(&:size).max
94
+ self
95
+ end
96
+
97
+ # Add extra lines to the "start" of the block
98
+ # @param other either {::Array} or {::String} to push_forward
99
+ # @example
100
+ # Block.column '1'
101
+ # block << %w[2 3] # now block has %w[2 3 1]
102
+ def >> other
103
+ case other
104
+ when Array
105
+ @width = [@width, other.collect(&:size).max].max
106
+ other.reverse_each { |i|
107
+ @array.unshift i
108
+ }
109
+ when String
110
+ @width = [@width, other.size].max
111
+ @array.unshift other
112
+ end
113
+ self
114
+ end
115
+
116
+ # Get {Block}'s height in symbols
117
+ # Block is a column => column's height is {Block}'s array size
118
+ def height
119
+ @array.size
120
+ end
121
+
122
+ # Modify each "row" of the {Block} inline
123
+ def collect! &block
124
+ @array.collect!(&block)
125
+ @width = @array.collect(&:size).max
126
+ end
127
+
128
+ private
129
+
130
+ # Constructor is private.
131
+ #
132
+ # {#column} and {#row} are to make blocks;
133
+ def initialize arg
134
+ @array = case arg
135
+ when Array then arg
136
+ when String then [arg]
137
+ else [arg.to_s]
138
+ end
139
+ @width = @array.collect(&:size).max
140
+ end
141
+ end
142
+ end
data/lib/tui/format.rb ADDED
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tui
4
+ # Collection of low-level {Block} formatting methods.
5
+ #
6
+ # Most methods
7
+ # - mutate the object they are called for => no need to re-assign
8
+ # - `return self` => are intended to be chained (`block.box!.v_pad!(2)`)
9
+ #
10
+ # The module is not to be used directly
11
+ # and exists just to de-couple formatting methods from the rest.
12
+ module Format
13
+
14
+ # Add horizontal (to the sides) padding to the block.
15
+ # @param size number of spaces to add
16
+ def h_pad! size = 1
17
+ collect! { |line|
18
+ "#{' ' * size}#{line}#{' ' * size}"
19
+ }
20
+ @width += size * 2
21
+ self
22
+ end
23
+
24
+ # Add vertical padding (before-after) to the block
25
+ # @param size number of spaces to add
26
+ def v_pad! size = 1
27
+ filler = ' ' * @width
28
+ size.times {
29
+ self >> filler
30
+ self << filler
31
+ }
32
+ self
33
+ end
34
+
35
+ # Adds spaces around the block
36
+ # @param size number of spaces to add
37
+ def pad! size = 1
38
+ # order matters!
39
+ v_pad! size
40
+ h_pad! size
41
+ end
42
+
43
+ # Aligns block elements vertically (by width) by adding spaces.
44
+ #
45
+ # Lines (rows) within the block can be of uneven width.
46
+ # This method changes all lines to have the same # of chars
47
+ # @param type {Symbol} :left, :center, :right
48
+ # @param width {Integer} target block width, is ignored if less than @width (look at {fit!})
49
+ def v_align! type = :left, width: @width
50
+ line_transformer = case type
51
+ when :center
52
+ ->(line, num_spaces) { ' ' * (num_spaces / 2) + line.to_s + (' ' * (num_spaces / 2)) + (num_spaces.odd? ? ' ' : '') }
53
+ when :right
54
+ ->(line, num_spaces) { (' ' * num_spaces) + line.to_s }
55
+ else # :left
56
+ ->(line, num_spaces) { line.to_s + (' ' * num_spaces) }
57
+ end
58
+
59
+ return self if width.nil? || @width > width # == case makes all lines width even
60
+
61
+ @width = width
62
+ @array.collect! { |line| line_transformer.call line, @width - line.size }
63
+ self
64
+ end
65
+
66
+ # Aligns block elements horisontally (by height) by adding spaces.
67
+ #
68
+ # New lines get added to the block to have the specified # of lines in total.
69
+ # @param type {Symbol} :top, :center, :bottom
70
+ # @param height {Integer} target block height, is ignored if less than @width (look at {fit!})
71
+ def h_align! type = :top, height: @height
72
+ return self if height.nil? or @array.size > height
73
+
74
+ extra_lines_count = height - @array.size
75
+ case type
76
+ when :center
77
+ (extra_lines_count/2).times { @array.prepend ' ' * @width }
78
+ (extra_lines_count/2 + (extra_lines_count.odd? ? 1 : 0)).times { @array.append ' ' * @width }
79
+ when :top
80
+ (extra_lines_count).times { @array.append ' ' * @width }
81
+ else # :bottom
82
+ (extra_lines_count).times { @array.prepend ' ' * @width }
83
+ end
84
+
85
+ self
86
+ end
87
+
88
+ # Align content to the center of the specified width and height.
89
+ #
90
+ # @param height {Number} target height
91
+ # @param width {Number} target width
92
+ def align! height: nil, width: nil
93
+ v_align! :center, width: width
94
+ h_align! :center, height: height
95
+ end
96
+
97
+ # Add a square box around the block.
98
+ #
99
+ # It auto-aligns the block, so use {v_align!} beforehand!
100
+ # if you want custom alignment for the block
101
+ def box! corners: :round
102
+ corners = Assets::CORNERS[corners]
103
+ lines = Assets::LINES[:single]
104
+ v_align!
105
+ @array.collect! { |line| "#{lines[0]}#{line}#{lines[0]}" }
106
+ @array.unshift "#{corners[0]}#{lines[1] * width}#{corners[1]}"
107
+ @array << "#{corners[2]}#{lines[1] * width}#{corners[3]}"
108
+ @width += 2
109
+ self
110
+ end
111
+
112
+ # Fit the current block to a rectangle by
113
+ # cropping the block and adding a special markers to its content.
114
+ #
115
+ # Actual content width and height will be 1 char less to store cropping symbols too.
116
+ #
117
+ # Filling does not align content, {v_align!} does.
118
+ #
119
+ # @param width width to fit, nil => don't touch width
120
+ # @param height height to fit, nil => don't touch height
121
+ # @param fill whether to fill {Block} to be of the size of the box
122
+ def fit! width: nil, height: nil, fill: false
123
+ # pre-calc width to use below
124
+ @width = width unless width.nil? || (@width < width && !fill)
125
+
126
+ unless height.nil?
127
+ if @array.size > height
128
+ @array.slice!((height - 1)..)
129
+ @array << ('░' * @width)
130
+ elsif fill && @array.size < height
131
+ @array += Array.new(height - @array.size, ' ' * @width)
132
+ end
133
+ end
134
+ unless width.nil?
135
+ collect! { |line|
136
+ if line.size > width
137
+ "#{line[...(width - 1)]}░"
138
+ elsif fill && line.size < width
139
+ extra = (width - line.size)
140
+ "#{' ' * (extra/2)}#{line}#{' ' * (extra/2 + (extra.odd? ? 1 : 0))}"
141
+ else
142
+ line
143
+ end
144
+ }
145
+ end
146
+ self
147
+ end
148
+
149
+ end
150
+ end
data/lib/tui/layout.rb ADDED
@@ -0,0 +1,21 @@
1
+ require_relative 'block'
2
+
3
+ # Main window layout.
4
+ #
5
+ # It represents the main window of the application's TUI
6
+ class Tui::Layout
7
+ # Internal content is expected to be rendered each time => should be callable
8
+ # @example
9
+ # Tui::Layout.new { Block.column "foo", "bar" }
10
+ def initialize &block
11
+ @root = block || -> { Tui::Block::column "Hello TUI!" }
12
+ end
13
+
14
+ # Render content to produce a frame, which could be print -ed.
15
+ def render
16
+ box = @root.call
17
+ raise "main window is not a block but #{box.class}" unless box.is_a? Tui::Block
18
+
19
+ box
20
+ end
21
+ end
@@ -0,0 +1,13 @@
1
+ # Settings for the TUI.
2
+ #
3
+ # - target_fps - how frequently you want the content to refresh. It's useful to control CPU, console I/O and data fetching usage
4
+ # - window_size - size of the main window, e.g. `{ height: 70, width: 200 }`
5
+ # - draw_main_window - whether to fit content in a box and center it by default within window
6
+ Tui::Settings = Data.define(:target_fps, :window_size, :draw_main_window) do
7
+ def initialize(
8
+ target_fps: 30,
9
+ window_size: { height: 70, width: 200 }.freeze,
10
+ draw_main_window: true)
11
+ super(target_fps:, window_size:, draw_main_window:)
12
+ end
13
+ end
data/lib/tui.rb ADDED
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tui; end
4
+
5
+ require 'date'
6
+ require 'io/console'
7
+
8
+
9
+ require_relative 'tui/assets'
10
+ require_relative 'tui/block'
11
+ require_relative 'tui/format'
12
+ require_relative 'tui/layout'
13
+ require_relative 'tui/settings'
14
+
15
+ ##
16
+ # Root module / singleton. See {Tui#run} - the main entrypoint.
17
+ module Tui
18
+ # Delay correction step to maintain FPS
19
+ DELAY_STEP_MS = 100
20
+
21
+ # Frames counter to calculate average FPS
22
+ @frames_count = 0
23
+
24
+ class << self
25
+
26
+ # Current delay between redraws
27
+ attr_reader :delay_ms
28
+ # Average redraws count / sec
29
+ attr_reader :current_avg_fps
30
+
31
+ ##
32
+ # The main entrypoint of the TUI
33
+ # @param layout {Tui::Layout} - UI's layout to render and draw
34
+ # @param settings {Tui::Settings} - UI's settings that affects UI's behavior
35
+ # @return {Thread}
36
+ def run layout, settings = Settings::new
37
+ raise "expected Layout, found #{layout.class}" unless layout.is_a? Layout
38
+
39
+ @settings = settings
40
+ @started_at = DateTime.now.strftime('%Q').to_i
41
+ @delay_ms = 1000 / @settings.target_fps
42
+
43
+ @thread = Thread.new {
44
+ loop {
45
+ clear
46
+ refresh
47
+ print @settings.draw_main_window ?
48
+ layout.render
49
+ .align!(**effective_window_size)
50
+ .fit!(**effective_window_size, fill: true)
51
+ .box!
52
+ : layout.render
53
+
54
+ sleep @delay_ms / 1000.0
55
+ }
56
+ }
57
+
58
+ @thread
59
+ end
60
+
61
+ private
62
+
63
+ def clear
64
+ print "\e[2J\e[f"
65
+ end
66
+
67
+ # Refresh terminal size and adjust delay
68
+ def refresh
69
+ ms_spent = 1 + DateTime.now.strftime('%Q').to_i - @started_at
70
+
71
+ @current_avg_fps = @frames_count * 1000.0 / ms_spent
72
+
73
+ # TODO: use progression
74
+ if @settings.target_fps > @current_avg_fps && @delay_ms > DELAY_STEP_MS
75
+ @delay_ms -= DELAY_STEP_MS
76
+ end
77
+
78
+ if @settings.target_fps < @current_avg_fps && @delay_ms < 1000
79
+ @delay_ms += DELAY_STEP_MS
80
+ end
81
+
82
+ @frames_count += 1;
83
+ end
84
+
85
+ # Calculate actual content size from settings and available terminal's shape
86
+ def effective_window_size
87
+ {
88
+ height: (IO.console&.winsize&.first.nil? || @settings.window_size[:height] < IO.console.winsize.first ? @settings.window_size[:height] : IO.console.winsize.first) - 4,
89
+ width: (IO.console&.winsize&.last.nil? || @settings.window_size[:width] < IO.console.winsize.last ? @settings.window_size[:width] : IO.console.winsize.last) - 2
90
+ }
91
+ end
92
+
93
+ end
94
+ end
metadata ADDED
@@ -0,0 +1,58 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: text-ui
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Skorobogaty Dmitry
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-12-31 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: |2
14
+
15
+
16
+ A gem to build Text User Interface of nested boxes for console with custom dynamic content.
17
+
18
+ It allows to positions and align nested boxes through notions of rows and columns to structure data.
19
+ email:
20
+ - skorobogaty.dmitry@gmail.com
21
+ executables: []
22
+ extensions: []
23
+ extra_rdoc_files: []
24
+ files:
25
+ - README.md
26
+ - lib/tui.rb
27
+ - lib/tui/assets.rb
28
+ - lib/tui/block.rb
29
+ - lib/tui/format.rb
30
+ - lib/tui/layout.rb
31
+ - lib/tui/settings.rb
32
+ homepage: https://github.com/skorobogatydmitry/tui
33
+ licenses:
34
+ - LGPL-3.0-only
35
+ metadata:
36
+ homepage_uri: https://github.com/skorobogatydmitry/tui
37
+ source_code_uri: https://github.com/skorobogatydmitry/tui
38
+ allowed_push_host: https://rubygems.org/
39
+ post_install_message:
40
+ rdoc_options: []
41
+ require_paths:
42
+ - lib
43
+ required_ruby_version: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '3.3'
48
+ required_rubygems_version: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: '0'
53
+ requirements: []
54
+ rubygems_version: 3.5.11
55
+ signing_key:
56
+ specification_version: 4
57
+ summary: Draw nested boxes in console.
58
+ test_files: []