raindown 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/nodes/endpoint_node.rb +3 -2
- data/lib/raindown/actions.rb +5 -3
- data/lib/raindown/bare_directives.rb +14 -27
- data/lib/raindown/blueprints.rb +255 -0
- data/lib/raindown/chart/chart.rb +12 -1
- data/lib/raindown/cli.rb +39 -54
- data/lib/raindown/config.rb +2 -3
- data/lib/raindown/directives.rb +30 -13
- data/lib/raindown/document.rb +22 -20
- data/lib/raindown/endpoint/curl.rb +11 -7
- data/lib/raindown/endpoint/endpoint_runner.rb +13 -5
- data/lib/raindown/node_blocks.rb +31 -22
- data/lib/raindown/node_injector.rb +7 -10
- data/lib/raindown/nodes.rb +1 -0
- data/lib/raindown/pages.rb +1 -1
- data/lib/raindown/pre_renderer.rb +18 -0
- data/lib/raindown/stat.rb +53 -0
- data/lib/raindown/tui/blueprints/blueprints_renderer.rb +93 -0
- data/lib/raindown/tui/blueprints/blueprints_view.rb +146 -0
- data/lib/raindown/tui/blueprints/canvas.rb +58 -0
- data/lib/raindown/tui/blueprints/code_comments.rb +71 -0
- data/lib/raindown/tui/blueprints/filter.rb +77 -0
- data/lib/raindown/tui/blueprints/traced_code.rb +56 -0
- data/lib/raindown/tui/blueprints/tracing.rb +129 -0
- data/lib/raindown/tui/code_box.rb +143 -0
- data/lib/raindown/tui/endpoints.rb +2 -2
- data/lib/raindown/tui/events.rb +18 -1
- data/lib/raindown/tui/feelings/batch.rb +47 -0
- data/lib/raindown/tui/feelings/bins.rb +67 -0
- data/lib/raindown/tui/feelings/celebration/celebration.rb +28 -0
- data/lib/raindown/tui/feelings/celebration/fireworks.rb +75 -0
- data/lib/raindown/tui/feelings/celebration/rain.rb +66 -0
- data/lib/raindown/tui/feelings/celebration/scene.rb +40 -0
- data/lib/raindown/tui/feelings/cell_moves.rb +34 -0
- data/lib/raindown/tui/feelings/code_methods.rb +134 -0
- data/lib/raindown/tui/feelings/feelings_code_box.rb +40 -0
- data/lib/raindown/tui/feelings/feelings_log.rb +136 -0
- data/lib/raindown/tui/feelings/feelings_renderer.rb +183 -0
- data/lib/raindown/tui/feelings/feelings_view.rb +157 -0
- data/lib/raindown/tui/feelings/magnifier.rb +68 -0
- data/lib/raindown/tui/feelings/motion.rb +93 -0
- data/lib/raindown/tui/feelings/waffle/baked_waffle.rb +88 -0
- data/lib/raindown/tui/feelings/waffle/balloons.rb +81 -0
- data/lib/raindown/tui/feelings/waffle/confetti.rb +55 -0
- data/lib/raindown/tui/feelings/waffle/sparkles.rb +61 -0
- data/lib/raindown/tui/feelings/waffle/waffle.rb +62 -0
- data/lib/raindown/tui/feelings/weekly_batch.rb +51 -0
- data/lib/raindown/tui/file_tree.rb +6 -5
- data/lib/raindown/tui/highlighter.rb +45 -0
- data/lib/raindown/tui/layout.rb +32 -18
- data/lib/raindown/tui/navigator/commands.rb +46 -2
- data/lib/raindown/tui/navigator/mouse.rb +43 -0
- data/lib/raindown/tui/navigator/navigator.rb +54 -25
- data/lib/raindown/tui/navigator/teleport.rb +128 -0
- data/lib/raindown/tui/navigator/views.rb +5 -3
- data/lib/raindown/tui/{workers/project.rb → project.rb} +38 -27
- data/lib/raindown/tui/{menu_overlay.rb → project_box.rb} +2 -2
- data/lib/raindown/tui/renderers/dashboard_renderer.rb +4 -4
- data/lib/raindown/tui/renderers/endpoint_renderer.rb +14 -10
- data/lib/raindown/tui/renderers/outline_renderer.rb +4 -4
- data/lib/raindown/tui/renderers/renderer.rb +4 -0
- data/lib/raindown/tui/row.rb +4 -2
- data/lib/raindown/tui/rows.rb +2 -1
- data/lib/raindown/tui/saves.rb +22 -0
- data/lib/raindown/tui/search_box.rb +86 -0
- data/lib/raindown/tui/terminal/input.rb +95 -7
- data/lib/raindown/tui/terminal/screen.rb +37 -29
- data/lib/raindown/tui/terminal/styles.rb +111 -26
- data/lib/raindown/tui/terminal/terminal_title.rb +31 -0
- data/lib/raindown/tui/text.rb +62 -12
- data/lib/raindown/tui/title.rb +1 -1
- data/lib/raindown/tui/views/dashboard_view.rb +7 -5
- data/lib/raindown/tui/views/endpoint_view.rb +4 -2
- data/lib/raindown/tui/views/list_view.rb +2 -0
- data/lib/raindown/tui/views/outline_view.rb +16 -6
- data/lib/raindown/tui.rb +19 -7
- data/lib/raindown/watcher/changes.rb +33 -0
- data/lib/raindown/watcher/file_watcher.rb +58 -0
- data/lib/raindown/watcher/project.rb +47 -0
- data/lib/raindown/watcher/supervisor.rb +111 -0
- data/lib/raindown/{tui/workers → workers}/worker.rb +37 -29
- data/lib/raindown/workers/workers.rb +63 -0
- data/lib/raindown.rb +4 -4
- data/lib/version.rb +1 -1
- metadata +60 -9
- data/lib/raindown/project_worker.rb +0 -48
- data/lib/raindown/supervisor.rb +0 -160
- data/lib/raindown/tui/workers/workers.rb +0 -53
- data/lib/raindown/watcher.rb +0 -57
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
require_relative 'endpoint'
|
|
4
4
|
require_relative 'views/endpoint_view'
|
|
5
5
|
require_relative 'row'
|
|
6
|
-
require_relative 'workers/worker'
|
|
6
|
+
require_relative '../workers/worker'
|
|
7
7
|
|
|
8
8
|
module Raindown
|
|
9
9
|
module TUI
|
|
@@ -22,7 +22,7 @@ module Raindown
|
|
|
22
22
|
def load
|
|
23
23
|
@endpoints = @workers.each.flat_map do |worker|
|
|
24
24
|
worker.call(:endpoints)
|
|
25
|
-
rescue Worker::Error
|
|
25
|
+
rescue Workers::Worker::Error
|
|
26
26
|
[]
|
|
27
27
|
end
|
|
28
28
|
end
|
data/lib/raindown/tui/events.rb
CHANGED
|
@@ -7,6 +7,14 @@ 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
|
+
# The mouse moved with its button held, by columns across and rows down, like to drag a blueprint's canvas.
|
|
13
|
+
Drag = Data.define(:columns, :rows)
|
|
14
|
+
|
|
15
|
+
# A character typed while typing, like in Teleport, which other keys' events would take, like "j" for :down.
|
|
16
|
+
Type = Data.define(:text)
|
|
17
|
+
|
|
10
18
|
# What the Screen does after an event, other than drawing again, or stopping for :quit.
|
|
11
19
|
# Leaves the screen for a command, like the editor, then tells the Navigator it ran.
|
|
12
20
|
Run = Data.define(:command)
|
|
@@ -15,7 +23,8 @@ module Raindown
|
|
|
15
23
|
Wait = Data.define(:message, :task)
|
|
16
24
|
|
|
17
25
|
# 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,
|
|
26
|
+
# 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,
|
|
27
|
+
# to draw the page a feeling was noted down in, or to ask Claude Code to fix a method.
|
|
19
28
|
OpenView = Data.define(:view) do
|
|
20
29
|
def take(navigator) = navigator.open_view(view)
|
|
21
30
|
end
|
|
@@ -31,5 +40,13 @@ module Raindown
|
|
|
31
40
|
Execute = Data.define(:path, :line, :name) do
|
|
32
41
|
def take(navigator) = navigator.run(path, line, name)
|
|
33
42
|
end
|
|
43
|
+
|
|
44
|
+
Felt = Data.define(:feeling) do
|
|
45
|
+
def take(navigator) = navigator.felt(feeling)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
Fix = Data.define(:feeling) do
|
|
49
|
+
def take(navigator) = navigator.fix_with_agent(feeling)
|
|
50
|
+
end
|
|
34
51
|
end
|
|
35
52
|
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 ────╮
|
|
10
|
+
# │ FROLIC │ │ NUMB │ │ WOE │ │ DREAD │ │ MALICE │
|
|
11
|
+
# │ ███░ 9 │ │ ████ 41 │ │ ██░░ 6 │ │ █░░░ 3 │ │ █░░░ 2 │
|
|
12
|
+
# ╰─────────╯ ╰─────────╯ ╰─────────╯ ╰─────────╯ ╰─────────╯
|
|
13
|
+
module Bins
|
|
14
|
+
# A bin's lines.
|
|
15
|
+
BIN_LINES = 4
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
# The five 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,28 @@
|
|
|
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 50 ms frames, then a waffle, until a key's pressed.
|
|
7
|
+
class Celebration
|
|
8
|
+
RAIN_FRAMES = 100
|
|
9
|
+
|
|
10
|
+
attr_reader :frame
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
@frame = 0
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def raining? = frame < RAIN_FRAMES
|
|
17
|
+
|
|
18
|
+
# The frames since the rain stopped, which pop the waffle.
|
|
19
|
+
def popping = frame - RAIN_FRAMES
|
|
20
|
+
|
|
21
|
+
# Each tick's a frame, and any other key puts the waffle away, which is when it's over.
|
|
22
|
+
def over?(key)
|
|
23
|
+
@frame += 1 if key == :tick
|
|
24
|
+
key != :tick
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'zlib'
|
|
4
|
+
|
|
5
|
+
require_relative 'rain'
|
|
6
|
+
|
|
7
|
+
module Raindown
|
|
8
|
+
module TUI
|
|
9
|
+
# Fireworks, like over the Rain when a batch of code feelings is refined: rockets rising from the bottom, a row a
|
|
10
|
+
# tick, that burst in a bright colour into sparks flying out eight ways, which fade, then another rises in their
|
|
11
|
+
# place. Sparks fly twice as far across as up or down, as the cells are about twice as tall as they're wide:
|
|
12
|
+
# · · ·
|
|
13
|
+
# + + +
|
|
14
|
+
# * * *
|
|
15
|
+
# · + * * * + ·
|
|
16
|
+
# * * *
|
|
17
|
+
# + + +
|
|
18
|
+
# · · ·
|
|
19
|
+
# ╵
|
|
20
|
+
# ·
|
|
21
|
+
class Fireworks
|
|
22
|
+
# The columns for each rocket in the air at once.
|
|
23
|
+
COLUMNS_A_ROCKET = 20
|
|
24
|
+
# The sparks, a frame each, as they fly out from one in the middle and fade.
|
|
25
|
+
SPARKS = %w[* * * + + · ·].freeze
|
|
26
|
+
# The eight ways sparks fly, as columns and rows a frame.
|
|
27
|
+
WAYS = [[-2, -1], [0, -1], [2, -1], [-2, 0], [2, 0], [-2, 1], [0, 1], [2, 1]].freeze
|
|
28
|
+
# Where a rocket rises, the row it bursts on, and its colour.
|
|
29
|
+
Rocket = Data.define(:column, :top, :color)
|
|
30
|
+
|
|
31
|
+
attr_reader :width, :height
|
|
32
|
+
|
|
33
|
+
def initialize(width:, height:)
|
|
34
|
+
@width = width
|
|
35
|
+
@height = height
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Draws the rockets and bursts at the tick into a Scene's cells, each rocket a tick apart from the others by a
|
|
39
|
+
# checksum of it.
|
|
40
|
+
def draw(cells, tick)
|
|
41
|
+
[width / COLUMNS_A_ROCKET, 1].max.times do |rocket|
|
|
42
|
+
launch, frame = (tick + Zlib.crc32("rocket #{rocket}")).divmod(height + SPARKS.length)
|
|
43
|
+
firework(cells, rocket(Zlib.crc32("rocket #{rocket} #{launch}")), frame)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# A rocket, from the seed, a checksum of it and how many it's launched, bursting in the top half or so.
|
|
50
|
+
def rocket(seed)
|
|
51
|
+
Rocket.new(column: seed % width, top: (height / 5) + ((seed >> 8) % [height / 3, 1].max),
|
|
52
|
+
color: Rain::COLORS[(seed >> 16) % Rain::COLORS.length])
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The rocket rising, with a spark trailing it, or once it's as high as it goes, bursting.
|
|
56
|
+
def firework(cells, rocket, frame)
|
|
57
|
+
rising = height - 1 - rocket.top
|
|
58
|
+
return burst(cells, rocket, frame - rising) if frame >= rising
|
|
59
|
+
|
|
60
|
+
put(cells, rocket.column, height - 1 - frame, '╵', rocket.color)
|
|
61
|
+
put(cells, rocket.column, height - frame, '·', rocket.color)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def burst(cells, rocket, frame)
|
|
65
|
+
return unless (spark = SPARKS[frame])
|
|
66
|
+
|
|
67
|
+
WAYS.each { |across, down| put(cells, rocket.column + (across * frame), rocket.top + (down * frame), spark, rocket.color) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def put(cells, column, row, char, color)
|
|
71
|
+
cells[row][column] = [char, color] if row.between?(0, height - 1) && column.between?(0, width - 1)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'zlib'
|
|
4
|
+
|
|
5
|
+
module Raindown
|
|
6
|
+
module TUI
|
|
7
|
+
# Rain, like when a batch of code feelings is refined: drops falling down some of the columns, each at its own speed
|
|
8
|
+
# and in a bright colour of its own, that splash and spread when they hit the bottom, then fall again from the top
|
|
9
|
+
# in another colour:
|
|
10
|
+
# │ ╎
|
|
11
|
+
# ╎ │ │
|
|
12
|
+
# │ ╎ │
|
|
13
|
+
# · · ◦ . .
|
|
14
|
+
class Rain
|
|
15
|
+
# One column in RAINY has a drop.
|
|
16
|
+
RAINY = 4
|
|
17
|
+
# The splash, a frame each after a drop hits the bottom, as its distance either side of the drop's column.
|
|
18
|
+
SPLASH = [[0, '◦'], [1, '·'], [2, '.']].freeze
|
|
19
|
+
COLORS = %i[bright_red bright_orange bright_yellow bright_green bright_cyan bright_blue bright_purple bright_pink].freeze
|
|
20
|
+
|
|
21
|
+
attr_reader :width, :height
|
|
22
|
+
|
|
23
|
+
def initialize(width:, height:)
|
|
24
|
+
@width = width
|
|
25
|
+
@height = height
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Draws the drops at the tick into a Scene's cells.
|
|
29
|
+
def draw(cells, tick)
|
|
30
|
+
width.times { drop(cells, it, Zlib.crc32("rain #{it}"), tick) }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# The column's drop, if it has one, from the seed, a checksum of the column, like how fast it falls and when it
|
|
36
|
+
# starts.
|
|
37
|
+
def drop(cells, column, seed, tick)
|
|
38
|
+
return unless (seed % RAINY).zero?
|
|
39
|
+
|
|
40
|
+
falling = (height + speed(seed) - 1) / speed(seed)
|
|
41
|
+
fallen, frame = (tick + (seed >> 8)).divmod(falling + SPLASH.length)
|
|
42
|
+
color = color(column, fallen)
|
|
43
|
+
return splash(cells, column, frame - falling, color) if frame >= falling
|
|
44
|
+
|
|
45
|
+
fall(cells, column, frame * speed(seed), color)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# A row or two a tick.
|
|
49
|
+
def speed(seed) = ((seed >> 4) % 2) + 1
|
|
50
|
+
|
|
51
|
+
# A colour each time the column's drop falls, from a checksum of how many times it has.
|
|
52
|
+
def color(column, fallen) = COLORS[Zlib.crc32("#{column} #{fallen}") % COLORS.length]
|
|
53
|
+
|
|
54
|
+
# The drop at the row, and its tail above it.
|
|
55
|
+
def fall(cells, column, row, color)
|
|
56
|
+
cells[row][column] = ['│', color]
|
|
57
|
+
cells[row - 1][column] = ['╎', color] if row.positive?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def splash(cells, column, frame, color)
|
|
61
|
+
spread, char = SPLASH[frame]
|
|
62
|
+
[column - spread, column + spread].uniq.each { cells[height - 1][it] = [char, color] if it.between?(0, width - 1) }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../../text'
|
|
4
|
+
require_relative '../waffle/balloons'
|
|
5
|
+
require_relative '../waffle/confetti'
|
|
6
|
+
require_relative '../waffle/sparkles'
|
|
7
|
+
require_relative 'fireworks'
|
|
8
|
+
require_relative 'rain'
|
|
9
|
+
|
|
10
|
+
module Raindown
|
|
11
|
+
module TUI
|
|
12
|
+
# Animations drawn over each other, each into the same cells, a character and its style each, in turn, so the later
|
|
13
|
+
# ones are on top, like Fireworks over the Rain.
|
|
14
|
+
class Scene
|
|
15
|
+
include Text
|
|
16
|
+
|
|
17
|
+
attr_reader :width, :height
|
|
18
|
+
|
|
19
|
+
def initialize(width:, height:, animations: [Rain, Fireworks])
|
|
20
|
+
@width = width
|
|
21
|
+
@height = height
|
|
22
|
+
@animations = animations.map { it.new(width:, height:) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# The lines at the tick, as segments.
|
|
26
|
+
def lines(tick)
|
|
27
|
+
cells = Array.new(height) { Array.new(width) { [' ', :blank] } }
|
|
28
|
+
@animations.each { it.draw(cells, tick) }
|
|
29
|
+
cells.map { segments(it) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# The row's runs of cells in the same style, a segment each.
|
|
35
|
+
def segments(row)
|
|
36
|
+
row.chunk_while { |cell, after| cell[1] == after[1] }.map { Segment.new(text: it.map(&:first).join, style: it[0][1], event: nil) }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Raindown
|
|
4
|
+
module TUI
|
|
5
|
+
# The cells Code feelings' methods take in its grid, and how its cursor moves between them, along them and a row up
|
|
6
|
+
# or down.
|
|
7
|
+
module CellMoves
|
|
8
|
+
# Each cell's method, by its index, in reading order, in a grid as many cells across as the columns.
|
|
9
|
+
# They're kept, so up and down move a row.
|
|
10
|
+
# The first time the grid's drawn, with its rows, right after the view opens, the batch is picked to fill them, and
|
|
11
|
+
# again a while after the screen's resized, keeping the selected method where it's still in the batch.
|
|
12
|
+
def cells(columns, rows = nil)
|
|
13
|
+
was = selected
|
|
14
|
+
reselect(was) if rows && @week.fill(columns * rows)
|
|
15
|
+
@columns = columns
|
|
16
|
+
@cells = code_methods.each_with_index.flat_map { |method, index| [index] * method.size }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def move(by)
|
|
22
|
+
self.cursor = @cursor + by
|
|
23
|
+
nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# To the method in the cell above or below the selected one's first cell.
|
|
27
|
+
def move_row(by)
|
|
28
|
+
cell = @cells[@cells.index(@cursor).to_i + (by * @columns)]
|
|
29
|
+
self.cursor = cell if cell
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
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,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../code_box'
|
|
4
|
+
|
|
5
|
+
module Raindown
|
|
6
|
+
module TUI
|
|
7
|
+
# Code feelings' CodeBox below the grid: the selected method's code, where it is and how many commits changed its
|
|
8
|
+
# file, then why it felt that way while that's typed, below a line.
|
|
9
|
+
module FeelingsCodeBox
|
|
10
|
+
include CodeBox
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def boxed_method = view.selected
|
|
15
|
+
def box_title(method) = "#{where(method)} · #{method.label}"
|
|
16
|
+
def box_note(method) = " changed #{method.changes}× ─"
|
|
17
|
+
def box_style = :feelings
|
|
18
|
+
def box_parts(method) = highlighted(method)
|
|
19
|
+
|
|
20
|
+
# As many lines as fit expanded, with the bins and a few of the grid's rows left above it.
|
|
21
|
+
def box_room = height - Bins::BIN_LINES - BOX_LINES - MIN_GRID_ROWS
|
|
22
|
+
|
|
23
|
+
def box_footer
|
|
24
|
+
return [] unless view.typing?
|
|
25
|
+
|
|
26
|
+
note_lines
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Why it felt that way, as it's typed, below a line, in as many lines as it takes.
|
|
30
|
+
def note_lines = [box_edge('├', '┤'), *hanging("Why #{view.noting.upcase}? > ", "#{view.note}▏").map { boxed([[it, :feelings]]) }]
|
|
31
|
+
|
|
32
|
+
# The text after the lead, wrapped to fit the box, with the lines after the first lined up under the text.
|
|
33
|
+
def hanging(lead, text)
|
|
34
|
+
indent = ' ' * display_width(lead)
|
|
35
|
+
lines = wrap(text, width - 6 - indent.length)
|
|
36
|
+
[lead, *Array.new(lines.length - 1, indent)].zip(lines).map(&:join)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|