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
|
@@ -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
|
-
#
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
"
|
|
9
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/r2ui/motion.rb
ADDED
|
@@ -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
|
data/lib/r2ui/panel_state.rb
CHANGED
|
@@ -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,
|
|
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
|
|