raindown 0.6.0 → 0.8.0

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