raindown 0.6.0 → 0.7.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/lib/raindown/chart/chart.rb +12 -1
  3. data/lib/raindown/document.rb +1 -5
  4. data/lib/raindown/endpoint/curl.rb +11 -7
  5. data/lib/raindown/node_blocks.rb +9 -6
  6. data/lib/raindown/nodes.rb +1 -0
  7. data/lib/raindown/stat.rb +53 -0
  8. data/lib/raindown/tui/events.rb +16 -1
  9. data/lib/raindown/tui/feelings/batch.rb +47 -0
  10. data/lib/raindown/tui/feelings/bins.rb +67 -0
  11. data/lib/raindown/tui/feelings/celebration.rb +25 -0
  12. data/lib/raindown/tui/feelings/code_box.rb +129 -0
  13. data/lib/raindown/tui/feelings/code_methods.rb +134 -0
  14. data/lib/raindown/tui/feelings/feelings_log.rb +133 -0
  15. data/lib/raindown/tui/feelings/feelings_renderer.rb +184 -0
  16. data/lib/raindown/tui/feelings/feelings_view.rb +161 -0
  17. data/lib/raindown/tui/feelings/fireworks.rb +75 -0
  18. data/lib/raindown/tui/feelings/rain.rb +66 -0
  19. data/lib/raindown/tui/feelings/scene.rb +37 -0
  20. data/lib/raindown/tui/feelings/waffle.rb +43 -0
  21. data/lib/raindown/tui/feelings/weekly_batch.rb +32 -0
  22. data/lib/raindown/tui/file_tree.rb +6 -5
  23. data/lib/raindown/tui/highlighter.rb +45 -0
  24. data/lib/raindown/tui/layout.rb +14 -7
  25. data/lib/raindown/tui/menu_overlay.rb +1 -1
  26. data/lib/raindown/tui/navigator/commands.rb +14 -1
  27. data/lib/raindown/tui/navigator/navigator.rb +42 -9
  28. data/lib/raindown/tui/navigator/search.rb +126 -0
  29. data/lib/raindown/tui/navigator/views.rb +6 -3
  30. data/lib/raindown/tui/renderers/dashboard_renderer.rb +4 -4
  31. data/lib/raindown/tui/renderers/endpoint_renderer.rb +14 -10
  32. data/lib/raindown/tui/renderers/outline_renderer.rb +4 -4
  33. data/lib/raindown/tui/renderers/renderer.rb +3 -0
  34. data/lib/raindown/tui/row.rb +4 -2
  35. data/lib/raindown/tui/rows.rb +2 -1
  36. data/lib/raindown/tui/search_overlay.rb +73 -0
  37. data/lib/raindown/tui/terminal/input.rb +42 -5
  38. data/lib/raindown/tui/terminal/screen.rb +12 -9
  39. data/lib/raindown/tui/terminal/styles.rb +78 -20
  40. data/lib/raindown/tui/text.rb +27 -0
  41. data/lib/raindown/tui/title.rb +1 -1
  42. data/lib/raindown/tui/views/dashboard_view.rb +6 -4
  43. data/lib/raindown/tui/views/endpoint_view.rb +2 -0
  44. data/lib/raindown/tui/views/list_view.rb +2 -0
  45. data/lib/raindown/tui/views/outline_view.rb +11 -2
  46. data/lib/raindown/tui/workers/project.rb +6 -6
  47. data/lib/raindown/tui.rb +2 -0
  48. data/lib/version.rb +1 -1
  49. metadata +33 -2
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'csv'
4
+ require 'date'
5
+ require 'fileutils'
6
+ require 'open3'
7
+
8
+ module Raindown
9
+ module TUI
10
+ # How a method felt: one of Severance's four tempers, woe, frolic, dread or malice, or numb, with an optional note.
11
+ Feeling = Data.define(:method, :temper, :note) do
12
+ # What Claude Code is asked, to fix the method.
13
+ def prompt
14
+ "Refactor #{method.label} in #{method.path}:#{method.line}. #{felt} " \
15
+ "Keep its behaviour, follow the project's CLAUDE.md if it has one, and run the specs."
16
+ end
17
+
18
+ def felt
19
+ return 'It needs a second look.' unless temper
20
+
21
+ "It felt like #{temper}#{": #{note}" if note}."
22
+ end
23
+ end
24
+
25
+ # The feelings everyone's sorted about the code as it is now, kept in .raindown/feelings.csv with the commit they were
26
+ # felt at, which a node can count, like the README's code debt. A feeling about code that's since changed, or gone, moves to
27
+ # .raindown/memories.csv, so the feelings stay few, and the history's kept.
28
+ class FeelingsLog
29
+ TEMPERS = %w[frolic hope numb woe dread malice].freeze
30
+ HEADER = %i[when where method temper note commit checksum].freeze
31
+ def initialize(root:)
32
+ @root = root
33
+ @csv = File.join(root, '.raindown', 'feelings.csv')
34
+ @memories = File.join(root, '.raindown', 'memories.csv')
35
+ end
36
+
37
+ # Adds the feeling, as the newest, in place of one about the method before, which moves to the memories if it was
38
+ # about the method's code before it changed.
39
+ def add(feeling)
40
+ method = feeling.method
41
+ row = {
42
+ when: Date.today.iso8601,
43
+ where: where(method),
44
+ method: method.label,
45
+ temper: feeling.temper,
46
+ note: feeling.note,
47
+ commit:,
48
+ checksum: method.checksum
49
+ }
50
+ changed, others = rows.reject { felt_row?(it, method) }.partition { about?(it, method) }
51
+ append_memories(changed)
52
+ write_rows(others + [row])
53
+ end
54
+
55
+ # Moves the feelings about methods that have changed, or gone, since they were felt, from the methods there are
56
+ # now, to the memories. A row from before checksums were kept stays while its method's there.
57
+ def move_stale_to_memories(methods)
58
+ now = methods.flat_map { [[relative(it.path), it.label, it.checksum], [relative(it.path), it.label, '']] }.to_set
59
+ stale, current = rows.partition { !now.include?(key(it)) }
60
+ return if stale.empty?
61
+
62
+ append_memories(stale)
63
+ write_rows(current)
64
+ end
65
+
66
+ # Read once, and again after a feeling's added.
67
+ def rows
68
+ return [] unless File.exist?(@csv)
69
+
70
+ @rows ||= CSV.read(@csv, headers: true, header_converters: :symbol).map(&:to_h)
71
+ end
72
+
73
+ # Whether the method's been felt, by its file, name and code's checksum, wherever its line is now. A row from before
74
+ # checksums were kept has none, so it counts until the method's felt again.
75
+ def felt?(method)
76
+ @felt ||= rows.to_set { key(it) }
77
+ [method.checksum, ''].any? { @felt.include?([relative(method.path), method.label, it]) }
78
+ end
79
+
80
+ # How the method last felt, while its code's the same as when it was felt, or nil.
81
+ def temper(method) = rows.reverse.find { felt_row?(it, method) }&.fetch(:temper)
82
+
83
+ # How many feelings there are of each temper, in the bins' order.
84
+ def counts
85
+ tally = rows.map { it[:temper] }.tally
86
+ TEMPERS.to_h { [it, tally.fetch(it, 0)] }
87
+ end
88
+
89
+ private
90
+
91
+ def felt_row?(row, method) = about?(row, method) && [method.checksum, ''].include?(row[:checksum].to_s)
92
+
93
+ def about?(row, method) = path(row) == relative(method.path) && row[:method] == method.label
94
+
95
+ # The row's file, without the line the method was on.
96
+ def path(row) = row[:where].sub(/:\d+\z/, '')
97
+
98
+ # The row's file, method and checksum, which is empty for a row from before checksums were kept.
99
+ def key(row) = [path(row), row[:method], row[:checksum].to_s]
100
+
101
+ # Adds the rows to the memories, of feelings about code that's since changed or gone.
102
+ def append_memories(rows)
103
+ return if rows.empty?
104
+
105
+ header = !File.exist?(@memories)
106
+ CSV.open(@memories, 'a') do |csv|
107
+ csv << HEADER if header
108
+ rows.each { csv << it.values_at(*HEADER) }
109
+ end
110
+ end
111
+
112
+ def write_rows(rows)
113
+ FileUtils.mkdir_p(File.dirname(@csv))
114
+ CSV.open(@csv, 'w') do |csv|
115
+ csv << HEADER
116
+ rows.each { csv << it.values_at(*HEADER) }
117
+ end
118
+ @rows = @felt = nil
119
+ end
120
+
121
+ def where(method) = "#{relative(method.path)}:#{method.line}"
122
+
123
+ def relative(path) = path.delete_prefix(File.join(@root, ''))
124
+
125
+ def commit
126
+ sha, status = Open3.capture2('git', '-C', @root, 'rev-parse', '--short', 'HEAD', err: File::NULL)
127
+ sha.strip if status.success?
128
+ rescue SystemCallError
129
+ nil
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zlib'
4
+
5
+ require_relative '../../stat'
6
+ require_relative '../renderers/renderer'
7
+ require_relative 'bins'
8
+ require_relative 'code_box'
9
+ require_relative 'scene'
10
+ require_relative 'waffle'
11
+
12
+ module Raindown
13
+ module TUI
14
+ # Code feelings, right of the files: the methods as clusters of digits in a grid, with the selected one highlighted
15
+ # and its code below, then why it felt that way while that's typed, then the six bins and how many each holds.
16
+ # When the batch is refined, it rains over them, then there's a waffle:
17
+ # 0 0 2 2 2 5 5 9 9 9 9 1
18
+ # 1 3 3 7 7 7 6 4 4 0 8 8
19
+ # ╭─ lib/text.rb:62 · Text#segments_between ──────────── changed 6× ─╮
20
+ # │ def segments_between(line, from, to) │
21
+ # ╰───────────────────────────────────────────────────────────────────╯
22
+ # ╭─ 01 ────╮ ╭─ 02 ────╮ ╭─ 03 ────╮ ╭─ 04 ────╮ ╭─ 05 ────╮ ╭─ 06 ────╮
23
+ # │ FROLIC │ │ HOPE │ │ NUMB │ │ WOE │ │ DREAD │ │ MALICE │
24
+ # │ ███░ 9 │ │ ██░░ 5 │ │ ████ 41 │ │ ██░░ 6 │ │ █░░░ 3 │ │ █░░░ 2 │
25
+ # ╰─────────╯ ╰─────────╯ ╰─────────╯ ╰─────────╯ ╰─────────╯ ╰─────────╯
26
+ class FeelingsRenderer < Renderer
27
+ include Bins
28
+ include CodeBox
29
+ include Waffle
30
+
31
+ # The columns and lines a cell takes, its digit small on the first line, with room around it for the digit magnified
32
+ # in a stat's block digits, over the line above and the spaces beside it, when its method's selected or the mouse is
33
+ # over it.
34
+ CELL = 6
35
+ CELL_LINES = 2
36
+ # The code box's lines of code that a batch is sized for.
37
+ FILLED_CODE_LINES = 1
38
+ # The drift from which a method's cells flicker, and the ticks, half a second each, between a cell's flickers.
39
+ RESTLESS = 7
40
+ FLICKER_TICKS = 12
41
+ # How far a cell's digit is from its method's drift, by its roll out of NOISE_ROLL, so about three in ten are off.
42
+ NOISE = { 0 => -1, 1 => 1, 2 => -1, 3 => 1, 4 => -2, 5 => 2 }.freeze
43
+ NOISE_ROLL = 20
44
+ BUTTONS = [['Tab', 'Pane', :tab], ['1-6', 'Feel', nil], ['e', 'Edit', :edit], ['x', 'Fix', :execute],
45
+ ['/', 'Search', :search], ['q', 'Quit', :quit]].freeze
46
+
47
+ def buttons = BUTTONS
48
+ def page = 'Code feelings'
49
+
50
+ # How much of the batch has been refined, on the right of the line above the panes.
51
+ def spacer
52
+ progress = "#{view.batch.progress} "
53
+ progress_width = display_width(progress)
54
+ padded([segment('', :blank, width: [width - progress_width, 0].max), segment(progress, :body, width: progress_width)])
55
+ end
56
+
57
+ # The cells come first, as the first draw picks the batch to fill them.
58
+ def lines
59
+ cells = view.cells(columns, filled_rows)
60
+ return instead if view.code_methods.empty? || view.celebration
61
+
62
+ below = [*code_box, *bins]
63
+ (grid(cells, below.length) + below).first(height)
64
+ end
65
+
66
+ private
67
+
68
+ def view = navigator.view
69
+
70
+ # Instead of the grid, that there's nothing to feel, or rain and fireworks over the pane when the batch is refined,
71
+ # then a waffle.
72
+ def instead
73
+ return blank(" No functions or methods in #{File.basename(navigator.root)} to feel") if view.code_methods.empty?
74
+ return Scene.new(width:, height:).lines(view.celebration.frame) if view.animating?
75
+
76
+ waffle
77
+ end
78
+
79
+ # The rows of the grid around the selected method's, each its cells' lines, below a blank line that leaves the first
80
+ # row room to be magnified, above the lines below it, and blank lines for any left over.
81
+ def grid(cells, below)
82
+ lines = [height - below, 0].max
83
+ rows = [(lines - 1) / CELL_LINES, 0].max
84
+ first = first_row(cells, columns, rows)
85
+ grid = magnified([padded([]), *grid_rows(cells, first, rows)], cells, first, rows)
86
+
87
+ [*grid, *Array.new(lines - grid.length) { padded([]) }].first(lines)
88
+ end
89
+
90
+ # The cells' lines of the rows from the first.
91
+ def grid_rows(cells, first, rows)
92
+ Array.new(rows * CELL_LINES) { grid_line(cells, (first + (it / CELL_LINES)) * columns, columns, it % CELL_LINES) }
93
+ end
94
+
95
+ # The lines with the digits of the selected method, and the one the mouse is over, magnified, over the line above them
96
+ # and beside them, each across its whole cell and the line above it.
97
+ def magnified(lines, cells, first, rows)
98
+ magnified_cells(cells, first, rows).each do |index, click|
99
+ magnify_cell(lines, ((index / columns) - first) * CELL_LINES, cells[index], index, click)
100
+ end
101
+ lines
102
+ end
103
+
104
+ # The cell's digit magnified over the lines from the top one, across the whole cell.
105
+ def magnify_cell(lines, top, owner, index, click)
106
+ style = magnified_style(owner)
107
+ Stat::DIGITS[digit(owner, index).to_s].each_with_index do |text, line|
108
+ magnify(lines, top + line, segment(" #{text} ", style, width: CELL, event: click), 1 + (index % columns * CELL))
109
+ end
110
+ end
111
+
112
+ # The selected method's cells, across the whole of each, on the selection's blue, so they join into one block.
113
+ def magnified_style(owner)
114
+ return :selected if owner == view.cursor
115
+
116
+ :feelings
117
+ end
118
+
119
+ # The cells of the rows from the first that are the selected method's, or the one the mouse is over's, with its click,
120
+ # unless they're blank.
121
+ def magnified_cells(cells, first, rows)
122
+ (first * columns...(first + rows) * columns).filter_map do |index|
123
+ owner = cells[index]
124
+ click = Click.new(pane:, index: owner, action: :focus)
125
+ [index, click] if owner && (owner == view.cursor || navigator.hovered == click) && !hidden?(owner)
126
+ end
127
+ end
128
+
129
+ def magnify(lines, line, segment, column)
130
+ lines[line] = overlay_segments(lines[line], [segment], column:)
131
+ end
132
+
133
+ def columns = [(width - 1) / CELL, 1].max
134
+
135
+ # The grid's rows with a code box of FILLED_CODE_LINES, between its top and bottom, which a batch fills. A taller box
136
+ # takes rows from the grid, which then scrolls to keep the selected method in view.
137
+ def filled_rows = [(height - BIN_LINES - 3 - FILLED_CODE_LINES) / CELL_LINES, 1].max
138
+
139
+ # The first row that shows, with the selected method's row in the middle where there's room.
140
+ def first_row(cells, columns, rows)
141
+ last = [((cells.length - 1) / columns) - rows + 1, 0].max
142
+ ((cells.index(view.cursor).to_i / columns) - (rows / 2)).clamp(0, last)
143
+ end
144
+
145
+ # The cells' line of the row from the index.
146
+ def grid_line(cells, from, columns, line)
147
+ segments = (from...(from + columns)).filter_map do |index|
148
+ owner = cells[index]
149
+ next unless owner
150
+
151
+ click = Click.new(pane:, index: owner, action: :focus)
152
+ segment(cell_text(owner, index, line), :feelings, width: CELL, event: click)
153
+ end
154
+ padded([segment('', :blank, width: 1), *segments])
155
+ end
156
+
157
+ # The cell's line: its digit small on the first, or blank once its method's felt, but still there to select, which
158
+ # shows it again with its code.
159
+ def cell_text(owner, index, line)
160
+ return ' ' * CELL if hidden?(owner) || line.nonzero?
161
+
162
+ " #{digit(owner, index)} "
163
+ end
164
+
165
+ # Whether the method's felt, and not selected, so its cells are blank.
166
+ def hidden?(owner) = owner != view.cursor && view.batch.felt?(view.code_methods[owner])
167
+
168
+ # Near the method's drift, from 0 to 9: a cell's checksum rolls a small chance of it being one or two away.
169
+ # A heavy method's cells also flicker one lower now and then, each at its own time, so they look restless.
170
+ def digit(owner, index)
171
+ drift = view.code_methods[owner].drift
172
+ seed = Zlib.crc32("#{owner}:#{index}")
173
+ digit = (drift + NOISE.fetch(seed % NOISE_ROLL, 0)).clamp(0, 9)
174
+ return digit if drift < RESTLESS || ((view.tick + seed) % FLICKER_TICKS).nonzero?
175
+
176
+ digit - 1
177
+ end
178
+
179
+ def blank(message)
180
+ [[segment(message, :feelings)], *Array.new([height - 1, 0].max) { [segment('', :blank)] }].first(height)
181
+ end
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../events'
4
+ require_relative 'celebration'
5
+ require_relative 'code_methods'
6
+ require_relative 'feelings_log'
7
+ require_relative 'weekly_batch'
8
+
9
+ module Raindown
10
+ module TUI
11
+ # Code feelings, like Severance's macrodata refinement: the project's functions and methods, in any language,
12
+ # as clusters of digits in a grid, where long methods, and ones whose files keep changing, drift. The cursor moves
13
+ # between them, the selected one's code shows below the grid, its first lines or with Enter all that fits, and 1 to 6
14
+ # sort how it feels into a bin: frolic, hope, numb, woe, dread or malice, which notes it down in the FeelingsLog,
15
+ # staying on it. Woe, dread and malice ask why first, typed until Enter, or Esc to leave it out. E opens the method in the
16
+ # editor, and X asks Claude Code to fix it, and either way it's read again after, to be felt again.
17
+ class FeelingsView
18
+ # Best to worst, as CodeHealth scores them, so the keys rate the code.
19
+ BINS = { bin1: 'frolic', bin2: 'hope', bin3: 'numb', bin4: 'woe', bin5: 'dread', bin6: 'malice' }.freeze
20
+ # The tempers that ask why.
21
+ NOTED = %w[woe dread malice].freeze
22
+ # Its own keys, besides the bins: Enter and Space show all of the selected method's code, or its first lines again.
23
+ # Ctrl+T celebrates, to try the rain and the waffle, and isn't shown with the buttons.
24
+ KEYS = %i[up down left right edit execute tick enter toggle expand celebrate].freeze
25
+
26
+ # The temper being noted, and the note typed so far, while it's asking why, whether the selected method's code
27
+ # shows all that fits, until another's selected, and the rain then the waffle, once the batch is refined.
28
+ attr_reader :noting, :note, :cursor, :tick, :log, :expanded, :celebration
29
+
30
+ def initialize(root:)
31
+ @root = root
32
+ @log = FeelingsLog.new(root:)
33
+ @last_feelings = {}
34
+ @cursor = 0
35
+ @tick = 0
36
+ @week = WeeklyBatch.new(log: @log)
37
+ end
38
+
39
+ def renderer = FeelingsRenderer
40
+ def target = self
41
+ # It shows Ruby code, which isn't written back.
42
+ def path = nil
43
+ def changed? = false
44
+ def typing? = !noting.nil?
45
+ def animating? = celebration&.raining? == true
46
+ def status(_navigator) = [:info, "This week's code to feel, #{batch.week}"]
47
+
48
+ # Reads the code again, keeping the batch, like after a method's edited or fixed, and the selected method, by its
49
+ # file and name, even when the methods around it change, moving the feelings about code that's changed, or gone, to
50
+ # the memories.
51
+ def load
52
+ was = selected
53
+ every = CodeMethods.list(@root)
54
+ @log.move_stale_to_memories(every)
55
+ @week.read(every)
56
+ self.cursor = code_methods.index { [it.path, it.label] == [was&.path, was&.label] } || @cursor
57
+ end
58
+
59
+ # The Batch of files whose methods are in the grid.
60
+ def batch = @week.batch
61
+ def code_methods = batch.code_methods
62
+
63
+ def selected = code_methods[@cursor]
64
+
65
+ def cursor=(index)
66
+ index = index.clamp(0, [code_methods.length - 1, 0].max)
67
+ @expanded = false unless index == @cursor
68
+ @cursor = index
69
+ end
70
+
71
+ # Each cell's method, by its index, in reading order, in a grid as many cells across as the columns.
72
+ # They're kept, so up and down move a row.
73
+ # The first time the grid's drawn, with its rows, right after the view opens, the batch is picked to fill them.
74
+ def cells(columns, rows = nil)
75
+ @week.fill(columns * rows) if rows
76
+ @columns = columns
77
+ @cells = code_methods.each_with_index.flat_map { |method, index| [index] * method.size }
78
+ end
79
+
80
+ # Every key while it's asking why or celebrating, and otherwise its own, but Tab to the files, or left from the
81
+ # first method.
82
+ def next_step?(key)
83
+ return true if typing? || celebration
84
+
85
+ (KEYS.include?(key) || BINS.key?(key)) && !(key == :left && @cursor.zero?)
86
+ end
87
+
88
+ def next_step(key)
89
+ return note_step(key) if typing?
90
+ return @celebration = nil if celebration&.over?(key)
91
+ return (@tick += 1).then { nil } if key == :tick
92
+ return (@celebration = Celebration.new).then { nil } if key == :celebrate
93
+ return unless selected
94
+ return feel(BINS[key]) if BINS.key?(key)
95
+
96
+ method_step(key)
97
+ end
98
+
99
+ private
100
+
101
+ # Moves between the methods, or acts on the selected one.
102
+ def method_step(key)
103
+ case key
104
+ when :left then move(-1)
105
+ when :right then move(1)
106
+ when :up then move_row(-1)
107
+ when :down then move_row(1)
108
+ when :edit then Edit.new(path: selected.path, line: selected.line)
109
+ when :execute then fix
110
+ when :enter, :toggle, :expand then (@expanded = !@expanded).then { nil }
111
+ end
112
+ end
113
+
114
+ # Asks to fix the method, with how it last felt, by its file and name, which stay the same when its lines change.
115
+ def fix = Fix.new(feeling: @last_feelings[[selected.path, selected.label]] || Feeling.new(method: selected, temper: nil, note: nil))
116
+
117
+ def move(by)
118
+ self.cursor = @cursor + by
119
+ nil
120
+ end
121
+
122
+ # To the method in the cell above or below the selected one's first cell.
123
+ def move_row(by)
124
+ cell = @cells[@cells.index(@cursor).to_i + (by * @columns)]
125
+ self.cursor = cell if cell
126
+ nil
127
+ end
128
+
129
+ # Frolic and numb are noted down straight away, while the rest ask why first.
130
+ def feel(temper)
131
+ return note_down(temper, nil) unless NOTED.include?(temper)
132
+
133
+ @noting = temper
134
+ @note = +''
135
+ nil
136
+ end
137
+
138
+ def note_step(key)
139
+ case key
140
+ when Type then @note << key.text
141
+ when :backspace then @note.chop!
142
+ when :enter then return note_down(@noting, (@note unless @note.strip.empty?))
143
+ when :escape then return note_down(@noting, nil)
144
+ end
145
+ nil
146
+ end
147
+
148
+ # Notes the feeling down, staying on the method, so it can be fixed with what was just said about it. It rains
149
+ # when that refines the batch.
150
+ def note_down(temper, note)
151
+ feeling = Feeling.new(method: selected, temper:, note:)
152
+ @last_feelings[[selected.path, selected.label]] = feeling
153
+ @noting = nil
154
+ refined = batch.refined?
155
+ @log.add(feeling)
156
+ @celebration = Celebration.new if !refined && batch.refined?
157
+ Felt.new(feeling:)
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zlib'
4
+
5
+ require_relative 'rain'
6
+
7
+ module Raindown
8
+ module TUI
9
+ # Fireworks, like over the Rain when a batch of code feelings is refined: rockets rising from the bottom, a row a
10
+ # tick, that burst in a bright colour into sparks flying out eight ways, which fade, then another rises in their
11
+ # place. Sparks fly twice as far across as up or down, as the cells are about twice as tall as they're wide:
12
+ # · · ·
13
+ # + + +
14
+ # * * *
15
+ # · + * * * + ·
16
+ # * * *
17
+ # + + +
18
+ # · · ·
19
+ # ╵
20
+ # ·
21
+ class Fireworks
22
+ # The columns for each rocket in the air at once.
23
+ COLUMNS_A_ROCKET = 20
24
+ # The sparks, a frame each, as they fly out from one in the middle and fade.
25
+ SPARKS = %w[* * * + + · ·].freeze
26
+ # The eight ways sparks fly, as columns and rows a frame.
27
+ WAYS = [[-2, -1], [0, -1], [2, -1], [-2, 0], [2, 0], [-2, 1], [0, 1], [2, 1]].freeze
28
+ # Where a rocket rises, the row it bursts on, and its colour.
29
+ Rocket = Data.define(:column, :top, :color)
30
+
31
+ attr_reader :width, :height
32
+
33
+ def initialize(width:, height:)
34
+ @width = width
35
+ @height = height
36
+ end
37
+
38
+ # Draws the rockets and bursts at the tick into a Scene's cells, each rocket a tick apart from the others by a
39
+ # checksum of it.
40
+ def draw(cells, tick)
41
+ [width / COLUMNS_A_ROCKET, 1].max.times do |rocket|
42
+ launch, frame = (tick + Zlib.crc32("rocket #{rocket}")).divmod(height + SPARKS.length)
43
+ firework(cells, rocket(Zlib.crc32("rocket #{rocket} #{launch}")), frame)
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ # A rocket, from the seed, a checksum of it and how many it's launched, bursting in the top half or so.
50
+ def rocket(seed)
51
+ Rocket.new(column: seed % width, top: (height / 5) + ((seed >> 8) % [height / 3, 1].max),
52
+ color: Rain::COLORS[(seed >> 16) % Rain::COLORS.length])
53
+ end
54
+
55
+ # The rocket rising, with a spark trailing it, or once it's as high as it goes, bursting.
56
+ def firework(cells, rocket, frame)
57
+ rising = height - 1 - rocket.top
58
+ return burst(cells, rocket, frame - rising) if frame >= rising
59
+
60
+ put(cells, rocket.column, height - 1 - frame, '╵', rocket.color)
61
+ put(cells, rocket.column, height - frame, '·', rocket.color)
62
+ end
63
+
64
+ def burst(cells, rocket, frame)
65
+ return unless (spark = SPARKS[frame])
66
+
67
+ WAYS.each { |across, down| put(cells, rocket.column + (across * frame), rocket.top + (down * frame), spark, rocket.color) }
68
+ end
69
+
70
+ def put(cells, column, row, char, color)
71
+ cells[row][column] = [char, color] if row.between?(0, height - 1) && column.between?(0, width - 1)
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zlib'
4
+
5
+ module Raindown
6
+ module TUI
7
+ # Rain, like when a batch of code feelings is refined: drops falling down some of the columns, each at its own speed
8
+ # and in a bright colour of its own, that splash and spread when they hit the bottom, then fall again from the top
9
+ # in another colour:
10
+ # │ ╎
11
+ # ╎ │ │
12
+ # │ ╎ │
13
+ # · · ◦ . .
14
+ class Rain
15
+ # One column in RAINY has a drop.
16
+ RAINY = 4
17
+ # The splash, a frame each after a drop hits the bottom, as its distance either side of the drop's column.
18
+ SPLASH = [[0, '◦'], [1, '·'], [2, '.']].freeze
19
+ COLORS = %i[bright_red bright_orange bright_yellow bright_green bright_cyan bright_blue bright_purple bright_pink].freeze
20
+
21
+ attr_reader :width, :height
22
+
23
+ def initialize(width:, height:)
24
+ @width = width
25
+ @height = height
26
+ end
27
+
28
+ # Draws the drops at the tick into a Scene's cells.
29
+ def draw(cells, tick)
30
+ width.times { drop(cells, it, Zlib.crc32("rain #{it}"), tick) }
31
+ end
32
+
33
+ private
34
+
35
+ # The column's drop, if it has one, from the seed, a checksum of the column, like how fast it falls and when it
36
+ # starts.
37
+ def drop(cells, column, seed, tick)
38
+ return unless (seed % RAINY).zero?
39
+
40
+ falling = (height + speed(seed) - 1) / speed(seed)
41
+ fallen, frame = (tick + (seed >> 8)).divmod(falling + SPLASH.length)
42
+ color = color(column, fallen)
43
+ return splash(cells, column, frame - falling, color) if frame >= falling
44
+
45
+ fall(cells, column, frame * speed(seed), color)
46
+ end
47
+
48
+ # A row or two a tick.
49
+ def speed(seed) = ((seed >> 4) % 2) + 1
50
+
51
+ # A colour each time the column's drop falls, from a checksum of how many times it has.
52
+ def color(column, fallen) = COLORS[Zlib.crc32("#{column} #{fallen}") % COLORS.length]
53
+
54
+ # The drop at the row, and its tail above it.
55
+ def fall(cells, column, row, color)
56
+ cells[row][column] = ['│', color]
57
+ cells[row - 1][column] = ['╎', color] if row.positive?
58
+ end
59
+
60
+ def splash(cells, column, frame, color)
61
+ spread, char = SPLASH[frame]
62
+ [column - spread, column + spread].uniq.each { cells[height - 1][it] = [char, color] if it.between?(0, width - 1) }
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../text'
4
+ require_relative 'fireworks'
5
+ require_relative 'rain'
6
+
7
+ module Raindown
8
+ module TUI
9
+ # Animations drawn over each other, each into the same cells, a character and its style each, in turn, so the later
10
+ # ones are on top, like Fireworks over the Rain.
11
+ class Scene
12
+ include Text
13
+
14
+ attr_reader :width, :height
15
+
16
+ def initialize(width:, height:, animations: [Rain, Fireworks])
17
+ @width = width
18
+ @height = height
19
+ @animations = animations.map { it.new(width:, height:) }
20
+ end
21
+
22
+ # The lines at the tick, as segments.
23
+ def lines(tick)
24
+ cells = Array.new(height) { Array.new(width) { [' ', :blank] } }
25
+ @animations.each { it.draw(cells, tick) }
26
+ cells.map { segments(it) }
27
+ end
28
+
29
+ private
30
+
31
+ # The row's runs of cells in the same style, a segment each.
32
+ def segments(row)
33
+ row.chunk_while { |cell, after| cell[1] == after[1] }.map { Segment.new(text: it.map(&:first).join, style: it[0][1], event: nil) }
34
+ end
35
+ end
36
+ end
37
+ end