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
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../code_box'
4
+ require_relative '../events'
5
+ require_relative '../../blueprints'
6
+ require_relative 'traced_code'
7
+ require_relative 'canvas'
8
+ require_relative 'code_comments'
9
+ require_relative 'filter'
10
+
11
+ module Raindown
12
+ module TUI
13
+ # The blueprints from Claude Code's replies, in the project's .raindown/blueprints.md, newest first, then those in its
14
+ # code's comments, one at a time, of
15
+ # those its Filter's keywords match, each traced to its code: the arrows move between the boxes drawn in one, or a
16
+ # click on one, the code each box names shows below, Space or another click moving on to
17
+ # the next it names, Enter showing more of it, and E opens it, or the blueprint when the box names none.
18
+ # The file's read again when it changes, like when a reply's saved more, showing the newest then.
19
+ class BlueprintsView
20
+ include Canvas
21
+ include TracedCode
22
+ include ShownCode
23
+ include Filter
24
+
25
+ # Left and right move between the boxes drawn in a blueprint, in reading order, and up and down to the nearest box
26
+ # above or below, while up and down move between the filter's matches.
27
+ BOX_MOVES = { left: -1, right: 1 }.freeze
28
+ MOVES = { up: -1, down: 1 }.freeze
29
+ BOX_ROWS = { up: :above?, down: :below? }.freeze
30
+ # Space moves on to the next code the selected box names, Enter or clicking its more lines shows them, and E opens it.
31
+ ACTIONS = { toggle: :next_code, enter: :toggle_expanded, expand: :toggle_expanded, edit: :edit }.freeze
32
+
33
+ attr_reader :cursor
34
+
35
+ def initialize(root:)
36
+ @root = root
37
+ @path = File.join(root, Blueprints::FILE)
38
+ @all = []
39
+ @cursor = 0
40
+ select_box(0)
41
+ end
42
+
43
+ def renderer = BlueprintsRenderer
44
+ def target = self
45
+ def path = nil
46
+ def pace(_hovered) = :active
47
+
48
+ def status(_navigator)
49
+ commented = @all.count { it.time.nil? }
50
+ [:info, "#{count(@all.length - commented, 'blueprint')} from Claude Code, in #{Blueprints::FILE}, and #{commented} in comments"]
51
+ end
52
+
53
+ # Whether there are any blueprints yet, saved or in the code's comments, which are looked for once until it's loaded.
54
+ def any? = File.exist?(@path) || commented.any?
55
+
56
+ # Whether the file's changed since it was read, so it's read again on the next tick.
57
+ def changed? = mtime != @read_at
58
+
59
+ def load
60
+ @read_at = mtime
61
+ newest = @all.first&.time
62
+ @commented = nil
63
+ @all = Blueprints.read(@root) + commented
64
+ self.cursor = 0 unless @all.first&.time == newest
65
+ self.cursor = @cursor
66
+ end
67
+
68
+ def selected = blueprints[@cursor]
69
+
70
+ # Another blueprint's shown from its first box, while the same one keeps its box and canvas, like when it's clicked.
71
+ def cursor=(index)
72
+ shown = selected
73
+ @cursor = index.clamp(0, [blueprints.length - 1, 0].max)
74
+ return if selected == shown
75
+
76
+ select_box(0)
77
+ follow(from_start: true)
78
+ end
79
+
80
+ # The selected box, of those drawn in the selected blueprint, or nil when it has none.
81
+ def box = (@box.to_i.clamp(0, boxes.length - 1) if boxes.any?)
82
+
83
+ # Every key but Tab to the files, or left from the first box, while the filter's closed.
84
+ def next_step?(key) = !%i[tab tick].include?(key) && !(key == :left && box.to_i.zero? && !typing?)
85
+
86
+ # The selected blueprint's characters by the columns they're shown in, with its boxes' sides straightened, and the
87
+ # boxes drawn in it, found again when another's selected.
88
+ def drawing
89
+ @drawing = [selected, Blueprints.tidied(Blueprints.columns(selected&.lines || []), boxes)] unless @drawing&.first == selected
90
+ @drawing.last
91
+ end
92
+
93
+ def boxes
94
+ @boxes = [selected, Blueprints.boxes(selected&.lines || [])] unless @boxes&.first == selected
95
+ @boxes.last
96
+ end
97
+
98
+ def next_step(key)
99
+ return filter_step(key) if typing?
100
+ return open_filter if %i[feelings filter].include?(key)
101
+ return clicked(key) unless key.is_a?(Symbol)
102
+ return move_box(BOX_MOVES[key]) if BOX_MOVES.key?(key)
103
+ return move_row(BOX_ROWS[key]) if BOX_ROWS.key?(key)
104
+ return scroll(*SCROLLS[key]) if SCROLLS.key?(key)
105
+
106
+ send(ACTIONS[key]) if ACTIONS.key?(key)
107
+ end
108
+
109
+ private
110
+
111
+ # The selected box's code, or the blueprint when the box names none.
112
+ def edit
113
+ return unless selected
114
+
115
+ method = code
116
+ return Edit.new(path: method.path, line: method.line) if method
117
+
118
+ Edit.new(path: File.join(@root, selected.path), line: selected.line)
119
+ end
120
+
121
+ # A click on a box drawn in the blueprint, which moves on to the next code it names when it's already selected.
122
+ def clicked(action)
123
+ return next_code if boxes.index(action) == box
124
+
125
+ select_box(boxes.index(action))
126
+ end
127
+
128
+ def move_box(by)
129
+ select_box((box + by).clamp(0, boxes.length - 1)) if box
130
+ end
131
+
132
+ # The nearest box above or below the selected one, if there's one that way.
133
+ def move_row(way)
134
+ from = boxes[box] if box
135
+ nearest = boxes.select { from && it.public_send(way, from) }.min_by { it.distance(from) }
136
+ select_box(boxes.index(nearest)) if nearest
137
+ end
138
+
139
+ def count(number, noun) = "#{number} #{number == 1 ? noun : "#{noun}s"}"
140
+
141
+ def mtime = File.exist?(@path) ? File.mtime(@path) : nil
142
+
143
+ def commented = @commented ||= CodeComments.blueprints(@root)
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raindown
4
+ module TUI
5
+ # A blueprint's drawing seen through the canvas, a window of a fixed size, from its offset: the columns and rows of
6
+ # the drawing off its left and top. The canvas follows the selected box, bringing it into view, until it's scrolled,
7
+ # with Shift and the arrows or by dragging the mouse, and follows again when another box is selected.
8
+ module Canvas
9
+ # Shift and an arrow scroll the canvas by columns across, or by rows up and down, like the arrows' own steps.
10
+ SCROLLS = { scroll_left: [-4, 0], scroll_right: [4, 0], scroll_up: [0, -1], scroll_down: [0, 1] }.freeze
11
+
12
+ # The offset for a canvas of the width and height, with the selected box in view while it's followed, and the
13
+ # drawing no further off than it can be while still filling the canvas.
14
+ def canvas(width, height)
15
+ column, row = @offset || [0, 0]
16
+ column, row = into_view(column, row, width, height) if @following && box
17
+ @offset = [column.clamp(0, [drawing_width - width, 0].max), row.clamp(0, [drawing_height - height, 0].max)]
18
+ end
19
+
20
+ # Whether there's more of the drawing past the canvas, to the left, right, top and bottom.
21
+ def beyond(width, height)
22
+ column, row = @offset
23
+ { left: column.positive?, right: drawing_width > column + width, up: row.positive?, down: drawing_height > row + height }
24
+ end
25
+
26
+ # Dragging the drawing moves it with the mouse, so the canvas scrolls the other way.
27
+ def drag(drag)
28
+ scroll(-drag.columns, -drag.rows)
29
+ end
30
+
31
+ private
32
+
33
+ def scroll(columns, rows)
34
+ column, row = @offset || [0, 0]
35
+ @offset = [[column + columns, 0].max, [row + rows, 0].max]
36
+ @following = false
37
+ nil
38
+ end
39
+
40
+ # Shown from its first column and row, following the selected box, like when another blueprint's selected.
41
+ def follow(from_start: false)
42
+ @offset = [0, 0] if from_start
43
+ @following = true
44
+ end
45
+
46
+ # The offset moved as little as it can for the box to be in view, or its top left when it's bigger than the canvas.
47
+ def into_view(column, row, width, height)
48
+ selected_box = boxes[box]
49
+ column = selected_box.right - width + 1 if selected_box.right >= column + width
50
+ row = selected_box.bottom - height + 1 if selected_box.bottom >= row + height
51
+ [[column, selected_box.left].min, [row, selected_box.top].min]
52
+ end
53
+
54
+ def drawing_width = drawing.map(&:length).max.to_i
55
+ def drawing_height = drawing.length
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../blueprints'
4
+ require_relative '../feelings/code_methods'
5
+
6
+ module Raindown
7
+ module TUI
8
+ # The blueprints drawn in the comments above a project's classes and methods, like this codebase's renderers', each a
9
+ # run of lines indented further than the comment's prose, described by the prose above it, but not a code block,
10
+ # like an example of a file.
11
+ module CodeComments
12
+ # A comment's line, by what's after its marker and a space, in the languages Code feelings reads.
13
+ COMMENT = %r{\A\s*(?:#|//)(?: |\z)(.*)\z}
14
+ # A line of a comment that's drawn, indented further than its prose.
15
+ DRAWN_LINE = /\A /
16
+
17
+ module_function
18
+
19
+ # The blueprints in the comments of the project's code, by file, from the top of each.
20
+ def blueprints(root)
21
+ CodeMethods.files(root).flat_map { |path| in_file(path, path.delete_prefix(File.join(root, ''))) }
22
+ end
23
+
24
+ def in_file(path, relative)
25
+ lines = File.readlines(path, chomp: true)
26
+ comments(lines).flat_map { |start, texts| drawings(texts, start, relative) }
27
+ rescue SystemCallError, ArgumentError
28
+ []
29
+ end
30
+
31
+ # Each block of comment lines above a class or method, by the index of its first line, with each line's text.
32
+ def comments(lines)
33
+ lines.each_index.filter_map do |index|
34
+ next unless lines[index].match?(COMMENT)
35
+ next if index.positive? && lines[index - 1].match?(COMMENT)
36
+
37
+ texts = lines[index..].take_while { it.match?(COMMENT) }.map { it[COMMENT, 1] }
38
+ [index, texts] if defines?(lines[index + texts.length])
39
+ end
40
+ end
41
+
42
+ def defines?(line) = line.to_s.match?(CodeMethods::DEFINITION) || line.to_s.match?(CodeMethods::OWNER)
43
+
44
+ # The comment's runs of indented lines that are blueprints, each described by the prose above it, if any.
45
+ def drawings(texts, start, path)
46
+ index = start
47
+ [[], *runs(texts)].each_cons(2).filter_map do |prose, drawn|
48
+ index += prose.length
49
+ next unless drawn.first.match?(DRAWN_LINE)
50
+
51
+ blueprint(drawn, prose.join(' ').strip, path:, line: index + 1)
52
+ end
53
+ end
54
+
55
+ # The comment's lines in runs of prose and of indented lines, by turns.
56
+ def runs(texts) = texts.slice_when { |before, after| before.match?(DRAWN_LINE) != after.match?(DRAWN_LINE) }
57
+
58
+ # The drawn lines, without the indent they share, as a blueprint when they're a drawing.
59
+ def blueprint(lines, prose, path:, line:)
60
+ indent = lines.map { it[/\A */].length }.min
61
+ lines = lines.map { it[indent..] }
62
+ return unless drawing?(lines)
63
+
64
+ Blueprints::Blueprint.new(time: nil, kind: Blueprints.kind(lines), lines:, path:, line:, description: (prose unless prose.empty?))
65
+ end
66
+
67
+ # Whether the lines are drawn, and whole, rather than a code block.
68
+ def drawing?(lines) = lines.none? { it.start_with?('```') } && lines.join.match?(Blueprints::DRAWN) && Blueprints.whole?(lines)
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raindown
4
+ module TUI
5
+ # The blueprints filtered by keywords, each a word in one or in its kind, ignoring case, typed into a box like
6
+ # Teleport's, which f or a click on the title opens. Up and down move between all the blueprints that match, as
7
+ # they're shown, with the box listing those around the selected one, Enter or a click keeps the filter, and Esc
8
+ # clears it.
9
+ module Filter
10
+ # The most matches the box lists at once.
11
+ LIMIT = 10
12
+
13
+ # The keywords typed, and whether the box is open, taking what's typed.
14
+ attr_reader :query
15
+
16
+ def typing? = @filtering == true
17
+
18
+ # The blueprints with every keyword, or all of them, filtered again only when the keywords or the blueprints change,
19
+ # as what's drawn asks for the selected one many times a frame.
20
+ def blueprints
21
+ @filtered = [query, @all, filtered(query.to_s.downcase.split)] unless filtered_now?
22
+ @filtered.last
23
+ end
24
+
25
+ # The box's matches, from the first it lists, each with its text and where it is, and the selected one, which is
26
+ # the one shown.
27
+ def matches = blueprints[first_match, LIMIT].to_a
28
+ def match = @cursor
29
+
30
+ # The first match the box lists, scrolled as little as it takes to list the selected one.
31
+ def first_match
32
+ first = @first_match.to_i.clamp(@cursor - LIMIT + 1, @cursor)
33
+ @first_match = [first, 0].max
34
+ end
35
+
36
+ # The f key opens the filter here, rather than Code feelings.
37
+ def own_key?(key) = key == :feelings
38
+
39
+ private
40
+
41
+ def filtered_now? = @filtered && @filtered[0] == query && @filtered[1].equal?(@all)
42
+
43
+ def filtered(keywords)
44
+ return @all if keywords.empty?
45
+
46
+ @all.select { |blueprint| keywords.all? { blueprint.words.downcase.include?(it) || blueprint.kind.include?(it) } }
47
+ end
48
+
49
+ def open_filter
50
+ @query = query.to_s
51
+ @filtering = true
52
+ nil
53
+ end
54
+
55
+ def filter_step(key)
56
+ case key
57
+ when Type then filter_to(query + key.text)
58
+ when :backspace then filter_to(query[0...-1])
59
+ when :up, :down then self.cursor = @cursor + self.class::MOVES[key]
60
+ when :enter then @filtering = false
61
+ when :escape then close_filter
62
+ end
63
+ nil
64
+ end
65
+
66
+ def filter_to(keywords)
67
+ @query = keywords
68
+ self.cursor = 0
69
+ end
70
+
71
+ def close_filter
72
+ filter_to('')
73
+ @filtering = false
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../feelings/code_methods'
4
+
5
+ module Raindown
6
+ module TUI
7
+ # The code a blueprint's box names, found among the project's methods, read once, when they're first needed, and which
8
+ # of it's shown.
9
+ module TracedCode
10
+ # The code the box names, each once, in the order it names it: by a method, like Watcher.listen or
11
+ # Navigator#next_step, or a class, like Workers, whose first method it is, or a method's name alone that only one
12
+ # method has.
13
+ def codes(box)
14
+ (@codes ||= {})[box] ||= box.text.scan(/[A-Z]\w*(?:::\w+)*(?:[.#][a-z_]\w*[?!]?)?|[a-z_]\w*[?!]?/).filter_map { named(it) }.uniq
15
+ end
16
+
17
+ # The code shown of what the selected box names, which Space or a click on the box moves on to the next of, or nil.
18
+ def code
19
+ named = codes(boxes[box]) if box
20
+ named[@code % named.length] if named&.any?
21
+ end
22
+
23
+ private
24
+
25
+ # The box at the index, showing the first code it names, from its first lines.
26
+ def select_box(index)
27
+ @box = index
28
+ @code = 0
29
+ @expanded = false
30
+ follow
31
+ nil
32
+ end
33
+
34
+ def next_code
35
+ @code += 1
36
+ @expanded = false
37
+ nil
38
+ end
39
+
40
+ # The method a name in a box names, by its owner and name, or its owner's first, or its name if only one has it.
41
+ def named(name)
42
+ owner, method = name.split(/[.#]/, 2)
43
+ owner = owner.split('::').last
44
+ return methods.find { it.owner == owner && it.name == method } if method
45
+ return first_method(owner) if owner.match?(/\A[A-Z]/)
46
+
47
+ found = methods.select { it.name == name }
48
+ found.first if found.one?
49
+ end
50
+
51
+ def first_method(owner) = methods.select { it.owner == owner }.min_by { [it.path, it.line] }
52
+
53
+ def methods = @methods ||= CodeMethods.list(@root)
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../code_box'
4
+
5
+ module Raindown
6
+ module TUI
7
+ # A blueprint traced to its code: its lines and text cornflower blue, the selected box highlighted, on a canvas that
8
+ # scrolls, with a marker at each edge there's more beyond, then the code the selected box names in a box below it,
9
+ # as tall whether or not there's code, so the canvas above stays still.
10
+ module Tracing
11
+ include CodeBox
12
+
13
+ # The rows the canvas keeps when the code box is expanded.
14
+ MIN_CANVAS_ROWS = 2
15
+ # The line above the canvas, and the rows for its up and down markers.
16
+ AROUND_CANVAS = 3
17
+ # The most lines its description wraps onto, above the canvas.
18
+ DESCRIPTION_LINES = 3
19
+
20
+ private
21
+
22
+ # When it was saved and which one it is, then its description, then the canvas, between the rows for its up and
23
+ # down markers, then the code its selected box names, in a CodeBox, which takes rows from the canvas when it's
24
+ # expanded.
25
+ def traced_lines
26
+ box = code_box
27
+ description = description_lines
28
+ @canvas_height = [height - box.length - description.length - AROUND_CANVAS, 1].max
29
+ canvas = canvas_lines
30
+ [about(view.selected), *description, marker_line(:up, '▲'), *canvas, marker_line(:down, '▼'), *box].first(height)
31
+ end
32
+
33
+ # The paragraph above it in Claude Code's reply, below a blank line, in the text's own colour, wrapped.
34
+ def description_lines
35
+ description = view.selected.description
36
+ return [] unless description
37
+
38
+ lines = wrap(description, width - 2).first(DESCRIPTION_LINES).map { [segment(" #{it.rstrip}", :text)] }
39
+ [[segment('', :blank)], *lines]
40
+ end
41
+
42
+ # The canvas's rows of the drawing, from its offset, which follows the selected box.
43
+ def canvas_lines
44
+ column, row = view.canvas(canvas_width, canvas_height)
45
+ @beyond = view.beyond(canvas_width, canvas_height)
46
+ Array.new(canvas_height) { canvas_line(row + it, column, middle: it == canvas_height / 2) }
47
+ end
48
+
49
+ # The pane's width, less a column at each side for the left and right markers.
50
+ def canvas_width = [width - 2, 1].max
51
+
52
+ # The rows between the line above, the markers' rows and the code box.
53
+ attr_reader :canvas_height
54
+
55
+ # A row of the drawing seen through the canvas, from its column, with the left and right markers on the middle row.
56
+ def canvas_line(row, column, middle:)
57
+ segments = drawn_segments(view.drawing[row].to_a, row, column)
58
+ drawn = segments.sum { display_width(it.text) }
59
+ [marker(:left, '◀', middle), *segments, segment('', :blank, width: [canvas_width - drawn, 0].max), marker(:right, '▶', middle)]
60
+ end
61
+
62
+ # The line's columns across the canvas, from its column, in runs of the same style and box.
63
+ def drawn_segments(line, row, column)
64
+ runs = (column...[column + canvas_width, line.length].min).chunk_while { drawn_part(row, _1) == drawn_part(row, _2) }
65
+ runs.map { |run| run_segment(line, run, row) }
66
+ end
67
+
68
+ def marker(side, arrow, shown)
69
+ return segment(arrow, :divider, width: 1) if shown && @beyond[side]
70
+
71
+ segment(' ', :blank, width: 1)
72
+ end
73
+
74
+ # The row above or below the canvas, with its marker in the middle when there's more of the drawing that way.
75
+ def marker_line(side, arrow)
76
+ return [segment('', :blank)] unless @beyond[side]
77
+
78
+ [segment(arrow.center(width), :divider)]
79
+ end
80
+
81
+ def drawn_part(row, column) = [part_style(row, column), box_click(row, column)]
82
+
83
+ # The line's characters at the run of columns, in their style, which select their box when they're clicked, with half
84
+ # a wide character, cut at the canvas's edge, left blank.
85
+ def run_segment(line, run, row)
86
+ segment(line[run.first..run.last].join, part_style(row, run.first), width: run.size, event: box_click(row, run.first))
87
+ end
88
+
89
+ def box_click(row, column)
90
+ clicked = view.boxes.find { it.cover?(row, column) }
91
+ Click.new(pane:, index: view.cursor, action: clicked) if clicked
92
+ end
93
+
94
+ # The selected box highlighted, and the rest cornflower blue.
95
+ def part_style(row, column)
96
+ return :selected if view.box && view.boxes[view.box].cover?(row, column)
97
+
98
+ :divider
99
+ end
100
+
101
+ # The code box shows the code the selected box names, or that it names none.
102
+ def boxed_method = view.code
103
+ def box_style = :divider
104
+ def box_footer = []
105
+ def box_room = height - AROUND_CANVAS - BOX_LINES - MIN_CANVAS_ROWS
106
+
107
+ def box_title(method)
108
+ return 'No code' unless method
109
+
110
+ "#{where(method)} · #{method.owner}##{method.name}"
111
+ end
112
+
113
+ # Which of the code the box names is shown, at the right of the box's top, when it names more than one.
114
+ def box_note(method)
115
+ named = view.codes(view.boxes[view.box]) if method
116
+ return '' unless named && named.length > 1
117
+
118
+ " #{named.index(method) + 1} of #{named.length} ─"
119
+ end
120
+
121
+ # The method's code highlighted, or that the box names no code.
122
+ def box_parts(method)
123
+ return [[['Nothing in this box names a class or method in the code.', :body]]] unless method
124
+
125
+ highlighted(method)
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'highlighter'
4
+
5
+ module Raindown
6
+ module TUI
7
+ # A box of a method's code, highlighted, its first lines or, expanded, all that fit, like Code feelings' below its
8
+ # grid, with why it felt that way while that's typed, below a line, or Blueprints' below a blueprint:
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
+ # The renderer gives the method shown, as boxed_method, or nil, and box_title, box_note for the top's right, box_style,
16
+ # box_room, the most lines of code that fit expanded, box_parts, the lines it shows, and box_footer, any lines below them.
17
+ # Its view keeps what it shows, as ShownCode.
18
+ module CodeBox
19
+ # The most of a method's code that shows, until it's expanded, and the rows the grid keeps when it is.
20
+ CODE_LINES = 6
21
+ MIN_GRID_ROWS = 2
22
+ # The box's top and bottom, and the line of how many more lines there are.
23
+ BOX_LINES = 3
24
+
25
+ private
26
+
27
+ # The method's code in a box, then the lines below it.
28
+ def code_box
29
+ method = boxed_method
30
+ footer = box_footer
31
+ code = code_lines(box_parts(method), box_room - footer.length)
32
+ top = box_edge("╭─ #{box_title(method)} ", "#{box_note(method)}╮")
33
+ [top, *code, *blank_lines(code.length + footer.length), *footer, box_edge('╰', '╯')]
34
+ end
35
+
36
+ # Blank lines after a short method's code and its footer, so the box is always as tall as it gets until it's expanded,
37
+ # and what's above it stays still.
38
+ def blank_lines(used) = Array.new([CODE_LINES + 1 - used, 0].max) { boxed([]) }
39
+
40
+ # The first lines of the parts, or all that fit in the room when it's expanded, wrapped to fit the box, then how
41
+ # many more there are, clicked to show them, or a line to show fewer again.
42
+ def code_lines(parts, room)
43
+ shown = CODE_LINES
44
+ shown = [room, CODE_LINES].max if view.expanded
45
+ lines = parts.map { wrapped(it) }
46
+ fitting = lines.take_while { (shown -= it.length) >= 0 }
47
+ code = fitting.flatten(1).map { boxed(it) }
48
+ return code if lines.sum(&:length) <= CODE_LINES
49
+
50
+ [*code, more_line(lines.length - fitting.length)]
51
+ end
52
+
53
+ # The highlighted line's [text, style] parts in as many lines as it takes to fit the box, like a note's, those
54
+ # after the first carrying on after an ellipsis.
55
+ def wrapped(parts)
56
+ room = width - 6
57
+ lead = [carried_lead(parts, room), box_style]
58
+ rest = room - display_width(lead[0])
59
+ return [parts] if rest < 1
60
+
61
+ first, *others = line_breaks(parts.map { printable(it[0]) }.join, room, rest).each_cons(2).map { parts_between(parts, *it) }
62
+ [first, *others.map { [lead, *it] }]
63
+ end
64
+
65
+ # Where the text breaks into lines, the first fitting the room and the others the rest.
66
+ def line_breaks(text, room, rest)
67
+ breaks = [0]
68
+ fits = room
69
+ while text.length - breaks.last > fits
70
+ breaks << line_break(text, breaks.last, fits)
71
+ fits = rest
72
+ end
73
+ breaks << text.length
74
+ end
75
+
76
+ # Where a line from the column breaks: after the last space that fits, or where it's full when none does.
77
+ def line_break(text, from, fits)
78
+ space = text[from, fits].rindex(' ').to_i
79
+ return from + fits unless space.positive?
80
+
81
+ from + space + 1
82
+ end
83
+
84
+ # An ellipsis at the line's own indent, up to half the room, which the lines it's wrapped onto start with.
85
+ def carried_lead(parts, room) = "#{' ' * [parts.map(&:first).join[/\A */].length, room / 2].min}… "
86
+
87
+ # The parts' text between two columns, in their styles.
88
+ def parts_between(parts, from, to)
89
+ segments = parts.map { |text, style| Text::Segment.new(text: printable(text), style:, event: nil) }
90
+ segments_between(segments, from, to).map { [it.text, it.style] }
91
+ end
92
+
93
+ def more_line(hidden)
94
+ text = "[#{hidden} more lines]"
95
+ text = '[1 more line]' if hidden == 1
96
+ text = "[Fewer lines]#{" · #{hidden} more don't fit" if hidden.positive?}" if view.expanded
97
+ boxed([[text, :button]], event: Click.new(pane:, index: view.cursor, action: :expand))
98
+ end
99
+
100
+ # The method's code highlighted, kept by the view while it's shown.
101
+ def highlighted(method) = view.highlighted(method) { Highlighter.lines(dedented(method.lines), filename: method.path) }
102
+
103
+ # The lines without the indent they share, like a method's in its class.
104
+ def dedented(lines)
105
+ indent = lines.reject { it.strip.empty? }.map { it[/\A */].length }.min.to_i
106
+ lines.map { it[indent..].to_s }
107
+ end
108
+
109
+ def where(method) = "#{navigator.relative(method.path)}:#{method.line}"
110
+
111
+ # A line of the box, a column in from each side of the pane, of [text, style] parts, like highlighted code's.
112
+ def boxed(parts, event: nil)
113
+ padded([segment(' │ ', box_style, width: 3), *code_segments(parts, width - 6, event:), segment(' │', box_style, width: 2)])
114
+ end
115
+
116
+ # The box's top or bottom, with the left text cut short, rather than the corner, when there isn't room.
117
+ def box_edge(left, right)
118
+ room = width - 2 - display_width(right)
119
+ left = "#{fit(left, room - 1)} " if display_width(left) > room
120
+ fill = [room - display_width(left), 0].max
121
+ padded([segment(" #{left}#{'─' * fill}#{right}", box_style, width: width - 1)])
122
+ end
123
+ end
124
+
125
+ # What a view keeps of the code its CodeBox shows: whether it's expanded, and its highlighting, from frame to frame
126
+ # while it's the one shown.
127
+ module ShownCode
128
+ attr_reader :expanded
129
+
130
+ def highlighted(method)
131
+ @highlighted = { method => yield } unless @highlighted&.key?(method)
132
+ @highlighted[method]
133
+ end
134
+
135
+ private
136
+
137
+ def toggle_expanded
138
+ @expanded = !@expanded
139
+ nil
140
+ end
141
+ end
142
+ end
143
+ end