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.
- checksums.yaml +4 -4
- data/lib/raindown/chart/chart.rb +12 -1
- data/lib/raindown/document.rb +1 -5
- data/lib/raindown/endpoint/curl.rb +11 -7
- data/lib/raindown/node_blocks.rb +9 -6
- data/lib/raindown/nodes.rb +1 -0
- data/lib/raindown/stat.rb +53 -0
- data/lib/raindown/tui/events.rb +16 -1
- data/lib/raindown/tui/feelings/batch.rb +47 -0
- data/lib/raindown/tui/feelings/bins.rb +67 -0
- data/lib/raindown/tui/feelings/celebration.rb +25 -0
- data/lib/raindown/tui/feelings/code_box.rb +129 -0
- data/lib/raindown/tui/feelings/code_methods.rb +134 -0
- data/lib/raindown/tui/feelings/feelings_log.rb +133 -0
- data/lib/raindown/tui/feelings/feelings_renderer.rb +184 -0
- data/lib/raindown/tui/feelings/feelings_view.rb +161 -0
- data/lib/raindown/tui/feelings/fireworks.rb +75 -0
- data/lib/raindown/tui/feelings/rain.rb +66 -0
- data/lib/raindown/tui/feelings/scene.rb +37 -0
- data/lib/raindown/tui/feelings/waffle.rb +43 -0
- data/lib/raindown/tui/feelings/weekly_batch.rb +32 -0
- data/lib/raindown/tui/file_tree.rb +6 -5
- data/lib/raindown/tui/highlighter.rb +45 -0
- data/lib/raindown/tui/layout.rb +14 -7
- data/lib/raindown/tui/menu_overlay.rb +1 -1
- data/lib/raindown/tui/navigator/commands.rb +14 -1
- data/lib/raindown/tui/navigator/navigator.rb +42 -9
- data/lib/raindown/tui/navigator/search.rb +126 -0
- data/lib/raindown/tui/navigator/views.rb +6 -3
- data/lib/raindown/tui/renderers/dashboard_renderer.rb +4 -4
- data/lib/raindown/tui/renderers/endpoint_renderer.rb +14 -10
- data/lib/raindown/tui/renderers/outline_renderer.rb +4 -4
- data/lib/raindown/tui/renderers/renderer.rb +3 -0
- data/lib/raindown/tui/row.rb +4 -2
- data/lib/raindown/tui/rows.rb +2 -1
- data/lib/raindown/tui/search_overlay.rb +73 -0
- data/lib/raindown/tui/terminal/input.rb +42 -5
- data/lib/raindown/tui/terminal/screen.rb +12 -9
- data/lib/raindown/tui/terminal/styles.rb +78 -20
- data/lib/raindown/tui/text.rb +27 -0
- data/lib/raindown/tui/title.rb +1 -1
- data/lib/raindown/tui/views/dashboard_view.rb +6 -4
- data/lib/raindown/tui/views/endpoint_view.rb +2 -0
- data/lib/raindown/tui/views/list_view.rb +2 -0
- data/lib/raindown/tui/views/outline_view.rb +11 -2
- data/lib/raindown/tui/workers/project.rb +6 -6
- data/lib/raindown/tui.rb +2 -0
- data/lib/version.rb +1 -1
- 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
|