riffer-rig 0.1.2 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 761f180d04e8bab840732d6649617fd6f7196406f0bc54c3f849d253dacefe9a
4
- data.tar.gz: 1f352b9cc7e0f663e13580216262ddc5db9af8ffafefd0b5e0bfd9d062ad19e4
3
+ metadata.gz: f87fb650798f95679ff56654620a0fcb3cc383d8ce650849d9e33382a7837a6f
4
+ data.tar.gz: e8784d13f472d45c5ffb7f1d269c5ab8ff7d0b41a62cfae446ceaee2345413b9
5
5
  SHA512:
6
- metadata.gz: dc46ed7d22bff69600f6c51c03f77c3b70f7c82aca55487c2cb0d4f95c7818c3f37bd80f5b16112168e43354b3914ea910101c33a598ba3b29f05b036d045585
7
- data.tar.gz: 32f112b0df082dc73833effe226bf8e940d201bac032a5c3630fb53ec29b9ed38162fd881f49933a2fc9158158ceb42634508150be91ef83602fc72626743972
6
+ metadata.gz: '09252fc5c265fdc91be5fc8753b3407560c3be9f94af9616b2d3ae9e8e3c28f100374bb1014d7b8038bd51abc5f86875e6116fc874733ba58dda87254b15e3cc'
7
+ data.tar.gz: 7f1cc7dae6f6ccf69cf370296458d1eef837111f01041adae31a59e48df6719903c1c43630cf0248ba4b7ba140d420f663a585dcb13f5c945d5f105eaa2b8fc9
data/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.0](https://github.com/bottrall/riffer-rig/compare/v0.1.2...v0.2.0) (2026-09-08)
4
+
5
+
6
+ ### ⚠ BREAKING CHANGES
7
+
8
+ * remove the Riffy mascot entirely ([#151](https://github.com/bottrall/riffer-rig/issues/151))
9
+
10
+ ### Features
11
+
12
+ * remove the Riffy mascot entirely ([#151](https://github.com/bottrall/riffer-rig/issues/151)) ([af17f99](https://github.com/bottrall/riffer-rig/commit/af17f9907e8d43a01f966e1a0979f868a8d50466))
13
+ * **ui:** keep the thinking indicator alive during reasoning ([#156](https://github.com/bottrall/riffer-rig/issues/156)) ([4ff67cc](https://github.com/bottrall/riffer-rig/commit/4ff67cc6a2e133324b98dbdf40f63c500ee00a4c))
14
+ * **ui:** smooth streamed text output at 60fps ([#153](https://github.com/bottrall/riffer-rig/issues/153)) ([42dc781](https://github.com/bottrall/riffer-rig/commit/42dc781b3fc4a942e0eaca755b9e0b4180659506))
15
+
16
+
17
+ ### Bug Fixes
18
+
19
+ * **ui:** drop 'code' subtitle from banner ([#157](https://github.com/bottrall/riffer-rig/issues/157)) ([dfd2e73](https://github.com/bottrall/riffer-rig/commit/dfd2e73d6b71f10adbf1dfa2ae659aad85ed547e))
20
+
3
21
  ## [0.1.2](https://github.com/bottrall/riffer-rig/compare/v0.1.1...v0.1.2) (2026-09-07)
4
22
 
5
23
 
@@ -30,8 +30,9 @@ module Riffer::Rig::CLI
30
30
  reveal_banner(theme, animator, model)
31
31
 
32
32
  tally = Riffer::Rig::TokenTally.new(pricing: Riffer::Rig::Settings.pricing_for(model))
33
- renderer = Riffer::Rig::UI::Renderer.new(io: output, theme:, tally:)
34
- Riffer::Rig::REPL.new(agent:, renderer:, animator:, theme:, input:, output:).run
33
+ smoother = Riffer::Rig::UI::Smoother.new(io: output, theme:)
34
+ renderer = Riffer::Rig::UI::Renderer.new(io: output, theme:, tally:, smoother:)
35
+ Riffer::Rig::REPL.new(agent:, renderer:, animator:, theme:, smoother:, input:, output:).run
35
36
  0
36
37
  end
37
38
 
@@ -81,11 +82,7 @@ module Riffer::Rig::CLI
81
82
  loaded = [Riffer::Rig::CodingAgent::GLOBAL_AGENTS_FILE, File.join(Dir.pwd, 'AGENTS.md')].select { |path| File.file?(path) }
82
83
  context = loaded.empty? ? 'none' : loaded.join(', ')
83
84
 
84
- glints = Riffer::Rig::UI::Riffy::GLINT_COLS + [nil]
85
- frames = glints.map do |glint_col|
86
- Riffer::Rig::UI::Banner.lines(theme, model: model, cwd: Dir.pwd, context: context, skills: count_skills, version: Riffer::Rig::VERSION, glint_col: glint_col)
87
- end
88
- animator.reveal(frames)
85
+ animator.reveal([Riffer::Rig::UI::Banner.lines(theme, model: model, cwd: Dir.pwd, context: context, skills: count_skills, version: Riffer::Rig::VERSION)])
89
86
  end
90
87
 
91
88
  def count_skills
@@ -4,14 +4,15 @@ class Riffer::Rig::REPL
4
4
  EXIT_COMMANDS = ['/exit', '/quit'].freeze
5
5
  SKILL_COMMAND = %r{\A/skill:([a-z0-9]+(?:-[a-z0-9]+)*)(?:\s+(.*))?\z}m
6
6
 
7
- def initialize(agent:, renderer:, input: $stdin, output: $stdout, theme: Riffer::Rig::UI::Theme.for(output), animator: Riffer::Rig::UI::Animator.new(io: output, theme:))
7
+ def initialize(agent:, renderer:, input: $stdin, output: $stdout, theme: Riffer::Rig::UI::Theme.for(output), animator: Riffer::Rig::UI::Animator.new(io: output, theme:), smoother: Riffer::Rig::UI::Smoother.new(io: output, theme:))
8
8
  @agent = agent
9
9
  @renderer = renderer
10
10
  @animator = animator
11
+ @smoother = smoother
11
12
  @theme = theme
12
13
  @input = input
13
14
  @output = output
14
- @agent.session.on_message { |message| @renderer.render_tool_result(message) }
15
+ @agent.session.on_message { |message| render_tool_result(message) }
15
16
  end
16
17
 
17
18
  def run
@@ -39,16 +40,38 @@ class Riffer::Rig::REPL
39
40
  private
40
41
 
41
42
  def run_turn(prompt)
42
- @animator.start_thinking
43
+ @animator.start
44
+ @smoother.start
43
45
  @agent.stream(prompt).each do |event|
44
- @animator.stop_thinking
46
+ case event
47
+ when Riffer::StreamEvents::ReasoningDelta
48
+ @animator.start(:reasoning)
49
+ when Riffer::StreamEvents::ReasoningDone
50
+ @animator.start
51
+ when Riffer::StreamEvents::ToolCallDelta, Riffer::StreamEvents::FinishReasonDone
52
+ # Round bookkeeping: nothing renders, so the indicator just carries on
53
+ # (or stays parked while the smoother finishes typing earlier text).
54
+ next
55
+ when Riffer::StreamEvents::ToolCallDone, Riffer::StreamEvents::SkillActivation, Riffer::StreamEvents::TokenUsageDone
56
+ # The spinner shares its line with what's about to print, and tool
57
+ # execution plus the next model invocation emit no events — stop it for
58
+ # the render, then bring it straight back to cover the silent stretch.
59
+ @animator.stop
60
+ @renderer.render(event)
61
+ @animator.start
62
+ next
63
+ else
64
+ @animator.stop
65
+ end
45
66
  @renderer.render(event)
46
67
  end
68
+ @smoother.finish
47
69
  @output.puts
48
70
  rescue StandardError => e
49
71
  @output.puts("\nError: #{e.message}")
50
72
  ensure
51
- @animator.stop_thinking
73
+ @smoother.finish
74
+ @animator.stop
52
75
  end
53
76
 
54
77
  def run_skill_command(name, args)
@@ -83,4 +106,15 @@ class Riffer::Rig::REPL
83
106
  def skill_block(name, body)
84
107
  "<skill name=\"#{name}\">\n#{body}\n</skill>"
85
108
  end
109
+
110
+ # Tool results can land mid-animation (tool execution emits no stream events,
111
+ # so the indicator is up); stop it around the line so its next frame doesn't
112
+ # erase what we printed.
113
+ def render_tool_result(message)
114
+ return unless message.is_a?(Riffer::Messages::Tool)
115
+
116
+ @animator.stop
117
+ @renderer.render_tool_result(message)
118
+ @animator.start
119
+ end
86
120
  end
@@ -5,11 +5,24 @@
5
5
  class Riffer::Rig::UI::Animator
6
6
  REVEAL_FRAME_SECONDS = 0.05
7
7
  SPINNER_FRAME_SECONDS = 0.12
8
+ EQ_LEVELS = '▁▂▃▄▅▆▇█'.chars.freeze
9
+ EQ_BARS = 7
10
+ NEUTRAL_LABEL = 'riffing…'
11
+ REASONING_PHRASES = [
12
+ 'contemplating…', 'pondering…', 'mulling it over…', 'reasoning…',
13
+ 'connecting the dots…', 'herding thoughts…', 'consulting the muse…',
14
+ 'doing some deep listening…', 'warming up…', 'tuning up…',
15
+ 'in the woodshed…', 'vamping…', 'finding the key…', 'counting it off…',
16
+ 'jamming internally…'
17
+ ].freeze
18
+ REASONING_TICK_RANGE = (1..5)
8
19
 
9
20
  def initialize(io: $stdout, theme: Riffer::Rig::UI::Theme.for(io))
10
21
  @io = io
11
22
  @theme = theme
12
23
  @thread = nil
24
+ @mode = :neutral
25
+ @phrase = nil
13
26
  end
14
27
 
15
28
  # When not on a TTY, prints the final frame once instead of animating.
@@ -28,22 +41,20 @@ class Riffer::Rig::UI::Animator
28
41
  end
29
42
  end
30
43
 
31
- def start_thinking
44
+ def start(mode = :neutral)
32
45
  return unless enabled?
33
46
 
34
- @stop = false
35
- @thread = Thread.new do
36
- tick = 0
37
- until @stop
38
- @io.print("\r #{Riffer::Rig::UI::Riffy.equalizer(@theme, tick)}\e[K")
39
- @io.flush
40
- sleep(SPINNER_FRAME_SECONDS)
41
- tick += 1
42
- end
47
+ if @thread
48
+ @mode = mode
49
+ return
43
50
  end
51
+
52
+ @mode = mode
53
+ @stop = false
54
+ @thread = Thread.new { animate }
44
55
  end
45
56
 
46
- def stop_thinking
57
+ def stop
47
58
  return unless @thread
48
59
 
49
60
  @stop = true
@@ -53,8 +64,42 @@ class Riffer::Rig::UI::Animator
53
64
  @io.flush
54
65
  end
55
66
 
67
+ def equalizer(tick, label = NEUTRAL_LABEL)
68
+ bars = Array.new(EQ_BARS) do |i|
69
+ height = (Math.sin((tick + i) * 0.6).abs * (EQ_LEVELS.length - 1)).round
70
+ bar = EQ_LEVELS[height]
71
+ i.even? ? @theme.cyan(bar) : @theme.magenta(bar)
72
+ end
73
+ "#{bars.join} #{@theme.grey(label)}"
74
+ end
75
+
56
76
  private
57
77
 
78
+ def animate
79
+ tick = 0
80
+ roll_at = 0.0 # force an immediate phrase roll when entering reasoning mode
81
+ until @stop
82
+ roll_at = roll_phrase(roll_at)
83
+ @io.print("\r #{equalizer(tick, label)}\e[K")
84
+ @io.flush
85
+ sleep(SPINNER_FRAME_SECONDS)
86
+ tick += 1
87
+ end
88
+ end
89
+
90
+ # Re-rolls both phrase and duration whenever the reasoning tick expires.
91
+ def roll_phrase(roll_at)
92
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
93
+ return roll_at unless @mode == :reasoning && now >= roll_at
94
+
95
+ @phrase = REASONING_PHRASES.sample
96
+ now + rand(REASONING_TICK_RANGE)
97
+ end
98
+
99
+ def label
100
+ @mode == :reasoning ? @phrase : NEUTRAL_LABEL
101
+ end
102
+
58
103
  def enabled?
59
104
  @theme.enabled && @io.respond_to?(:tty?) && @io.tty?
60
105
  end
@@ -23,27 +23,19 @@ module Riffer::Rig::UI::Banner
23
23
 
24
24
  INFO_LABEL_WIDTH = 8
25
25
  INDENT = ' '
26
- BEAR_GAP = ' '
27
26
 
28
- def call(theme, model:, cwd:, context:, skills:, version:, glint_col: nil)
29
- lines(theme, model: model, cwd: cwd, context: context, skills: skills, version: version, glint_col: glint_col).join("\n")
27
+ def call(theme, model:, cwd:, context:, skills:, version:)
28
+ lines(theme, model: model, cwd: cwd, context: context, skills: skills, version: version).join("\n")
30
29
  end
31
30
 
32
- def lines(theme, model:, cwd:, context:, skills:, version:, glint_col: nil)
33
- [''] + art(theme, glint_col: glint_col) + [''] +
31
+ def lines(theme, model:, cwd:, context:, skills:, version:)
32
+ [''] + art(theme) + [''] +
34
33
  info(theme, model: model, cwd: cwd, context: context, skills: skills, version: version) + ['']
35
34
  end
36
35
 
37
- def art(theme, glint_col: nil)
38
- bear = Riffer::Rig::UI::Riffy.render(theme, glint_col: glint_col)
39
- right = WORDMARK.each_index.map { |i| theme.paint(WORDMARK[i], ROW_COLOURS[i]) }
40
- right += ['', "#{theme.grey('· code ·')} #{theme.cyan("♪ let's riff ♪")}"]
41
-
42
- pad = (bear.length - right.length) / 2
43
- bear.each_index.map do |i|
44
- side = right[i - pad] if (pad...(pad + right.length)).cover?(i)
45
- "#{INDENT}#{bear[i]}#{BEAR_GAP}#{side}".rstrip
46
- end
36
+ def art(theme)
37
+ art = WORDMARK.each_index.map { |i| "#{INDENT}#{theme.paint(WORDMARK[i], ROW_COLOURS[i])}" }
38
+ art + ["#{INDENT}#{theme.cyan("♪ let's riff ♪")}"]
47
39
  end
48
40
 
49
41
  def info(theme, model:, cwd:, context:, skills:, version:)
@@ -5,23 +5,28 @@ require 'json'
5
5
  class Riffer::Rig::UI::Renderer
6
6
  RESULT_PREVIEW_LIMIT = 200
7
7
 
8
- def initialize(io: $stdout, theme: Riffer::Rig::UI::Theme.for(io), tally: nil)
8
+ def initialize(io: $stdout, theme: Riffer::Rig::UI::Theme.for(io), tally: nil, smoother: nil)
9
9
  @io = io
10
10
  @theme = theme
11
11
  @tally = tally
12
+ @smoother = smoother
12
13
  end
13
14
 
14
15
  def render(event)
15
16
  case event
16
17
  when Riffer::StreamEvents::TextDelta
17
- @io.print(event.content)
18
+ smoother << event.content
18
19
  when Riffer::StreamEvents::ToolCallDone
20
+ drain_smoother
19
21
  @io.puts("\n#{@theme.cyan("⚙ #{event.name}(#{format_arguments(event.arguments)})")}")
20
22
  when Riffer::StreamEvents::SkillActivation
23
+ drain_smoother
21
24
  @io.puts("\n#{@theme.magenta("✦ skill: #{event.name}")}")
22
25
  when Riffer::StreamEvents::Interrupt
26
+ drain_smoother
23
27
  @io.puts(@theme.dim("[interrupted: #{event.reason}]"))
24
28
  when Riffer::StreamEvents::TokenUsageDone
29
+ drain_smoother
25
30
  render_token_usage(event.token_usage)
26
31
  end
27
32
  end
@@ -35,6 +40,30 @@ class Riffer::Rig::UI::Renderer
35
40
 
36
41
  private
37
42
 
43
+ def smoother
44
+ @smoother || PassThroughSmoother.new(@io)
45
+ end
46
+
47
+ # Stand-in when no smoother is injected, so a bare Renderer still prints
48
+ # synchronously.
49
+ class PassThroughSmoother
50
+ def initialize(io) = @io = io
51
+
52
+ def <<(content)
53
+ @io.print(content)
54
+ @io.flush
55
+ self
56
+ end
57
+
58
+ def drain = nil
59
+
60
+ def finish = nil
61
+ end
62
+
63
+ def drain_smoother
64
+ smoother.drain
65
+ end
66
+
38
67
  def render_token_usage(usage)
39
68
  return unless @tally
40
69
 
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Paces streamed text onto a 60fps cadence so API bursts read as continuous
4
+ # typing. On a non-TTY output it is transparent: writes pass through
5
+ # synchronously and no thread ever runs.
6
+ class Riffer::Rig::UI::Smoother
7
+ TICK_SECONDS = 1.0 / 60.0
8
+
9
+ # A frame removes only a sixtieth of the remaining backlog — an exponential
10
+ # decay whose tail keeps text flowing for a second or more, so the reveal
11
+ # never visibly stops between chunks. The carry accrues sub-character
12
+ # releases so the effective rate can settle at the provider's throughput;
13
+ # the floor is fractional (10 chars/s) so it can't over-drain a backlog
14
+ # that's trickling in slower than 60 chars/s.
15
+ BACKLOG_FRACTION_PER_TICK = Rational(1, 60)
16
+ MIN_CHARS_PER_TICK = Rational(1, 6)
17
+
18
+ def initialize(io: $stdout, theme: Riffer::Rig::UI::Theme.for(io), clock: Kernel)
19
+ @io = io
20
+ @theme = theme
21
+ @clock = clock
22
+ @backlog = +''
23
+ @carry = Rational(0)
24
+ @mutex = Mutex.new
25
+ @thread = nil
26
+ @stop = false
27
+ end
28
+
29
+ def start
30
+ return unless enabled?
31
+ return if @thread
32
+
33
+ @stop = false
34
+ @thread = Thread.new do
35
+ until @stop
36
+ tick
37
+ @clock.sleep(TICK_SECONDS)
38
+ end
39
+ end
40
+ end
41
+
42
+ def <<(content)
43
+ if enabled?
44
+ @mutex.synchronize { @backlog << content }
45
+ else
46
+ @io.print(content)
47
+ @io.flush
48
+ end
49
+ self
50
+ end
51
+
52
+ # The write sits inside the mutex so a concurrent tick can't reorder a drain.
53
+ def tick
54
+ @mutex.synchronize do
55
+ return if @backlog.empty?
56
+
57
+ @carry += [@backlog.length * BACKLOG_FRACTION_PER_TICK, MIN_CHARS_PER_TICK].max
58
+ count = @carry.floor
59
+ return if count.zero?
60
+
61
+ @carry -= count
62
+ @io.print(@backlog.slice!(0, count))
63
+ @io.flush
64
+ end
65
+ end
66
+
67
+ # Called before non-delta output so printed order matches stream order.
68
+ def drain
69
+ @mutex.synchronize do
70
+ return if @backlog.empty?
71
+
72
+ @io.print(@backlog.slice!(0, @backlog.length))
73
+ @io.flush
74
+ end
75
+ end
76
+
77
+ # Idempotent: the REPL's ensure path runs it even after a happy-path finish.
78
+ def finish
79
+ if @thread
80
+ @stop = true
81
+ @thread.join
82
+ @thread = nil
83
+ end
84
+ drain
85
+ end
86
+
87
+ private
88
+
89
+ def enabled?
90
+ @theme.enabled && @io.respond_to?(:tty?) && @io.tty?
91
+ end
92
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Riffer
4
4
  module Rig
5
- VERSION = '0.1.2'
5
+ VERSION = '0.2.0'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: riffer-rig
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jake Bottrall
@@ -105,9 +105,8 @@ files:
105
105
  - lib/riffer/rig/ui/animator.rb
106
106
  - lib/riffer/rig/ui/banner.rb
107
107
  - lib/riffer/rig/ui/palette.rb
108
- - lib/riffer/rig/ui/pixels.rb
109
108
  - lib/riffer/rig/ui/renderer.rb
110
- - lib/riffer/rig/ui/riffy.rb
109
+ - lib/riffer/rig/ui/smoother.rb
111
110
  - lib/riffer/rig/ui/theme.rb
112
111
  - lib/riffer/rig/version.rb
113
112
  homepage: https://github.com/bottrall/riffer-rig
@@ -1,47 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Each character cell stacks two vertical pixels via half-block glyphs (▀ top,
4
- # ▄ bottom): the foreground colours the top pixel and the background the bottom,
5
- # doubling vertical resolution. A disabled theme falls back to plain block
6
- # glyphs so piped/non-colour output stays escape-free.
7
- module Riffer::Rig::UI::Pixels
8
- extend self
9
-
10
- RESET = "\e[0m"
11
-
12
- def render(sprite, palette, theme)
13
- sprite.each_slice(2).map do |top, bottom|
14
- render_row(top, bottom || [], palette, theme)
15
- end
16
- end
17
-
18
- private
19
-
20
- def render_row(top, bottom, palette, theme)
21
- width = [top.length, bottom.length].max
22
- (0...width).map { |x| cell(palette[top[x]], palette[bottom[x]], theme) }.join
23
- end
24
-
25
- def cell(top_rgb, bottom_rgb, theme)
26
- return ' ' if top_rgb.nil? && bottom_rgb.nil?
27
-
28
- unless theme.enabled
29
- return '█' if top_rgb && bottom_rgb
30
- return '▀' if top_rgb
31
-
32
- return '▄'
33
- end
34
-
35
- if top_rgb && bottom_rgb
36
- "#{fg(top_rgb)}#{bg(bottom_rgb)}▀#{RESET}"
37
- elsif top_rgb
38
- "#{fg(top_rgb)}▀#{RESET}"
39
- else
40
- "#{fg(bottom_rgb)}▄#{RESET}"
41
- end
42
- end
43
-
44
- def fg(rgb) = "\e[38;2;#{rgb[0]};#{rgb[1]};#{rgb[2]}m"
45
-
46
- def bg(rgb) = "\e[48;2;#{rgb[0]};#{rgb[1]};#{rgb[2]}m"
47
- end
@@ -1,81 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # riffer-rig's mascot sprite, authored as a character grid (see LEGEND).
4
- module Riffer::Rig::UI::Riffy
5
- extend self
6
-
7
- LEGEND = {
8
- '.' => nil,
9
- 'D' => :outline, # deep purple outline / shadow / nose
10
- 'B' => :body, # purple fur
11
- 'M' => :shade, # mid purple (inner ear, jaw shadow, leg lines)
12
- 'L' => :light, # light lilac (snout, paw pads)
13
- 'K' => :shades, # near-black sunglasses
14
- 'W' => :glint # lens glint (intro only)
15
- }.freeze
16
-
17
- PALETTE = {
18
- outline: [74, 46, 122],
19
- body: [157, 92, 232],
20
- shade: [120, 66, 190],
21
- light: [212, 180, 250],
22
- shades: [12, 10, 20],
23
- glint: [240, 240, 255]
24
- }.freeze
25
-
26
- GRID = [
27
- '...DDDD......DDDD...',
28
- '..DBBBBD....DBBBBD..',
29
- '..DBMMBD....DBMMBD..',
30
- '..DBMMBD....DBMMBD..',
31
- '..DBBDDDDDDDDDDBBD..',
32
- '...DBBBBBBBBBBBBD...',
33
- '...DKKKKKKKKKKKKD...',
34
- '...DKKKKKBBKKKKKD...',
35
- '...DBKKKBBBBKKKBD...',
36
- '...DBBBBBDDBBBBBD...',
37
- '...DBBBBLLLLBBBBD...',
38
- '...DBBBMLLLLMBBBD...',
39
- '....DBBBBBBBBBBD....',
40
- '....DDMMMMMMMMDD....',
41
- '.....DBBBBBBBBD.....',
42
- '....DBBBBBBBBBBD....',
43
- '...DBBBBBBBBBBBBD...',
44
- '..DBBBBBBBBBBBBBBD..',
45
- '.DBBBBBBBBBBBBBBBBD.',
46
- '.DBBBBDBBDDBBDBBBBD.',
47
- '.DLLLDDLLDDLLDDLLLD.'
48
- ].freeze
49
-
50
- WIDTH = GRID.first.length
51
- HEIGHT = (GRID.length + 1) / 2
52
-
53
- LENS_ROWS = [6, 7, 8].freeze
54
- GLINT_COLS = [4, 5, 6, 7, 8, 11, 12, 13, 14, 15].freeze
55
-
56
- EQ_LEVELS = '▁▂▃▄▅▆▇█'.chars.freeze
57
- EQ_BARS = 7
58
-
59
- def render(theme, glint_col: nil)
60
- sprite = GRID.map { |row| row.chars.map { |char| LEGEND.fetch(char) } }
61
- apply_glint(sprite, glint_col) if glint_col
62
- Riffer::Rig::UI::Pixels.render(sprite, PALETTE, theme)
63
- end
64
-
65
- def equalizer(theme, tick)
66
- bars = Array.new(EQ_BARS) do |i|
67
- height = (Math.sin((tick + i) * 0.6).abs * (EQ_LEVELS.length - 1)).round
68
- bar = EQ_LEVELS[height]
69
- i.even? ? theme.cyan(bar) : theme.magenta(bar)
70
- end
71
- "#{bars.join} #{theme.grey('riffing…')}"
72
- end
73
-
74
- private
75
-
76
- def apply_glint(sprite, column)
77
- LENS_ROWS.each do |row|
78
- sprite[row][column] = :glint if sprite[row][column] == :shades
79
- end
80
- end
81
- end