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.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/lib/nodes/endpoint_node.rb +3 -2
  3. data/lib/raindown/actions.rb +5 -3
  4. data/lib/raindown/bare_directives.rb +14 -27
  5. data/lib/raindown/blueprints.rb +255 -0
  6. data/lib/raindown/chart/chart.rb +12 -1
  7. data/lib/raindown/cli.rb +39 -54
  8. data/lib/raindown/config.rb +2 -3
  9. data/lib/raindown/directives.rb +30 -13
  10. data/lib/raindown/document.rb +22 -20
  11. data/lib/raindown/endpoint/curl.rb +11 -7
  12. data/lib/raindown/endpoint/endpoint_runner.rb +13 -5
  13. data/lib/raindown/node_blocks.rb +31 -22
  14. data/lib/raindown/node_injector.rb +7 -10
  15. data/lib/raindown/nodes.rb +1 -0
  16. data/lib/raindown/pages.rb +1 -1
  17. data/lib/raindown/pre_renderer.rb +18 -0
  18. data/lib/raindown/stat.rb +53 -0
  19. data/lib/raindown/tui/blueprints/blueprints_renderer.rb +93 -0
  20. data/lib/raindown/tui/blueprints/blueprints_view.rb +146 -0
  21. data/lib/raindown/tui/blueprints/canvas.rb +58 -0
  22. data/lib/raindown/tui/blueprints/code_comments.rb +71 -0
  23. data/lib/raindown/tui/blueprints/filter.rb +77 -0
  24. data/lib/raindown/tui/blueprints/traced_code.rb +56 -0
  25. data/lib/raindown/tui/blueprints/tracing.rb +129 -0
  26. data/lib/raindown/tui/code_box.rb +143 -0
  27. data/lib/raindown/tui/endpoints.rb +2 -2
  28. data/lib/raindown/tui/events.rb +18 -1
  29. data/lib/raindown/tui/feelings/batch.rb +47 -0
  30. data/lib/raindown/tui/feelings/bins.rb +67 -0
  31. data/lib/raindown/tui/feelings/celebration/celebration.rb +28 -0
  32. data/lib/raindown/tui/feelings/celebration/fireworks.rb +75 -0
  33. data/lib/raindown/tui/feelings/celebration/rain.rb +66 -0
  34. data/lib/raindown/tui/feelings/celebration/scene.rb +40 -0
  35. data/lib/raindown/tui/feelings/cell_moves.rb +34 -0
  36. data/lib/raindown/tui/feelings/code_methods.rb +134 -0
  37. data/lib/raindown/tui/feelings/feelings_code_box.rb +40 -0
  38. data/lib/raindown/tui/feelings/feelings_log.rb +136 -0
  39. data/lib/raindown/tui/feelings/feelings_renderer.rb +183 -0
  40. data/lib/raindown/tui/feelings/feelings_view.rb +157 -0
  41. data/lib/raindown/tui/feelings/magnifier.rb +68 -0
  42. data/lib/raindown/tui/feelings/motion.rb +93 -0
  43. data/lib/raindown/tui/feelings/waffle/baked_waffle.rb +88 -0
  44. data/lib/raindown/tui/feelings/waffle/balloons.rb +81 -0
  45. data/lib/raindown/tui/feelings/waffle/confetti.rb +55 -0
  46. data/lib/raindown/tui/feelings/waffle/sparkles.rb +61 -0
  47. data/lib/raindown/tui/feelings/waffle/waffle.rb +62 -0
  48. data/lib/raindown/tui/feelings/weekly_batch.rb +51 -0
  49. data/lib/raindown/tui/file_tree.rb +6 -5
  50. data/lib/raindown/tui/highlighter.rb +45 -0
  51. data/lib/raindown/tui/layout.rb +32 -18
  52. data/lib/raindown/tui/navigator/commands.rb +46 -2
  53. data/lib/raindown/tui/navigator/mouse.rb +43 -0
  54. data/lib/raindown/tui/navigator/navigator.rb +54 -25
  55. data/lib/raindown/tui/navigator/teleport.rb +128 -0
  56. data/lib/raindown/tui/navigator/views.rb +5 -3
  57. data/lib/raindown/tui/{workers/project.rb → project.rb} +38 -27
  58. data/lib/raindown/tui/{menu_overlay.rb → project_box.rb} +2 -2
  59. data/lib/raindown/tui/renderers/dashboard_renderer.rb +4 -4
  60. data/lib/raindown/tui/renderers/endpoint_renderer.rb +14 -10
  61. data/lib/raindown/tui/renderers/outline_renderer.rb +4 -4
  62. data/lib/raindown/tui/renderers/renderer.rb +4 -0
  63. data/lib/raindown/tui/row.rb +4 -2
  64. data/lib/raindown/tui/rows.rb +2 -1
  65. data/lib/raindown/tui/saves.rb +22 -0
  66. data/lib/raindown/tui/search_box.rb +86 -0
  67. data/lib/raindown/tui/terminal/input.rb +95 -7
  68. data/lib/raindown/tui/terminal/screen.rb +37 -29
  69. data/lib/raindown/tui/terminal/styles.rb +111 -26
  70. data/lib/raindown/tui/terminal/terminal_title.rb +31 -0
  71. data/lib/raindown/tui/text.rb +62 -12
  72. data/lib/raindown/tui/title.rb +1 -1
  73. data/lib/raindown/tui/views/dashboard_view.rb +7 -5
  74. data/lib/raindown/tui/views/endpoint_view.rb +4 -2
  75. data/lib/raindown/tui/views/list_view.rb +2 -0
  76. data/lib/raindown/tui/views/outline_view.rb +16 -6
  77. data/lib/raindown/tui.rb +19 -7
  78. data/lib/raindown/watcher/changes.rb +33 -0
  79. data/lib/raindown/watcher/file_watcher.rb +58 -0
  80. data/lib/raindown/watcher/project.rb +47 -0
  81. data/lib/raindown/watcher/supervisor.rb +111 -0
  82. data/lib/raindown/{tui/workers → workers}/worker.rb +37 -29
  83. data/lib/raindown/workers/workers.rb +63 -0
  84. data/lib/raindown.rb +4 -4
  85. data/lib/version.rb +1 -1
  86. metadata +60 -9
  87. data/lib/raindown/project_worker.rb +0 -48
  88. data/lib/raindown/supervisor.rb +0 -160
  89. data/lib/raindown/tui/workers/workers.rb +0 -53
  90. 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