raindown 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/lib/raindown/chart/chart.rb +12 -1
  3. data/lib/raindown/cli.rb +2 -2
  4. data/lib/raindown/document.rb +16 -9
  5. data/lib/raindown/endpoint/curl.rb +19 -0
  6. data/lib/raindown/node_blocks.rb +9 -6
  7. data/lib/raindown/nodes.rb +1 -0
  8. data/lib/raindown/projects.rb +8 -4
  9. data/lib/raindown/stat.rb +53 -0
  10. data/lib/raindown/tui/card.rb +56 -0
  11. data/lib/raindown/tui/endpoint.rb +16 -0
  12. data/lib/raindown/tui/endpoints.rb +40 -0
  13. data/lib/raindown/tui/events.rb +50 -0
  14. data/lib/raindown/tui/feelings/batch.rb +47 -0
  15. data/lib/raindown/tui/feelings/bins.rb +67 -0
  16. data/lib/raindown/tui/feelings/celebration.rb +25 -0
  17. data/lib/raindown/tui/feelings/code_box.rb +129 -0
  18. data/lib/raindown/tui/feelings/code_methods.rb +134 -0
  19. data/lib/raindown/tui/feelings/feelings_log.rb +133 -0
  20. data/lib/raindown/tui/feelings/feelings_renderer.rb +184 -0
  21. data/lib/raindown/tui/feelings/feelings_view.rb +161 -0
  22. data/lib/raindown/tui/feelings/fireworks.rb +75 -0
  23. data/lib/raindown/tui/feelings/rain.rb +66 -0
  24. data/lib/raindown/tui/feelings/scene.rb +37 -0
  25. data/lib/raindown/tui/feelings/waffle.rb +43 -0
  26. data/lib/raindown/tui/feelings/weekly_batch.rb +32 -0
  27. data/lib/raindown/tui/file_tree.rb +55 -12
  28. data/lib/raindown/tui/highlighter.rb +45 -0
  29. data/lib/raindown/tui/layout.rb +66 -77
  30. data/lib/raindown/tui/menu_overlay.rb +59 -0
  31. data/lib/raindown/tui/navigator/commands.rb +97 -0
  32. data/lib/raindown/tui/navigator/focus.rb +25 -0
  33. data/lib/raindown/tui/navigator/navigator.rb +160 -0
  34. data/lib/raindown/tui/navigator/project_menu.rb +61 -0
  35. data/lib/raindown/tui/navigator/search.rb +126 -0
  36. data/lib/raindown/tui/navigator/views.rb +52 -0
  37. data/lib/raindown/tui/renderers/dashboard_renderer.rb +102 -0
  38. data/lib/raindown/tui/renderers/endpoint_renderer.rb +81 -0
  39. data/lib/raindown/tui/renderers/list_renderer.rb +15 -0
  40. data/lib/raindown/tui/renderers/outline_renderer.rb +21 -0
  41. data/lib/raindown/tui/renderers/renderer.rb +18 -0
  42. data/lib/raindown/tui/row.rb +12 -3
  43. data/lib/raindown/tui/rows.rb +58 -0
  44. data/lib/raindown/tui/search_overlay.rb +73 -0
  45. data/lib/raindown/tui/terminal/input.rb +91 -0
  46. data/lib/raindown/tui/terminal/ncurses_shim.rb +46 -0
  47. data/lib/raindown/tui/terminal/quiet.rb +32 -0
  48. data/lib/raindown/tui/{screen.rb → terminal/screen.rb} +42 -24
  49. data/lib/raindown/tui/terminal/styles.rb +154 -0
  50. data/lib/raindown/tui/text.rb +110 -0
  51. data/lib/raindown/tui/title.rb +29 -0
  52. data/lib/raindown/tui/views/dashboard_view.rb +147 -0
  53. data/lib/raindown/tui/views/endpoint_view.rb +77 -0
  54. data/lib/raindown/tui/{pane.rb → views/list_view.rb} +11 -8
  55. data/lib/raindown/tui/{outline.rb → views/outline_view.rb} +48 -20
  56. data/lib/raindown/tui/workers/project.rb +122 -0
  57. data/lib/raindown/tui/workers/worker.rb +124 -0
  58. data/lib/raindown/tui/workers/workers.rb +53 -0
  59. data/lib/raindown/tui.rb +10 -5
  60. data/lib/version.rb +1 -1
  61. metadata +63 -9
  62. data/lib/raindown/tui/commands.rb +0 -70
  63. data/lib/raindown/tui/input.rb +0 -55
  64. data/lib/raindown/tui/navigator.rb +0 -123
  65. data/lib/raindown/tui/styles.rb +0 -36
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../highlighter'
4
+
5
+ module Raindown
6
+ module TUI
7
+ # Code feelings' box below the grid: the selected method's code, highlighted, its first lines or, expanded, all that
8
+ # fit, then why it felt that way while that's typed, below a line:
9
+ # ╭─ lib/text.rb:62 · Text#segments_between ──────────── changed 6× ─╮
10
+ # │ def segments_between(line, from, to) │
11
+ # │ [12 more lines] │
12
+ # ├───────────────────────────────────────────────────────────────────┤
13
+ # │ Why DREAD? > column maths, a character at a time▏ │
14
+ # ╰───────────────────────────────────────────────────────────────────╯
15
+ module CodeBox
16
+ # The most of a method's code that shows, until it's expanded, and the rows the grid keeps when it is.
17
+ CODE_LINES = 8
18
+ MIN_GRID_ROWS = 2
19
+ # The box's top and bottom, and the line of how many more lines there are.
20
+ BOX_LINES = 3
21
+
22
+ private
23
+
24
+ # The selected method's code in a box, then why it felt that way while that's typed, below a line.
25
+ def code_box
26
+ notes = []
27
+ notes = note_lines if view.typing?
28
+ [box_top(view.selected), *code_lines(height - Bins::BIN_LINES - BOX_LINES - MIN_GRID_ROWS - notes.length), *notes,
29
+ box_edge('╰', '╯')]
30
+ end
31
+
32
+ # The method's first lines, or all that fit in the room when it's expanded, highlighted and wrapped to fit the box,
33
+ # then how many more there are, clicked to show them, or a line to show fewer again.
34
+ def code_lines(room)
35
+ shown = CODE_LINES
36
+ shown = [room, CODE_LINES].max if view.expanded
37
+ lines = highlighted_code.map { wrapped(it) }
38
+ fitting = lines.take_while { (shown -= it.length) >= 0 }
39
+ code = fitting.flatten(1).map { boxed(it) }
40
+ return code if lines.sum(&:length) <= CODE_LINES
41
+
42
+ [*code, more_line(lines.length - fitting.length)]
43
+ end
44
+
45
+ def highlighted_code = Highlighter.lines(dedented(view.selected.lines), filename: view.selected.path)
46
+
47
+ # The highlighted line's [text, style] parts in as many lines as it takes to fit the box, like a note's, those
48
+ # after the first carrying on after an ellipsis.
49
+ def wrapped(parts)
50
+ room = width - 6
51
+ lead = [carried_lead(parts, room), :feelings]
52
+ rest = room - display_width(lead[0])
53
+ return [parts] if rest < 1
54
+
55
+ first, *others = line_breaks(parts.map { printable(it[0]) }.join, room, rest).each_cons(2).map { parts_between(parts, *it) }
56
+ [first, *others.map { [lead, *it] }]
57
+ end
58
+
59
+ # Where the text breaks into lines, the first fitting the room and the others the rest.
60
+ def line_breaks(text, room, rest)
61
+ breaks = [0]
62
+ fits = room
63
+ while text.length - breaks.last > fits
64
+ breaks << line_break(text, breaks.last, fits)
65
+ fits = rest
66
+ end
67
+ breaks << text.length
68
+ end
69
+
70
+ # Where a line from the column breaks: after the last space that fits, or where it's full when none does.
71
+ def line_break(text, from, fits)
72
+ space = text[from, fits].rindex(' ').to_i
73
+ return from + fits unless space.positive?
74
+
75
+ from + space + 1
76
+ end
77
+
78
+ # An ellipsis at the line's own indent, up to half the room, which the lines it's wrapped onto start with.
79
+ def carried_lead(parts, room) = "#{' ' * [parts.map(&:first).join[/\A */].length, room / 2].min}… "
80
+
81
+ # The parts' text between two columns, in their styles.
82
+ def parts_between(parts, from, to)
83
+ segments = parts.map { |text, style| Text::Segment.new(text: printable(text), style:, event: nil) }
84
+ segments_between(segments, from, to).map { [it.text, it.style] }
85
+ end
86
+
87
+ def more_line(hidden)
88
+ text = "[#{hidden} more lines]"
89
+ text = '[1 more line]' if hidden == 1
90
+ text = "[Fewer lines]#{" · #{hidden} more don't fit" if hidden.positive?}" if view.expanded
91
+ boxed([[text, :button]], event: Click.new(pane:, index: view.cursor, action: :expand))
92
+ end
93
+
94
+ # Why it felt that way, as it's typed, below a line, in as many lines as it takes.
95
+ def note_lines = [box_edge('├', '┤'), *hanging("Why #{view.noting.upcase}? > ", "#{view.note}▏").map { boxed([[it, :feelings]]) }]
96
+
97
+ # The text after the lead, wrapped to fit the box, with the lines after the first lined up under the text.
98
+ def hanging(lead, text)
99
+ indent = ' ' * display_width(lead)
100
+ lines = wrap(text, width - 6 - indent.length)
101
+ [lead, *Array.new(lines.length - 1, indent)].zip(lines).map(&:join)
102
+ end
103
+
104
+ # Where the method is, its name, and how many commits changed its file.
105
+ def box_top(method) = box_edge("╭─ #{where(method)} · #{method.label} ", " changed #{method.changes}× ─╮")
106
+
107
+ # The lines without the indent they share, like a method's in its class.
108
+ def dedented(lines)
109
+ indent = lines.reject { it.strip.empty? }.map { it[/\A */].length }.min.to_i
110
+ lines.map { it[indent..].to_s }
111
+ end
112
+
113
+ def where(method) = "#{navigator.relative(method.path)}:#{method.line}"
114
+
115
+ # A line of the box, a column in from each side of the pane, of [text, style] parts, like highlighted code's.
116
+ def boxed(parts, event: nil)
117
+ padded([segment(' │ ', :feelings, width: 3), *code_segments(parts, width - 6, event:), segment(' │', :feelings, width: 2)])
118
+ end
119
+
120
+ # The box's top or bottom, with the left text cut short, rather than the corner, when there isn't room.
121
+ def box_edge(left, right)
122
+ room = width - 2 - display_width(right)
123
+ left = "#{fit(left, room - 1)} " if display_width(left) > room
124
+ fill = [room - display_width(left), 0].max
125
+ padded([segment(" #{left}#{'─' * fill}#{right}", :feelings, width: width - 1)])
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'open3'
5
+ require 'zlib'
6
+
7
+ module Raindown
8
+ module TUI
9
+ # A function or method in a project's code, to sort how it feels: where it is, its lines, how many commits changed
10
+ # its file, and how much it drifts, from 0 to 9, which is how it ranks among the project's by both of them.
11
+ CodeMethod = Data.define(:path, :owner, :name, :line, :lines, :changes, :drift) do
12
+ # Like Layout#lines, or lines alone outside a class or the like.
13
+ def label = [owner, name].compact.join('#')
14
+
15
+ # A cell for every few lines, up to 8.
16
+ def size = ((lines.length / 4) + 1).clamp(1, 8)
17
+
18
+ # What drift ranks: long methods in files that keep changing weigh most.
19
+ def weight = lines.length * (changes + 1)
20
+
21
+ # Its code's checksum, which changes when its code does, but not its indent, trailing spaces or blank lines.
22
+ def checksum = Zlib.crc32(lines.map(&:strip).reject(&:empty?).join("\n")).to_s(16)
23
+ end
24
+
25
+ # Every function and method in a project's code, in any language: from Universal Ctags when it's installed,
26
+ # which knows over 100 languages, or else from how the code's indented. Specs, tests, and folders a project
27
+ # doesn't write, like vendor, are left out.
28
+ module CodeMethods
29
+ SKIPPED = %w[.git spec test tests vendor tmp pkg node_modules].freeze
30
+ # The kinds Ctags gives functions and methods, across languages.
31
+ KINDS = %w[function method singletonMethod procedure subroutine func constructor].freeze
32
+ # The files the indentation fallback reads, by extension.
33
+ EXTENSIONS = %w[rb py js mjs ts tsx jsx go rs java kt swift c h cc cpp cs php ex exs lua sh scala].freeze
34
+ MODIFIERS = '(?:(?:export|default|async|static|public|private|protected|pub(?:\(\w+\))?|override|final)\s+)*'
35
+ # A line that starts a function, with its name, like "def area", "pub fn draw(" or "func (s *Square) Area(".
36
+ DEFINITION = /\A\s*#{MODIFIERS}(?:def|function|fn|func|sub|proc)\s+(?:\([^)]*\)\s*)?(?:self\.)?([\w?!=]+)/
37
+ # A line that starts a class or the like, with its name, which names the functions inside it.
38
+ OWNER = /\A\s*#{MODIFIERS}(?:class|module|struct|impl|interface|trait|object)\s+(\w+(?:::\w+)*)/
39
+ # A line that closes a block, at the indent of the line that opened it.
40
+ CLOSER = /\A\s*(?:end\b|[}\])])/
41
+
42
+ module_function
43
+
44
+ def list(root)
45
+ changes = file_changes(root)
46
+ drifting((tagged(root) || indented(root)).map { it.with(changes: changes.fetch(it.path, 0)) })
47
+ end
48
+
49
+ # Each method with its drift, from 0 for the lightest to 9 for the heaviest, where methods that weigh the same
50
+ # drift the same, so the digits spread out in any project, whatever its language or size.
51
+ def drifting(methods)
52
+ weights = methods.map(&:weight).sort
53
+ methods.map { |method| method.with(drift: weights.bsearch_index { it >= method.weight } * 9 / [weights.length - 1, 1].max) }
54
+ end
55
+
56
+ # From Universal Ctags, or nil when it isn't installed, or the ctags installed is another, without JSON.
57
+ def tagged(root)
58
+ json, status = Open3.capture2('ctags', '--output-format=json', '--fields=+ne', '-R', '-f', '-',
59
+ *SKIPPED.map { "--exclude=#{it}" }, '.', chdir: root, err: File::NULL)
60
+ return unless status.success?
61
+
62
+ sources = Hash.new { |hash, path| hash[path] = File.readlines(path, chomp: true) }
63
+ json.each_line.filter_map { tag_method(root, JSON.parse(it), sources) }.sort_by { [it.path, it.line] }
64
+ rescue SystemCallError, JSON::ParserError
65
+ nil
66
+ end
67
+
68
+ def tag_method(root, tag, sources)
69
+ return unless tag['_type'] == 'tag' && KINDS.include?(tag['kind']) && tag['end']
70
+
71
+ path = File.join(root, tag['path'].delete_prefix('./'))
72
+ lines = sources[path][(tag['line'] - 1)...tag['end']]
73
+ CodeMethod.new(path:, owner: tag['scope']&.split(/\.|::/)&.last, name: tag['name'], line: tag['line'], lines:, changes: 0, drift: 0)
74
+ end
75
+
76
+ def indented(root)
77
+ files(root).flat_map { indented_in(it) }
78
+ end
79
+
80
+ def files(root)
81
+ Dir.glob('**/*', File::FNM_DOTMATCH, base: root)
82
+ .select { EXTENSIONS.include?(File.extname(it).delete_prefix('.')) }
83
+ .reject { it.split('/').intersect?(SKIPPED) }.sort.map { File.join(root, it) }
84
+ end
85
+
86
+ # Each line that starts a function, to where the indent comes back to its own, named after the class it's in.
87
+ def indented_in(path)
88
+ lines = File.readlines(path, chomp: true)
89
+ lines.each_index.filter_map do |index|
90
+ name = lines[index][DEFINITION, 1]
91
+ next unless name
92
+
93
+ CodeMethod.new(path:, owner: owner_of(lines, index), name:, line: index + 1,
94
+ lines: lines[index..block_end(lines, index)], changes: 0, drift: 0)
95
+ end
96
+ rescue SystemCallError, ArgumentError
97
+ []
98
+ end
99
+
100
+ # The block's last line: the last one indented further than its first, or the line closing it, like "end".
101
+ def block_end(lines, index)
102
+ start = indent(lines[index])
103
+ last = index
104
+ lines.each_with_index.drop(index + 1).each do |line, number|
105
+ next if line.strip.empty?
106
+ return number if indent(line) == start && line.match?(CLOSER)
107
+ return last if indent(line) <= start
108
+
109
+ last = number
110
+ end
111
+ last
112
+ end
113
+
114
+ # The class or the like that the line's inside, by the nearest line above that's indented less and starts one.
115
+ def owner_of(lines, index)
116
+ depth = indent(lines[index])
117
+ owner = lines[0...index].reverse_each.find { indent(it) < depth && it.match?(OWNER) }
118
+ owner && owner[OWNER, 1].split('::').last
119
+ end
120
+
121
+ def indent(line) = line[/\A\s*/].length
122
+
123
+ # How many commits changed each file, from git log, or none outside a repository.
124
+ def file_changes(root)
125
+ log, status = Open3.capture2('git', '-C', root, 'log', '--format=', '--name-only', '--relative', err: File::NULL)
126
+ return {} unless status.success?
127
+
128
+ log.split("\n").reject(&:empty?).tally.transform_keys { File.join(root, it) }
129
+ rescue SystemCallError
130
+ {}
131
+ end
132
+ end
133
+ end
134
+ end
@@ -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