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.
- checksums.yaml +4 -4
- data/lib/raindown/chart/chart.rb +12 -1
- data/lib/raindown/cli.rb +2 -2
- data/lib/raindown/document.rb +16 -9
- data/lib/raindown/endpoint/curl.rb +19 -0
- data/lib/raindown/node_blocks.rb +9 -6
- data/lib/raindown/nodes.rb +1 -0
- data/lib/raindown/projects.rb +8 -4
- data/lib/raindown/stat.rb +53 -0
- data/lib/raindown/tui/card.rb +56 -0
- data/lib/raindown/tui/endpoint.rb +16 -0
- data/lib/raindown/tui/endpoints.rb +40 -0
- data/lib/raindown/tui/events.rb +50 -0
- data/lib/raindown/tui/feelings/batch.rb +47 -0
- data/lib/raindown/tui/feelings/bins.rb +67 -0
- data/lib/raindown/tui/feelings/celebration.rb +25 -0
- data/lib/raindown/tui/feelings/code_box.rb +129 -0
- data/lib/raindown/tui/feelings/code_methods.rb +134 -0
- data/lib/raindown/tui/feelings/feelings_log.rb +133 -0
- data/lib/raindown/tui/feelings/feelings_renderer.rb +184 -0
- data/lib/raindown/tui/feelings/feelings_view.rb +161 -0
- data/lib/raindown/tui/feelings/fireworks.rb +75 -0
- data/lib/raindown/tui/feelings/rain.rb +66 -0
- data/lib/raindown/tui/feelings/scene.rb +37 -0
- data/lib/raindown/tui/feelings/waffle.rb +43 -0
- data/lib/raindown/tui/feelings/weekly_batch.rb +32 -0
- data/lib/raindown/tui/file_tree.rb +55 -12
- data/lib/raindown/tui/highlighter.rb +45 -0
- data/lib/raindown/tui/layout.rb +66 -77
- data/lib/raindown/tui/menu_overlay.rb +59 -0
- data/lib/raindown/tui/navigator/commands.rb +97 -0
- data/lib/raindown/tui/navigator/focus.rb +25 -0
- data/lib/raindown/tui/navigator/navigator.rb +160 -0
- data/lib/raindown/tui/navigator/project_menu.rb +61 -0
- data/lib/raindown/tui/navigator/search.rb +126 -0
- data/lib/raindown/tui/navigator/views.rb +52 -0
- data/lib/raindown/tui/renderers/dashboard_renderer.rb +102 -0
- data/lib/raindown/tui/renderers/endpoint_renderer.rb +81 -0
- data/lib/raindown/tui/renderers/list_renderer.rb +15 -0
- data/lib/raindown/tui/renderers/outline_renderer.rb +21 -0
- data/lib/raindown/tui/renderers/renderer.rb +18 -0
- data/lib/raindown/tui/row.rb +12 -3
- data/lib/raindown/tui/rows.rb +58 -0
- data/lib/raindown/tui/search_overlay.rb +73 -0
- data/lib/raindown/tui/terminal/input.rb +91 -0
- data/lib/raindown/tui/terminal/ncurses_shim.rb +46 -0
- data/lib/raindown/tui/terminal/quiet.rb +32 -0
- data/lib/raindown/tui/{screen.rb → terminal/screen.rb} +42 -24
- data/lib/raindown/tui/terminal/styles.rb +154 -0
- data/lib/raindown/tui/text.rb +110 -0
- data/lib/raindown/tui/title.rb +29 -0
- data/lib/raindown/tui/views/dashboard_view.rb +147 -0
- data/lib/raindown/tui/views/endpoint_view.rb +77 -0
- data/lib/raindown/tui/{pane.rb → views/list_view.rb} +11 -8
- data/lib/raindown/tui/{outline.rb → views/outline_view.rb} +48 -20
- data/lib/raindown/tui/workers/project.rb +122 -0
- data/lib/raindown/tui/workers/worker.rb +124 -0
- data/lib/raindown/tui/workers/workers.rb +53 -0
- data/lib/raindown/tui.rb +10 -5
- data/lib/version.rb +1 -1
- metadata +63 -9
- data/lib/raindown/tui/commands.rb +0 -70
- data/lib/raindown/tui/input.rb +0 -55
- data/lib/raindown/tui/navigator.rb +0 -123
- 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
|