raindown 0.7.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/cli.rb +39 -54
- data/lib/raindown/config.rb +2 -3
- data/lib/raindown/directives.rb +30 -13
- data/lib/raindown/document.rb +21 -15
- data/lib/raindown/endpoint/endpoint_runner.rb +13 -5
- data/lib/raindown/node_blocks.rb +22 -16
- data/lib/raindown/node_injector.rb +7 -10
- data/lib/raindown/pages.rb +1 -1
- data/lib/raindown/pre_renderer.rb +18 -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/{feelings/code_box.rb → code_box.rb} +44 -30
- data/lib/raindown/tui/endpoints.rb +2 -2
- data/lib/raindown/tui/events.rb +3 -1
- data/lib/raindown/tui/feelings/bins.rb +5 -5
- data/lib/raindown/tui/feelings/{celebration.rb → celebration/celebration.rb} +5 -2
- data/lib/raindown/tui/feelings/{scene.rb → celebration/scene.rb} +4 -1
- data/lib/raindown/tui/feelings/cell_moves.rb +34 -0
- data/lib/raindown/tui/feelings/feelings_code_box.rb +40 -0
- data/lib/raindown/tui/feelings/feelings_log.rb +22 -19
- data/lib/raindown/tui/feelings/feelings_renderer.rb +94 -95
- data/lib/raindown/tui/feelings/feelings_view.rb +28 -32
- 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 +24 -5
- data/lib/raindown/tui/layout.rb +25 -18
- data/lib/raindown/tui/navigator/commands.rb +32 -1
- data/lib/raindown/tui/navigator/mouse.rb +43 -0
- data/lib/raindown/tui/navigator/navigator.rb +40 -44
- data/lib/raindown/tui/navigator/{search.rb → teleport.rb} +14 -12
- data/lib/raindown/tui/navigator/views.rb +2 -3
- data/lib/raindown/tui/{workers/project.rb → project.rb} +33 -22
- data/lib/raindown/tui/{menu_overlay.rb → project_box.rb} +1 -1
- data/lib/raindown/tui/renderers/renderer.rb +1 -0
- data/lib/raindown/tui/saves.rb +22 -0
- data/lib/raindown/tui/{search_overlay.rb → search_box.rb} +27 -14
- data/lib/raindown/tui/terminal/input.rb +58 -7
- data/lib/raindown/tui/terminal/screen.rb +31 -26
- data/lib/raindown/tui/terminal/styles.rb +55 -28
- data/lib/raindown/tui/terminal/terminal_title.rb +31 -0
- data/lib/raindown/tui/text.rb +35 -12
- data/lib/raindown/tui/views/dashboard_view.rb +2 -2
- data/lib/raindown/tui/views/endpoint_view.rb +3 -3
- data/lib/raindown/tui/views/list_view.rb +1 -1
- data/lib/raindown/tui/views/outline_view.rb +5 -4
- data/lib/raindown/tui.rb +17 -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 +37 -17
- data/lib/raindown/project_worker.rb +0 -48
- data/lib/raindown/supervisor.rb +0 -160
- data/lib/raindown/tui/feelings/waffle.rb +0 -43
- data/lib/raindown/tui/workers/workers.rb +0 -53
- data/lib/raindown/watcher.rb +0 -57
- /data/lib/raindown/tui/feelings/{fireworks.rb → celebration/fireworks.rb} +0 -0
- /data/lib/raindown/tui/feelings/{rain.rb → celebration/rain.rb} +0 -0
data/lib/raindown/document.rb
CHANGED
|
@@ -20,7 +20,8 @@ module Raindown
|
|
|
20
20
|
# It spans line to end_line, its end tag, or the last line of a bare block.
|
|
21
21
|
# Body is the lines inside it, between it and its end tag, like an endpoint's request and response.
|
|
22
22
|
# Output is the lines Raindown writes back, like a node's Markdown or an endpoint's response, and is nil
|
|
23
|
-
# when nothing's written yet.
|
|
23
|
+
# when nothing's written yet. One in Antlers' delimiters, like <{ toc: }> or <{ :toc }>, only renders, so it never
|
|
24
|
+
# writes.
|
|
24
25
|
Directive = Data.define(:name, :args, :line, :end_line, :body, :output, :writes) do
|
|
25
26
|
def endpoint? = name == 'endpoint'
|
|
26
27
|
end
|
|
@@ -28,17 +29,17 @@ module Raindown
|
|
|
28
29
|
# Inline nodes whose text a reader sees, like code spans, as opposed to inline HTML tags.
|
|
29
30
|
TEXT_NODES = %i[text code].freeze
|
|
30
31
|
BREAK_NODES = %i[softbreak linebreak].freeze
|
|
31
|
-
|
|
32
|
-
END_TAG = /\A {0,3}<\{\s*:[\w:]+\s*\}>[ \t]*$/
|
|
32
|
+
END_TAG = /\A {0,3}(?:<\{\s*:[\w:]+\s*\}>|<!--\s*:[\w:]+\s*-->)[ \t]*$/
|
|
33
33
|
|
|
34
34
|
def self.parse(markdown, bare: false)
|
|
35
35
|
new(markdown:, bare:)
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
-
# Raindown files read bare directives, as do Markdown files with "raindown: true" front matter
|
|
38
|
+
# Raindown files read bare directives, as do Markdown files with "raindown: true" front matter, or any file when
|
|
39
|
+
# they're allowed, like with --bare.
|
|
39
40
|
# A project's own nodes are only directives once they're loaded, like with Nodes.load(path), the same as when the file renders.
|
|
40
|
-
def self.read(path)
|
|
41
|
-
new(markdown: File.read(path, encoding: Encoding::UTF_8), bare: BareDirectives.file?(path))
|
|
41
|
+
def self.read(path, bare: false)
|
|
42
|
+
new(markdown: File.read(path, encoding: Encoding::UTF_8), bare: bare || BareDirectives.file?(path))
|
|
42
43
|
end
|
|
43
44
|
|
|
44
45
|
def initialize(markdown:, bare: false)
|
|
@@ -83,7 +84,7 @@ module Raindown
|
|
|
83
84
|
def node_directives
|
|
84
85
|
NodeBlocks.spans(@lines, bare: @bare).map do |span|
|
|
85
86
|
output = trimmed(span[:open] + 1, span[:close] ? span[:close] - 1 : span[:last])
|
|
86
|
-
directive(span[:name], span[:args], span[:open]..span[:last], output:, writes:
|
|
87
|
+
directive(span[:name], span[:args], span[:open]..span[:last], output:, writes: span[:writes])
|
|
87
88
|
end
|
|
88
89
|
end
|
|
89
90
|
|
|
@@ -94,8 +95,8 @@ module Raindown
|
|
|
94
95
|
while index < @lines.length
|
|
95
96
|
if (code = CodeBlocks.at(@lines, index))
|
|
96
97
|
index = code[:close]
|
|
97
|
-
elsif (
|
|
98
|
-
directives << endpoint(index,
|
|
98
|
+
elsif (found = endpoint_args(@lines[index]))
|
|
99
|
+
directives << endpoint(index, *found)
|
|
99
100
|
index = directives.last.end_line - 1
|
|
100
101
|
end
|
|
101
102
|
|
|
@@ -105,20 +106,25 @@ module Raindown
|
|
|
105
106
|
directives
|
|
106
107
|
end
|
|
107
108
|
|
|
109
|
+
# The endpoint's arguments, in any of the ways it can be written, and whether it writes its response back, which
|
|
110
|
+
# it does unless it's in Antlers' delimiters, or nil when the line isn't an endpoint.
|
|
108
111
|
def endpoint_args(line)
|
|
109
|
-
line
|
|
110
|
-
line[EndpointRunner::
|
|
112
|
+
return [line[EndpointRunner::DIRECTIVE, 1].strip, false] if line.match?(EndpointRunner::DIRECTIVE)
|
|
113
|
+
return [line[EndpointRunner::COMMENT, 1].strip, true] if line.match?(EndpointRunner::COMMENT)
|
|
114
|
+
|
|
115
|
+
bare = BareDirectives.wrap(line)&.[](EndpointRunner::DIRECTIVE, 1) if @bare
|
|
116
|
+
[bare.strip, true] if bare
|
|
111
117
|
end
|
|
112
118
|
|
|
113
119
|
# The response and the date it was fetched are the output, while the curl snippet above them is the author's.
|
|
114
|
-
def endpoint(open, args)
|
|
120
|
+
def endpoint(open, args, writes)
|
|
115
121
|
curl, response = CodeBlocks.endpoint(@lines, open + 1)
|
|
116
122
|
output = response && (response[:open]..(fetched?(response[:close] + 1) ? response[:close] + 1 : response[:close]))
|
|
117
123
|
last = output&.end || curl&.fetch(:close) || open
|
|
118
124
|
# A bare endpoint, which isn't delimited, ends after its blocks.
|
|
119
|
-
last = end_tag(after: last) || last
|
|
125
|
+
last = end_tag(after: last) || last unless Directives.bare?(@lines[open])
|
|
120
126
|
|
|
121
|
-
directive('endpoint', args, open..last, output:, writes:
|
|
127
|
+
directive('endpoint', args, open..last, output:, writes:)
|
|
122
128
|
end
|
|
123
129
|
|
|
124
130
|
def fetched?(index)
|
|
@@ -128,7 +134,7 @@ module Raindown
|
|
|
128
134
|
# The end tag after the endpoint's blocks, with only blank lines between.
|
|
129
135
|
def end_tag(after:)
|
|
130
136
|
index = CodeBlocks.skip_blank_lines(@lines, after + 1)
|
|
131
|
-
index if @lines[index]&.match?(
|
|
137
|
+
index if @lines[index]&.match?(EndpointRunner::END_TAG)
|
|
132
138
|
end
|
|
133
139
|
|
|
134
140
|
# Lines from first to last with the blank lines around them left out, or nil when there are none.
|
|
@@ -6,11 +6,12 @@ require_relative '../code_blocks'
|
|
|
6
6
|
require_relative 'curl'
|
|
7
7
|
|
|
8
8
|
module Raindown
|
|
9
|
-
# Runs the curl block under an endpoint directive and writes the response in the code
|
|
9
|
+
# Runs the curl block under an endpoint directive in a comment, or a bare one, and writes the response in the code
|
|
10
|
+
# block below it,
|
|
10
11
|
# followed by the date it was fetched.
|
|
11
12
|
# Only runs when that block is missing or empty, and the directive has :x/:execute or --execute is passed.
|
|
12
13
|
# Or with only: and a directive's line index, runs just that endpoint, replacing any response it has.
|
|
13
|
-
#
|
|
14
|
+
# <!-- endpoint: :x -->
|
|
14
15
|
# ```sh
|
|
15
16
|
# curl https://api.example.com
|
|
16
17
|
# ```
|
|
@@ -18,9 +19,14 @@ module Raindown
|
|
|
18
19
|
# { "id": 1 }
|
|
19
20
|
# ```
|
|
20
21
|
# <!-- raindown:fetched 2026-10-14 -->
|
|
21
|
-
#
|
|
22
|
+
# <!-- :endpoint -->
|
|
23
|
+
# One in Antlers' delimiters, <{ endpoint: }>, only renders, so it's never run.
|
|
22
24
|
class EndpointRunner
|
|
25
|
+
# An endpoint's directive in Antlers' delimiters, which only renders, or in a comment, which writes its response back,
|
|
26
|
+
# and the end tag of either.
|
|
23
27
|
DIRECTIVE = /\A {0,3}<\{\s*endpoint:(.*?)\}>[ \t]*$/
|
|
28
|
+
COMMENT = /\A {0,3}<!--\s*endpoint:(.*?)-->[ \t]*$/
|
|
29
|
+
END_TAG = /\A {0,3}(?:<\{\s*:endpoint\s*\}>|<!--\s*:endpoint\s*-->)[ \t]*$/
|
|
24
30
|
EXECUTE_ARGS = %w[:x :execute].freeze
|
|
25
31
|
# The date a response was fetched, on the line after it.
|
|
26
32
|
FETCHED = /\A {0,3}<!-- raindown:fetched [^\n]*? -->[ \t]*$/
|
|
@@ -54,9 +60,11 @@ module Raindown
|
|
|
54
60
|
|
|
55
61
|
private
|
|
56
62
|
|
|
63
|
+
# The arguments of an endpoint that writes back: in a comment, or bare.
|
|
57
64
|
def directive_args(line)
|
|
58
|
-
|
|
59
|
-
line[DIRECTIVE, 1
|
|
65
|
+
args = line[COMMENT, 1]
|
|
66
|
+
args ||= BareDirectives.wrap(line)&.[](DIRECTIVE, 1) if @bare
|
|
67
|
+
args&.split
|
|
60
68
|
end
|
|
61
69
|
|
|
62
70
|
# Returns the index of the last line it handled.
|
data/lib/raindown/node_blocks.rb
CHANGED
|
@@ -10,20 +10,21 @@ require_relative 'variables'
|
|
|
10
10
|
|
|
11
11
|
module Raindown
|
|
12
12
|
# Finds node blocks in a file's lines: a node's directive and the Markdown it wrote below it.
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
13
|
+
# In comments the block ends with the node's end tag, and bare at the next blank line, and both write back:
|
|
14
|
+
# <!-- Changelog: limit=3 --> Changelog: limit=3
|
|
15
|
+
# - **1.2.0** Bare directives
|
|
16
16
|
# - **1.2.0** Bare directives
|
|
17
17
|
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
# toc: and files: are Raindown's own nodes. A name only starts
|
|
21
|
-
# so other text like "Note:" is left alone.
|
|
18
|
+
# <!-- :Changelog -->
|
|
19
|
+
# In Antlers' delimiters, <{ Changelog: }> to <{ :Changelog }>, or a self-closing <{ :Changelog }> or bare
|
|
20
|
+
# :Changelog, it renders the node without writing it. toc: and files: are Raindown's own nodes. A name only starts
|
|
21
|
+
# a block when it's a loaded node, so other text like "Note:" is left alone.
|
|
22
22
|
module NodeBlocks
|
|
23
23
|
class MissingEndTag < StandardError; end
|
|
24
24
|
|
|
25
25
|
NAME = "(?:#{Nodes::BUILT_INS.keys.join('|')}|[A-Z]\\w*(?:::[A-Z]\\w*)*)".freeze
|
|
26
26
|
OPEN = /\A( {0,3})<\{\s*(#{NAME}):(?:\s+(.*?))?\s*\}>[ \t]*$/
|
|
27
|
+
COMMENT_OPEN = /\A( {0,3})<!--\s*(#{NAME}):(?:\s+(.*?))?\s*-->[ \t]*$/
|
|
27
28
|
BARE_OPEN = /\A( {0,3})(#{NAME}):(?:[ \t]+(.*?))?[ \t]*$/
|
|
28
29
|
SELF_CLOSING = /\A( {0,3})<\{\s*:(#{NAME})(?:\s+(.*?))?\s*\}>[ \t]*$/
|
|
29
30
|
BARE_SELF_CLOSING = /\A( {0,3}):(#{NAME})(?:[ \t]+(.*?))?[ \t]*$/
|
|
@@ -35,8 +36,9 @@ module Raindown
|
|
|
35
36
|
|
|
36
37
|
module_function
|
|
37
38
|
|
|
38
|
-
# Each block outside code and front matter as { open:, close:, last:, node: }, where close is nil for a
|
|
39
|
-
# A self-closing node is a block of one line
|
|
39
|
+
# Each block outside code and front matter as { open:, close:, last:, writes:, node: }, where close is nil for a
|
|
40
|
+
# bare block, and writes is whether its output's written into the file. A self-closing node is a block of one line
|
|
41
|
+
# with self_closing: true.
|
|
40
42
|
def find(lines, front_matter: {}, bare: false)
|
|
41
43
|
variables = Variables.new(front_matter)
|
|
42
44
|
|
|
@@ -99,15 +101,17 @@ module Raindown
|
|
|
99
101
|
|
|
100
102
|
span = { name: found[2], class: klass, args: found[3].to_s }
|
|
101
103
|
case pattern
|
|
102
|
-
when :open
|
|
103
|
-
|
|
104
|
-
|
|
104
|
+
when :open, :comment_open
|
|
105
|
+
end_tag(lines, index, found[2], comment: pattern == :comment_open)
|
|
106
|
+
.then { |close| { open: index, close:, last: close, writes: pattern == :comment_open, **span } }
|
|
107
|
+
when :bare_open then { **bare_block(lines, index), writes: true, **span }
|
|
108
|
+
else { open: index, close: nil, last: index, writes: false, self_closing: true, **span }
|
|
105
109
|
end
|
|
106
110
|
end
|
|
107
111
|
|
|
108
112
|
# The directive on the line and which kind it is, or nil.
|
|
109
113
|
def directive(line, bare:)
|
|
110
|
-
patterns = { open: OPEN, self_closing: SELF_CLOSING }
|
|
114
|
+
patterns = { open: OPEN, comment_open: COMMENT_OPEN, self_closing: SELF_CLOSING }
|
|
111
115
|
patterns.merge!(bare_open: BARE_OPEN, bare_self_closing: BARE_SELF_CLOSING) if bare
|
|
112
116
|
|
|
113
117
|
patterns.each do |pattern, regexp|
|
|
@@ -126,10 +130,12 @@ module Raindown
|
|
|
126
130
|
{ open:, close: nil, last: }
|
|
127
131
|
end
|
|
128
132
|
|
|
129
|
-
|
|
130
|
-
|
|
133
|
+
# The end tag written the same way as the directive: in a comment, or in Antlers' delimiters.
|
|
134
|
+
def end_tag(lines, open, name, comment:)
|
|
135
|
+
first, last = comment ? Directives::COMMENT : Directives::DELIMITERS
|
|
136
|
+
tag = /\A {0,3}#{Regexp.escape(first)}\s*:#{Regexp.escape(name)}\s*#{Regexp.escape(last)}[ \t]*$/
|
|
131
137
|
close = (open + 1...lines.length).find { |index| lines[index].match?(tag) }
|
|
132
|
-
raise MissingEndTag, "
|
|
138
|
+
raise MissingEndTag, "#{first} #{name}: #{last} on line #{open + 1} needs #{first} :#{name} #{last}" unless close
|
|
133
139
|
|
|
134
140
|
close
|
|
135
141
|
end
|
|
@@ -6,8 +6,8 @@ require_relative 'front_matter'
|
|
|
6
6
|
require_relative 'node_blocks'
|
|
7
7
|
|
|
8
8
|
module Raindown
|
|
9
|
-
# Writes each node block's Markdown below its directive, replacing what it wrote before
|
|
10
|
-
#
|
|
9
|
+
# Writes each node block's Markdown below its directive, replacing what it wrote before, when it's in comments or
|
|
10
|
+
# bare. Ones in Antlers' delimiters, and self-closing ones, only render, so they're left alone.
|
|
11
11
|
class NodeInjector
|
|
12
12
|
class BlankLine < StandardError; end
|
|
13
13
|
|
|
@@ -25,7 +25,7 @@ module Raindown
|
|
|
25
25
|
def call
|
|
26
26
|
lines = @content.lines
|
|
27
27
|
blocks = NodeBlocks.find(lines, front_matter: FrontMatter.parse(@content).first, bare: @bare)
|
|
28
|
-
.
|
|
28
|
+
.select { |block| block[:writes] }
|
|
29
29
|
return @content if blocks.empty?
|
|
30
30
|
|
|
31
31
|
blocks.reverse_each { |block| lines[block[:open]..block[:last]] = block_lines(lines, block) }
|
|
@@ -42,19 +42,16 @@ module Raindown
|
|
|
42
42
|
delimited(lines, block, written)
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
-
# The directive, what the node wrote and the end tag, each
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
result.push("\n", *written) unless written.empty?
|
|
49
|
-
result.push("\n", line(lines[block[:close]]))
|
|
50
|
-
end
|
|
45
|
+
# The directive, what the node wrote and the end tag, straight after each other, as a comment on its own line ends
|
|
46
|
+
# at its -->, so what's written below it, like a list, is its own block.
|
|
47
|
+
def delimited(lines, block, written) = [line(lines[block[:open]]), *written, line(lines[block[:close]])]
|
|
51
48
|
|
|
52
49
|
# A bare block ends at the next blank line, so what it writes can't have one.
|
|
53
50
|
def bare(written, block)
|
|
54
51
|
return written unless written.any? { |line| line.strip.empty? }
|
|
55
52
|
|
|
56
53
|
name = block[:node].class.name
|
|
57
|
-
raise BlankLine, "#{name} writes blank lines, so it needs
|
|
54
|
+
raise BlankLine, "#{name} writes blank lines, so it needs <!-- #{name}: --> and <!-- :#{name} --> around it"
|
|
58
55
|
end
|
|
59
56
|
|
|
60
57
|
def line(line)
|
data/lib/raindown/pages.rb
CHANGED
|
@@ -33,7 +33,7 @@ module Raindown
|
|
|
33
33
|
|
|
34
34
|
attr_reader :root
|
|
35
35
|
|
|
36
|
-
# Render options, like
|
|
36
|
+
# Render options, like highlight_code: false, are for rendering each listed file's content.
|
|
37
37
|
def initialize(metadata:, root:, render_options: {})
|
|
38
38
|
@root = root
|
|
39
39
|
@render_options = render_options
|
|
@@ -4,6 +4,7 @@ require 'antlers'
|
|
|
4
4
|
require_relative '../elements'
|
|
5
5
|
require_relative 'bare_directives'
|
|
6
6
|
require_relative 'code'
|
|
7
|
+
require_relative 'code_blocks'
|
|
7
8
|
require_relative 'front_matter'
|
|
8
9
|
|
|
9
10
|
module Raindown
|
|
@@ -16,11 +17,14 @@ module Raindown
|
|
|
16
17
|
BLOCK_DIRECTIVES = %w[for if list].freeze
|
|
17
18
|
# A for, if or list directive alone on its line, which leaves no line behind, so a loop's list items stay together.
|
|
18
19
|
BLOCK_LINE = /^[ \t]*(<\{\s*:?(?:#{BLOCK_DIRECTIVES.join('|')})\b[^}]*\}>)[ \t]*\n/
|
|
20
|
+
# An endpoint's directive or end tag in a comment, which renders as it would in Antlers' delimiters.
|
|
21
|
+
ENDPOINT_COMMENT = /\A( {0,3})<!--\s*(endpoint:.*?|:endpoint)\s*-->/
|
|
19
22
|
|
|
20
23
|
module_function
|
|
21
24
|
|
|
22
25
|
def render(markdown, variables:, metadata: {}, bare: false)
|
|
23
26
|
markdown = BareDirectives.expand(markdown) if bare
|
|
27
|
+
markdown = delimited_endpoints(markdown) if markdown.include?('<!--')
|
|
24
28
|
return markdown unless markdown.include?('{')
|
|
25
29
|
|
|
26
30
|
markdown = Code.hide_braces(markdown.dup.force_encoding('UTF-8'))
|
|
@@ -28,6 +32,20 @@ module Raindown
|
|
|
28
32
|
"#{markdown.delete_suffix(body)}#{render_body(body, variables, metadata)}"
|
|
29
33
|
end
|
|
30
34
|
|
|
35
|
+
# The endpoints in comments in Antlers' delimiters, which the template pass renders, leaving code as it is.
|
|
36
|
+
def delimited_endpoints(markdown)
|
|
37
|
+
lines = markdown.lines
|
|
38
|
+
index = 0
|
|
39
|
+
|
|
40
|
+
while index < lines.length
|
|
41
|
+
code = CodeBlocks.at(lines, index)
|
|
42
|
+
lines[index] = lines[index].sub(ENDPOINT_COMMENT) { "#{::Regexp.last_match(1)}<{ #{::Regexp.last_match(2)} }>" } unless code
|
|
43
|
+
index = code ? code[:close] + 1 : index + 1
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
lines.join
|
|
47
|
+
end
|
|
48
|
+
|
|
31
49
|
def render_body(body, variables, metadata)
|
|
32
50
|
template = body.gsub(BLOCK_LINE, '\1')
|
|
33
51
|
return body unless template.include?('{')
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'tracing'
|
|
4
|
+
require_relative '../renderers/renderer'
|
|
5
|
+
require_relative '../search_box'
|
|
6
|
+
|
|
7
|
+
module Raindown
|
|
8
|
+
module TUI
|
|
9
|
+
# The blueprints from Claude Code's replies, right of the files, below the keywords they're filtered by, in the border,
|
|
10
|
+
# which opens the filter, like Teleport, over it, listing those that match: when it was saved and its kind, and which one of how
|
|
11
|
+
# many, then the blueprint, cut to fit the pane:
|
|
12
|
+
# ─────┌─────────────────── Filter ──────────────────┐───
|
|
13
|
+
# │ / weather▏ │
|
|
14
|
+
# ╭─│ Weather Report Light rain 2026-10-30 03:14 │
|
|
15
|
+
# │ └─────────────────────────────────────────────┘
|
|
16
|
+
# Each is traced to its code: it's cornflower blue, the selected box is highlighted, and the code it names is below:
|
|
17
|
+
# ╭──────────────╮ ╭──────────────╮
|
|
18
|
+
# │ Watcher.listen│ │ Workers │
|
|
19
|
+
# ╰──────────────╯ ╰──────────────╯
|
|
20
|
+
# ╭─ lib/raindown/watcher/changes.rb:17 · Watcher#listen ─────────────╮
|
|
21
|
+
# │ def self.listen(directory, &) │
|
|
22
|
+
# ╰────────────────────────────────────────────────────────────────────╯
|
|
23
|
+
class BlueprintsRenderer < Renderer
|
|
24
|
+
include Tracing
|
|
25
|
+
|
|
26
|
+
BUTTONS = [['Tab', 'Pane', :tab], ['f', 'Filter', :filter], ['↑↓←→', 'Box', nil], ['Space', 'Code', :toggle],
|
|
27
|
+
['Enter', 'More', :expand], ['e', 'Edit', :edit], ['/', 'Search', :search], ['q', 'Quit', :quit]].freeze
|
|
28
|
+
|
|
29
|
+
def buttons = BUTTONS
|
|
30
|
+
|
|
31
|
+
def page = 'Blueprints'
|
|
32
|
+
|
|
33
|
+
# The keywords the blueprints are filtered by, in the middle of the line above the panes, which opens the filter,
|
|
34
|
+
# whose box's top is drawn over it while it's open.
|
|
35
|
+
def spacer
|
|
36
|
+
return title_line unless view.typing?
|
|
37
|
+
|
|
38
|
+
with_filter.first
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The blueprint, with the rest of the filter over it while it's open.
|
|
42
|
+
def lines
|
|
43
|
+
return shown_lines unless view.typing?
|
|
44
|
+
|
|
45
|
+
with_filter.drop(1)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def view = navigator.view
|
|
51
|
+
|
|
52
|
+
def title_line
|
|
53
|
+
title = " #{filtered_by} ▾ "
|
|
54
|
+
title_width = display_width(title)
|
|
55
|
+
left = [(width - title_width) / 2, 0].max
|
|
56
|
+
padded([segment('', :blank, width: left), segment(title, :divider, width: title_width, event: filter_click),
|
|
57
|
+
segment('', :blank, width: [width - left - title_width, 0].max)])
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The title line and the blueprint, with the filter drawn over them from the title line, once a frame.
|
|
61
|
+
def with_filter = @with_filter ||= SearchBox.new(view, width:, title: 'Filter', pane:, top: 0).apply([title_line, *shown_lines])
|
|
62
|
+
|
|
63
|
+
def shown_lines
|
|
64
|
+
return traced_lines if view.selected
|
|
65
|
+
return blank(" No blueprints match #{filtered_by}.") unless view.query.to_s.empty?
|
|
66
|
+
|
|
67
|
+
blank(" No blueprints yet. #{saving}")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def filtered_by
|
|
71
|
+
return 'All' if view.query.to_s.strip.empty?
|
|
72
|
+
|
|
73
|
+
"“#{view.query.strip}”"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The click on the title, which keeps the blueprint shown.
|
|
77
|
+
def filter_click = Click.new(pane:, index: view.cursor, action: :filter)
|
|
78
|
+
|
|
79
|
+
# When it was saved and its kind, then which one it is of how many, at the right.
|
|
80
|
+
def about(blueprint)
|
|
81
|
+
saved = " #{blueprint.where}"
|
|
82
|
+
place = "#{view.cursor + 1} of #{view.blueprints.length} "
|
|
83
|
+
[segment(saved, :body, width: [width - display_width(place), 0].max), segment(place, :body, width: display_width(place))]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def saving = "Claude Code's are saved to #{Blueprints::FILE} by `raindown blueprints`."
|
|
87
|
+
|
|
88
|
+
def blank(message)
|
|
89
|
+
[[segment(message, :info)], *Array.new([height - 1, 0].max) { [segment('', :blank)] }]
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -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
|