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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/lib/raindown/chart/chart.rb +12 -1
  3. data/lib/raindown/document.rb +1 -5
  4. data/lib/raindown/endpoint/curl.rb +11 -7
  5. data/lib/raindown/node_blocks.rb +9 -6
  6. data/lib/raindown/nodes.rb +1 -0
  7. data/lib/raindown/stat.rb +53 -0
  8. data/lib/raindown/tui/events.rb +16 -1
  9. data/lib/raindown/tui/feelings/batch.rb +47 -0
  10. data/lib/raindown/tui/feelings/bins.rb +67 -0
  11. data/lib/raindown/tui/feelings/celebration.rb +25 -0
  12. data/lib/raindown/tui/feelings/code_box.rb +129 -0
  13. data/lib/raindown/tui/feelings/code_methods.rb +134 -0
  14. data/lib/raindown/tui/feelings/feelings_log.rb +133 -0
  15. data/lib/raindown/tui/feelings/feelings_renderer.rb +184 -0
  16. data/lib/raindown/tui/feelings/feelings_view.rb +161 -0
  17. data/lib/raindown/tui/feelings/fireworks.rb +75 -0
  18. data/lib/raindown/tui/feelings/rain.rb +66 -0
  19. data/lib/raindown/tui/feelings/scene.rb +37 -0
  20. data/lib/raindown/tui/feelings/waffle.rb +43 -0
  21. data/lib/raindown/tui/feelings/weekly_batch.rb +32 -0
  22. data/lib/raindown/tui/file_tree.rb +6 -5
  23. data/lib/raindown/tui/highlighter.rb +45 -0
  24. data/lib/raindown/tui/layout.rb +14 -7
  25. data/lib/raindown/tui/menu_overlay.rb +1 -1
  26. data/lib/raindown/tui/navigator/commands.rb +14 -1
  27. data/lib/raindown/tui/navigator/navigator.rb +42 -9
  28. data/lib/raindown/tui/navigator/search.rb +126 -0
  29. data/lib/raindown/tui/navigator/views.rb +6 -3
  30. data/lib/raindown/tui/renderers/dashboard_renderer.rb +4 -4
  31. data/lib/raindown/tui/renderers/endpoint_renderer.rb +14 -10
  32. data/lib/raindown/tui/renderers/outline_renderer.rb +4 -4
  33. data/lib/raindown/tui/renderers/renderer.rb +3 -0
  34. data/lib/raindown/tui/row.rb +4 -2
  35. data/lib/raindown/tui/rows.rb +2 -1
  36. data/lib/raindown/tui/search_overlay.rb +73 -0
  37. data/lib/raindown/tui/terminal/input.rb +42 -5
  38. data/lib/raindown/tui/terminal/screen.rb +12 -9
  39. data/lib/raindown/tui/terminal/styles.rb +78 -20
  40. data/lib/raindown/tui/text.rb +27 -0
  41. data/lib/raindown/tui/title.rb +1 -1
  42. data/lib/raindown/tui/views/dashboard_view.rb +6 -4
  43. data/lib/raindown/tui/views/endpoint_view.rb +2 -0
  44. data/lib/raindown/tui/views/list_view.rb +2 -0
  45. data/lib/raindown/tui/views/outline_view.rb +11 -2
  46. data/lib/raindown/tui/workers/project.rb +6 -6
  47. data/lib/raindown/tui.rb +2 -0
  48. data/lib/version.rb +1 -1
  49. metadata +33 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c9a61e7a842dcd5dc4cc5f6d004db388173ef2d5c48af1a50e32fea6c317b678
4
- data.tar.gz: 7bbae724e020d48a3f095ba1cc8e1c45b9ee5dd483daf0af5f8edd4333d83205
3
+ metadata.gz: ad2e1df6e9cbfbac50533a9d6dd7f1d8bedb254aa6277a1889fb380871c52ec1
4
+ data.tar.gz: d32bbefdbcae559679033db6b1fc7e1dc4846f6a6356c0944e711cf50699eab0
5
5
  SHA512:
6
- metadata.gz: b8f312582ede3900be43490c52c54ce3df6292764c04c177f54510e397a4814c9a9184cbb0710113055bb0dd140f00ced0bcf309f59583d34485a900579ac64b
7
- data.tar.gz: 932d51981cd38660a402a40bbf9b0015da35baf62bad04152cdfdc262287f9593643daf66d2b60b6282e4df8a3de553a9ebba1793b37bec0972c145ea275c5a0
6
+ metadata.gz: 0dd6247d9f49558b5dc53b9eae6860d841c34c1e03edbaca61ba02ff59ec59a932d647c7f70eff731b1fe195ece4f3d2e999be2a783e0e9f1b57f97a166a5b25
7
+ data.tar.gz: bfc9d3fed994284ae6f0ec94a51e6599a754e1064e648092e595bd80d6224f8f4acc4e258a790de9fe5bbb9e9cfe07ede3d4c6d7bd633c3574ff9f8ec9a3e90a
@@ -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
- def initialize(data = nil, format: nil, label: nil, value: nil, minus: nil, width: 40, height: 8) # rubocop:disable Metrics/ParameterLists -- each is a prop
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
 
@@ -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 ||= nodes(:heading).map do |node|
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 = args.find { it.match?(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'
@@ -77,13 +77,16 @@ module Raindown
77
77
  end
78
78
 
79
79
  def rendered_lines(lines, block, markdown:, metadata:, bare:)
80
- node = block[:node]
81
- output = if Nodes.html?(node) then ["#{Nodes.placeholder(node)}\n"]
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, *(marker(lines[block[:close]]) if block[:close])]
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)
@@ -3,6 +3,7 @@
3
3
  require_relative 'chart/chart'
4
4
  require_relative 'files'
5
5
  require_relative 'node'
6
+ require_relative 'stat'
6
7
  require_relative 'toc'
7
8
 
8
9
  module Raindown
@@ -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
@@ -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, or to run an endpoint.
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