r2ui 0.1.0 → 0.2.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 +64 -0
- data/README.md +79 -0
- data/lib/r2ui/app.rb +311 -34
- data/lib/r2ui/canvas.rb +135 -14
- data/lib/r2ui/cli/builder.rb +72 -0
- data/lib/r2ui/cli/context.rb +45 -0
- data/lib/r2ui/cli/definition.rb +174 -0
- data/lib/r2ui/cli/ext/ask.rb +133 -0
- data/lib/r2ui/cli/ext/box.rb +112 -0
- data/lib/r2ui/cli/ext/choose.rb +162 -0
- data/lib/r2ui/cli/ext/choose_many.rb +241 -0
- data/lib/r2ui/cli/ext/color_option.rb +64 -0
- data/lib/r2ui/cli/ext/completion.rb +520 -0
- data/lib/r2ui/cli/ext/config.rb +179 -0
- data/lib/r2ui/cli/ext/confirm.rb +110 -0
- data/lib/r2ui/cli/ext/dashboard.rb +105 -0
- data/lib/r2ui/cli/ext/diff.rb +195 -0
- data/lib/r2ui/cli/ext/editor.rb +94 -0
- data/lib/r2ui/cli/ext/env.rb +108 -0
- data/lib/r2ui/cli/ext/examples.rb +62 -0
- data/lib/r2ui/cli/ext/filter.rb +269 -0
- data/lib/r2ui/cli/ext/format.rb +104 -0
- data/lib/r2ui/cli/ext/list.rb +117 -0
- data/lib/r2ui/cli/ext/log.rb +89 -0
- data/lib/r2ui/cli/ext/pager.rb +97 -0
- data/lib/r2ui/cli/ext/pairs.rb +62 -0
- data/lib/r2ui/cli/ext/parallel.rb +274 -0
- data/lib/r2ui/cli/ext/password.rb +158 -0
- data/lib/r2ui/cli/ext/progress.rb +330 -0
- data/lib/r2ui/cli/ext/reference.rb +129 -0
- data/lib/r2ui/cli/ext/spinner.rb +165 -0
- data/lib/r2ui/cli/ext/summary.rb +63 -0
- data/lib/r2ui/cli/ext/table.rb +141 -0
- data/lib/r2ui/cli/ext/tasks.rb +216 -0
- data/lib/r2ui/cli/ext/trace.rb +64 -0
- data/lib/r2ui/cli/ext/tree.rb +114 -0
- data/lib/r2ui/cli/ext/version.rb +67 -0
- data/lib/r2ui/cli/extension.rb +129 -0
- data/lib/r2ui/cli/help.rb +115 -0
- data/lib/r2ui/cli/helpers.rb +20 -0
- data/lib/r2ui/cli/live.rb +150 -0
- data/lib/r2ui/cli/parser.rb +165 -0
- data/lib/r2ui/cli/prompt.rb +133 -0
- data/lib/r2ui/cli/runner.rb +86 -0
- data/lib/r2ui/cli/shell.rb +185 -0
- data/lib/r2ui/cli/testing.rb +49 -0
- data/lib/r2ui/cli.rb +129 -0
- data/lib/r2ui/compat/bubbletea/LICENSE-bubbletea.txt +21 -0
- data/lib/r2ui/compat/bubbletea/ansi.rb +424 -0
- data/lib/r2ui/compat/bubbletea/commands.rb +143 -0
- data/lib/r2ui/compat/bubbletea/input.rb +269 -0
- data/lib/r2ui/compat/bubbletea/input_reader.rb +66 -0
- data/lib/r2ui/compat/bubbletea/messages.rb +251 -0
- data/lib/r2ui/compat/bubbletea/model.rb +65 -0
- data/lib/r2ui/compat/bubbletea/native.rb +254 -0
- data/lib/r2ui/compat/bubbletea/renderer.rb +192 -0
- data/lib/r2ui/compat/bubbletea/runner.rb +425 -0
- data/lib/r2ui/compat/bubbletea/terminal.rb +230 -0
- data/lib/r2ui/compat/bubbletea/version.rb +8 -0
- data/lib/r2ui/compat/bubbletea/width_table.rb +228 -0
- data/lib/r2ui/compat/bubbletea.rb +16 -0
- data/lib/r2ui/compat/lipgloss/border.rb +17 -0
- data/lib/r2ui/compat/lipgloss/color.rb +78 -0
- data/lib/r2ui/compat/lipgloss/color_blend.rb +166 -0
- data/lib/r2ui/compat/lipgloss/colorful.rb +585 -0
- data/lib/r2ui/compat/lipgloss/layout.rb +461 -0
- data/lib/r2ui/compat/lipgloss/list.rb +85 -0
- data/lib/r2ui/compat/lipgloss/position.rb +30 -0
- data/lib/r2ui/compat/lipgloss/style/border.rb +134 -0
- data/lib/r2ui/compat/lipgloss/style.rb +1062 -0
- data/lib/r2ui/compat/lipgloss/table/render.rb +518 -0
- data/lib/r2ui/compat/lipgloss/table.rb +185 -0
- data/lib/r2ui/compat/lipgloss/termenv.rb +768 -0
- data/lib/r2ui/compat/lipgloss/text/unicode_tables.rb +428 -0
- data/lib/r2ui/compat/lipgloss/text.rb +1555 -0
- data/lib/r2ui/compat/lipgloss/tree/go_tree.rb +243 -0
- data/lib/r2ui/compat/lipgloss/tree.rb +105 -0
- data/lib/r2ui/compat/lipgloss/version.rb +15 -0
- data/lib/r2ui/compat/lipgloss.rb +64 -0
- data/lib/r2ui/compat/load_path/bubbletea.rb +4 -0
- data/lib/r2ui/compat/load_path/lipgloss.rb +4 -0
- data/lib/r2ui/component.rb +67 -0
- data/lib/r2ui/context.rb +87 -0
- data/lib/r2ui/drop_in.rb +16 -0
- data/lib/r2ui/dsl/column.rb +11 -3
- data/lib/r2ui/dsl/dashboard.rb +45 -9
- data/lib/r2ui/dsl/resource.rb +12 -0
- data/lib/r2ui/ext/after.rb +54 -0
- data/lib/r2ui/ext/async.rb +81 -0
- data/lib/r2ui/ext/batch_sequence.rb +54 -0
- data/lib/r2ui/ext/countdown.rb +52 -0
- data/lib/r2ui/ext/data_table.rb +71 -0
- data/lib/r2ui/ext/every.rb +66 -0
- data/lib/r2ui/ext/exec.rb +116 -0
- data/lib/r2ui/ext/file_picker.rb +78 -0
- data/lib/r2ui/ext/focus_report.rb +69 -0
- data/lib/r2ui/ext/help.rb +62 -0
- data/lib/r2ui/ext/keys.rb +64 -0
- data/lib/r2ui/ext/list.rb +145 -0
- data/lib/r2ui/ext/messages.rb +87 -0
- data/lib/r2ui/ext/mouse.rb +134 -0
- data/lib/r2ui/ext/paginator.rb +86 -0
- data/lib/r2ui/ext/paste.rb +86 -0
- data/lib/r2ui/ext/println.rb +21 -0
- data/lib/r2ui/ext/progress.rb +85 -0
- data/lib/r2ui/ext/refresh.rb +56 -0
- data/lib/r2ui/ext/resize.rb +62 -0
- data/lib/r2ui/ext/screen.rb +47 -0
- data/lib/r2ui/ext/screen_mode.rb +63 -0
- data/lib/r2ui/ext/spinner.rb +59 -0
- data/lib/r2ui/ext/state.rb +45 -0
- data/lib/r2ui/ext/stopwatch.rb +67 -0
- data/lib/r2ui/ext/suspend.rb +56 -0
- data/lib/r2ui/ext/text_area.rb +73 -0
- data/lib/r2ui/ext/text_input.rb +83 -0
- data/lib/r2ui/ext/theme.rb +112 -0
- data/lib/r2ui/ext/view.rb +37 -0
- data/lib/r2ui/ext/viewport.rb +92 -0
- data/lib/r2ui/ext/window_title.rb +51 -0
- data/lib/r2ui/extension.rb +199 -0
- data/lib/r2ui/history.rb +4 -1
- data/lib/r2ui/keys.rb +35 -8
- data/lib/r2ui/motion.rb +169 -0
- data/lib/r2ui/panel_state.rb +46 -2
- data/lib/r2ui/renderer.rb +87 -22
- data/lib/r2ui/version.rb +1 -1
- data/lib/r2ui/widgets/box.rb +6 -3
- data/lib/r2ui/widgets/glyphs.rb +149 -0
- data/lib/r2ui/widgets/table.rb +109 -25
- data/lib/r2ui.rb +12 -1
- metadata +119 -4
- data/lib/r2ui/terminal.rb +0 -53
data/lib/r2ui/renderer.rb
CHANGED
|
@@ -3,21 +3,34 @@
|
|
|
3
3
|
module R2UI
|
|
4
4
|
# Lays out a dashboard and draws every panel into a canvas.
|
|
5
5
|
class Renderer
|
|
6
|
-
|
|
6
|
+
HINT_PAIRS = [
|
|
7
|
+
["tab", "panel"], ["[ ]", "scope"], ["g", "group"], ["s/S", "sort"], ["/", "search"], ["⏎", "fold"],
|
|
8
|
+
["z", "zoom"], ["q", "quit"]
|
|
9
|
+
].freeze
|
|
10
|
+
HINTS = HINT_PAIRS.map { |key, label| "#{key} #{label}" }.join(" ").freeze
|
|
7
11
|
|
|
8
12
|
# The lines each table panel showed in the last frame, for selection and actions.
|
|
9
13
|
attr_reader :lines
|
|
14
|
+
# Where each panel was drawn in the last frame (panel => Rect), e.g. for mouse hit tests.
|
|
15
|
+
attr_reader :rects
|
|
10
16
|
|
|
11
|
-
|
|
17
|
+
# `draw_item` draws items the core doesn't know (extension items): called with
|
|
18
|
+
# (panel, item, width, height), it returns a String, or nil if no extension draws that item.
|
|
19
|
+
# `motion` (an R2UI::Motion) animates tables declared with `motion: true`.
|
|
20
|
+
def initialize(registry, feeds, states, draw_item: nil, motion: nil)
|
|
12
21
|
@registry = registry
|
|
13
22
|
@feeds = feeds
|
|
14
23
|
@states = states
|
|
24
|
+
@draw_item = draw_item
|
|
25
|
+
@motion = motion
|
|
15
26
|
@lines = {}
|
|
27
|
+
@rects = {}
|
|
16
28
|
end
|
|
17
29
|
|
|
18
|
-
def render(dashboard, width:, height:, focus:, zoomed: false, prompt: nil)
|
|
19
|
-
canvas = Canvas.new(width, height)
|
|
30
|
+
def render(dashboard, width:, height:, focus:, zoomed: false, prompt: nil, hints: HINTS, styles: nil)
|
|
31
|
+
canvas = Canvas.new(width, height, styles:)
|
|
20
32
|
body = Rect.new(x: 0, y: 0, width:, height: height - 1)
|
|
33
|
+
@rects = {}
|
|
21
34
|
if zoomed
|
|
22
35
|
draw_panel(canvas, body, focus, true)
|
|
23
36
|
else
|
|
@@ -25,20 +38,30 @@ module R2UI
|
|
|
25
38
|
draw_panel(canvas, rect, panel, panel == focus)
|
|
26
39
|
end
|
|
27
40
|
end
|
|
28
|
-
draw_status(canvas, height - 1, width, focus, prompt)
|
|
41
|
+
draw_status(canvas, height - 1, width, focus, prompt, hints)
|
|
29
42
|
canvas
|
|
30
43
|
end
|
|
31
44
|
|
|
32
45
|
private
|
|
33
46
|
|
|
47
|
+
# Fixed heights (an Integer, or a callable returning one each frame) are clamped so the rows
|
|
48
|
+
# never exceed the body; rows without one share what is left.
|
|
34
49
|
def rows_layout(dashboard, body)
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
50
|
+
room = body.height
|
|
51
|
+
heights = dashboard.rows.map do |row|
|
|
52
|
+
h = row.height.respond_to?(:call) ? row.height.call : row.height
|
|
53
|
+
next nil if h.nil?
|
|
54
|
+
|
|
55
|
+
h = Integer(h).clamp(0, room)
|
|
56
|
+
room -= h
|
|
57
|
+
h
|
|
58
|
+
end
|
|
59
|
+
flexible = heights.count(&:nil?)
|
|
60
|
+
share = flexible.zero? ? 0 : room / flexible
|
|
38
61
|
y = body.y
|
|
39
62
|
dashboard.rows.each_with_index.flat_map do |row, i|
|
|
40
63
|
last = i == dashboard.rows.size - 1
|
|
41
|
-
h = last ? body.bottom - y : (
|
|
64
|
+
h = last ? body.bottom - y : (heights[i] || share)
|
|
42
65
|
rect = Rect.new(x: body.x, y:, width: body.width, height: [h, 0].max)
|
|
43
66
|
y += rect.height
|
|
44
67
|
split(rect, row.panels)
|
|
@@ -57,32 +80,64 @@ module R2UI
|
|
|
57
80
|
end
|
|
58
81
|
|
|
59
82
|
def draw_panel(canvas, rect, panel, focused)
|
|
60
|
-
|
|
61
|
-
|
|
83
|
+
@rects[panel] = rect
|
|
84
|
+
resource = panel.resource && @registry.resource(panel.resource)
|
|
85
|
+
feed = resource && @feeds.fetch(resource.name)
|
|
62
86
|
state = @states[panel]
|
|
63
|
-
|
|
64
|
-
|
|
87
|
+
inner = Widgets::Box.draw(canvas, rect, title: panel_title(panel, resource), focused:,
|
|
88
|
+
tabs: state ? state.scope_tabs : [], style: border_style(panel))
|
|
65
89
|
return if inner.empty?
|
|
66
90
|
|
|
67
|
-
if (error = feed
|
|
91
|
+
if (error = feed&.error)
|
|
68
92
|
canvas.write(inner.x, inner.y, error, :alert, max: inner.width)
|
|
69
93
|
inner = inner.take(1).last
|
|
70
94
|
end
|
|
71
95
|
|
|
72
|
-
rows = feed.rows
|
|
96
|
+
rows = feed ? feed.rows : []
|
|
73
97
|
record = rows.first
|
|
74
98
|
panel.items.each do |item|
|
|
75
99
|
break if inner.empty?
|
|
76
100
|
|
|
77
101
|
inner = case item
|
|
78
|
-
when DSL::Gauge then draw_gauge(canvas, inner, resource, record, item)
|
|
79
|
-
when DSL::Stat then draw_stats(canvas, inner, resource, record, item)
|
|
80
|
-
when DSL::Sparkline then draw_sparkline(canvas, inner, resource, feed, item)
|
|
81
|
-
when DSL::Table
|
|
102
|
+
when DSL::Gauge then draw_gauge(canvas, inner, needs(resource, panel), record, item)
|
|
103
|
+
when DSL::Stat then draw_stats(canvas, inner, needs(resource, panel), record, item)
|
|
104
|
+
when DSL::Sparkline then draw_sparkline(canvas, inner, needs(resource, panel), feed, item)
|
|
105
|
+
when DSL::Table
|
|
106
|
+
draw_table(canvas, inner, needs(resource, panel), feed, rows, panel, state, focused, item)
|
|
107
|
+
else draw_extension_item(canvas, inner, panel, item)
|
|
82
108
|
end
|
|
83
109
|
end
|
|
84
110
|
end
|
|
85
111
|
|
|
112
|
+
# `panel(..., border_style: -> { ... })`: a style for the border and title, or nil for the default.
|
|
113
|
+
def border_style(panel)
|
|
114
|
+
style = panel.options[:border_style]
|
|
115
|
+
style.respond_to?(:call) ? style.call : style
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def panel_title(panel, resource)
|
|
119
|
+
return panel.title if panel.title
|
|
120
|
+
return resource.title if resource && panel.name == resource.name
|
|
121
|
+
|
|
122
|
+
panel.name.to_s.tr("_", " ").capitalize
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def needs(resource, panel)
|
|
126
|
+
resource || raise(Error, "panel #{panel.name} has no resource for its gauge/stat/sparkline/table")
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def draw_extension_item(canvas, rect, panel, item)
|
|
130
|
+
text = @draw_item&.call(panel, item, rect.width, rect.height)
|
|
131
|
+
raise Error, "panel #{panel.name}: no extension draws #{item.class}" if text.nil?
|
|
132
|
+
|
|
133
|
+
lines = text.to_s.split("\n")
|
|
134
|
+
area, rest = rect.take(lines.size)
|
|
135
|
+
lines.first(area.height).each_with_index do |line, i|
|
|
136
|
+
canvas.write_ansi(area.x, area.y + i, line, max: area.width)
|
|
137
|
+
end
|
|
138
|
+
rest
|
|
139
|
+
end
|
|
140
|
+
|
|
86
141
|
def draw_gauge(canvas, rect, resource, record, item)
|
|
87
142
|
line, rest = rect.take(1)
|
|
88
143
|
return rest unless record
|
|
@@ -118,17 +173,27 @@ module R2UI
|
|
|
118
173
|
sort: state.sort, collapsed: state.collapsed).lines
|
|
119
174
|
lines = lines.first(item.limit) if item.limit
|
|
120
175
|
@lines[panel] = lines
|
|
176
|
+
motion = item.motion ? @motion : nil
|
|
177
|
+
state.follow_selection(lines) if motion
|
|
121
178
|
label_key = state.grouping && !state.grouping.tree? && resource.column(state.grouping.by) ? state.grouping.by : nil
|
|
122
179
|
spark = ->(line, col) { feed.history.sum(line.rows.map { |r| feed.series_id(r) }, col.key) }
|
|
123
|
-
Widgets::Table.new(resource, lines, state:, focused:, label_key:, spark:
|
|
180
|
+
Widgets::Table.new(resource, lines, state:, focused:, label_key:, spark:, motion:, motion_key: panel.name,
|
|
181
|
+
columns: table_columns(resource, panel, item))
|
|
182
|
+
.draw(canvas, rect)
|
|
124
183
|
rect.with(height: 0)
|
|
125
184
|
end
|
|
126
185
|
|
|
127
|
-
|
|
186
|
+
# `table(columns: [...])`: those index columns, in that order (nil: all of them).
|
|
187
|
+
def table_columns(resource, panel, item)
|
|
188
|
+
item.columns&.map do |key|
|
|
189
|
+
resource.column(key) || raise(Error, "panel #{panel.name}: #{resource.name} has no column #{key.inspect}")
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def draw_status(canvas, y, width, focus, prompt, hints)
|
|
128
194
|
left = prompt || @states[focus]&.status.to_s
|
|
129
195
|
canvas.fill(Rect.new(x: 0, y:, width:, height: 1), " ", :reverse)
|
|
130
196
|
canvas.write(1, y, left, :reverse, max: width - 2)
|
|
131
|
-
hints = HINTS
|
|
132
197
|
canvas.write(width - hints.length - 1, y, hints, :reverse) if left.length + hints.length + 4 <= width
|
|
133
198
|
end
|
|
134
199
|
|
data/lib/r2ui/version.rb
CHANGED
data/lib/r2ui/widgets/box.rb
CHANGED
|
@@ -6,10 +6,13 @@ module R2UI
|
|
|
6
6
|
module Box
|
|
7
7
|
module_function
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
# `style` (a palette Symbol or SGR String) colours the border and title instead of the
|
|
10
|
+
# :focus/:border and :title defaults.
|
|
11
|
+
def draw(canvas, rect, title: nil, focused: false, tabs: [], style: nil)
|
|
10
12
|
return rect if rect.width < 2 || rect.height < 2
|
|
11
13
|
|
|
12
|
-
|
|
14
|
+
title_style = style || :title
|
|
15
|
+
style ||= focused ? :focus : :border
|
|
13
16
|
right = rect.x + rect.width - 1
|
|
14
17
|
canvas.write(rect.x, rect.y, "╭#{"─" * (rect.width - 2)}╮", style)
|
|
15
18
|
(rect.height - 2).times do |i|
|
|
@@ -21,7 +24,7 @@ module R2UI
|
|
|
21
24
|
x = rect.x + 2
|
|
22
25
|
max = right - 1
|
|
23
26
|
if title
|
|
24
|
-
canvas.write(x, rect.y, " #{title} ",
|
|
27
|
+
canvas.write(x, rect.y, " #{title} ", title_style, max: max - x)
|
|
25
28
|
x += title.length + 3
|
|
26
29
|
end
|
|
27
30
|
tabs.each do |label, active|
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module R2UI
|
|
4
|
+
module Widgets
|
|
5
|
+
# Dense glyphs for charts and colour helpers. Pure functions returning Strings.
|
|
6
|
+
module Glyphs
|
|
7
|
+
BLANK = 0x2800
|
|
8
|
+
# Braille dot bits per column, from the bottom dot up.
|
|
9
|
+
LEFT = [0x40, 0x04, 0x02, 0x01].freeze
|
|
10
|
+
RIGHT = [0x80, 0x20, 0x10, 0x08].freeze
|
|
11
|
+
# The bits of the bottom `n` dots (n = 0..4) per column, and every braille cell by its bits.
|
|
12
|
+
LEFT_SUMS = (0..4).map { |n| LEFT.first(n).sum }.freeze
|
|
13
|
+
RIGHT_SUMS = (0..4).map { |n| RIGHT.first(n).sum }.freeze
|
|
14
|
+
BRAILLE = Array.new(256) { |bits| (BLANK + bits).chr(Encoding::UTF_8).freeze }.freeze
|
|
15
|
+
PARTIAL = %w[▏ ▎ ▍ ▌ ▋ ▊ ▉].freeze
|
|
16
|
+
HEAT = ["#5FAF5F", "#E5A50A", "#E5484D"].freeze
|
|
17
|
+
HEAT_STEPS = 64
|
|
18
|
+
|
|
19
|
+
# stops (frozen copy) => the 65 SGRs of `heat`, one per step; HEAT's table is kept apart so
|
|
20
|
+
# the default needs no hash lookup. Reads take no lock; a miss builds the table outside it.
|
|
21
|
+
@heat_tables = {}
|
|
22
|
+
@heat_default = nil
|
|
23
|
+
# hex => "38;2;r;g;b" / "48;2;r;g;b" (frozen), bounded.
|
|
24
|
+
@fg_cache = {}
|
|
25
|
+
@bg_cache = {}
|
|
26
|
+
SGR_CACHE_LIMIT = 4096
|
|
27
|
+
HEAT_CACHE_LIMIT = 64
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
# A 24-bit foreground SGR for `fraction` (0..1) across the colour `stops`.
|
|
31
|
+
def heat(fraction, stops: HEAT)
|
|
32
|
+
step = (fraction.to_f.clamp(0.0, 1.0) * HEAT_STEPS).round
|
|
33
|
+
table = stops.equal?(HEAT) ? (@heat_default ||= heat_table(HEAT)) : @heat_tables[stops]
|
|
34
|
+
table ||= begin
|
|
35
|
+
@heat_tables.clear if @heat_tables.size >= HEAT_CACHE_LIMIT
|
|
36
|
+
@heat_tables[stops.map { |s| s.dup.freeze }.freeze] = heat_table(stops)
|
|
37
|
+
end
|
|
38
|
+
table[step]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def fg(hex) = @fg_cache[hex] || remember(@fg_cache, hex, "38;2;#{Motion.rgb(hex).join(";")}")
|
|
42
|
+
|
|
43
|
+
def bg(hex) = @bg_cache[hex] || remember(@bg_cache, hex, "48;2;#{Motion.rgb(hex).join(";")}")
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# Dots (0..4) a braille_line sample fills.
|
|
48
|
+
def line_level(value, top, baseline)
|
|
49
|
+
if value <= 0 then baseline ? 1 : 0
|
|
50
|
+
else ((value / top) * 4).round.clamp(1, 4)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def heat_table(stops)
|
|
55
|
+
Array.new(HEAT_STEPS + 1) { |step| fg(gradient(stops, step / HEAT_STEPS.to_f)) }.freeze
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def remember(cache, key, value)
|
|
59
|
+
cache.clear if cache.size >= SGR_CACHE_LIMIT
|
|
60
|
+
cache[key] = value.freeze
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
module_function
|
|
65
|
+
|
|
66
|
+
# One line of braille, exactly `width` cells, 2 samples per cell, newest at the right.
|
|
67
|
+
# Each sample fills 1..4 dots from the bottom; zero shows the bottom dot when `baseline`.
|
|
68
|
+
# (Walks `values` in place, without building a window: it is drawn for every row, every frame.)
|
|
69
|
+
def braille_line(values, width, max: nil, baseline: true)
|
|
70
|
+
return "" if width <= 0
|
|
71
|
+
|
|
72
|
+
start = [values.size - (width * 2), 0].max
|
|
73
|
+
top = max
|
|
74
|
+
unless top
|
|
75
|
+
(start...values.size).each do |i|
|
|
76
|
+
v = values[i].to_f
|
|
77
|
+
top = v if top.nil? || v > top
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
top = top.nil? || top <= 0 ? 1.0 : top.to_f
|
|
81
|
+
pad = (width * 2) - (values.size - start) # samples missing on the left
|
|
82
|
+
out = String.new(capacity: width * 3, encoding: Encoding::UTF_8)
|
|
83
|
+
width.times do |cell|
|
|
84
|
+
left = (cell * 2) - pad
|
|
85
|
+
right = left + 1
|
|
86
|
+
bits = 0
|
|
87
|
+
bits |= LEFT_SUMS[line_level(values[start + left].to_f, top, baseline)] if left >= 0
|
|
88
|
+
bits |= RIGHT_SUMS[line_level(values[start + right].to_f, top, baseline)] if right >= 0
|
|
89
|
+
out << BRAILLE[bits]
|
|
90
|
+
end
|
|
91
|
+
out
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# A filled area chart: `height` lines of `width` braille cells, 4 * height levels tall,
|
|
95
|
+
# 2 samples per cell, newest at the right.
|
|
96
|
+
def braille_area(values, width, height, max: nil)
|
|
97
|
+
return Array.new([height, 0].max, "") if width <= 0
|
|
98
|
+
return [] if height <= 0
|
|
99
|
+
|
|
100
|
+
window = values.last(width * 2).map(&:to_f)
|
|
101
|
+
top = scale(window, max)
|
|
102
|
+
total = height * 4
|
|
103
|
+
levels = window.map { |v| v <= 0 ? 0 : ((v / top) * total).round.clamp(1, total) }
|
|
104
|
+
Array.new(height) do |row|
|
|
105
|
+
from_bottom = height - 1 - row
|
|
106
|
+
cells(levels, width) { |level, dots| dots.first((level - (from_bottom * 4)).clamp(0, 4)).sum }
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# A horizontal bar exactly `width` chars: full blocks, one eighth-precision partial, spaces.
|
|
111
|
+
def bar(fraction, width)
|
|
112
|
+
return "" if width <= 0
|
|
113
|
+
|
|
114
|
+
eighths = (fraction.to_f.clamp(0.0, 1.0) * width * 8).round
|
|
115
|
+
full, rest = eighths.divmod(8)
|
|
116
|
+
text = "█" * full
|
|
117
|
+
text += PARTIAL[rest - 1] if rest.positive?
|
|
118
|
+
text.ljust(width)[0, width]
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def paint(text, sgr) = sgr ? "\e[#{sgr}m#{text}\e[0m" : text
|
|
122
|
+
|
|
123
|
+
def gradient(stops, t)
|
|
124
|
+
return stops.first if stops.size == 1
|
|
125
|
+
|
|
126
|
+
pos = t * (stops.size - 1)
|
|
127
|
+
i = [pos.floor, stops.size - 2].min
|
|
128
|
+
Motion.mix_hex(stops[i], stops[i + 1], pos - i)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def scale(window, max)
|
|
132
|
+
top = max || window.max
|
|
133
|
+
top.nil? || top <= 0 ? 1.0 : top.to_f
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Right-aligns the levels into `width` cells (2 per cell, blank padding) and builds each cell
|
|
137
|
+
# from the dots the block picks for each half.
|
|
138
|
+
def cells(levels, width)
|
|
139
|
+
padded = Array.new((width * 2) - levels.size) + levels
|
|
140
|
+
padded.each_slice(2).map do |left, right|
|
|
141
|
+
bits = 0
|
|
142
|
+
bits |= yield(left, LEFT) if left
|
|
143
|
+
bits |= yield(right, RIGHT) if right
|
|
144
|
+
(BLANK + bits).chr(Encoding::UTF_8)
|
|
145
|
+
end.join
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
data/lib/r2ui/widgets/table.rb
CHANGED
|
@@ -3,38 +3,83 @@
|
|
|
3
3
|
module R2UI
|
|
4
4
|
module Widgets
|
|
5
5
|
# The index table: header, sort marker, grouped/tree labels, inline sparklines, selection.
|
|
6
|
+
# With a `motion`, a 1-cell gutter on the left marks lines that moved (▴/▾) or are new (•).
|
|
6
7
|
class Table
|
|
7
8
|
SPARK_WIDTH = 10
|
|
9
|
+
BRAILLE_WIDTH = 5
|
|
8
10
|
MIN_TEXT = 8
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
11
|
+
MARKS = { up: "▴", down: "▾", new: "•" }.freeze
|
|
12
|
+
MARK_SECONDS = { up: 1.5, down: 1.5, new: 2.0 }.freeze
|
|
13
|
+
MARK_FROM = "#D97757"
|
|
14
|
+
MARK_TO = "#555555"
|
|
15
|
+
|
|
16
|
+
# `spark` is called with (line, column) and returns that line's recent values. `motion` (an
|
|
17
|
+
# R2UI::Motion) turns on the gutter marks; `motion_key` tells tables sharing a Motion apart.
|
|
18
|
+
# `columns` (Column objects) draws only those, in that order; default all of the resource's.
|
|
19
|
+
def initialize(resource, lines, state:, focused:, label_key: nil, spark: nil, motion: nil, motion_key: nil,
|
|
20
|
+
columns: nil)
|
|
12
21
|
@resource = resource
|
|
22
|
+
@columns = columns || resource.columns
|
|
13
23
|
@lines = lines
|
|
14
24
|
@state = state
|
|
15
25
|
@focused = focused
|
|
16
26
|
@spark = spark
|
|
17
|
-
@
|
|
27
|
+
@motion = motion
|
|
28
|
+
@motion_key = motion_key || state.object_id
|
|
29
|
+
label_key = nil if label_key && @columns.none? { |c| c.key == label_key }
|
|
30
|
+
@label_key = label_key || @columns.find { |c| !c.numeric? && c.format != :id }&.key
|
|
18
31
|
end
|
|
19
32
|
|
|
20
33
|
def draw(canvas, rect)
|
|
21
34
|
return if rect.empty?
|
|
22
35
|
|
|
23
|
-
|
|
24
|
-
|
|
36
|
+
gutter = @motion ? 1 : 0
|
|
37
|
+
marks = @motion ? gutter_marks : {}
|
|
38
|
+
cols = rect.with(x: rect.x + gutter, width: rect.width - gutter)
|
|
39
|
+
layout = widths(cols.width)
|
|
40
|
+
draw_header(canvas, cols, layout)
|
|
25
41
|
body = rect.with(y: rect.y + 1, height: rect.height - 1)
|
|
26
42
|
@state.scroll_to_selection(body.height, @lines.size)
|
|
27
|
-
@
|
|
28
|
-
|
|
43
|
+
offset = @state.offset
|
|
44
|
+
last = [@lines.size, offset + body.height].min
|
|
45
|
+
(offset...last).each do |index|
|
|
46
|
+
line = @lines[index]
|
|
47
|
+
i = index - offset
|
|
29
48
|
selected = @focused && index == @state.selected
|
|
30
49
|
canvas.fill(body.with(y: body.y + i, height: 1), " ", :selected) if selected
|
|
31
|
-
draw_line(canvas,
|
|
50
|
+
draw_line(canvas, cols.x, body.y + i, line, layout, selected)
|
|
51
|
+
mark = marks[line.id]
|
|
52
|
+
canvas.write(rect.x, body.y + i, mark[0], mark[1]) if mark
|
|
32
53
|
end
|
|
33
54
|
draw_footer(canvas, rect) if @lines.size > body.height
|
|
34
55
|
end
|
|
35
56
|
|
|
36
57
|
private
|
|
37
58
|
|
|
59
|
+
# {line id => [glyph, sgr]} for lines whose move/new mark is still fading. Keeps the motion
|
|
60
|
+
# active while any mark shows.
|
|
61
|
+
def gutter_marks
|
|
62
|
+
@state.track_positions(@lines)
|
|
63
|
+
hold = 0.0
|
|
64
|
+
marks = {}
|
|
65
|
+
@lines.each_with_index do |line, i|
|
|
66
|
+
elapsed = @motion.age([:r2ui_table, @motion_key, line.id], i)
|
|
67
|
+
kind = @state.marks[line.id]
|
|
68
|
+
next unless kind
|
|
69
|
+
|
|
70
|
+
left = MARK_SECONDS[kind] - elapsed
|
|
71
|
+
if left <= 0 || !@motion.enabled
|
|
72
|
+
@state.marks.delete(line.id)
|
|
73
|
+
next
|
|
74
|
+
end
|
|
75
|
+
hold = left if left > hold
|
|
76
|
+
colour = Motion.mix_hex(MARK_FROM, MARK_TO, elapsed / MARK_SECONDS[kind])
|
|
77
|
+
marks[line.id] = [MARKS[kind], Glyphs.fg(colour)]
|
|
78
|
+
end
|
|
79
|
+
@motion.hold(hold) if hold.positive?
|
|
80
|
+
marks
|
|
81
|
+
end
|
|
82
|
+
|
|
38
83
|
def draw_header(canvas, rect, layout)
|
|
39
84
|
sort_key, sort_dir = @state.sort
|
|
40
85
|
x = rect.x
|
|
@@ -48,35 +93,70 @@ module R2UI
|
|
|
48
93
|
|
|
49
94
|
def draw_line(canvas, x, y, line, layout, selected)
|
|
50
95
|
layout.each do |col, w|
|
|
51
|
-
|
|
52
|
-
canvas.write(x, y, text, selected ? :selected : style, max: w)
|
|
96
|
+
draw_cell(canvas, x, y, line, col, w, selected)
|
|
53
97
|
x += w + 1
|
|
54
98
|
end
|
|
55
99
|
end
|
|
56
100
|
|
|
57
|
-
|
|
101
|
+
# Draws one cell: one segment, or the sparkline, a space and the number for spark columns.
|
|
102
|
+
def draw_cell(canvas, x, y, line, col, width, selected)
|
|
103
|
+
stop = x + width
|
|
58
104
|
if col.key == @label_key && (line.label || line.depth.positive? || line.expandable?)
|
|
59
105
|
prefix, body, suffix = label_parts(line, col)
|
|
60
106
|
body = fit(body, [width - prefix.length - suffix.length, 1].max, Format.truncate_from(col.format))
|
|
61
|
-
|
|
107
|
+
text = fit("#{prefix}#{body}#{suffix}", width, :right)
|
|
108
|
+
return segment(canvas, x, stop, y, text, selected ? :selected : (line.label ? :bold : :plain))
|
|
62
109
|
end
|
|
63
110
|
|
|
64
111
|
value = line.values[col.key]
|
|
65
112
|
value = "×#{line.count}" if col.aggregate == :count && line.label
|
|
66
113
|
text = value.is_a?(String) && col.aggregate == :count ? value : col.render(value)
|
|
67
|
-
if col.sparkline && @spark
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
114
|
+
return draw_spark(canvas, x, stop, y, line, col, width, value, text, selected) if col.sparkline && @spark
|
|
115
|
+
|
|
116
|
+
text = align(fit(text, width, Format.truncate_from(col.format)), width, col.align)
|
|
117
|
+
style = custom_style(col, value, line) || style_for(col, value)
|
|
118
|
+
segment(canvas, x, stop, y, text, selected ? :selected : style)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Writes `text` at `at` if the cell (ending before `stop`) has room left; the next x, or nil.
|
|
122
|
+
def segment(canvas, at, stop, y, text, style)
|
|
123
|
+
left = stop - at
|
|
124
|
+
return if left <= 0
|
|
125
|
+
|
|
126
|
+
canvas.write(at, y, text, style, max: left)
|
|
127
|
+
at + text.length
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# The sparkline and the number, each in its own style.
|
|
131
|
+
def draw_spark(canvas, x, stop, y, line, col, width, value, text, selected)
|
|
132
|
+
values = @spark.call(line, col)
|
|
133
|
+
spark_w = spark_width(col)
|
|
134
|
+
if col.sparkline == :braille
|
|
135
|
+
# Fewer than 2 samples have no shape yet: baseline dots only, never a full column.
|
|
136
|
+
chart = Glyphs.braille_line(values.size < 2 ? values.map { 0 } : values, spark_w, max: col.spark_max)
|
|
137
|
+
default = :accent
|
|
138
|
+
else
|
|
139
|
+
chart = Sparkline.line(values, spark_w, max: col.spark_max)
|
|
140
|
+
chart = chart.rjust(spark_w) if chart.length < spark_w
|
|
141
|
+
default = :plain
|
|
71
142
|
end
|
|
72
|
-
|
|
143
|
+
spark_style = col.spark_style.respond_to?(:call) ? col.spark_style.call(values, line) : col.spark_style
|
|
144
|
+
text = align(text, width - spark_w - 1, col.align)
|
|
145
|
+
text_style = custom_style(col, value, line) || :plain
|
|
146
|
+
at = segment(canvas, x, stop, y, chart, selected ? :selected : (spark_style || default))
|
|
147
|
+
at &&= segment(canvas, at, stop, y, " ", selected ? :selected : :plain)
|
|
148
|
+
segment(canvas, at, stop, y, text, selected ? :selected : text_style) if at
|
|
73
149
|
end
|
|
74
150
|
|
|
151
|
+
def custom_style(col, value, line) = col.style&.call(value, line)
|
|
152
|
+
|
|
153
|
+
def spark_width(col) = col.spark_width || (col.sparkline == :braille ? BRAILLE_WIDTH : SPARK_WIDTH)
|
|
154
|
+
|
|
75
155
|
# [indent and tree marker, the value (truncated to fit), count suffix]
|
|
76
156
|
def label_parts(line, col)
|
|
77
157
|
if line.label
|
|
78
158
|
body = Format.call(col.format, line.label)
|
|
79
|
-
counted = @
|
|
159
|
+
counted = @columns.any? { |c| c.aggregate == :count }
|
|
80
160
|
["", body.empty? ? "(none)" : body, counted ? "" : " ×#{line.count}"]
|
|
81
161
|
else
|
|
82
162
|
marker = if line.expandable? then line.collapsed ? "▸ " : "▾ " else " " end
|
|
@@ -93,13 +173,13 @@ module R2UI
|
|
|
93
173
|
end
|
|
94
174
|
end
|
|
95
175
|
|
|
96
|
-
# Fixed-width columns get their width; text columns share the rest. Drops columns that don't
|
|
176
|
+
# Fixed-width columns get their width; text columns share the rest. Drops columns that don't
|
|
177
|
+
# fit: the lowest priority first, the last declared among equals.
|
|
97
178
|
def widths(total)
|
|
98
|
-
|
|
99
|
-
fixed = cols.to_h do |c|
|
|
179
|
+
fixed = @columns.to_h do |c|
|
|
100
180
|
w = c.width || Format.default_width(c.format)
|
|
101
181
|
w &&= [w, c.label.length + 1].max
|
|
102
|
-
w +=
|
|
182
|
+
w += spark_width(c) + 1 if w && c.sparkline
|
|
103
183
|
[c, w]
|
|
104
184
|
end
|
|
105
185
|
loop do
|
|
@@ -109,7 +189,7 @@ module R2UI
|
|
|
109
189
|
break if flexible.zero? ? spare >= 0 : spare >= flexible * MIN_TEXT
|
|
110
190
|
break if fixed.size == 1
|
|
111
191
|
|
|
112
|
-
fixed.delete(fixed.keys.
|
|
192
|
+
fixed.delete(fixed.keys.reverse.min_by { |c| c.priority || 0 })
|
|
113
193
|
end
|
|
114
194
|
flexible = fixed.count { |_, w| w.nil? }
|
|
115
195
|
spare = total - fixed.values.compact.sum - (fixed.size - 1)
|
|
@@ -128,7 +208,11 @@ module R2UI
|
|
|
128
208
|
from == :left ? "…#{text[-(width - 1)..]}" : "#{text[0, width - 1]}…"
|
|
129
209
|
end
|
|
130
210
|
|
|
131
|
-
def align(text, width, side)
|
|
211
|
+
def align(text, width, side)
|
|
212
|
+
return text if text.length >= width
|
|
213
|
+
|
|
214
|
+
side == :right ? text.rjust(width) : text.ljust(width)
|
|
215
|
+
end
|
|
132
216
|
end
|
|
133
217
|
end
|
|
134
218
|
end
|
data/lib/r2ui.rb
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# r2ui runs on its own pure-Ruby Bubbletea, loaded by path. This doesn't touch $LOAD_PATH:
|
|
4
|
+
# `require "bubbletea"` still means whatever it meant; `require "r2ui/drop_in"` opts into r2ui's.
|
|
5
|
+
require_relative "r2ui/compat/bubbletea"
|
|
6
|
+
|
|
3
7
|
require_relative "r2ui/version"
|
|
4
8
|
require_relative "r2ui/value"
|
|
5
9
|
require_relative "r2ui/format"
|
|
@@ -11,6 +15,8 @@ require_relative "r2ui/registry"
|
|
|
11
15
|
require_relative "r2ui/search"
|
|
12
16
|
require_relative "r2ui/query"
|
|
13
17
|
require_relative "r2ui/canvas"
|
|
18
|
+
require_relative "r2ui/motion"
|
|
19
|
+
require_relative "r2ui/widgets/glyphs"
|
|
14
20
|
require_relative "r2ui/widgets/box"
|
|
15
21
|
require_relative "r2ui/widgets/table"
|
|
16
22
|
require_relative "r2ui/widgets/gauge"
|
|
@@ -20,7 +26,9 @@ require_relative "r2ui/panel_state"
|
|
|
20
26
|
require_relative "r2ui/feed"
|
|
21
27
|
require_relative "r2ui/renderer"
|
|
22
28
|
require_relative "r2ui/keys"
|
|
23
|
-
require_relative "r2ui/
|
|
29
|
+
require_relative "r2ui/context"
|
|
30
|
+
require_relative "r2ui/extension"
|
|
31
|
+
require_relative "r2ui/component"
|
|
24
32
|
require_relative "r2ui/app"
|
|
25
33
|
|
|
26
34
|
# R2UI: declare terminal dashboards the way ActiveAdmin declares admin pages.
|
|
@@ -51,3 +59,6 @@ module R2UI
|
|
|
51
59
|
def reset! = @registry = Registry.new
|
|
52
60
|
end
|
|
53
61
|
end
|
|
62
|
+
|
|
63
|
+
# Capabilities: one self-registering file each (docs/dsl.md).
|
|
64
|
+
R2UI::Extensions.load_all
|