r2ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module R2UI
4
+ # Column formats: how a value is shown, aligned, aggregated and parsed back from a search.
5
+ module Format
6
+ NUMERIC = %i[bytes bytes_per_sec percent number integer ratio].freeze
7
+ SUMMED = %i[bytes bytes_per_sec percent number integer].freeze
8
+ UNITS = { "" => 1, "K" => 1024, "M" => 1024**2, "G" => 1024**3, "T" => 1024**4 }.freeze
9
+
10
+ module_function
11
+
12
+ def numeric?(format) = NUMERIC.include?(format)
13
+
14
+ # What a grouped line shows for this column: :sum, :count or :distinct.
15
+ def default_aggregate(format)
16
+ return :sum if SUMMED.include?(format)
17
+ return :count if format == :id
18
+
19
+ :distinct
20
+ end
21
+
22
+ def default_align(format) = numeric?(format) || format == :id ? :right : :left
23
+
24
+ # Paths keep their tail when they don't fit.
25
+ def truncate_from(format) = format == :short_path ? :left : :right
26
+
27
+ def default_width(format)
28
+ case format
29
+ when :bytes, :bytes_per_sec then 8
30
+ when :percent then 7
31
+ when :ratio then 6
32
+ when :id, :integer, :number then 8
33
+ end
34
+ end
35
+
36
+ def call(format, value)
37
+ return "" if value.nil?
38
+
39
+ case format
40
+ when :bytes then bytes(value)
41
+ when :bytes_per_sec then "#{bytes(value)}/s"
42
+ when :percent then format("%.1f%%", value)
43
+ when :ratio then format("%.1fx", value)
44
+ when :number then value.is_a?(Float) ? format("%.1f", value) : value.to_s
45
+ when :short_path then short_path(value)
46
+ else value.to_s
47
+ end
48
+ end
49
+
50
+ def bytes(value)
51
+ value = value.to_f
52
+ unit = UNITS.keys.reverse.find { |u| value >= UNITS[u] } || ""
53
+ scaled = value / UNITS[unit]
54
+ unit.empty? ? "#{value.round}B" : format(scaled < 10 ? "%.1f%s" : "%.0f%s", scaled, unit)
55
+ end
56
+
57
+ def short_path(value)
58
+ home = Dir.home
59
+ value.to_s.start_with?(home) ? "~#{value.to_s.delete_prefix(home)}" : value.to_s
60
+ end
61
+
62
+ # Parse a search operand ("500M", "1.5G", "5") into the column's units.
63
+ def parse(format, text)
64
+ case format
65
+ when :bytes, :bytes_per_sec
66
+ m = text.strip.upcase.match(/\A([\d.]+)\s*([KMGT]?)I?B?\z/) or return nil
67
+ m[1].to_f * UNITS[m[2]]
68
+ when *NUMERIC, :id
69
+ Float(text.delete("%x"), exception: false)
70
+ else
71
+ text
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module R2UI
4
+ # Recent values per (series id, attribute), for sparklines.
5
+ class History
6
+ def initialize(capacity: 120)
7
+ @capacity = capacity
8
+ @series = Hash.new { |h, k| h[k] = [] }
9
+ end
10
+
11
+ def record(id, key, value)
12
+ list = @series[[id, key]]
13
+ list << value.to_f
14
+ list.shift while list.size > @capacity
15
+ end
16
+
17
+ def [](id, key) = @series.fetch([id, key], [])
18
+
19
+ # Sum several series aligned at their newest value (for grouped lines).
20
+ def sum(ids, key)
21
+ lists = ids.map { |id| self[id, key] }.reject(&:empty?)
22
+ return [] if lists.empty?
23
+
24
+ len = lists.map(&:size).max
25
+ Array.new(len) do |i|
26
+ lists.sum { |l| l[l.size - len + i] || 0.0 }
27
+ end
28
+ end
29
+
30
+ # Forget series whose ids are no longer present.
31
+ def prune(live_ids)
32
+ live = live_ids.to_set
33
+ @series.delete_if { |(id, _), _| !live.include?(id) }
34
+ end
35
+ end
36
+ end
data/lib/r2ui/keys.rb ADDED
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module R2UI
4
+ # Splits raw terminal input into key names (:up, :tab, "q", ...).
5
+ 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
11
+ }.freeze
12
+ PATTERN = Regexp.union(*SEQUENCES.keys.sort_by { -_1.length }, /./m)
13
+
14
+ module_function
15
+
16
+ def parse(input) = input.scan(PATTERN).map { |k| SEQUENCES.fetch(k, k) }
17
+ end
18
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module R2UI
6
+ # What the viewer has chosen for one table panel: scope, grouping, sort, search, selection.
7
+ class PanelState
8
+ attr_accessor :search
9
+ attr_reader :sort, :selected, :offset, :collapsed
10
+
11
+ def initialize(resource, table = nil)
12
+ @resource = resource
13
+ @scope_index = index_of(resource.scopes, table&.scope) || resource.scopes.index(resource.default_scope)
14
+ @group_index = index_of(resource.groupings, table&.group_by)
15
+ @sort = normalize_sort(table&.sort) || resource.default_sort
16
+ @search = ""
17
+ @selected = 0
18
+ @offset = 0
19
+ @collapsed = Set.new
20
+ end
21
+
22
+ def scope = @scope_index && @resource.scopes[@scope_index]
23
+
24
+ def grouping = @group_index && @resource.groupings[@group_index]
25
+
26
+ def scope_tabs = @resource.scopes.each_with_index.map { |s, i| [s.label, i == @scope_index] }
27
+
28
+ def cycle_scope(step)
29
+ return if @resource.scopes.empty?
30
+
31
+ @scope_index = ((@scope_index || 0) + step) % @resource.scopes.size
32
+ reset_position
33
+ end
34
+
35
+ # Off → each grouping in turn → off.
36
+ def cycle_grouping
37
+ count = @resource.groupings.size
38
+ return if count.zero?
39
+
40
+ @group_index = @group_index.nil? ? 0 : (@group_index + 1 < count ? @group_index + 1 : nil)
41
+ reset_position
42
+ end
43
+
44
+ # Next column; numbers sort descending first, text ascending.
45
+ def cycle_sort
46
+ keys = @resource.columns.map(&:key)
47
+ current = keys.index(@sort&.first)
48
+ key = keys[current.nil? ? 0 : (current + 1) % keys.size]
49
+ @sort = [key, @resource.column(key).numeric? ? :desc : :asc]
50
+ end
51
+
52
+ def reverse_sort
53
+ @sort = [@sort.first, @sort.last == :desc ? :asc : :desc] if @sort
54
+ end
55
+
56
+ def move(delta, count)
57
+ @selected = (@selected + delta).clamp(0, [count - 1, 0].max)
58
+ end
59
+
60
+ def toggle(line)
61
+ return unless line&.expandable?
62
+
63
+ @collapsed.include?(line.id) ? @collapsed.delete(line.id) : @collapsed.add(line.id)
64
+ end
65
+
66
+ def scroll_to_selection(height, count)
67
+ @selected = @selected.clamp(0, [count - 1, 0].max)
68
+ @offset = @selected if @selected < @offset
69
+ @offset = @selected - height + 1 if height.positive? && @selected >= @offset + height
70
+ @offset = @offset.clamp(0, [count - height, 0].max)
71
+ end
72
+
73
+ def status
74
+ parts = []
75
+ parts << "group: #{grouping.label}" if grouping
76
+ parts << "sort: #{@resource.column(sort.first)&.label}#{sort.last == :desc ? "▼" : "▲"}" if sort
77
+ parts << "search: #{search}" unless search.empty?
78
+ parts.join(" ")
79
+ end
80
+
81
+ private
82
+
83
+ def reset_position
84
+ @selected = 0
85
+ @offset = 0
86
+ end
87
+
88
+ def index_of(list, name) = name && list.index { |item| item.name == name }
89
+
90
+ def normalize_sort(sort)
91
+ case sort
92
+ when nil then nil
93
+ when Array then sort
94
+ else [sort, @resource.column(sort)&.numeric? ? :desc : :asc]
95
+ end
96
+ end
97
+ end
98
+ end
data/lib/r2ui/query.rb ADDED
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module R2UI
6
+ # One table line: a row, a group of rows, or a tree node with its subtree rolled up.
7
+ Line = Data.define(:id, :label, :values, :depth, :rows, :children, :collapsed) do
8
+ def count = rows.size
9
+ def expandable? = children.positive?
10
+ end
11
+
12
+ # Turns raw rows into table lines: scope → search → group or tree → sort. Pure, so it is easy to test.
13
+ class Query
14
+ def initialize(resource, rows, scope: nil, grouping: nil, search: nil, sort: nil, collapsed: Set.new)
15
+ @resource = resource
16
+ @rows = rows
17
+ @scope = scope
18
+ @grouping = grouping
19
+ @search = search
20
+ @sort_key, @sort_dir = sort || resource.default_sort
21
+ @collapsed = collapsed
22
+ end
23
+
24
+ def lines
25
+ rows = @scope ? @scope.call(@rows) : @rows
26
+ rows = Search.new(@resource, @search).call(rows)
27
+ return sort(rows.map { |row| line_for(row) }) unless @grouping
28
+
29
+ @grouping.tree? ? tree(rows) : groups(rows)
30
+ end
31
+
32
+ private
33
+
34
+ def columns = @resource.columns
35
+
36
+ def line_for(row)
37
+ Line.new(id: @resource.identify(row), label: nil, values: columns.to_h { |c| [c.key, c.read(row)] },
38
+ depth: 0, rows: [row], children: 0, collapsed: false)
39
+ end
40
+
41
+ def groups(rows)
42
+ by = @grouping.by
43
+ reader = @resource.column(by)&.method(:read) || ->(row) { Value.fetch(row, by) }
44
+ grouped = rows.group_by(&reader)
45
+ sort(grouped.map do |key, members|
46
+ Line.new(id: [:group, by, key], label: key, values: aggregate(members), depth: 0, rows: members,
47
+ children: 0, collapsed: false)
48
+ end)
49
+ end
50
+
51
+ def aggregate(rows)
52
+ columns.to_h do |c|
53
+ values = rows.map { |r| c.read(r) }
54
+ value = case c.aggregate
55
+ when :sum then values.compact.sum
56
+ when :count then rows.size
57
+ else values.uniq.size == 1 ? values.first : nil
58
+ end
59
+ [c.key, value]
60
+ end
61
+ end
62
+
63
+ # Each node's summed columns hold its whole subtree; other columns hold its own value.
64
+ def tree(rows)
65
+ by_id = rows.to_h { |r| [@resource.identify(r), r] }
66
+ kids = Hash.new { |h, k| h[k] = [] }
67
+ roots = []
68
+ rows.each do |row|
69
+ id = @resource.identify(row)
70
+ parent = Value.fetch(row, @resource.parent_key)
71
+ parent != id && by_id.key?(parent) ? kids[parent] << row : roots << row
72
+ end
73
+
74
+ subtree = {}
75
+ collect = lambda do |row, seen|
76
+ id = @resource.identify(row)
77
+ return [] unless seen.add?(id)
78
+
79
+ subtree[id] ||= [row] + kids[id].flat_map { |k| collect.call(k, seen) }
80
+ end
81
+
82
+ nodes = lambda do |siblings, depth|
83
+ lines = sort(siblings.map do |row|
84
+ id = @resource.identify(row)
85
+ members = subtree[id] || collect.call(row, Set.new)
86
+ own = line_for(row).values
87
+ values = aggregate(members).to_h { |k, v| [k, @resource.column(k).aggregate == :sum ? v : own[k]] }
88
+ Line.new(id:, label: nil, values:, depth:, rows: members, children: kids[id].size,
89
+ collapsed: @collapsed.include?(id))
90
+ end)
91
+ lines.flat_map do |line|
92
+ line.collapsed ? [line] : [line, *nodes.call(kids[line.id], depth + 1)]
93
+ end
94
+ end
95
+ nodes.call(roots, 0)
96
+ end
97
+
98
+ def sort(lines)
99
+ return lines unless @sort_key
100
+
101
+ sorted = lines.sort_by do |line|
102
+ v = line.values[@sort_key]
103
+ v.nil? ? [1, 0] : [0, v.is_a?(Numeric) ? v : v.to_s.downcase]
104
+ end
105
+ return sorted unless @sort_dir == :desc
106
+
107
+ present, missing = sorted.partition { |l| !l.values[@sort_key].nil? }
108
+ present.reverse + missing
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module R2UI
4
+ class Registry
5
+ attr_reader :resources, :dashboards
6
+
7
+ def initialize
8
+ @resources = {}
9
+ @dashboards = {}
10
+ end
11
+
12
+ def add_resource(resource) = @resources[resource.name] = resource
13
+
14
+ def add_dashboard(dashboard) = @dashboards[dashboard.name] = dashboard
15
+
16
+ def resource(name) = @resources.fetch(name.to_sym) { raise Error, "no resource named #{name}" }
17
+
18
+ # A dashboard by name, or a resource shown full screen. With no name: :main, else the first defined.
19
+ def screen(name = nil)
20
+ name = name&.to_sym
21
+ return dashboards[name] if name && dashboards.key?(name)
22
+ return DSL::Dashboard.for_resource(resource(name)) if name
23
+ return dashboards[:main] if dashboards.key?(:main)
24
+ return dashboards.values.first if dashboards.any?
25
+ raise Error, "nothing to show: define a resource or dashboard" if resources.empty?
26
+
27
+ DSL::Dashboard.for_resource(resources.values.first)
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module R2UI
4
+ # Lays out a dashboard and draws every panel into a canvas.
5
+ class Renderer
6
+ HINTS = "tab panel [ ] scope g group s/S sort / search ⏎ fold z zoom q quit"
7
+
8
+ # The lines each table panel showed in the last frame, for selection and actions.
9
+ attr_reader :lines
10
+
11
+ def initialize(registry, feeds, states)
12
+ @registry = registry
13
+ @feeds = feeds
14
+ @states = states
15
+ @lines = {}
16
+ end
17
+
18
+ def render(dashboard, width:, height:, focus:, zoomed: false, prompt: nil)
19
+ canvas = Canvas.new(width, height)
20
+ body = Rect.new(x: 0, y: 0, width:, height: height - 1)
21
+ if zoomed
22
+ draw_panel(canvas, body, focus, true)
23
+ else
24
+ rows_layout(dashboard, body).each do |rect, panel|
25
+ draw_panel(canvas, rect, panel, panel == focus)
26
+ end
27
+ end
28
+ draw_status(canvas, height - 1, width, focus, prompt)
29
+ canvas
30
+ end
31
+
32
+ private
33
+
34
+ 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
38
+ y = body.y
39
+ dashboard.rows.each_with_index.flat_map do |row, i|
40
+ last = i == dashboard.rows.size - 1
41
+ h = last ? body.bottom - y : (row.height || share)
42
+ rect = Rect.new(x: body.x, y:, width: body.width, height: [h, 0].max)
43
+ y += rect.height
44
+ split(rect, row.panels)
45
+ end
46
+ end
47
+
48
+ def split(rect, panels)
49
+ total = panels.sum(&:span)
50
+ x = rect.x
51
+ panels.each_with_index.map do |panel, i|
52
+ w = i == panels.size - 1 ? rect.x + rect.width - x : (rect.width * panel.span / total)
53
+ r = rect.with(x:, width: w)
54
+ x += w
55
+ [r, panel]
56
+ end
57
+ end
58
+
59
+ def draw_panel(canvas, rect, panel, focused)
60
+ resource = @registry.resource(panel.resource)
61
+ feed = @feeds.fetch(resource.name)
62
+ 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 : [])
65
+ return if inner.empty?
66
+
67
+ if (error = feed.error)
68
+ canvas.write(inner.x, inner.y, error, :alert, max: inner.width)
69
+ inner = inner.take(1).last
70
+ end
71
+
72
+ rows = feed.rows
73
+ record = rows.first
74
+ panel.items.each do |item|
75
+ break if inner.empty?
76
+
77
+ 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)
82
+ end
83
+ end
84
+ end
85
+
86
+ def draw_gauge(canvas, rect, resource, record, item)
87
+ line, rest = rect.take(1)
88
+ return rest unless record
89
+
90
+ column = column_for(resource, item.attr)
91
+ Widgets::Gauge.draw(canvas, line, label: item.label || column.label, value: column.read(record),
92
+ total: column_for(resource, item.of).read(record), column:)
93
+ rest
94
+ end
95
+
96
+ def draw_stats(canvas, rect, resource, record, item)
97
+ area, rest = rect.take(item.attrs.size)
98
+ return rest unless record
99
+
100
+ Widgets::Stat.draw(canvas, area, item.attrs.map { |a| column_for(resource, a).then { |c| [c, c.read(record)] } })
101
+ rest
102
+ end
103
+
104
+ def draw_sparkline(canvas, rect, resource, feed, item)
105
+ area, rest = rect.take(item.height + 1)
106
+ column = column_for(resource, item.attr)
107
+ values = feed.history[Feed::SINGLE, column.key]
108
+ label, chart = area.take(1)
109
+ canvas.write(label.x, label.y, item.label || column.label, :muted)
110
+ now = column.render(values.last)
111
+ canvas.write(label.x + label.width - now.length, label.y, now, :bold)
112
+ Widgets::Sparkline.draw(canvas, chart, values, max: column.format == :percent ? 100 : nil, style: :accent)
113
+ rest
114
+ end
115
+
116
+ def draw_table(canvas, rect, resource, feed, rows, panel, state, focused, item)
117
+ lines = Query.new(resource, rows, scope: state.scope, grouping: state.grouping, search: state.search,
118
+ sort: state.sort, collapsed: state.collapsed).lines
119
+ lines = lines.first(item.limit) if item.limit
120
+ @lines[panel] = lines
121
+ label_key = state.grouping && !state.grouping.tree? && resource.column(state.grouping.by) ? state.grouping.by : nil
122
+ 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)
124
+ rect.with(height: 0)
125
+ end
126
+
127
+ def draw_status(canvas, y, width, focus, prompt)
128
+ left = prompt || @states[focus]&.status.to_s
129
+ canvas.fill(Rect.new(x: 0, y:, width:, height: 1), " ", :reverse)
130
+ canvas.write(1, y, left, :reverse, max: width - 2)
131
+ hints = HINTS
132
+ canvas.write(width - hints.length - 1, y, hints, :reverse) if left.length + hints.length + 4 <= width
133
+ end
134
+
135
+ def column_for(resource, key)
136
+ resource.column(key) || DSL::Column.build(key)
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module R2UI
4
+ # The "/" search. Space-separated terms, all must match:
5
+ # codex free text, case-insensitive, against the resource's `filter` attributes
6
+ # cpu>5 comparison on a column, operand parsed by its format ("mem>=500M")
7
+ # cwd~starseed substring match on one column
8
+ class Search
9
+ TERM = /\A(?<field>\w+)(?<op>>=|<=|!=|>|<|=|~)(?<operand>.+)\z/
10
+
11
+ def initialize(resource, text)
12
+ @resource = resource
13
+ @predicates = text.to_s.split.map { |term| predicate(term) }
14
+ end
15
+
16
+ def call(rows) = @predicates.empty? ? rows : rows.select { |row| @predicates.all? { |p| p.call(row) } }
17
+
18
+ private
19
+
20
+ def predicate(term)
21
+ m = TERM.match(term)
22
+ column = m && @resource.column(m[:field].to_sym)
23
+ return free_text(term) unless column
24
+
25
+ compare(column, m[:op], m[:operand])
26
+ end
27
+
28
+ def free_text(term)
29
+ needle = term.downcase
30
+ keys = @resource.searchable.any? ? @resource.searchable : text_columns
31
+ readers = keys.map { |k| @resource.column(k)&.method(:read) || ->(row) { Value.fetch(row, k) } }
32
+ ->(row) { readers.any? { |read| read.call(row).to_s.downcase.include?(needle) } }
33
+ end
34
+
35
+ def compare(column, op, operand)
36
+ return ->(row) { column.read(row).to_s.downcase.include?(operand.downcase) } if op == "~"
37
+
38
+ target = Format.parse(column.format, operand)
39
+ return ->(_) { false } if target.nil?
40
+
41
+ lambda do |row|
42
+ value = column.read(row)
43
+ value = value.to_s.downcase if target.is_a?(String)
44
+ target = target.downcase if target.is_a?(String)
45
+ case op
46
+ when ">" then value > target
47
+ when "<" then value < target
48
+ when ">=" then value >= target
49
+ when "<=" then value <= target
50
+ when "=" then value == target
51
+ when "!=" then value != target
52
+ end
53
+ rescue ArgumentError, NoMethodError
54
+ false
55
+ end
56
+ end
57
+
58
+ def text_columns = @resource.columns.reject(&:numeric?).map(&:key)
59
+ end
60
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/console"
4
+
5
+ module R2UI
6
+ # Raw mode, alternate screen, line-diffed redraws. Always restores the terminal on exit.
7
+ class Terminal
8
+ def initialize(input: $stdin, output: $stdout)
9
+ @in = input
10
+ @out = output
11
+ @previous = []
12
+ end
13
+
14
+ def open
15
+ @in.raw!
16
+ @out.write("\e[?1049h\e[?25l\e[2J")
17
+ @out.flush
18
+ yield self
19
+ ensure
20
+ @out.write("\e[0m\e[?25h\e[?1049l")
21
+ @out.flush
22
+ @in.cooked!
23
+ end
24
+
25
+ def size
26
+ rows, cols = @out.winsize
27
+ [cols, rows]
28
+ end
29
+
30
+ def clear = @previous = []
31
+
32
+ def draw(canvas)
33
+ out = +""
34
+ lines = canvas.ansi_lines
35
+ lines.each_with_index do |line, y|
36
+ next if @previous[y] == line
37
+
38
+ out << "\e[#{y + 1};1H" << line
39
+ end
40
+ @previous = lines
41
+ @out.write(out)
42
+ @out.flush
43
+ end
44
+
45
+ # Key names typed within `timeout` seconds (empty if none).
46
+ def read_keys(timeout)
47
+ return [] unless @in.wait_readable(timeout)
48
+
49
+ data = @in.read_nonblock(64, exception: false)
50
+ data.is_a?(String) ? Keys.parse(data) : []
51
+ end
52
+ end
53
+ end
data/lib/r2ui/value.rb ADDED
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module R2UI
4
+ # Rows can be hashes, Data/Struct objects or ActiveRecord models; read them all the same way.
5
+ module Value
6
+ module_function
7
+
8
+ def fetch(row, key)
9
+ case row
10
+ when Hash then row.key?(key) ? row[key] : row[key.to_s]
11
+ else row.public_send(key)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module R2UI
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module R2UI
4
+ module Widgets
5
+ # A bordered panel with a title on the top edge. Returns the inner rect.
6
+ module Box
7
+ module_function
8
+
9
+ def draw(canvas, rect, title: nil, focused: false, tabs: [])
10
+ return rect if rect.width < 2 || rect.height < 2
11
+
12
+ style = focused ? :focus : :border
13
+ right = rect.x + rect.width - 1
14
+ canvas.write(rect.x, rect.y, "╭#{"─" * (rect.width - 2)}╮", style)
15
+ (rect.height - 2).times do |i|
16
+ canvas.write(rect.x, rect.y + 1 + i, "│", style)
17
+ canvas.write(right, rect.y + 1 + i, "│", style)
18
+ end
19
+ canvas.write(rect.x, rect.bottom - 1, "╰#{"─" * (rect.width - 2)}╯", style)
20
+
21
+ x = rect.x + 2
22
+ max = right - 1
23
+ if title
24
+ canvas.write(x, rect.y, " #{title} ", :title, max: max - x)
25
+ x += title.length + 3
26
+ end
27
+ tabs.each do |label, active|
28
+ break if x >= max
29
+
30
+ canvas.write(x, rect.y, active ? "[#{label}]" : " #{label} ", active ? :accent : :muted, max: max - x)
31
+ x += label.length + 2
32
+ end
33
+ rect.inner
34
+ end
35
+ end
36
+ end
37
+ end