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.
Files changed (133) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +64 -0
  3. data/README.md +79 -0
  4. data/lib/r2ui/app.rb +311 -34
  5. data/lib/r2ui/canvas.rb +135 -14
  6. data/lib/r2ui/cli/builder.rb +72 -0
  7. data/lib/r2ui/cli/context.rb +45 -0
  8. data/lib/r2ui/cli/definition.rb +174 -0
  9. data/lib/r2ui/cli/ext/ask.rb +133 -0
  10. data/lib/r2ui/cli/ext/box.rb +112 -0
  11. data/lib/r2ui/cli/ext/choose.rb +162 -0
  12. data/lib/r2ui/cli/ext/choose_many.rb +241 -0
  13. data/lib/r2ui/cli/ext/color_option.rb +64 -0
  14. data/lib/r2ui/cli/ext/completion.rb +520 -0
  15. data/lib/r2ui/cli/ext/config.rb +179 -0
  16. data/lib/r2ui/cli/ext/confirm.rb +110 -0
  17. data/lib/r2ui/cli/ext/dashboard.rb +105 -0
  18. data/lib/r2ui/cli/ext/diff.rb +195 -0
  19. data/lib/r2ui/cli/ext/editor.rb +94 -0
  20. data/lib/r2ui/cli/ext/env.rb +108 -0
  21. data/lib/r2ui/cli/ext/examples.rb +62 -0
  22. data/lib/r2ui/cli/ext/filter.rb +269 -0
  23. data/lib/r2ui/cli/ext/format.rb +104 -0
  24. data/lib/r2ui/cli/ext/list.rb +117 -0
  25. data/lib/r2ui/cli/ext/log.rb +89 -0
  26. data/lib/r2ui/cli/ext/pager.rb +97 -0
  27. data/lib/r2ui/cli/ext/pairs.rb +62 -0
  28. data/lib/r2ui/cli/ext/parallel.rb +274 -0
  29. data/lib/r2ui/cli/ext/password.rb +158 -0
  30. data/lib/r2ui/cli/ext/progress.rb +330 -0
  31. data/lib/r2ui/cli/ext/reference.rb +129 -0
  32. data/lib/r2ui/cli/ext/spinner.rb +165 -0
  33. data/lib/r2ui/cli/ext/summary.rb +63 -0
  34. data/lib/r2ui/cli/ext/table.rb +141 -0
  35. data/lib/r2ui/cli/ext/tasks.rb +216 -0
  36. data/lib/r2ui/cli/ext/trace.rb +64 -0
  37. data/lib/r2ui/cli/ext/tree.rb +114 -0
  38. data/lib/r2ui/cli/ext/version.rb +67 -0
  39. data/lib/r2ui/cli/extension.rb +129 -0
  40. data/lib/r2ui/cli/help.rb +115 -0
  41. data/lib/r2ui/cli/helpers.rb +20 -0
  42. data/lib/r2ui/cli/live.rb +150 -0
  43. data/lib/r2ui/cli/parser.rb +165 -0
  44. data/lib/r2ui/cli/prompt.rb +133 -0
  45. data/lib/r2ui/cli/runner.rb +86 -0
  46. data/lib/r2ui/cli/shell.rb +185 -0
  47. data/lib/r2ui/cli/testing.rb +49 -0
  48. data/lib/r2ui/cli.rb +129 -0
  49. data/lib/r2ui/compat/bubbletea/LICENSE-bubbletea.txt +21 -0
  50. data/lib/r2ui/compat/bubbletea/ansi.rb +424 -0
  51. data/lib/r2ui/compat/bubbletea/commands.rb +143 -0
  52. data/lib/r2ui/compat/bubbletea/input.rb +269 -0
  53. data/lib/r2ui/compat/bubbletea/input_reader.rb +66 -0
  54. data/lib/r2ui/compat/bubbletea/messages.rb +251 -0
  55. data/lib/r2ui/compat/bubbletea/model.rb +65 -0
  56. data/lib/r2ui/compat/bubbletea/native.rb +254 -0
  57. data/lib/r2ui/compat/bubbletea/renderer.rb +192 -0
  58. data/lib/r2ui/compat/bubbletea/runner.rb +425 -0
  59. data/lib/r2ui/compat/bubbletea/terminal.rb +230 -0
  60. data/lib/r2ui/compat/bubbletea/version.rb +8 -0
  61. data/lib/r2ui/compat/bubbletea/width_table.rb +228 -0
  62. data/lib/r2ui/compat/bubbletea.rb +16 -0
  63. data/lib/r2ui/compat/lipgloss/border.rb +17 -0
  64. data/lib/r2ui/compat/lipgloss/color.rb +78 -0
  65. data/lib/r2ui/compat/lipgloss/color_blend.rb +166 -0
  66. data/lib/r2ui/compat/lipgloss/colorful.rb +585 -0
  67. data/lib/r2ui/compat/lipgloss/layout.rb +461 -0
  68. data/lib/r2ui/compat/lipgloss/list.rb +85 -0
  69. data/lib/r2ui/compat/lipgloss/position.rb +30 -0
  70. data/lib/r2ui/compat/lipgloss/style/border.rb +134 -0
  71. data/lib/r2ui/compat/lipgloss/style.rb +1062 -0
  72. data/lib/r2ui/compat/lipgloss/table/render.rb +518 -0
  73. data/lib/r2ui/compat/lipgloss/table.rb +185 -0
  74. data/lib/r2ui/compat/lipgloss/termenv.rb +768 -0
  75. data/lib/r2ui/compat/lipgloss/text/unicode_tables.rb +428 -0
  76. data/lib/r2ui/compat/lipgloss/text.rb +1555 -0
  77. data/lib/r2ui/compat/lipgloss/tree/go_tree.rb +243 -0
  78. data/lib/r2ui/compat/lipgloss/tree.rb +105 -0
  79. data/lib/r2ui/compat/lipgloss/version.rb +15 -0
  80. data/lib/r2ui/compat/lipgloss.rb +64 -0
  81. data/lib/r2ui/compat/load_path/bubbletea.rb +4 -0
  82. data/lib/r2ui/compat/load_path/lipgloss.rb +4 -0
  83. data/lib/r2ui/component.rb +67 -0
  84. data/lib/r2ui/context.rb +87 -0
  85. data/lib/r2ui/drop_in.rb +16 -0
  86. data/lib/r2ui/dsl/column.rb +11 -3
  87. data/lib/r2ui/dsl/dashboard.rb +45 -9
  88. data/lib/r2ui/dsl/resource.rb +12 -0
  89. data/lib/r2ui/ext/after.rb +54 -0
  90. data/lib/r2ui/ext/async.rb +81 -0
  91. data/lib/r2ui/ext/batch_sequence.rb +54 -0
  92. data/lib/r2ui/ext/countdown.rb +52 -0
  93. data/lib/r2ui/ext/data_table.rb +71 -0
  94. data/lib/r2ui/ext/every.rb +66 -0
  95. data/lib/r2ui/ext/exec.rb +116 -0
  96. data/lib/r2ui/ext/file_picker.rb +78 -0
  97. data/lib/r2ui/ext/focus_report.rb +69 -0
  98. data/lib/r2ui/ext/help.rb +62 -0
  99. data/lib/r2ui/ext/keys.rb +64 -0
  100. data/lib/r2ui/ext/list.rb +145 -0
  101. data/lib/r2ui/ext/messages.rb +87 -0
  102. data/lib/r2ui/ext/mouse.rb +134 -0
  103. data/lib/r2ui/ext/paginator.rb +86 -0
  104. data/lib/r2ui/ext/paste.rb +86 -0
  105. data/lib/r2ui/ext/println.rb +21 -0
  106. data/lib/r2ui/ext/progress.rb +85 -0
  107. data/lib/r2ui/ext/refresh.rb +56 -0
  108. data/lib/r2ui/ext/resize.rb +62 -0
  109. data/lib/r2ui/ext/screen.rb +47 -0
  110. data/lib/r2ui/ext/screen_mode.rb +63 -0
  111. data/lib/r2ui/ext/spinner.rb +59 -0
  112. data/lib/r2ui/ext/state.rb +45 -0
  113. data/lib/r2ui/ext/stopwatch.rb +67 -0
  114. data/lib/r2ui/ext/suspend.rb +56 -0
  115. data/lib/r2ui/ext/text_area.rb +73 -0
  116. data/lib/r2ui/ext/text_input.rb +83 -0
  117. data/lib/r2ui/ext/theme.rb +112 -0
  118. data/lib/r2ui/ext/view.rb +37 -0
  119. data/lib/r2ui/ext/viewport.rb +92 -0
  120. data/lib/r2ui/ext/window_title.rb +51 -0
  121. data/lib/r2ui/extension.rb +199 -0
  122. data/lib/r2ui/history.rb +4 -1
  123. data/lib/r2ui/keys.rb +35 -8
  124. data/lib/r2ui/motion.rb +169 -0
  125. data/lib/r2ui/panel_state.rb +46 -2
  126. data/lib/r2ui/renderer.rb +87 -22
  127. data/lib/r2ui/version.rb +1 -1
  128. data/lib/r2ui/widgets/box.rb +6 -3
  129. data/lib/r2ui/widgets/glyphs.rb +149 -0
  130. data/lib/r2ui/widgets/table.rb +109 -25
  131. data/lib/r2ui.rb +12 -1
  132. metadata +119 -4
  133. 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
- HINTS = "tab panel [ ] scope g group s/S sort / search ⏎ fold z zoom q quit"
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
- def initialize(registry, feeds, states)
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
- fixed = dashboard.rows.sum { |r| r.height || 0 }
36
- flexible = dashboard.rows.count { |r| r.height.nil? }
37
- share = flexible.zero? ? 0 : (body.height - fixed) / flexible
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 : (row.height || share)
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
- resource = @registry.resource(panel.resource)
61
- feed = @feeds.fetch(resource.name)
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
- title = panel.title || (panel.name == resource.name ? resource.title : panel.name.to_s.tr("_", " ").capitalize)
64
- inner = Widgets::Box.draw(canvas, rect, title:, focused:, tabs: state ? state.scope_tabs : [])
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.error)
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 then draw_table(canvas, inner, resource, feed, rows, panel, state, focused, item)
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:).draw(canvas, rect)
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
- def draw_status(canvas, y, width, focus, prompt)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module R2UI
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -6,10 +6,13 @@ module R2UI
6
6
  module Box
7
7
  module_function
8
8
 
9
- def draw(canvas, rect, title: nil, focused: false, tabs: [])
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
- style = focused ? :focus : :border
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} ", :title, max: max - x)
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
@@ -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
- # `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)
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
- @label_key = label_key || resource.columns.find { |c| !c.numeric? && c.format != :id }&.key
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
- layout = widths(rect.width)
24
- draw_header(canvas, rect, layout)
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
- @lines.drop(@state.offset).first(body.height).each_with_index do |line, i|
28
- index = @state.offset + i
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, rect.x, body.y + i, line, layout, selected)
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
- text, style = cell(line, col, w)
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
- def cell(line, col, width)
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
- return [fit("#{prefix}#{body}#{suffix}", width, :right), line.label ? :bold : :plain]
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
- 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]
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
- [align(fit(text, width, Format.truncate_from(col.format)), width, col.align), style_for(col, value)]
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 = @resource.columns.any? { |c| c.aggregate == :count }
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 fit.
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
- cols = @resource.columns
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 += SPARK_WIDTH + 1 if w && c.sparkline
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.last)
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) = side == :right ? text.rjust(width) : text.ljust(width)
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/terminal"
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