raindown 0.6.0 → 0.8.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/nodes/endpoint_node.rb +3 -2
- data/lib/raindown/actions.rb +5 -3
- data/lib/raindown/bare_directives.rb +14 -27
- data/lib/raindown/blueprints.rb +255 -0
- data/lib/raindown/chart/chart.rb +12 -1
- data/lib/raindown/cli.rb +39 -54
- data/lib/raindown/config.rb +2 -3
- data/lib/raindown/directives.rb +30 -13
- data/lib/raindown/document.rb +22 -20
- data/lib/raindown/endpoint/curl.rb +11 -7
- data/lib/raindown/endpoint/endpoint_runner.rb +13 -5
- data/lib/raindown/node_blocks.rb +31 -22
- data/lib/raindown/node_injector.rb +7 -10
- data/lib/raindown/nodes.rb +1 -0
- data/lib/raindown/pages.rb +1 -1
- data/lib/raindown/pre_renderer.rb +18 -0
- data/lib/raindown/stat.rb +53 -0
- data/lib/raindown/tui/blueprints/blueprints_renderer.rb +93 -0
- data/lib/raindown/tui/blueprints/blueprints_view.rb +146 -0
- data/lib/raindown/tui/blueprints/canvas.rb +58 -0
- data/lib/raindown/tui/blueprints/code_comments.rb +71 -0
- data/lib/raindown/tui/blueprints/filter.rb +77 -0
- data/lib/raindown/tui/blueprints/traced_code.rb +56 -0
- data/lib/raindown/tui/blueprints/tracing.rb +129 -0
- data/lib/raindown/tui/code_box.rb +143 -0
- data/lib/raindown/tui/endpoints.rb +2 -2
- data/lib/raindown/tui/events.rb +18 -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/celebration.rb +28 -0
- data/lib/raindown/tui/feelings/celebration/fireworks.rb +75 -0
- data/lib/raindown/tui/feelings/celebration/rain.rb +66 -0
- data/lib/raindown/tui/feelings/celebration/scene.rb +40 -0
- data/lib/raindown/tui/feelings/cell_moves.rb +34 -0
- data/lib/raindown/tui/feelings/code_methods.rb +134 -0
- data/lib/raindown/tui/feelings/feelings_code_box.rb +40 -0
- data/lib/raindown/tui/feelings/feelings_log.rb +136 -0
- data/lib/raindown/tui/feelings/feelings_renderer.rb +183 -0
- data/lib/raindown/tui/feelings/feelings_view.rb +157 -0
- data/lib/raindown/tui/feelings/magnifier.rb +68 -0
- data/lib/raindown/tui/feelings/motion.rb +93 -0
- data/lib/raindown/tui/feelings/waffle/baked_waffle.rb +88 -0
- data/lib/raindown/tui/feelings/waffle/balloons.rb +81 -0
- data/lib/raindown/tui/feelings/waffle/confetti.rb +55 -0
- data/lib/raindown/tui/feelings/waffle/sparkles.rb +61 -0
- data/lib/raindown/tui/feelings/waffle/waffle.rb +62 -0
- data/lib/raindown/tui/feelings/weekly_batch.rb +51 -0
- data/lib/raindown/tui/file_tree.rb +6 -5
- data/lib/raindown/tui/highlighter.rb +45 -0
- data/lib/raindown/tui/layout.rb +32 -18
- data/lib/raindown/tui/navigator/commands.rb +46 -2
- data/lib/raindown/tui/navigator/mouse.rb +43 -0
- data/lib/raindown/tui/navigator/navigator.rb +54 -25
- data/lib/raindown/tui/navigator/teleport.rb +128 -0
- data/lib/raindown/tui/navigator/views.rb +5 -3
- data/lib/raindown/tui/{workers/project.rb → project.rb} +38 -27
- data/lib/raindown/tui/{menu_overlay.rb → project_box.rb} +2 -2
- 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 +4 -0
- data/lib/raindown/tui/row.rb +4 -2
- data/lib/raindown/tui/rows.rb +2 -1
- data/lib/raindown/tui/saves.rb +22 -0
- data/lib/raindown/tui/search_box.rb +86 -0
- data/lib/raindown/tui/terminal/input.rb +95 -7
- data/lib/raindown/tui/terminal/screen.rb +37 -29
- data/lib/raindown/tui/terminal/styles.rb +111 -26
- data/lib/raindown/tui/terminal/terminal_title.rb +31 -0
- data/lib/raindown/tui/text.rb +62 -12
- data/lib/raindown/tui/title.rb +1 -1
- data/lib/raindown/tui/views/dashboard_view.rb +7 -5
- data/lib/raindown/tui/views/endpoint_view.rb +4 -2
- data/lib/raindown/tui/views/list_view.rb +2 -0
- data/lib/raindown/tui/views/outline_view.rb +16 -6
- data/lib/raindown/tui.rb +19 -7
- data/lib/raindown/watcher/changes.rb +33 -0
- data/lib/raindown/watcher/file_watcher.rb +58 -0
- data/lib/raindown/watcher/project.rb +47 -0
- data/lib/raindown/watcher/supervisor.rb +111 -0
- data/lib/raindown/{tui/workers → workers}/worker.rb +37 -29
- data/lib/raindown/workers/workers.rb +63 -0
- data/lib/raindown.rb +4 -4
- data/lib/version.rb +1 -1
- metadata +60 -9
- data/lib/raindown/project_worker.rb +0 -48
- data/lib/raindown/supervisor.rb +0 -160
- data/lib/raindown/tui/workers/workers.rb +0 -53
- data/lib/raindown/watcher.rb +0 -57
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'csv'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'open3'
|
|
6
|
+
require 'time'
|
|
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 numb woe dread malice].freeze
|
|
30
|
+
HEADER = %i[timestamp commit checksum location method temper note].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
|
+
timestamp: Time.now.utc.iso8601,
|
|
43
|
+
commit:,
|
|
44
|
+
checksum: method.checksum,
|
|
45
|
+
location: location(method),
|
|
46
|
+
method: method.label,
|
|
47
|
+
temper: feeling.temper,
|
|
48
|
+
note: feeling.note
|
|
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.
|
|
57
|
+
def move_stale_to_memories(methods)
|
|
58
|
+
now = methods.to_set { method_key(it) }
|
|
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. The answer's kept
|
|
74
|
+
# until a feeling's added.
|
|
75
|
+
def felt?(method)
|
|
76
|
+
@felt ||= rows.to_set { key(it) }
|
|
77
|
+
@felt_methods ||= {}
|
|
78
|
+
@felt_methods.fetch(method) { @felt_methods[method] = @felt.include?(method_key(method)) }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# How the method last felt, while its code's the same as when it was felt, or nil.
|
|
82
|
+
def temper(method) = rows.reverse.find { felt_row?(it, method) }&.fetch(:temper)
|
|
83
|
+
|
|
84
|
+
# How many feelings there are of each temper, in the bins' order.
|
|
85
|
+
def counts
|
|
86
|
+
tally = rows.map { it[:temper] }.tally
|
|
87
|
+
TEMPERS.to_h { [it, tally.fetch(it, 0)] }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def felt_row?(row, method) = key(row) == method_key(method)
|
|
93
|
+
|
|
94
|
+
def about?(row, method) = path(row) == relative(method.path) && row[:method] == method.label
|
|
95
|
+
|
|
96
|
+
# The row's file, without the line the method was on.
|
|
97
|
+
def path(row) = row[:location].sub(/:\d+\z/, '')
|
|
98
|
+
|
|
99
|
+
# The row's file, method and checksum, to match a method's.
|
|
100
|
+
def key(row) = [path(row), row[:method], row[:checksum]]
|
|
101
|
+
def method_key(method) = [relative(method.path), method.label, method.checksum]
|
|
102
|
+
|
|
103
|
+
# Adds the rows to the memories, of feelings about code that's since changed or gone.
|
|
104
|
+
def append_memories(rows)
|
|
105
|
+
return if rows.empty?
|
|
106
|
+
|
|
107
|
+
header = !File.exist?(@memories)
|
|
108
|
+
CSV.open(@memories, 'a') do |csv|
|
|
109
|
+
csv << HEADER if header
|
|
110
|
+
rows.each { csv << it.values_at(*HEADER) }
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def write_rows(rows)
|
|
115
|
+
FileUtils.mkdir_p(File.dirname(@csv))
|
|
116
|
+
CSV.open(@csv, 'w') do |csv|
|
|
117
|
+
csv << HEADER
|
|
118
|
+
rows.each { csv << it.values_at(*HEADER) }
|
|
119
|
+
end
|
|
120
|
+
@rows = @felt = @felt_methods = nil
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# The method's file and line.
|
|
124
|
+
def location(method) = "#{relative(method.path)}:#{method.line}"
|
|
125
|
+
|
|
126
|
+
def relative(path) = path.delete_prefix(File.join(@root, ''))
|
|
127
|
+
|
|
128
|
+
def commit
|
|
129
|
+
sha, status = Open3.capture2('git', '-C', @root, 'rev-parse', '--short', 'HEAD', err: File::NULL)
|
|
130
|
+
sha.strip if status.success?
|
|
131
|
+
rescue SystemCallError
|
|
132
|
+
nil
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'zlib'
|
|
4
|
+
|
|
5
|
+
require_relative '../renderers/renderer'
|
|
6
|
+
require_relative 'bins'
|
|
7
|
+
require_relative 'feelings_code_box'
|
|
8
|
+
require_relative 'magnifier'
|
|
9
|
+
require_relative 'motion'
|
|
10
|
+
require_relative 'celebration/scene'
|
|
11
|
+
require_relative 'waffle/waffle'
|
|
12
|
+
|
|
13
|
+
module Raindown
|
|
14
|
+
module TUI
|
|
15
|
+
# Code feelings, right of the files: the methods as clusters of digits in a grid, with the selected one highlighted
|
|
16
|
+
# and its code below, then why it felt that way while that's typed, then the five bins and how many each holds.
|
|
17
|
+
# When the batch is refined, it rains over them, then there's a waffle:
|
|
18
|
+
# 0 0 2 2 2 5 5 9 9 9 9 1
|
|
19
|
+
# 1 3 3 7 7 7 6 4 4 0 8 8
|
|
20
|
+
# ╭─ lib/text.rb:62 · Text#segments_between ──────────── changed 6× ─╮
|
|
21
|
+
# │ def segments_between(line, from, to) │
|
|
22
|
+
# ╰───────────────────────────────────────────────────────────────────╯
|
|
23
|
+
# ╭─ 01 ────╮ ╭─ 02 ────╮ ╭─ 03 ────╮ ╭─ 04 ────╮ ╭─ 05 ────╮
|
|
24
|
+
# │ FROLIC │ │ NUMB │ │ WOE │ │ DREAD │ │ MALICE │
|
|
25
|
+
# │ ███░ 9 │ │ ████ 41 │ │ ██░░ 6 │ │ █░░░ 3 │ │ █░░░ 2 │
|
|
26
|
+
# ╰─────────╯ ╰─────────╯ ╰─────────╯ ╰─────────╯ ╰─────────╯
|
|
27
|
+
class FeelingsRenderer < Renderer
|
|
28
|
+
include Bins
|
|
29
|
+
include FeelingsCodeBox
|
|
30
|
+
include Magnifier
|
|
31
|
+
include Motion
|
|
32
|
+
include Waffle
|
|
33
|
+
|
|
34
|
+
# The columns a cell takes, on a line of its own, its digit small in the middle, which is magnified in a stat's block
|
|
35
|
+
# digits across the cell and over the cells above and below it when the mouse is over its method.
|
|
36
|
+
CELL = 3
|
|
37
|
+
# The drift from which a method's cells are restless, and flicker now and then.
|
|
38
|
+
RESTLESS = 7
|
|
39
|
+
# How far a cell's digit is from its method's drift, by its roll out of NOISE_ROLL, so about four in ten are off.
|
|
40
|
+
NOISE = { 0 => -1, 1 => 1, 2 => -1, 3 => 1, 4 => -2, 5 => 2, 6 => -3, 7 => 3 }.freeze
|
|
41
|
+
NOISE_ROLL = 20
|
|
42
|
+
# The smallest grid that's worth refining, below which the pane's red, saying the screen's too small, under a
|
|
43
|
+
# window with arrows out from its corners, when there's room for it.
|
|
44
|
+
MIN_COLUMNS = 15
|
|
45
|
+
MIN_ROWS = 5
|
|
46
|
+
TOO_SMALL = 'Screen too small.'
|
|
47
|
+
TOO_SMALL_ART = ['↖ ↗', ' ┌───┐ ', ' └───┘ ', '↙ ↘', ''].freeze
|
|
48
|
+
TITLE = 'Weekly Batch'
|
|
49
|
+
BUTTONS = [['Tab', 'Pane', :tab], ['1-5', 'Feel', nil], ['e', 'Edit', :edit], ['x', 'Fix', :execute],
|
|
50
|
+
['/', 'Search', :search], ['q', 'Quit', :quit]].freeze
|
|
51
|
+
|
|
52
|
+
def buttons = BUTTONS
|
|
53
|
+
def page = 'Code feelings'
|
|
54
|
+
|
|
55
|
+
# The pane's title, in the middle of the line above the panes, with the border either side, in its colour.
|
|
56
|
+
def spacer
|
|
57
|
+
title = " #{TITLE} "
|
|
58
|
+
title_width = display_width(title)
|
|
59
|
+
left = [(width - title_width) / 2, 0].max
|
|
60
|
+
padded([segment('', :blank, width: left), segment(title, :divider, width: title_width),
|
|
61
|
+
segment('', :blank, width: [width - left - title_width, 0].max)])
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The cells come first, as the first draw picks the batch to fill them, unless the screen's too small for a grid.
|
|
65
|
+
def lines
|
|
66
|
+
return too_small if too_small?
|
|
67
|
+
|
|
68
|
+
cells = view.cells(columns, filled_rows)
|
|
69
|
+
return instead if view.code_methods.empty? || view.celebration
|
|
70
|
+
|
|
71
|
+
below = [*code_box, *bins]
|
|
72
|
+
(grid(cells, below.length) + below).first(height)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def view = navigator.view
|
|
78
|
+
|
|
79
|
+
# Instead of the grid, that there's nothing to feel, or the celebration.
|
|
80
|
+
def instead
|
|
81
|
+
return blank(" No functions or methods in #{File.basename(navigator.root)} to feel") if view.code_methods.empty?
|
|
82
|
+
|
|
83
|
+
celebration(view.celebration)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Rain and fireworks over the pane when the batch is refined, then a waffle popping out, among confetti, balloons and
|
|
87
|
+
# sparkles.
|
|
88
|
+
def celebration(celebration)
|
|
89
|
+
return Scene.new(width:, height:).lines(celebration.frame) if celebration.raining?
|
|
90
|
+
|
|
91
|
+
waffle(Scene.new(width:, height:, animations: [Confetti, Balloons, Sparkles]).lines(celebration.popping), celebration.popping)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# The rows of the grid around the selected method's, between blank lines that leave the first and last rows room to
|
|
95
|
+
# be magnified, above the lines below it, and blank lines for any left over.
|
|
96
|
+
def grid(cells, below)
|
|
97
|
+
@cells = cells
|
|
98
|
+
lines = [height - below, 0].max
|
|
99
|
+
rows = [lines - 2, 0].max
|
|
100
|
+
first = first_row(cells, columns, rows)
|
|
101
|
+
grid = magnified([progress_line(cells, first, rows), *grid_rows(cells, first, rows), padded([])], cells, first, rows)
|
|
102
|
+
|
|
103
|
+
[*grid, *Array.new([lines - grid.length, 0].max) { padded([]) }].first(lines)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# The cells' lines of the rows from the first.
|
|
107
|
+
def grid_rows(cells, first, rows) = Array.new(rows) { grid_line(cells, (first + it) * columns, columns) }
|
|
108
|
+
|
|
109
|
+
def columns = [(width - 1) / CELL, 1].max
|
|
110
|
+
|
|
111
|
+
# The grid's rows between its blank lines, above the code box, as tall as it always is until it's expanded, and the
|
|
112
|
+
# bins, which a batch fills. An expanded box takes rows from the grid, which then scrolls to keep the selected method
|
|
113
|
+
# in view.
|
|
114
|
+
def filled_rows = [height - 2 - CODE_LINES - BOX_LINES - BIN_LINES, 1].max
|
|
115
|
+
|
|
116
|
+
# The first row that shows, with the selected method's row in the middle where there's room.
|
|
117
|
+
def first_row(cells, columns, rows)
|
|
118
|
+
last = [((cells.length - 1) / columns) - rows + 1, 0].max
|
|
119
|
+
((cells.index(view.cursor).to_i / columns) - (rows / 2)).clamp(0, last)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# The cells' line of the row from the index.
|
|
123
|
+
def grid_line(cells, from, columns)
|
|
124
|
+
segments = (from...(from + columns)).filter_map do |index|
|
|
125
|
+
owner = cells[index]
|
|
126
|
+
next unless owner
|
|
127
|
+
|
|
128
|
+
click = Click.new(pane:, index: owner, action: :focus)
|
|
129
|
+
segment(cell_text(owner, index), grid_style(owner, index), width: CELL, event: click)
|
|
130
|
+
end
|
|
131
|
+
padded([segment('', :blank, width: 1), *segments])
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# The cell's digit, small, where it moves, or blank once its method's felt, but still there to select, which shows it
|
|
135
|
+
# again with its code.
|
|
136
|
+
def cell_text(owner, index)
|
|
137
|
+
return ' ' * CELL if hidden?(owner)
|
|
138
|
+
|
|
139
|
+
column = digit_column(owner, index)
|
|
140
|
+
"#{' ' * column}#{digit(owner, index)}#{' ' * (CELL - 1 - column)}"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# The selected method's cells, across the whole of each, on the selection's blue, so they join into one block, or on
|
|
144
|
+
# green when it's already been felt, and others as they move.
|
|
145
|
+
def grid_style(owner, index)
|
|
146
|
+
return cell_style(index) unless owner == view.cursor
|
|
147
|
+
return :felt_selected if view.batch.felt?(view.code_methods[owner])
|
|
148
|
+
|
|
149
|
+
:feelings_selected
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Whether the method's felt, and not selected, so its cells are blank, asked once a frame for each method.
|
|
153
|
+
def hidden?(owner)
|
|
154
|
+
@hidden ||= Hash.new { |hidden, felt| hidden[felt] = felt != view.cursor && view.batch.felt?(view.code_methods[felt]) }
|
|
155
|
+
@hidden[owner]
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Near the method's drift, from 0 to 9: a cell's checksum rolls a small chance of it being one to three away, and a
|
|
159
|
+
# restless one flickers one lower now and then.
|
|
160
|
+
def digit(owner, index)
|
|
161
|
+
drift = view.code_methods[owner].drift
|
|
162
|
+
(drift + NOISE.fetch(Zlib.crc32("#{owner}:#{index}") % NOISE_ROLL, 0)).clamp(0, 9) + flicker(owner, index)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def too_small? = columns < MIN_COLUMNS || filled_rows < MIN_ROWS
|
|
166
|
+
|
|
167
|
+
# The pane in red, saying the screen's too small, in the middle, with the cells laid out without picking a batch for
|
|
168
|
+
# so small a grid, so the keys still move between them.
|
|
169
|
+
def too_small
|
|
170
|
+
view.cells(columns)
|
|
171
|
+
shown = [*TOO_SMALL_ART, TOO_SMALL]
|
|
172
|
+
shown = [TOO_SMALL] if shown.length > height
|
|
173
|
+
lines = Array.new(height, '')
|
|
174
|
+
lines[(height - shown.length) / 2, shown.length] = shown
|
|
175
|
+
lines.map { [segment(it.center(width), :too_small)] }
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def blank(message)
|
|
179
|
+
[[segment(message, :feelings)], *Array.new([height - 1, 0].max) { [segment('', :blank)] }].first(height)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../code_box'
|
|
4
|
+
require_relative '../events'
|
|
5
|
+
require_relative 'cell_moves'
|
|
6
|
+
require_relative 'celebration/celebration'
|
|
7
|
+
require_relative 'code_methods'
|
|
8
|
+
require_relative 'feelings_log'
|
|
9
|
+
require_relative 'weekly_batch'
|
|
10
|
+
|
|
11
|
+
module Raindown
|
|
12
|
+
module TUI
|
|
13
|
+
# Code feelings, like Severance's macrodata refinement: the project's functions and methods, in any language,
|
|
14
|
+
# as clusters of digits in a grid, where long methods, and ones whose files keep changing, drift. The cursor moves
|
|
15
|
+
# between them, the selected one's code shows below the grid, its first lines or with Enter all that fits, and 1 to 5
|
|
16
|
+
# sort how it feels into a bin: frolic, numb, woe, dread or malice, which notes it down in the FeelingsLog,
|
|
17
|
+
# 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
|
|
18
|
+
# editor, and X asks Claude Code to fix it, and either way it's read again after, to be felt again.
|
|
19
|
+
class FeelingsView
|
|
20
|
+
include CellMoves
|
|
21
|
+
include ShownCode
|
|
22
|
+
|
|
23
|
+
# Best to worst, as CodeHealth scores them, so the keys rate the code.
|
|
24
|
+
BINS = { bin1: 'frolic', bin2: 'numb', bin3: 'woe', bin4: 'dread', bin5: 'malice' }.freeze
|
|
25
|
+
# The tempers that ask why.
|
|
26
|
+
NOTED = %w[woe dread malice].freeze
|
|
27
|
+
# Its own keys, besides the bins: Enter and Space show all of the selected method's code, or its first lines again.
|
|
28
|
+
# Ctrl+T celebrates, to try the rain and the waffle, and isn't shown with the buttons.
|
|
29
|
+
KEYS = %i[up down left right edit execute enter toggle expand celebrate].freeze
|
|
30
|
+
|
|
31
|
+
# The temper being noted, and the note typed so far, while it's asking why, whether the selected method's code
|
|
32
|
+
# shows all that fits, until another's selected, and the rain then the waffle, once the batch is refined.
|
|
33
|
+
attr_reader :noting, :note, :cursor, :log, :celebration
|
|
34
|
+
|
|
35
|
+
def initialize(root:)
|
|
36
|
+
@root = root
|
|
37
|
+
@log = FeelingsLog.new(root:)
|
|
38
|
+
@last_feelings = {}
|
|
39
|
+
@cursor = 0
|
|
40
|
+
@week = WeeklyBatch.new(log: @log)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def renderer = FeelingsRenderer
|
|
44
|
+
def target = self
|
|
45
|
+
# It shows Ruby code, which isn't written back.
|
|
46
|
+
def path = nil
|
|
47
|
+
def changed? = false
|
|
48
|
+
def typing? = !noting.nil?
|
|
49
|
+
|
|
50
|
+
# As fast as an animation while it celebrates, through the rain and the waffle, and otherwise reactive, so its digits
|
|
51
|
+
# tremble at the same pace whether it's used with the keys or the mouse.
|
|
52
|
+
def pace(_hovered)
|
|
53
|
+
return :animation if celebration
|
|
54
|
+
|
|
55
|
+
:reactive
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Opening it says nothing, as its title is above the grid.
|
|
59
|
+
def status(_navigator) = [:info, '']
|
|
60
|
+
|
|
61
|
+
# Reads the code again, keeping the batch, like after a method's edited or fixed, and the selected method, by its
|
|
62
|
+
# file and name, even when the methods around it change, moving the feelings about code that's changed, or gone, to
|
|
63
|
+
# the memories.
|
|
64
|
+
def load
|
|
65
|
+
was = selected
|
|
66
|
+
every = CodeMethods.list(@root)
|
|
67
|
+
@log.move_stale_to_memories(every)
|
|
68
|
+
@week.read(every)
|
|
69
|
+
reselect(was)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Selects the method again, by its file and name, wherever it is now, or keeps the cursor where it was.
|
|
73
|
+
def reselect(was)
|
|
74
|
+
self.cursor = code_methods.index { [it.path, it.label] == [was&.path, was&.label] } || @cursor
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The Batch of files whose methods are in the grid.
|
|
78
|
+
def batch = @week.batch
|
|
79
|
+
def code_methods = batch.code_methods
|
|
80
|
+
|
|
81
|
+
def selected = code_methods[@cursor]
|
|
82
|
+
|
|
83
|
+
def cursor=(index)
|
|
84
|
+
index = index.clamp(0, [code_methods.length - 1, 0].max)
|
|
85
|
+
@expanded = false unless index == @cursor
|
|
86
|
+
@cursor = index
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Every key while it's asking why or celebrating, and otherwise its own, but Tab to the files, or left from the
|
|
90
|
+
# first method.
|
|
91
|
+
def next_step?(key)
|
|
92
|
+
return true if typing? || celebration
|
|
93
|
+
|
|
94
|
+
(KEYS.include?(key) || BINS.key?(key)) && !(key == :left && @cursor.zero?)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def next_step(key)
|
|
98
|
+
return note_step(key) if typing?
|
|
99
|
+
return @celebration = nil if celebration&.over?(key)
|
|
100
|
+
return (@celebration = Celebration.new).then { nil } if key == :celebrate
|
|
101
|
+
return unless selected
|
|
102
|
+
return feel(BINS[key]) if BINS.key?(key)
|
|
103
|
+
|
|
104
|
+
method_step(key)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
# Moves between the methods, or acts on the selected one.
|
|
110
|
+
def method_step(key)
|
|
111
|
+
case key
|
|
112
|
+
when :left then move(-1)
|
|
113
|
+
when :right then move(1)
|
|
114
|
+
when :up then move_row(-1)
|
|
115
|
+
when :down then move_row(1)
|
|
116
|
+
when :edit then Edit.new(path: selected.path, line: selected.line)
|
|
117
|
+
when :execute then fix
|
|
118
|
+
when :enter, :toggle, :expand then toggle_expanded
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Asks to fix the method, with how it last felt, by its file and name, which stay the same when its lines change.
|
|
123
|
+
def fix = Fix.new(feeling: @last_feelings[[selected.path, selected.label]] || Feeling.new(method: selected, temper: nil, note: nil))
|
|
124
|
+
|
|
125
|
+
# Frolic and numb are noted down straight away, while the rest ask why first.
|
|
126
|
+
def feel(temper)
|
|
127
|
+
return note_down(temper, nil) unless NOTED.include?(temper)
|
|
128
|
+
|
|
129
|
+
@noting = temper
|
|
130
|
+
@note = +''
|
|
131
|
+
nil
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def note_step(key)
|
|
135
|
+
case key
|
|
136
|
+
when Type then @note << key.text
|
|
137
|
+
when :backspace then @note.chop!
|
|
138
|
+
when :enter then return note_down(@noting, (@note unless @note.strip.empty?))
|
|
139
|
+
when :escape then return note_down(@noting, nil)
|
|
140
|
+
end
|
|
141
|
+
nil
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Notes the feeling down, staying on the method, so it can be fixed with what was just said about it. It rains
|
|
145
|
+
# when that refines the batch.
|
|
146
|
+
def note_down(temper, note)
|
|
147
|
+
feeling = Feeling.new(method: selected, temper:, note:)
|
|
148
|
+
@last_feelings[[selected.path, selected.label]] = feeling
|
|
149
|
+
@noting = nil
|
|
150
|
+
refined = batch.refined?
|
|
151
|
+
@log.add(feeling)
|
|
152
|
+
@celebration = Celebration.new if !refined && batch.refined?
|
|
153
|
+
Felt.new(feeling:)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../../stat'
|
|
4
|
+
|
|
5
|
+
module Raindown
|
|
6
|
+
module TUI
|
|
7
|
+
# Code feelings' magnified digits: the method the mouse is over's, in purple and lavender by turns, so the digits
|
|
8
|
+
# beside and below each other stand apart, each in a stat's block digits across its cell and the cells above and below
|
|
9
|
+
# it, drawn over the grid.
|
|
10
|
+
module Magnifier
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
# The lines with the digits of the method the mouse is over magnified, each across its cell and the cells above and
|
|
14
|
+
# below it.
|
|
15
|
+
def magnified(lines, cells, first, rows)
|
|
16
|
+
magnified_cells(cells, first, rows).each do |index|
|
|
17
|
+
magnify_cell(lines, (index / columns) - first, cells[index], index)
|
|
18
|
+
end
|
|
19
|
+
lines
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# The cell's digit magnified over its row's line, the top one, and the lines above and below it.
|
|
23
|
+
def magnify_cell(lines, top, owner, index)
|
|
24
|
+
style = magnified_style(index)
|
|
25
|
+
Stat::DIGITS[digit(owner, index).to_s].each_with_index do |text, line|
|
|
26
|
+
magnify(lines, top + line, segment(text, style, width: FeelingsRenderer::CELL), 1 + (index % columns * FeelingsRenderer::CELL))
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Purple and lavender in turn, along the cell's row and down its column, like a chessboard's squares.
|
|
31
|
+
def magnified_style(index)
|
|
32
|
+
return :feelings_purple if ((index / columns) + (index % columns)).odd?
|
|
33
|
+
|
|
34
|
+
:feelings
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# How much of the batch is refined, at the right of the line above the grid, or nothing while a magnified digit
|
|
38
|
+
# reaches it there.
|
|
39
|
+
def progress_line(cells, first, rows)
|
|
40
|
+
progress = "#{view.batch.progress} "
|
|
41
|
+
start = width - display_width(progress)
|
|
42
|
+
return padded([]) if magnified_from?(cells, first, rows, start)
|
|
43
|
+
|
|
44
|
+
padded([segment('', :blank, width: [start, 0].max), segment(progress, :body, width: display_width(progress))])
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Whether a magnified digit in the first row reaches the column, or past it.
|
|
48
|
+
def magnified_from?(cells, first, rows, column)
|
|
49
|
+
magnified_cells(cells, first, rows).any? { (it / columns) == first && 1 + (((it % columns) + 1) * FeelingsRenderer::CELL) > column }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The cells of the rows from the first that are the method the mouse is over's, unless they're blank or selected,
|
|
53
|
+
# which keeps its selection's style.
|
|
54
|
+
def magnified_cells(cells, first, rows)
|
|
55
|
+
(first * columns...(first + rows) * columns).select do |index|
|
|
56
|
+
owner = cells[index]
|
|
57
|
+
owner && owner != view.cursor && navigator.hovered == Click.new(pane:, index: owner, action: :focus) && !hidden?(owner)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The segment over the line at the column, clicking whatever cell it covers, so the mouse over it is over that cell.
|
|
62
|
+
def magnify(lines, line, segment, column)
|
|
63
|
+
covered = segments_between(lines[line], column, column + display_width(segment.text)).first
|
|
64
|
+
lines[line] = overlay_segments(lines[line], [segment.with(event: covered&.event)], column:)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'zlib'
|
|
4
|
+
|
|
5
|
+
module Raindown
|
|
6
|
+
module TUI
|
|
7
|
+
# How Code feelings' digits move in their cells: the restless ones, the heaviest, now and then flicker one lower, and
|
|
8
|
+
# the ones beside the method the mouse is over flee from it, to the far side of their cells, where they tremble often,
|
|
9
|
+
# as if they're trying to escape, while every cell around it is bold.
|
|
10
|
+
module Motion
|
|
11
|
+
# The columns a fleeing digit moves by when it trembles, each for a step of FLEEING_TREMBLE_SECONDS, a little longer
|
|
12
|
+
# than a frame, so none are missed, once every FLEEING_TREMBLE_EVERY of them, about every 0.7 seconds, each cell at its
|
|
13
|
+
# own moment.
|
|
14
|
+
TREMBLE = [1, -1, 1, -1].freeze
|
|
15
|
+
FLEEING_TREMBLE_SECONDS = 0.09
|
|
16
|
+
FLEEING_TREMBLE_EVERY = 8
|
|
17
|
+
# The columns a digit flees by, from the method beside it, from the middle of its cell, where it's still.
|
|
18
|
+
FLEE = 1
|
|
19
|
+
MIDDLE = 1
|
|
20
|
+
# A restless digit flickers one lower for FLICKER_SECONDS, once every FLICKER_EVERY of them, about every 30 seconds.
|
|
21
|
+
FLICKER_SECONDS = 0.5
|
|
22
|
+
FLICKER_EVERY = 60
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
# The column of the cell's digit in its cell: the middle, or fleeing to its edge, where it trembles often.
|
|
27
|
+
def digit_column(owner, index)
|
|
28
|
+
away = flee(index)
|
|
29
|
+
return MIDDLE if away.zero?
|
|
30
|
+
|
|
31
|
+
fleeing_column(owner, index, away)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# At the edge of its cell it's fled to, trembling often and quickly, but never out of the cell.
|
|
35
|
+
def fleeing_column(owner, index, away)
|
|
36
|
+
(MIDDLE + away + tremble(owner, index)).clamp(0, FeelingsRenderer::CELL - 1)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# A column either way, during the cell's moment once every FLEEING_TREMBLE_EVERY steps, or else none.
|
|
40
|
+
def tremble(owner, index)
|
|
41
|
+
TREMBLE.fetch(((now / FLEEING_TREMBLE_SECONDS).floor + Zlib.crc32("#{owner}:#{index}")) % FLEEING_TREMBLE_EVERY, 0)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# One lower, during the restless cell's moment once every FLICKER_EVERY steps of FLICKER_SECONDS, or else none.
|
|
45
|
+
def flicker(owner, index)
|
|
46
|
+
return 0 if view.code_methods[owner].drift < FeelingsRenderer::RESTLESS
|
|
47
|
+
return 0 unless (((now / FLICKER_SECONDS).floor + Zlib.crc32("#{owner}:#{index}")) % FLICKER_EVERY).zero?
|
|
48
|
+
|
|
49
|
+
-1
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The time the frame's drawn at, in seconds, the same for all its cells.
|
|
53
|
+
def now = @now ||= Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
54
|
+
|
|
55
|
+
# Away from the method the mouse is over, when it's beside the cell in its row.
|
|
56
|
+
def flee(index)
|
|
57
|
+
away = 0
|
|
58
|
+
away += FLEE if beside_hovered?(index, -1)
|
|
59
|
+
away -= FLEE if beside_hovered?(index, 1)
|
|
60
|
+
away
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Whether the cell a step along its row, -1 to the left or 1 to the right, is the method the mouse is over's, and
|
|
64
|
+
# this one isn't.
|
|
65
|
+
def beside_hovered?(index, step)
|
|
66
|
+
((index % columns) + step).between?(0, columns - 1) && hovered.include?(index + step) && !hovered.include?(index)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Bold around the method the mouse is over, beside it, above it, below it, or diagonally.
|
|
70
|
+
def cell_style(index)
|
|
71
|
+
@nearby ||= hovered.flat_map { around(it) }.to_set - hovered
|
|
72
|
+
return :nearby if @nearby.include?(index)
|
|
73
|
+
|
|
74
|
+
:feelings
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The cells around the cell, in the rows above and below it and beside it in its own.
|
|
78
|
+
def around(index)
|
|
79
|
+
column = index % columns
|
|
80
|
+
[-1, 0, 1].product([-1, 0, 1]).filter_map do |down, across|
|
|
81
|
+
next if (down.zero? && across.zero?) || !(column + across).between?(0, columns - 1)
|
|
82
|
+
|
|
83
|
+
index + (down * columns) + across
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The cells of the method the mouse is over, by their index.
|
|
88
|
+
def hovered
|
|
89
|
+
@hovered ||= @cells.each_index.select { navigator.hovered == Click.new(pane:, index: @cells[it], action: :focus) }.to_set
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|