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
|
@@ -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
|