raindown 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/raindown/chart/chart.rb +12 -1
- data/lib/raindown/document.rb +1 -5
- data/lib/raindown/endpoint/curl.rb +11 -7
- data/lib/raindown/node_blocks.rb +9 -6
- data/lib/raindown/nodes.rb +1 -0
- data/lib/raindown/stat.rb +53 -0
- data/lib/raindown/tui/events.rb +16 -1
- data/lib/raindown/tui/feelings/batch.rb +47 -0
- data/lib/raindown/tui/feelings/bins.rb +67 -0
- data/lib/raindown/tui/feelings/celebration.rb +25 -0
- data/lib/raindown/tui/feelings/code_box.rb +129 -0
- data/lib/raindown/tui/feelings/code_methods.rb +134 -0
- data/lib/raindown/tui/feelings/feelings_log.rb +133 -0
- data/lib/raindown/tui/feelings/feelings_renderer.rb +184 -0
- data/lib/raindown/tui/feelings/feelings_view.rb +161 -0
- data/lib/raindown/tui/feelings/fireworks.rb +75 -0
- data/lib/raindown/tui/feelings/rain.rb +66 -0
- data/lib/raindown/tui/feelings/scene.rb +37 -0
- data/lib/raindown/tui/feelings/waffle.rb +43 -0
- data/lib/raindown/tui/feelings/weekly_batch.rb +32 -0
- data/lib/raindown/tui/file_tree.rb +6 -5
- data/lib/raindown/tui/highlighter.rb +45 -0
- data/lib/raindown/tui/layout.rb +14 -7
- data/lib/raindown/tui/menu_overlay.rb +1 -1
- data/lib/raindown/tui/navigator/commands.rb +14 -1
- data/lib/raindown/tui/navigator/navigator.rb +42 -9
- data/lib/raindown/tui/navigator/search.rb +126 -0
- data/lib/raindown/tui/navigator/views.rb +6 -3
- data/lib/raindown/tui/renderers/dashboard_renderer.rb +4 -4
- data/lib/raindown/tui/renderers/endpoint_renderer.rb +14 -10
- data/lib/raindown/tui/renderers/outline_renderer.rb +4 -4
- data/lib/raindown/tui/renderers/renderer.rb +3 -0
- data/lib/raindown/tui/row.rb +4 -2
- data/lib/raindown/tui/rows.rb +2 -1
- data/lib/raindown/tui/search_overlay.rb +73 -0
- data/lib/raindown/tui/terminal/input.rb +42 -5
- data/lib/raindown/tui/terminal/screen.rb +12 -9
- data/lib/raindown/tui/terminal/styles.rb +78 -20
- data/lib/raindown/tui/text.rb +27 -0
- data/lib/raindown/tui/title.rb +1 -1
- data/lib/raindown/tui/views/dashboard_view.rb +6 -4
- data/lib/raindown/tui/views/endpoint_view.rb +2 -0
- data/lib/raindown/tui/views/list_view.rb +2 -0
- data/lib/raindown/tui/views/outline_view.rb +11 -2
- data/lib/raindown/tui/workers/project.rb +6 -6
- data/lib/raindown/tui.rb +2 -0
- data/lib/version.rb +1 -1
- metadata +33 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ad2e1df6e9cbfbac50533a9d6dd7f1d8bedb254aa6277a1889fb380871c52ec1
|
|
4
|
+
data.tar.gz: d32bbefdbcae559679033db6b1fc7e1dc4846f6a6356c0944e711cf50699eab0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0dd6247d9f49558b5dc53b9eae6860d841c34c1e03edbaca61ba02ff59ec59a932d647c7f70eff731b1fe195ece4f3d2e999be2a783e0e9f1b57f97a166a5b25
|
|
7
|
+
data.tar.gz: bfc9d3fed994284ae6f0ec94a51e6599a754e1064e648092e595bd80d6224f8f4acc4e258a790de9fe5bbb9e9cfe07ede3d4c6d7bd633c3574ff9f8ec9a3e90a
|
data/lib/raindown/chart/chart.rb
CHANGED
|
@@ -19,7 +19,8 @@ module Raindown
|
|
|
19
19
|
|
|
20
20
|
# Label and value are the columns to chart, by name or index, and minus a column to chart against the values.
|
|
21
21
|
# Width is in characters, for bars and lines, and height in lines, for columns and lines.
|
|
22
|
-
|
|
22
|
+
# Tail keeps only the last points, like the latest months of a file that grows.
|
|
23
|
+
def initialize(data = nil, format: nil, label: nil, value: nil, minus: nil, width: 40, height: 8, tail: nil) # rubocop:disable Metrics/ParameterLists -- each is a prop
|
|
23
24
|
@data = data
|
|
24
25
|
@format = format
|
|
25
26
|
@label = label
|
|
@@ -27,6 +28,7 @@ module Raindown
|
|
|
27
28
|
@minus = minus
|
|
28
29
|
@width = width
|
|
29
30
|
@height = height
|
|
31
|
+
@tail = tail
|
|
30
32
|
end
|
|
31
33
|
|
|
32
34
|
# The data is read every time, so the chart follows changes to its file.
|
|
@@ -45,7 +47,16 @@ module Raindown
|
|
|
45
47
|
Points.new(label: @label, value: @value, minus: @minus, root:).from(@data)
|
|
46
48
|
end
|
|
47
49
|
|
|
50
|
+
# The chart's lines, as a card on the terminal UI's Dashboard shows them, or that there's no data.
|
|
51
|
+
def card_lines(metadata: NoMetadata)
|
|
52
|
+
points = points(metadata:)
|
|
53
|
+
return ['No data'] if points.empty?
|
|
54
|
+
|
|
55
|
+
draw(points).map(&:rstrip)
|
|
56
|
+
end
|
|
57
|
+
|
|
48
58
|
def draw(points)
|
|
59
|
+
points = points.last(@tail) if @tail
|
|
49
60
|
format = @format || (points.any?(&:first) ? 'bars' : 'sparkline')
|
|
50
61
|
drawing = FORMATS[format] || raise(Error, "format: is #{FORMATS.keys.join(', ')}, not #{format.inspect}")
|
|
51
62
|
|
data/lib/raindown/document.rb
CHANGED
|
@@ -49,7 +49,7 @@ module Raindown
|
|
|
49
49
|
|
|
50
50
|
# The headings in the order they appear, with their source text, like "Notes for {name}" before it's rendered.
|
|
51
51
|
def headings
|
|
52
|
-
@headings ||=
|
|
52
|
+
@headings ||= root.walk.select { |node| node.type == :heading }.map do |node|
|
|
53
53
|
Heading.new(level: node.header_level, text: text(node), line: node.source_position[:start_line])
|
|
54
54
|
end
|
|
55
55
|
end
|
|
@@ -71,10 +71,6 @@ module Raindown
|
|
|
71
71
|
@root ||= Commonmarker.parse(@markdown, options: MARKDOWN_OPTIONS)
|
|
72
72
|
end
|
|
73
73
|
|
|
74
|
-
def nodes(type)
|
|
75
|
-
[].tap { |nodes| root.walk { |node| nodes << node if node.type == type } }
|
|
76
|
-
end
|
|
77
|
-
|
|
78
74
|
def text(heading)
|
|
79
75
|
parts = []
|
|
80
76
|
heading.walk do |node|
|
|
@@ -66,19 +66,23 @@ module Raindown
|
|
|
66
66
|
end
|
|
67
67
|
|
|
68
68
|
# The snippet's method and URL, like GET https://httpbin.org/uuid, or nil when it can't be read.
|
|
69
|
-
# A snippet that sends data is a POST, unless it says otherwise, as with curl.
|
|
70
69
|
def request(snippet)
|
|
71
70
|
args = arguments(snippet)
|
|
72
|
-
url
|
|
73
|
-
method = args[args.index('--request') + 1] if args.include?('--request')
|
|
74
|
-
method ||= 'HEAD' if args.include?('--head')
|
|
75
|
-
method ||= 'POST' if args.intersect?(VALUE_CHECKS.keys.grep(/data|json/))
|
|
76
|
-
|
|
77
|
-
Request.new(method: (method || 'GET').upcase, url:) if url
|
|
71
|
+
Request.new(method: request_method(args), url: args.find { it.match?(URL) })
|
|
78
72
|
rescue Error
|
|
79
73
|
nil
|
|
80
74
|
end
|
|
81
75
|
|
|
76
|
+
# A snippet that sends data is a POST, unless it says otherwise, as with curl.
|
|
77
|
+
def request_method(args)
|
|
78
|
+
index = args.index('--request')
|
|
79
|
+
return args[index + 1].upcase if index
|
|
80
|
+
return 'HEAD' if args.include?('--head')
|
|
81
|
+
return 'POST' if args.intersect?(VALUE_CHECKS.keys.grep(/data|json/))
|
|
82
|
+
|
|
83
|
+
'GET'
|
|
84
|
+
end
|
|
85
|
+
|
|
82
86
|
def arguments(snippet)
|
|
83
87
|
words = Shellwords.split(snippet.gsub(/\\\r?\n/, ' ').strip.delete_prefix('$ '))
|
|
84
88
|
raise Error, 'Endpoint snippets must start with curl' unless words.first == 'curl'
|
data/lib/raindown/node_blocks.rb
CHANGED
|
@@ -77,13 +77,16 @@ module Raindown
|
|
|
77
77
|
end
|
|
78
78
|
|
|
79
79
|
def rendered_lines(lines, block, markdown:, metadata:, bare:)
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
else "#{Nodes.call(node, :replace, document: markdown, metadata:, bare:).to_s.chomp}\n".lines
|
|
83
|
-
end
|
|
84
|
-
output = [] if output == ["\n"]
|
|
80
|
+
output = node_markdown(block[:node], markdown:, metadata:, bare:).chomp
|
|
81
|
+
close = marker(lines[block[:close]]) if block[:close]
|
|
85
82
|
|
|
86
|
-
[marker(lines[block[:open]]), *output, *
|
|
83
|
+
[marker(lines[block[:open]]), *("#{output}\n".lines unless output.empty?), *close]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def node_markdown(node, markdown:, metadata:, bare:)
|
|
87
|
+
return Nodes.placeholder(node) if Nodes.html?(node)
|
|
88
|
+
|
|
89
|
+
Nodes.call(node, :replace, document: markdown, metadata:, bare:).to_s
|
|
87
90
|
end
|
|
88
91
|
|
|
89
92
|
def marker(line)
|
data/lib/raindown/nodes.rb
CHANGED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'node'
|
|
4
|
+
|
|
5
|
+
module Raindown
|
|
6
|
+
# The base of a project's stats: one big number in block digits, above its label, written into the Markdown as code,
|
|
7
|
+
# and shown on the terminal UI's Dashboard as a card, like a chart:
|
|
8
|
+
# ▀█ ▀▀█
|
|
9
|
+
# █ █▀▀
|
|
10
|
+
# ▀▀▀ ▀▀▀
|
|
11
|
+
# open issues
|
|
12
|
+
# A stat's a node in .raindown that says its value, and its label:
|
|
13
|
+
# class Issues < Raindown::Stat
|
|
14
|
+
# def value(metadata:) = Issue.open.count
|
|
15
|
+
# def label = 'open issues'
|
|
16
|
+
# end
|
|
17
|
+
class Stat < Node
|
|
18
|
+
# Each character's three lines in block digits, which end on the top half of the third, where a point sits too.
|
|
19
|
+
# Others are left as they are, on the middle line.
|
|
20
|
+
DIGITS = { '0' => ['█▀█', '█ █', '▀▀▀'], '1' => ['▀█ ', ' █ ', '▀▀▀'], '2' => ['▀▀█', '█▀▀', '▀▀▀'],
|
|
21
|
+
'3' => ['▀▀█', ' ▀█', '▀▀▀'], '4' => ['█ █', '▀▀█', ' ▀'], '5' => ['█▀▀', '▀▀█', '▀▀▀'],
|
|
22
|
+
'6' => ['█▀▀', '█▀█', '▀▀▀'], '7' => ['▀▀█', ' █', ' ▀'], '8' => ['█▀█', '█▀█', '▀▀▀'],
|
|
23
|
+
'9' => ['█▀█', '▀▀█', '▀▀▀'], '-' => [' ', '▀▀▀', ' '], '+' => [' ▄ ', '▀█▀', ' '],
|
|
24
|
+
'.' => [' ', ' ', '▀'], ',' => [' ', ' ', '▀'] }.freeze
|
|
25
|
+
|
|
26
|
+
def replace(metadata: NoMetadata) = "```\n#{card_lines(metadata:).join("\n")}\n```"
|
|
27
|
+
|
|
28
|
+
# The number in block digits, then the label, as a card on the Dashboard shows them.
|
|
29
|
+
def card_lines(metadata: NoMetadata)
|
|
30
|
+
characters = number(value(metadata:)).chars.map { digit(it) }
|
|
31
|
+
[*Array.new(3) { |line| characters.map { it[line] }.join(' ').rstrip }, label]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def value(metadata: NoMetadata) = raise(NotImplementedError, "#{self.class} needs a value")
|
|
35
|
+
|
|
36
|
+
def label = ''
|
|
37
|
+
|
|
38
|
+
# How the value's written, which a stat can change, like with a sign.
|
|
39
|
+
def number(value) = value.to_s
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# The character's three lines in block digits, or itself on the middle line.
|
|
44
|
+
def digit(character) = DIGITS.fetch(character) { [' ', character, ' '] }
|
|
45
|
+
|
|
46
|
+
# The project's folder, from the page being rendered, or else the current one.
|
|
47
|
+
def root(metadata)
|
|
48
|
+
return metadata.root if metadata.respond_to?(:root)
|
|
49
|
+
|
|
50
|
+
Dir.pwd
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
data/lib/raindown/tui/events.rb
CHANGED
|
@@ -7,6 +7,12 @@ module Raindown
|
|
|
7
7
|
# Keys are their events' names, like :down or :enter.
|
|
8
8
|
Click = Data.define(:pane, :index, :action)
|
|
9
9
|
|
|
10
|
+
# The mouse moving over what a click there would send, if anything, to show it, like a method's cells in bold.
|
|
11
|
+
Hover = Data.define(:target)
|
|
12
|
+
|
|
13
|
+
# A character typed while typing, like in the search, which other keys' events would take, like "j" for :down.
|
|
14
|
+
Type = Data.define(:text)
|
|
15
|
+
|
|
10
16
|
# What the Screen does after an event, other than drawing again, or stopping for :quit.
|
|
11
17
|
# Leaves the screen for a command, like the editor, then tells the Navigator it ran.
|
|
12
18
|
Run = Data.define(:command)
|
|
@@ -15,7 +21,8 @@ module Raindown
|
|
|
15
21
|
Wait = Data.define(:message, :task)
|
|
16
22
|
|
|
17
23
|
# Steps a row or a view asks of the Navigator, which each takes on it, returning what the Screen does next: to open
|
|
18
|
-
# a view right of the files, or a file in the outline at a directive, to edit a file at a line,
|
|
24
|
+
# a view right of the files, or a file in the outline at a directive, to edit a file at a line, to run an endpoint,
|
|
25
|
+
# to draw the page a feeling was noted down in, or to ask Claude Code to fix a method.
|
|
19
26
|
OpenView = Data.define(:view) do
|
|
20
27
|
def take(navigator) = navigator.open_view(view)
|
|
21
28
|
end
|
|
@@ -31,5 +38,13 @@ module Raindown
|
|
|
31
38
|
Execute = Data.define(:path, :line, :name) do
|
|
32
39
|
def take(navigator) = navigator.run(path, line, name)
|
|
33
40
|
end
|
|
41
|
+
|
|
42
|
+
Felt = Data.define(:feeling) do
|
|
43
|
+
def take(navigator) = navigator.felt(feeling)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
Fix = Data.define(:feeling) do
|
|
47
|
+
def take(navigator) = navigator.fix_with_agent(feeling)
|
|
48
|
+
end
|
|
34
49
|
end
|
|
35
50
|
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'date'
|
|
4
|
+
require 'zlib'
|
|
5
|
+
|
|
6
|
+
module Raindown
|
|
7
|
+
module TUI
|
|
8
|
+
# Files to feel, like the files Severance's refiners work through: the first in the week's shuffled order, as many as
|
|
9
|
+
# fill the grid's cells, with their methods shuffled together. The order is random, but the same all week, so the
|
|
10
|
+
# grid stays put through reloads, edits and restarts, and once it's refined, that's the week's work done.
|
|
11
|
+
Batch = Data.define(:files, :code_methods, :log, :week) do
|
|
12
|
+
# Like 2026-W44, from the ISO year and week.
|
|
13
|
+
def self.this_week(date = Date.today) = "#{date.cwyear}-W#{date.cweek}"
|
|
14
|
+
|
|
15
|
+
def self.pick(every, log:, cells:, week: this_week)
|
|
16
|
+
files = filling(every.group_by(&:path).sort_by { |path, _| Zlib.crc32("#{week}:#{path}") }, cells)
|
|
17
|
+
|
|
18
|
+
new(files:, code_methods: [], log:, week:).reading(every)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# The files, in order, whose methods' cells fit in the grid's, passing over any too big for what's left, and
|
|
22
|
+
# at least the first.
|
|
23
|
+
def self.filling(files, cells)
|
|
24
|
+
used = 0
|
|
25
|
+
files.each_with_object([]) do |(path, methods), picked|
|
|
26
|
+
size = methods.sum(&:size)
|
|
27
|
+
next unless picked.empty? || used + size <= cells
|
|
28
|
+
|
|
29
|
+
picked << path
|
|
30
|
+
used += size
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The same files, with their methods read again, like after one's edited.
|
|
35
|
+
def reading(every)
|
|
36
|
+
with(code_methods: every.select { files.include?(it.path) }.sort_by { Zlib.crc32("#{week}:#{it.path}:#{it.label}") })
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def felt?(method) = log.felt?(method)
|
|
40
|
+
def refined? = code_methods.all? { felt?(it) }
|
|
41
|
+
|
|
42
|
+
def progress
|
|
43
|
+
"#{code_methods.count { felt?(it) } * 100 / [code_methods.length, 1].max}% refined"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'feelings_log'
|
|
4
|
+
|
|
5
|
+
module Raindown
|
|
6
|
+
module TUI
|
|
7
|
+
# Code feelings' bins below the code box, one for each temper, best to worst, each a box with its key's number, its
|
|
8
|
+
# temper and a bar of how many feelings it holds, highlighted when it's the chosen temper:
|
|
9
|
+
# ╭─ 01 ────╮ ╭─ 02 ────╮ ╭─ 03 ────╮ ╭─ 04 ────╮ ╭─ 05 ────╮ ╭─ 06 ────╮
|
|
10
|
+
# │ FROLIC │ │ HOPE │ │ NUMB │ │ WOE │ │ DREAD │ │ MALICE │
|
|
11
|
+
# │ ███░ 9 │ │ ██░░ 5 │ │ ████ 41 │ │ ██░░ 6 │ │ █░░░ 3 │ │ █░░░ 2 │
|
|
12
|
+
# ╰─────────╯ ╰─────────╯ ╰─────────╯ ╰─────────╯ ╰─────────╯ ╰─────────╯
|
|
13
|
+
module Bins
|
|
14
|
+
# A bin's lines.
|
|
15
|
+
BIN_LINES = 4
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
# The six bins, each a box with its number, its temper, and a bar of how many feelings it holds.
|
|
20
|
+
def bins
|
|
21
|
+
counts = view.log.counts
|
|
22
|
+
most = [counts.values.max, 1].max
|
|
23
|
+
chosen = chosen_temper
|
|
24
|
+
boxes = counts.each_with_index.map { |(temper, count), index| bin(index + 1, temper, count, most, chosen) }
|
|
25
|
+
|
|
26
|
+
Array.new(BIN_LINES) do |line|
|
|
27
|
+
padded([segment('', :blank, width: 1), *boxes.flat_map { [it[line], segment(' ', :blank, width: 1)] }])
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The temper being noted, or else how the selected method felt, if it's been felt.
|
|
32
|
+
def chosen_temper = view.noting || view.log.temper(view.selected)
|
|
33
|
+
|
|
34
|
+
# As wide as they all fit, which cuts their borders short in a pane too narrow for them.
|
|
35
|
+
def bin_width = [(width - 1 - FeelingsLog::TEMPERS.length) / FeelingsLog::TEMPERS.length, 1].max
|
|
36
|
+
|
|
37
|
+
# A space inside a bin's sides, unless the longest temper would only fit without it.
|
|
38
|
+
def bin_inside
|
|
39
|
+
return '' if bin_width < FeelingsLog::TEMPERS.map(&:length).max + 4
|
|
40
|
+
|
|
41
|
+
' '
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The bin's lines, highlighted when it's the chosen temper: the one being noted, or how the selected method felt.
|
|
45
|
+
def bin(number, temper, count, most, chosen)
|
|
46
|
+
style = :feelings
|
|
47
|
+
style = :selected if temper == chosen
|
|
48
|
+
bin_lines(number, temper, count, most).map { segment(it, style, width: bin_width) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Its key's number on its top line, its temper, the bar of its count, and its bottom.
|
|
52
|
+
def bin_lines(number, temper, count, most)
|
|
53
|
+
inside = bin_inside
|
|
54
|
+
name = temper.upcase.ljust(bin_width - 2 - (2 * inside.length))
|
|
55
|
+
["╭─ 0#{number} #{'─' * [bin_width - 7, 0].max}╮", "│#{inside}#{name}#{inside}│",
|
|
56
|
+
"│#{inside}#{bar(count, most)} #{count}#{inside}│", "╰#{'─' * (bin_width - 2)}╯"]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# As long as there's room beside the count, and filled as far as its share of the fullest bin's.
|
|
60
|
+
def bar(count, most)
|
|
61
|
+
length = bin_width - 3 - (2 * bin_inside.length) - count.to_s.length
|
|
62
|
+
filled = (length * count / most.to_f).round
|
|
63
|
+
"#{'█' * filled}#{'░' * (length - filled)}"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Raindown
|
|
4
|
+
module TUI
|
|
5
|
+
# When a batch of code feelings is refined, like Severance's waffle party: rain, a frame each tick, for about five
|
|
6
|
+
# seconds of the Screen's 60 ms frames, then a waffle, until a key's pressed.
|
|
7
|
+
class Celebration
|
|
8
|
+
RAIN_FRAMES = 83
|
|
9
|
+
|
|
10
|
+
attr_reader :frame
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
@frame = 0
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def raining? = frame < RAIN_FRAMES
|
|
17
|
+
|
|
18
|
+
# Each tick's a frame, and any other key puts the waffle away, which is when it's over.
|
|
19
|
+
def over?(key)
|
|
20
|
+
@frame += 1 if key == :tick
|
|
21
|
+
key != :tick
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -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
|