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
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ module R2UI
4
+ # One capability in one file. An extension registers everything it needs without editing shared
5
+ # files: DSL keywords on the builders, helpers for handler blocks, Bubbletea init/update hooks,
6
+ # panel item drawers, status-bar hints and program options. Files in lib/r2ui/ext/ load
7
+ # automatically. See docs/dsl.md and lib/r2ui/ext/every.rb (the reference extension).
8
+ #
9
+ # R2UI.extension :every do
10
+ # dsl(:dashboard) { def every(seconds, &block) = declare(:every, ...) }
11
+ # init { dashboard.declared(:every).each { |t| command(tick_for(t)) } }
12
+ # on(Tick) { |tick| ... }
13
+ # end
14
+ #
15
+ # Every hook block runs on a Context (instance_exec), so it can call the core helpers
16
+ # (state, flash, quit, command, pass, ...) and any extension's `helpers`.
17
+ class Extension
18
+ # The builder classes `dsl` can add keywords to.
19
+ TARGETS = {
20
+ dashboard: -> { DSL::Dashboard::Builder },
21
+ row: -> { DSL::Dashboard::RowBuilder },
22
+ panel: -> { DSL::Dashboard::PanelBuilder },
23
+ resource: -> { DSL::Resource::Builder }
24
+ }.freeze
25
+
26
+ Hook = Data.define(:extension, :matcher, :priority, :order, :block) do
27
+ def match?(message) = matcher.nil? || matcher === message # rubocop:disable Style/CaseEquality
28
+ end
29
+
30
+ attr_reader :name, :modules, :hooks
31
+
32
+ def initialize(name)
33
+ @name = name.to_sym
34
+ @modules = [] # [target class, module] pairs, for remove
35
+ @hooks = Hash.new { |h, k| h[k] = [] }
36
+ end
37
+
38
+ # Adds the methods defined in the block as keywords on a DSL builder (:dashboard, :row,
39
+ # :panel, :resource). Inside them, `declare(key, value)` records on the dashboard/resource
40
+ # and `item(value)` adds a panel item. A keyword that already exists is an error.
41
+ def dsl(target, &body)
42
+ builder = TARGETS.fetch(target) { raise Error, "unknown DSL target #{target.inspect}" }.call
43
+ add_module(builder, Module.new(&body))
44
+ end
45
+
46
+ # Methods available inside every hook and every user block (a Context).
47
+ def helpers(&body) = add_module(Context, Module.new(&body))
48
+
49
+ # Runs in App.new, before any frame (snapshots included): seed state here. Commands are ignored.
50
+ def setup(&block) = hook(:setup, nil, block)
51
+
52
+ # Runs once in App#init. Enqueue startup commands with `command(...)` (or return one).
53
+ def init(&block) = hook(:init, nil, block)
54
+
55
+ # Runs after init and after every update, on the same Context (its commands go out with that
56
+ # update's): e.g. re-send the window title when the value it shows has changed.
57
+ def after_update(&block) = hook(:after_update, nil, block)
58
+
59
+ # Handles messages matching `matcher` (anything with ===: a class, a proc, a regexp...).
60
+ # A matching handler consumes the message unless it calls `pass`. Higher priority runs first:
61
+ # 100 = an open modal capturing keys, 50 and up = before the focused component (which comes
62
+ # next), 0 = ordinary bindings, -100 = fallbacks. Core keys (tab, q, the table keys) run after
63
+ # every handler; ctrl+c and the core search/confirm prompts run before them. See docs/dsl.md.
64
+ def on(matcher, priority: 0, &block) = hook(:on, matcher, block, priority:)
65
+
66
+ # Sees every matching message before any `on` handler; never consumes it.
67
+ def observe(matcher = nil, &block) = hook(:observe, matcher, block)
68
+
69
+ # Draws panel items of class `klass`. The block gets the item and returns a String (ANSI
70
+ # allowed, lines split on "\n"); `width`, `height` and `panel` give the space it has. The panel
71
+ # gives it as many lines as the string has (at most `height`).
72
+ def panel_item(klass, &block) = hook(:panel_item, klass, block)
73
+
74
+ # Hosts a Bubbletea-style model for each panel item of class `klass`; the block builds it from
75
+ # the item. See R2UI::Component for what the app does with it. `focusable: true` routes keys
76
+ # to it while its panel has focus.
77
+ def component(klass, focusable: false, &build)
78
+ raise ArgumentError, "component needs a build block" unless build
79
+
80
+ hook(:component, klass, Component::Spec.new(matcher: klass, options: { focusable: }, build:))
81
+ end
82
+
83
+ # Extra status-bar hints: return [[key, label], ...] or nil.
84
+ def hints(&block) = hook(:hints, nil, block)
85
+
86
+ # Text for the left of the status bar when the core shows nothing (no prompt or flash).
87
+ def status(&block) = hook(:status, nil, block)
88
+
89
+ # Options for Bubbletea::Runner (alt_screen:, mouse_cell_motion:, bracketed_paste:, ...).
90
+ # Return a Hash; later extensions win on the same key.
91
+ def program_options(&block) = hook(:program_options, nil, block)
92
+
93
+ # Replaces the whole view (no panels, no status bar) when it returns a String; nil leaves the
94
+ # dashboard. `width` and `height` are the frame size. The first non-nil one wins.
95
+ def view_override(&block) = hook(:view_override, nil, block)
96
+
97
+ # Changes the size the app draws at: gets [width, height], returns [width, height].
98
+ def frame_size(&block) = hook(:frame_size, nil, block)
99
+
100
+ # Canvas styles: return { style_name => "SGR params" } to add or override (see Canvas::STYLES).
101
+ def styles(&block) = hook(:styles, nil, block)
102
+
103
+ # Empties its modules (they stay included, with no methods) and drops its hooks.
104
+ def remove!
105
+ @modules.each do |_target, mod|
106
+ (mod.instance_methods(false) + mod.private_instance_methods(false)).each do |m|
107
+ mod.send(:remove_method, m)
108
+ end
109
+ end
110
+ @hooks.clear
111
+ Extensions.changed!
112
+ end
113
+
114
+ private
115
+
116
+ def hook(kind, matcher, block, priority: 0)
117
+ raise ArgumentError, "#{kind} needs a block" unless block
118
+
119
+ @hooks[kind] << Hook.new(extension: name, matcher:, priority:, order: Extensions.next_order, block:)
120
+ Extensions.changed!
121
+ @hooks[kind]
122
+ end
123
+
124
+ def add_module(target, mod)
125
+ defined = mod.instance_methods(false) + mod.private_instance_methods(false)
126
+ taken = defined.select do |m|
127
+ target.method_defined?(m) || target.private_method_defined?(m)
128
+ end
129
+ raise Error, "extension #{name}: #{target} already has #{taken.join(", ")}" if taken.any?
130
+
131
+ target.include(mod)
132
+ @modules << [target, mod]
133
+ mod
134
+ end
135
+ end
136
+
137
+ # The loaded extensions, in load order.
138
+ module Extensions
139
+ @all = {}
140
+ @order = 0
141
+ @lock = Mutex.new
142
+ @sorted = {} # kind => frozen sorted hooks; replaced (not cleared) on any change
143
+
144
+ class << self
145
+ def register(name, &block)
146
+ name = name.to_sym
147
+ raise Error, "extension #{name} is already registered" if @all.key?(name)
148
+
149
+ extension = Extension.new(name)
150
+ @all[name] = extension
151
+ changed!
152
+ begin
153
+ extension.instance_eval(&block) if block
154
+ rescue StandardError
155
+ remove(name)
156
+ raise
157
+ end
158
+ extension
159
+ end
160
+
161
+ def [](name) = @all[name.to_sym]
162
+
163
+ def names = @all.keys
164
+
165
+ # Unregisters an extension and removes its keywords and helpers (for tests).
166
+ def remove(name)
167
+ removed = @all.delete(name.to_sym)
168
+ changed!
169
+ removed&.remove!
170
+ end
171
+
172
+ # Hooks of one kind across all extensions: highest priority first, then load order. Cached
173
+ # until an extension or hook is added or removed; the list is frozen (callers only read it).
174
+ def hooks(kind)
175
+ sorted = @sorted
176
+ sorted[kind] || (sorted[kind] = sort_hooks(kind))
177
+ end
178
+
179
+ # Drops the cached hook lists (an extension or hook was added or removed). A list being
180
+ # built concurrently lands in the dropped cache, never in the new one.
181
+ def changed! = @sorted = {}
182
+
183
+ def next_order = @lock.synchronize { @order += 1 }
184
+
185
+ # Loads every file in `dir` (default lib/r2ui/ext/), sorted by name.
186
+ def load_all(dir = File.expand_path("ext", __dir__))
187
+ Dir[File.join(dir, "*.rb")].sort.each { |file| require file }
188
+ end
189
+
190
+ private
191
+
192
+ def sort_hooks(kind)
193
+ @all.values.flat_map { |e| e.hooks.fetch(kind, []) }.sort_by { |h| [-h.priority, h.order] }.freeze
194
+ end
195
+ end
196
+ end
197
+
198
+ def self.extension(name, &) = Extensions.register(name, &)
199
+ end
data/lib/r2ui/history.rb CHANGED
@@ -16,8 +16,11 @@ module R2UI
16
16
 
17
17
  def [](id, key) = @series.fetch([id, key], [])
18
18
 
19
- # Sum several series aligned at their newest value (for grouped lines).
19
+ # Sum several series aligned at their newest value (for grouped lines). For one id this is that
20
+ # series itself (not a copy): callers read it and must not change it.
20
21
  def sum(ids, key)
22
+ return self[ids.first, key] if ids.size == 1
23
+
21
24
  lists = ids.map { |id| self[id, key] }.reject(&:empty?)
22
25
  return [] if lists.empty?
23
26
 
data/lib/r2ui/keys.rb CHANGED
@@ -1,18 +1,45 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module R2UI
4
- # Splits raw terminal input into key names (:up, :tab, "q", ...).
4
+ # Converts between Bubbletea key messages and the key names r2ui's core uses:
5
+ # typed text as a String ("q", "K", " ", or a whole paste), named keys as Symbols
6
+ # (:up, :page_down, :enter, :escape, :interrupt, :back_tab, ...); keys the core has no name for
7
+ # keep their Bubbletea name as a Symbol (:"ctrl+r", :f1, :delete, :"alt+x").
5
8
  module Keys
6
- SEQUENCES = {
7
- "\e[A" => :up, "\e[B" => :down, "\e[C" => :right, "\e[D" => :left,
8
- "\e[5~" => :page_up, "\e[6~" => :page_down, "\e[H" => :home, "\e[F" => :end,
9
- "\e[Z" => :back_tab, "\t" => :tab, "\r" => :enter, "\n" => :enter,
10
- "\x7F" => :backspace, "\b" => :backspace, "\x03" => :interrupt, "\e" => :escape
9
+ # Bubbletea name => core name, where they differ.
10
+ NAMES = {
11
+ "pgup" => :page_up, "pgdown" => :page_down, "shift+tab" => :back_tab, "esc" => :escape,
12
+ "ctrl+c" => :interrupt, "ctrl+h" => :backspace, "ctrl+j" => :enter, "space" => " "
11
13
  }.freeze
12
- PATTERN = Regexp.union(*SEQUENCES.keys.sort_by { -_1.length }, /./m)
14
+ # Core name => Bubbletea name, for building messages (App#press).
15
+ BUBBLETEA = NAMES.reject { |k, _| %w[ctrl+h ctrl+j].include?(k) }.to_h { |k, v| [v, k] }.freeze
13
16
 
14
17
  module_function
15
18
 
16
- def parse(input) = input.scan(PATTERN).map { |k| SEQUENCES.fetch(k, k) }
19
+ # The core name for a Bubbletea::KeyMessage.
20
+ def name(message)
21
+ return message.char if message.runes? && !message.alt
22
+
23
+ key = message.to_s
24
+ NAMES.fetch(key) { key.to_sym }
25
+ end
26
+
27
+ # A Bubbletea::KeyMessage for a core name (:up, "q", " ") or a Bubbletea name ("ctrl+r", "f1").
28
+ def message(key)
29
+ return key if key.is_a?(Bubbletea::KeyMessage)
30
+
31
+ name = BUBBLETEA.fetch(key, key.to_s)
32
+ alt = name.start_with?("alt+") && name.length > 4
33
+ base = alt ? name.delete_prefix("alt+") : name
34
+ type = key_type(base)
35
+ return Bubbletea::KeyMessage.new(key_type: type, runes: base == "space" ? [32] : [], alt:) if type
36
+
37
+ Bubbletea::KeyMessage.new(key_type: Bubbletea::KeyMessage::KEY_RUNES, runes: base.codepoints, alt:)
38
+ end
39
+
40
+ def key_type(name)
41
+ @types ||= R2UI::Compat::Tea::Input::KEY_NAMES.to_h { |type, n| [n, type] }.except("runes")
42
+ @types[name]
43
+ end
17
44
  end
18
45
  end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ module R2UI
4
+ # Time-based animation state for one app: tweens (a number glides to its new value), pulses (a
5
+ # flash that fades after an event) and ages (how long a value has been what it is). Everything is
6
+ # keyed by whatever the caller passes (a pid, [:row, id], ...), evaluated while drawing a frame.
7
+ #
8
+ # `active?` tells the app whether anything was still moving at the last evaluation, so it keeps
9
+ # drawing frames only while something animates.
10
+ class Motion
11
+ EASES = {
12
+ linear: ->(t) { t },
13
+ out_cubic: ->(t) { 1 - ((1 - t)**3) },
14
+ in_out_sine: ->(t) { -(Math.cos(Math::PI * t) - 1) / 2 },
15
+ out_back: lambda do |t|
16
+ c1 = 1.70158
17
+ 1 + ((c1 + 1) * ((t - 1)**3)) + (c1 * ((t - 1)**2))
18
+ end
19
+ }.freeze
20
+
21
+ # How long `active?` may stay true after the last evaluation if nothing evaluates again (e.g. the
22
+ # animated thing stopped being drawn).
23
+ GRACE = 1.0
24
+
25
+ attr_accessor :enabled, :clock
26
+
27
+ def initialize(clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
28
+ @clock = clock
29
+ @enabled = true
30
+ @tweens = {} # key => [from, to, start, duration, ease]
31
+ @pulses = {} # key => [fired, start, duration]
32
+ @ages = {} # key => [value, since]
33
+ @seen = {} # key => last evaluated at
34
+ @busy_until = nil
35
+ @evaluated_at = nil
36
+ end
37
+
38
+ def self.lerp(a, b, t) = a + ((b - a) * t)
39
+
40
+ # "#rrggbb" between two "#rrggbb" colours (t in 0..1, plain RGB lerp).
41
+ def self.mix_hex(from_hex, to_hex, t)
42
+ t = t.to_f.clamp(0.0, 1.0)
43
+ ar, ag, ab = rgb(from_hex)
44
+ br, bg, bb = rgb(to_hex)
45
+ format("#%02x%02x%02x", lerp(ar, br, t).round.clamp(0, 255), lerp(ag, bg, t).round.clamp(0, 255),
46
+ lerp(ab, bb, t).round.clamp(0, 255))
47
+ end
48
+
49
+ RGB_CACHE_LIMIT = 4096
50
+ @rgb_cache = {}
51
+
52
+ # [r, g, b] (frozen) for "#rrggbb" or "#rgb". Memoized (bounded).
53
+ def self.rgb(hex)
54
+ cached = @rgb_cache[hex]
55
+ return cached if cached
56
+
57
+ @rgb_cache.clear if @rgb_cache.size >= RGB_CACHE_LIMIT
58
+ @rgb_cache[hex] = parse_rgb(hex)
59
+ end
60
+
61
+ def self.parse_rgb(hex)
62
+ h = hex.to_s.delete_prefix("#")
63
+ h = h.chars.map { |c| c * 2 }.join if h.length == 3
64
+ [h[0, 2], h[2, 2], h[4, 2]].map { |p| p.to_i(16) }.freeze
65
+ end
66
+ private_class_method :parse_rgb
67
+
68
+ def now = @clock.call
69
+
70
+ # The displayed value for `key`: glides from the displayed value to `target` whenever the target
71
+ # changes. nil forgets the key; a non-numeric target is returned unchanged.
72
+ def tween(key, target, duration: 0.4, ease: :out_cubic)
73
+ if target.nil?
74
+ forget(key)
75
+ return nil
76
+ end
77
+ return target unless target.is_a?(Numeric)
78
+
79
+ t = mark(key)
80
+ state = @tweens[key]
81
+ if state.nil? || !@enabled
82
+ @tweens[key] = [target.to_f, target.to_f, t, duration, ease]
83
+ return target.to_f
84
+ end
85
+ if target.to_f != state[1]
86
+ from = tween_value(state, t)
87
+ state = @tweens[key] = [from, target.to_f, t, duration, ease]
88
+ end
89
+ tween_value(state, t)
90
+ end
91
+
92
+ # 1.0 on the frame `fire` turns truthy, easing out to 0.0 over `duration`; 0.0 otherwise.
93
+ def pulse(key, fire, duration: 1.0)
94
+ t = mark(key)
95
+ fired, start, = @pulses[key]
96
+ start = t if fire && !fired
97
+ @pulses[key] = [fire ? true : false, start, duration]
98
+ return 0.0 unless @enabled && start
99
+
100
+ progress = duration.positive? ? (t - start) / duration : 1.0
101
+ return 0.0 if progress >= 1
102
+
103
+ busy(start + duration)
104
+ 1.0 - EASES[:out_cubic].call(progress.clamp(0.0, 1.0))
105
+ end
106
+
107
+ # Seconds since `value` last changed for `key` (0.0 the first time it is seen).
108
+ def age(key, value)
109
+ t = mark(key)
110
+ old, since = @ages[key]
111
+ if since.nil? || old != value
112
+ @ages[key] = [value, t]
113
+ return 0.0
114
+ end
115
+ t - since
116
+ end
117
+
118
+ # Keeps `active?` true for `seconds` more (for marks drawn from `age` that are still fading).
119
+ def hold(seconds)
120
+ t = now
121
+ @evaluated_at = t
122
+ busy(t + seconds) if @enabled && seconds.positive?
123
+ end
124
+
125
+ # Whether a tween, pulse or hold was still in progress at the last evaluation.
126
+ def active?
127
+ return false unless @enabled && @busy_until && @evaluated_at
128
+
129
+ @evaluated_at < @busy_until && now < @busy_until + GRACE
130
+ end
131
+
132
+ # Forgets keys not evaluated for `older_than` seconds.
133
+ def sweep!(older_than: 30)
134
+ cutoff = now - older_than
135
+ stale = @seen.select { |_, at| at < cutoff }.keys
136
+ stale.each { |key| forget(key) }
137
+ stale.size
138
+ end
139
+
140
+ def forget(key)
141
+ @tweens.delete(key)
142
+ @pulses.delete(key)
143
+ @ages.delete(key)
144
+ @seen.delete(key)
145
+ end
146
+
147
+ private
148
+
149
+ def mark(key)
150
+ t = now
151
+ @seen[key] = t
152
+ @evaluated_at = t
153
+ t
154
+ end
155
+
156
+ def busy(until_time)
157
+ @busy_until = until_time if @busy_until.nil? || until_time > @busy_until
158
+ end
159
+
160
+ def tween_value(state, t)
161
+ from, to, start, duration, ease = state
162
+ progress = duration.positive? ? (t - start) / duration : 1.0
163
+ return to if progress >= 1
164
+
165
+ busy(start + duration)
166
+ Motion.lerp(from, to, EASES.fetch(ease).call(progress.clamp(0.0, 1.0)))
167
+ end
168
+ end
169
+ end
@@ -7,6 +7,9 @@ module R2UI
7
7
  class PanelState
8
8
  attr_accessor :search
9
9
  attr_reader :sort, :selected, :offset, :collapsed
10
+ # For tables with motion: the id of the selected line in the last frame, each line id's index
11
+ # in the last frame (nil before the first), and the gutter mark (:up, :down, :new) per line id.
12
+ attr_reader :selected_id, :positions, :marks
10
13
 
11
14
  def initialize(resource, table = nil)
12
15
  @resource = resource
@@ -17,6 +20,42 @@ module R2UI
17
20
  @selected = 0
18
21
  @offset = 0
19
22
  @collapsed = Set.new
23
+ @selected_id = nil
24
+ @positions = nil
25
+ @marks = {}
26
+ @column_keys = table&.columns&.select { |k| resource.column(k) }
27
+ end
28
+
29
+ # Keeps the selection on the same line when lines re-sort: if the line selected last frame is
30
+ # now at another index, the selection moves there. Then remembers the selected line's id.
31
+ def follow_selection(lines)
32
+ if @selected_id && (index = lines.index { |l| l.id == @selected_id }) && index != @selected
33
+ @selected = index
34
+ end
35
+ @selected_id = lines[@selected.clamp(0, [lines.size - 1, 0].max)]&.id
36
+ end
37
+
38
+ # Records where each line is this frame. Lines that moved since the last frame get an :up or
39
+ # :down mark, lines not there before a :new mark; the first frame (or one after the view
40
+ # changed: scope, grouping, sort, search) marks nothing. Returns the new marks ({id => kind}).
41
+ def track_positions(lines)
42
+ signature = [@scope_index, @group_index, @sort, @search]
43
+ current = {}
44
+ lines.each_with_index { |line, i| current[line.id] = i }
45
+ changes = {}
46
+ if @positions && signature == @positions_signature
47
+ current.each do |id, i|
48
+ before = @positions[id]
49
+ if before.nil? then changes[id] = :new
50
+ elsif before != i then changes[id] = i < before ? :up : :down
51
+ end
52
+ end
53
+ end
54
+ @positions = current
55
+ @positions_signature = signature
56
+ @marks.select! { |id, _| current.key?(id) }
57
+ @marks.merge!(changes)
58
+ changes
20
59
  end
21
60
 
22
61
  def scope = @scope_index && @resource.scopes[@scope_index]
@@ -41,9 +80,12 @@ module R2UI
41
80
  reset_position
42
81
  end
43
82
 
44
- # Next column; numbers sort descending first, text ascending.
83
+ # Next column (of the table's `columns:` when it picks some); numbers sort descending first,
84
+ # text ascending.
45
85
  def cycle_sort
46
- keys = @resource.columns.map(&:key)
86
+ keys = @column_keys || @resource.columns.map(&:key)
87
+ return if keys.empty?
88
+
47
89
  current = keys.index(@sort&.first)
48
90
  key = keys[current.nil? ? 0 : (current + 1) % keys.size]
49
91
  @sort = [key, @resource.column(key).numeric? ? :desc : :asc]
@@ -55,6 +97,7 @@ module R2UI
55
97
 
56
98
  def move(delta, count)
57
99
  @selected = (@selected + delta).clamp(0, [count - 1, 0].max)
100
+ @selected_id = nil
58
101
  end
59
102
 
60
103
  def toggle(line)
@@ -82,6 +125,7 @@ module R2UI
82
125
 
83
126
  def reset_position
84
127
  @selected = 0
128
+ @selected_id = nil
85
129
  @offset = 0
86
130
  end
87
131