r2ui 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.
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module R2UI
4
+ module Widgets
5
+ # "Used ▕██████░░░░▏ 21G / 32G 67%" on one line.
6
+ module Gauge
7
+ module_function
8
+
9
+ def draw(canvas, rect, label:, value:, total:, column:)
10
+ return if rect.empty?
11
+
12
+ fraction = total.to_f.positive? ? (value.to_f / total).clamp(0, 1) : 0
13
+ right = "#{column.render(value)} / #{column.render(total)} #{(fraction * 100).round}%"
14
+ label = label.ljust(10)
15
+ bar_w = rect.width - label.length - right.length - 4
16
+ canvas.write(rect.x, rect.y, label, :bold)
17
+ if bar_w >= 4
18
+ filled = (fraction * bar_w).round
19
+ canvas.write(rect.x + label.length, rect.y, "▕", :muted)
20
+ canvas.write(rect.x + label.length + 1, rect.y, "█" * filled, style(fraction))
21
+ canvas.write(rect.x + label.length + 1 + filled, rect.y, "░" * (bar_w - filled), :muted)
22
+ canvas.write(rect.x + label.length + 1 + bar_w, rect.y, "▏", :muted)
23
+ end
24
+ canvas.write(rect.x + rect.width - right.length, rect.y, right)
25
+ end
26
+
27
+ def style(fraction)
28
+ if fraction >= 0.9 then :alert
29
+ elsif fraction >= 0.7 then :warn
30
+ else :ok
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module R2UI
4
+ module Widgets
5
+ module Sparkline
6
+ BARS = %w[▁ ▂ ▃ ▄ ▅ ▆ ▇ █].freeze
7
+ # Eighths of a full cell, for multi-line charts.
8
+ BLOCKS = [" ", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"].freeze
9
+
10
+ module_function
11
+
12
+ # One line of bars for the newest `width` values, scaled to the window's max (or `max:`).
13
+ def line(values, width, max: nil)
14
+ window = values.last(width).map(&:to_f)
15
+ return "" if window.empty?
16
+
17
+ top = max || window.max
18
+ top = 1.0 if top.nil? || top <= 0
19
+ window.map { |v| BARS[((v / top) * (BARS.size - 1)).round.clamp(0, BARS.size - 1)] }.join
20
+ end
21
+
22
+ # A chart `rect.height` lines tall, newest value at the right edge.
23
+ def draw(canvas, rect, values, max: nil, style: :ok)
24
+ return if rect.empty?
25
+
26
+ window = values.last(rect.width).map(&:to_f)
27
+ top = max || window.max
28
+ top = 1.0 if top.nil? || top <= 0
29
+ offset = rect.width - window.size
30
+ window.each_with_index do |v, i|
31
+ eighths = ((v / top).clamp(0, 1) * rect.height * 8).round
32
+ rect.height.times do |row|
33
+ fill = (eighths - (row * 8)).clamp(0, 8)
34
+ next if fill.zero?
35
+
36
+ canvas.write(rect.x + offset + i, rect.bottom - 1 - row, BLOCKS[fill], style)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module R2UI
4
+ module Widgets
5
+ # Label on the left, formatted value on the right, one per line.
6
+ module Stat
7
+ module_function
8
+
9
+ def draw(canvas, rect, pairs)
10
+ pairs.first(rect.height).each_with_index do |(column, value), i|
11
+ text = column.render(value)
12
+ canvas.write(rect.x, rect.y + i, column.label, :muted, max: rect.width - text.length - 1)
13
+ canvas.write(rect.x + rect.width - text.length, rect.y + i, text, :bold)
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module R2UI
4
+ module Widgets
5
+ # The index table: header, sort marker, grouped/tree labels, inline sparklines, selection.
6
+ class Table
7
+ SPARK_WIDTH = 10
8
+ MIN_TEXT = 8
9
+
10
+ # `spark` is called with (line, column) and returns that line's recent values.
11
+ def initialize(resource, lines, state:, focused:, label_key: nil, spark: nil)
12
+ @resource = resource
13
+ @lines = lines
14
+ @state = state
15
+ @focused = focused
16
+ @spark = spark
17
+ @label_key = label_key || resource.columns.find { |c| !c.numeric? && c.format != :id }&.key
18
+ end
19
+
20
+ def draw(canvas, rect)
21
+ return if rect.empty?
22
+
23
+ layout = widths(rect.width)
24
+ draw_header(canvas, rect, layout)
25
+ body = rect.with(y: rect.y + 1, height: rect.height - 1)
26
+ @state.scroll_to_selection(body.height, @lines.size)
27
+ @lines.drop(@state.offset).first(body.height).each_with_index do |line, i|
28
+ index = @state.offset + i
29
+ selected = @focused && index == @state.selected
30
+ canvas.fill(body.with(y: body.y + i, height: 1), " ", :selected) if selected
31
+ draw_line(canvas, rect.x, body.y + i, line, layout, selected)
32
+ end
33
+ draw_footer(canvas, rect) if @lines.size > body.height
34
+ end
35
+
36
+ private
37
+
38
+ def draw_header(canvas, rect, layout)
39
+ sort_key, sort_dir = @state.sort
40
+ x = rect.x
41
+ layout.each do |col, w|
42
+ label = col.label
43
+ label = "#{label}#{sort_dir == :desc ? "▼" : "▲"}" if col.key == sort_key
44
+ canvas.write(x, rect.y, align(label, w, col.align), :header, max: w)
45
+ x += w + 1
46
+ end
47
+ end
48
+
49
+ def draw_line(canvas, x, y, line, layout, selected)
50
+ layout.each do |col, w|
51
+ text, style = cell(line, col, w)
52
+ canvas.write(x, y, text, selected ? :selected : style, max: w)
53
+ x += w + 1
54
+ end
55
+ end
56
+
57
+ def cell(line, col, width)
58
+ if col.key == @label_key && (line.label || line.depth.positive? || line.expandable?)
59
+ prefix, body, suffix = label_parts(line, col)
60
+ body = fit(body, [width - prefix.length - suffix.length, 1].max, Format.truncate_from(col.format))
61
+ return [fit("#{prefix}#{body}#{suffix}", width, :right), line.label ? :bold : :plain]
62
+ end
63
+
64
+ value = line.values[col.key]
65
+ value = "×#{line.count}" if col.aggregate == :count && line.label
66
+ text = value.is_a?(String) && col.aggregate == :count ? value : col.render(value)
67
+ if col.sparkline && @spark
68
+ text_w = width - SPARK_WIDTH - 1
69
+ text = "#{Sparkline.line(@spark.call(line, col), SPARK_WIDTH).rjust(SPARK_WIDTH)} #{align(text, text_w, col.align)}"
70
+ return [text, :plain]
71
+ end
72
+ [align(fit(text, width, Format.truncate_from(col.format)), width, col.align), style_for(col, value)]
73
+ end
74
+
75
+ # [indent and tree marker, the value (truncated to fit), count suffix]
76
+ def label_parts(line, col)
77
+ if line.label
78
+ body = Format.call(col.format, line.label)
79
+ counted = @resource.columns.any? { |c| c.aggregate == :count }
80
+ ["", body.empty? ? "(none)" : body, counted ? "" : " ×#{line.count}"]
81
+ else
82
+ marker = if line.expandable? then line.collapsed ? "▸ " : "▾ " else " " end
83
+ ["#{" " * line.depth}#{marker}", col.render(line.values[col.key]), line.count > 1 ? " (#{line.count})" : ""]
84
+ end
85
+ end
86
+
87
+ def style_for(col, value)
88
+ return :plain unless col.format == :percent && value.is_a?(Numeric)
89
+
90
+ if value >= 80 then :alert
91
+ elsif value >= 25 then :warn
92
+ else :plain
93
+ end
94
+ end
95
+
96
+ # Fixed-width columns get their width; text columns share the rest. Drops columns that don't fit.
97
+ def widths(total)
98
+ cols = @resource.columns
99
+ fixed = cols.to_h do |c|
100
+ w = c.width || Format.default_width(c.format)
101
+ w &&= [w, c.label.length + 1].max
102
+ w += SPARK_WIDTH + 1 if w && c.sparkline
103
+ [c, w]
104
+ end
105
+ loop do
106
+ flexible = fixed.count { |_, w| w.nil? }
107
+ used = fixed.values.compact.sum + fixed.size - 1
108
+ spare = total - used
109
+ break if flexible.zero? ? spare >= 0 : spare >= flexible * MIN_TEXT
110
+ break if fixed.size == 1
111
+
112
+ fixed.delete(fixed.keys.last)
113
+ end
114
+ flexible = fixed.count { |_, w| w.nil? }
115
+ spare = total - fixed.values.compact.sum - (fixed.size - 1)
116
+ fixed.to_h { |c, w| [c, w || [spare / [flexible, 1].max, 1].max] }
117
+ end
118
+
119
+ def draw_footer(canvas, rect)
120
+ text = " #{@state.selected + 1}/#{@lines.size} "
121
+ canvas.write(rect.x + rect.width - text.length, rect.bottom, text, :muted)
122
+ end
123
+
124
+ def fit(text, width, from)
125
+ return text if text.length <= width
126
+ return text[0, width] if width < 2
127
+
128
+ from == :left ? "…#{text[-(width - 1)..]}" : "#{text[0, width - 1]}…"
129
+ end
130
+
131
+ def align(text, width, side) = side == :right ? text.rjust(width) : text.ljust(width)
132
+ end
133
+ end
134
+ end
data/lib/r2ui.rb ADDED
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "r2ui/version"
4
+ require_relative "r2ui/value"
5
+ require_relative "r2ui/format"
6
+ require_relative "r2ui/history"
7
+ require_relative "r2ui/dsl/column"
8
+ require_relative "r2ui/dsl/resource"
9
+ require_relative "r2ui/dsl/dashboard"
10
+ require_relative "r2ui/registry"
11
+ require_relative "r2ui/search"
12
+ require_relative "r2ui/query"
13
+ require_relative "r2ui/canvas"
14
+ require_relative "r2ui/widgets/box"
15
+ require_relative "r2ui/widgets/table"
16
+ require_relative "r2ui/widgets/gauge"
17
+ require_relative "r2ui/widgets/stat"
18
+ require_relative "r2ui/widgets/sparkline"
19
+ require_relative "r2ui/panel_state"
20
+ require_relative "r2ui/feed"
21
+ require_relative "r2ui/renderer"
22
+ require_relative "r2ui/keys"
23
+ require_relative "r2ui/terminal"
24
+ require_relative "r2ui/app"
25
+
26
+ # R2UI: declare terminal dashboards the way ActiveAdmin declares admin pages.
27
+ #
28
+ # R2UI.resource :process do
29
+ # source { MyProbe.processes }
30
+ # scope :all, default: true
31
+ # group_by :name
32
+ # index { column :pid, format: :id; column :cpu, format: :percent }
33
+ # end
34
+ #
35
+ # R2UI.run
36
+ module R2UI
37
+ class Error < StandardError; end
38
+
39
+ class << self
40
+ def registry = @registry ||= Registry.new
41
+
42
+ def resource(name, &) = registry.add_resource(DSL::Resource.build(name, &))
43
+
44
+ def dashboard(name = :main, &) = registry.add_dashboard(DSL::Dashboard.build(name, &))
45
+
46
+ def run(name = nil) = App.new(registry, name).run
47
+
48
+ # One frame as plain text, without a terminal. Used by `r2ui --snapshot` and tests.
49
+ def snapshot(name = nil, width: 120, height: 40, ticks: 1) = App.new(registry, name).snapshot(width:, height:, ticks:)
50
+
51
+ def reset! = @registry = Registry.new
52
+ end
53
+ end
metadata ADDED
@@ -0,0 +1,70 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: r2ui
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Ryan Gavin
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Declare resources, scopes, groupings, columns and actions; R2UI draws
13
+ the tables, charts and key bindings in your terminal.
14
+ email:
15
+ - ryan.michael.gavin@gmail.com
16
+ executables:
17
+ - r2ui
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - LICENSE.txt
22
+ - README.md
23
+ - exe/r2ui
24
+ - lib/r2ui.rb
25
+ - lib/r2ui/app.rb
26
+ - lib/r2ui/canvas.rb
27
+ - lib/r2ui/dsl/column.rb
28
+ - lib/r2ui/dsl/dashboard.rb
29
+ - lib/r2ui/dsl/resource.rb
30
+ - lib/r2ui/feed.rb
31
+ - lib/r2ui/format.rb
32
+ - lib/r2ui/history.rb
33
+ - lib/r2ui/keys.rb
34
+ - lib/r2ui/panel_state.rb
35
+ - lib/r2ui/query.rb
36
+ - lib/r2ui/registry.rb
37
+ - lib/r2ui/renderer.rb
38
+ - lib/r2ui/search.rb
39
+ - lib/r2ui/terminal.rb
40
+ - lib/r2ui/value.rb
41
+ - lib/r2ui/version.rb
42
+ - lib/r2ui/widgets/box.rb
43
+ - lib/r2ui/widgets/gauge.rb
44
+ - lib/r2ui/widgets/sparkline.rb
45
+ - lib/r2ui/widgets/stat.rb
46
+ - lib/r2ui/widgets/table.rb
47
+ homepage: https://github.com/satoramoto/r2ui
48
+ licenses:
49
+ - MIT
50
+ metadata:
51
+ source_code_uri: https://github.com/satoramoto/r2ui
52
+ changelog_uri: https://github.com/satoramoto/r2ui/commits/main
53
+ rdoc_options: []
54
+ require_paths:
55
+ - lib
56
+ required_ruby_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '3.3'
61
+ required_rubygems_version: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: '0'
66
+ requirements: []
67
+ rubygems_version: 3.7.1
68
+ specification_version: 4
69
+ summary: ActiveAdmin-style DSL for terminal dashboards
70
+ test_files: []