raindown 0.12.0 → 0.13.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/raindown/bare_directives.rb +10 -16
- data/lib/raindown/code_blocks.rb +23 -15
- data/lib/raindown/document.rb +9 -9
- data/lib/raindown/endpoint/endpoint_runner.rb +15 -24
- data/lib/raindown/node_blocks.rb +1 -6
- data/lib/raindown/node_injector.rb +7 -1
- data/lib/raindown/nodes.rb +5 -3
- data/lib/raindown/pages.rb +6 -4
- data/lib/raindown/table/markdown_table.rb +33 -0
- data/lib/raindown/table/table.rb +73 -0
- data/lib/raindown/tui/cloud.rb +154 -0
- data/lib/raindown/tui/feelings/celebration/celebration_renderer.rb +22 -0
- data/lib/raindown/tui/feelings/feelings_renderer.rb +3 -16
- data/lib/raindown/tui/layout.rb +8 -1
- data/lib/raindown/tui/project.rb +23 -11
- data/lib/raindown/tui/renderers/endpoint_renderer.rb +4 -3
- data/lib/raindown/tui/terminal/loading.rb +34 -0
- data/lib/raindown/tui/terminal/screen.rb +6 -5
- data/lib/raindown/tui/terminal/styles.rb +13 -1
- data/lib/raindown/tui/views/endpoint_view.rb +5 -4
- data/lib/raindown/tui.rb +10 -6
- data/lib/version.rb +1 -1
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f795c35070ab8ae4f6b40e5a32595ab9ba6099b99f42aada8d0faafe2f56121d
|
|
4
|
+
data.tar.gz: a01426b3aab916e21b0e68f66fddd7f6b55d7e9c34ceb9665118a60b3ceae4b5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 052231532f48cc3a575388ebe95fa62b70bc3eba2121742142071c2ab256ced437de1f4c80f2e439599d579c2b5eea2d793b7cb05439601523bc72ff9c252dd6
|
|
7
|
+
data.tar.gz: 98ff7b646f8e380817bbf69efc06a0bf67b4945a233349c9702d3140458bb27e387ecd010498c80dbfa4a9d2e64872b4f7af75bcd12b28b992e8b82ac66f56c9
|
|
@@ -13,17 +13,17 @@ module Raindown
|
|
|
13
13
|
#
|
|
14
14
|
# A bare schema: shows in bold, with its fields in a code block below it.
|
|
15
15
|
#
|
|
16
|
-
# Bare directives have no end tag. A bare endpoint
|
|
17
|
-
#
|
|
16
|
+
# Bare directives have no end tag. A bare endpoint holds the lines straight below it, its curl snippet, the comment
|
|
17
|
+
# with its response's status, time and date, and its response, up to the next blank line, like a bare node block, so
|
|
18
|
+
# where it needs blank lines, write it in comments with an end tag instead:
|
|
18
19
|
# <!-- endpoint: -->
|
|
19
20
|
# ...
|
|
20
21
|
# <!-- :endpoint -->
|
|
21
22
|
module BareDirectives
|
|
22
23
|
EXTENSIONS = %w[.rd .raindown].freeze
|
|
23
|
-
#
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
LINE = /\A( {0,3})((#{BLOCK_KEYWORDS.keys.join('|')})(?:[ \t]+\S.*?)?)[ \t]*$/
|
|
24
|
+
# A bare endpoint, the one bare directive with a block. Four spaces of indent would make the line a Markdown code
|
|
25
|
+
# block.
|
|
26
|
+
LINE = /\A( {0,3})(endpoint:(?:[ \t]+\S.*?)?)[ \t]*$/
|
|
27
27
|
|
|
28
28
|
module_function
|
|
29
29
|
|
|
@@ -75,7 +75,7 @@ module Raindown
|
|
|
75
75
|
return index unless match
|
|
76
76
|
|
|
77
77
|
lines[index] = "#{Directives.marker(match[2], shown: true)}\n#{wrap(lines[index])}"
|
|
78
|
-
close_block(lines, index, indent: match[1]
|
|
78
|
+
close_block(lines, index, indent: match[1])
|
|
79
79
|
index
|
|
80
80
|
end
|
|
81
81
|
|
|
@@ -88,16 +88,10 @@ module Raindown
|
|
|
88
88
|
end
|
|
89
89
|
|
|
90
90
|
# The end tag after the block, then the end tag in bold, as a bare directive's shown.
|
|
91
|
-
def close_block(lines, index, indent
|
|
92
|
-
last =
|
|
91
|
+
def close_block(lines, index, indent:)
|
|
92
|
+
last = CodeBlocks.last_before_blank(lines, index)
|
|
93
93
|
lines[last] = "#{lines[last]}\n" unless lines[last].end_with?("\n")
|
|
94
|
-
|
|
95
|
-
lines.insert(last + 1, "#{indent}<{ :#{name} }>\n#{Directives.marker(":#{name}", shown: true)}\n")
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
# The last line of the curl snippet and response below an endpoint.
|
|
99
|
-
def after_endpoint(lines, index)
|
|
100
|
-
CodeBlocks.endpoint(lines, index).last&.fetch(:close)
|
|
94
|
+
lines.insert(last + 1, "#{indent}<{ :endpoint }>\n#{Directives.marker(':endpoint', shown: true)}\n")
|
|
101
95
|
end
|
|
102
96
|
end
|
|
103
97
|
end
|
data/lib/raindown/code_blocks.rb
CHANGED
|
@@ -5,6 +5,9 @@ module Raindown
|
|
|
5
5
|
module CodeBlocks
|
|
6
6
|
FENCE = /\A {0,3}(`{3,}|~{3,})[ \t]*(\S*)/
|
|
7
7
|
SNIPPET_LANGUAGES = %w[sh bash shell zsh console curl].freeze
|
|
8
|
+
# The comment Raindown writes between an endpoint's snippet and its response, with the response's status, how long
|
|
9
|
+
# it took and the date it was fetched, like <!-- response 200 · 142 ms · 2026-10-14 -->.
|
|
10
|
+
RESPONSE_COMMENT = /\A {0,3}<!-- response [^\n]*? -->[ \t]*$/
|
|
8
11
|
# The line closing a fence, like ``` or ~~~~, compiled once per fence.
|
|
9
12
|
CLOSINGS = Hash.new { |closings, fence| closings[fence] = /\A {0,3}#{fence[0]}{#{fence.length},}[ \t]*$/ }
|
|
10
13
|
|
|
@@ -19,27 +22,32 @@ module Raindown
|
|
|
19
22
|
{ open: index, close:, language:, body: lines[index + 1...close].join } if close
|
|
20
23
|
end
|
|
21
24
|
|
|
22
|
-
# An endpoint's curl snippet and its response, if it has one yet
|
|
23
|
-
# A second snippet straight after is another example rather than the
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
# An endpoint's curl snippet and its response, if it has one yet, with the line of the response comment between them
|
|
26
|
+
# as the response's :comment, if there is one. A second snippet straight after is another example rather than the
|
|
27
|
+
# response. A bare endpoint's are in the lines straight below it, up to the next blank line.
|
|
28
|
+
def endpoint(lines, index, bare: false)
|
|
29
|
+
lines = lines[..last_before_blank(lines, index - 1)] if bare
|
|
30
|
+
snippet = at(lines, skip_blank_lines(lines, index))
|
|
31
|
+
return [] unless snippet
|
|
32
|
+
|
|
33
|
+
index = skip_blank_lines(lines, snippet[:close] + 1)
|
|
34
|
+
comment = index if lines[index]&.match?(RESPONSE_COMMENT)
|
|
35
|
+
response = at(lines, comment ? skip_blank_lines(lines, comment + 1) : index)
|
|
36
|
+
return [snippet] if response.nil? || snippet?(response)
|
|
37
|
+
|
|
38
|
+
[snippet, response.merge(comment:)]
|
|
27
39
|
end
|
|
28
40
|
|
|
29
41
|
def snippet?(block)
|
|
30
42
|
SNIPPET_LANGUAGES.include?(block[:language].downcase) || block[:body].lstrip.start_with?('curl')
|
|
31
43
|
end
|
|
32
44
|
|
|
33
|
-
#
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
index = block[:close] + 1
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
blocks
|
|
45
|
+
# The last of the lines straight below open, up to the next blank line, with fenced code kept whole, or open when
|
|
46
|
+
# there are none, like a bare block's.
|
|
47
|
+
def last_before_blank(lines, open)
|
|
48
|
+
last = open
|
|
49
|
+
last = at(lines, last + 1)&.fetch(:close) || (last + 1) while lines[last + 1] && !lines[last + 1].strip.empty?
|
|
50
|
+
last
|
|
43
51
|
end
|
|
44
52
|
|
|
45
53
|
def skip_blank_lines(lines, index)
|
data/lib/raindown/document.rb
CHANGED
|
@@ -16,6 +16,9 @@ module Raindown
|
|
|
16
16
|
class Document
|
|
17
17
|
Heading = Data.define(:level, :text, :line)
|
|
18
18
|
|
|
19
|
+
# Its Markdown, and whether it allows bare directives.
|
|
20
|
+
attr_reader :markdown, :bare
|
|
21
|
+
|
|
19
22
|
# A content directive: a node, like toc: or a project's Changelog:, or an endpoint.
|
|
20
23
|
# It spans line to end_line, its end tag, or the last line of a bare block.
|
|
21
24
|
# Body is the lines inside it, between it and its end tag, like an endpoint's request and response.
|
|
@@ -116,21 +119,18 @@ module Raindown
|
|
|
116
119
|
[bare.strip, true] if bare
|
|
117
120
|
end
|
|
118
121
|
|
|
119
|
-
# The response and the
|
|
122
|
+
# The response and the comment above it are the output, while the curl snippet above them is the author's.
|
|
120
123
|
def endpoint(open, args, writes)
|
|
121
|
-
|
|
122
|
-
|
|
124
|
+
bare = Directives.bare?(@lines[open])
|
|
125
|
+
curl, response = CodeBlocks.endpoint(@lines, open + 1, bare:)
|
|
126
|
+
output = response && ((response[:comment] || response[:open])..response[:close])
|
|
123
127
|
last = output&.end || curl&.fetch(:close) || open
|
|
124
|
-
# A bare endpoint
|
|
125
|
-
last = end_tag(after: last) || last
|
|
128
|
+
# A bare endpoint ends at the next blank line, and one in a comment at its end tag.
|
|
129
|
+
last = bare ? CodeBlocks.last_before_blank(@lines, open) : end_tag(after: last) || last
|
|
126
130
|
|
|
127
131
|
directive('endpoint', args, open..last, output:, writes:)
|
|
128
132
|
end
|
|
129
133
|
|
|
130
|
-
def fetched?(index)
|
|
131
|
-
@lines[index]&.match?(EndpointRunner::FETCHED)
|
|
132
|
-
end
|
|
133
|
-
|
|
134
134
|
# The end tag after the endpoint's blocks, with only blank lines between.
|
|
135
135
|
def end_tag(after:)
|
|
136
136
|
index = CodeBlocks.skip_blank_lines(@lines, after + 1)
|
|
@@ -10,17 +10,17 @@ require_relative 'endpoint_variables'
|
|
|
10
10
|
|
|
11
11
|
module Raindown
|
|
12
12
|
# Runs the curl block under an endpoint directive in a comment, or a bare one, and writes the response in the code
|
|
13
|
-
# block below it,
|
|
13
|
+
# block below it, after a comment of its status, how long it took and the date it was fetched.
|
|
14
14
|
# Only runs when that block is missing or empty, and --execute is passed, so a file can't run requests by itself.
|
|
15
15
|
# Or with only: and a directive's line index, runs just that endpoint, replacing any response it has.
|
|
16
16
|
# <!-- endpoint: -->
|
|
17
17
|
# ```sh
|
|
18
18
|
# curl https://api.example.com
|
|
19
19
|
# ```
|
|
20
|
+
# <!-- response 200 · 142 ms · 2026-10-14 -->
|
|
20
21
|
# ```json
|
|
21
22
|
# { "id": 1 }
|
|
22
23
|
# ```
|
|
23
|
-
# <!-- raindown:fetched 2026-10-14 · 200 · 142 ms -->
|
|
24
24
|
# <!-- :endpoint -->
|
|
25
25
|
# Its snippet's {placeholders} are filled from EndpointVariables, and with as: login, later snippets can use its
|
|
26
26
|
# response, like {login.token}. With status: and schema:, each response in the file is checked, and failures are kept.
|
|
@@ -32,9 +32,7 @@ module Raindown
|
|
|
32
32
|
DIRECTIVE = /\A {0,3}<\{\s*endpoint:(.*?)\}>[ \t]*$/
|
|
33
33
|
COMMENT = /\A {0,3}<!--\s*endpoint:(.*?)-->[ \t]*$/
|
|
34
34
|
END_TAG = /\A {0,3}(?:<\{\s*:endpoint\s*\}>|<!--\s*:endpoint\s*-->)[ \t]*$/
|
|
35
|
-
|
|
36
|
-
FETCHED = /\A {0,3}<!-- raindown:fetched [^\n]*? -->[ \t]*$/
|
|
37
|
-
FETCHED_STATUS = /raindown:fetched \S+ · (\d{3}) /
|
|
35
|
+
RESPONSE_STATUS = /<!-- response (\d{3}) /
|
|
38
36
|
|
|
39
37
|
attr_reader :failures
|
|
40
38
|
|
|
@@ -83,19 +81,19 @@ module Raindown
|
|
|
83
81
|
|
|
84
82
|
# Returns the index of the last line it handled.
|
|
85
83
|
def fill(index, args, directive:)
|
|
86
|
-
curl, output = CodeBlocks.endpoint(@lines, index)
|
|
84
|
+
curl, output = CodeBlocks.endpoint(@lines, index, bare: !@lines[directive].match?(COMMENT))
|
|
87
85
|
return index - 1 unless curl&.dig(:body)&.lstrip&.start_with?('curl')
|
|
88
86
|
return write_output(curl:, output:, args:, directive:) if run?(output, directive)
|
|
89
87
|
return curl[:close] unless output
|
|
90
88
|
|
|
91
89
|
@variables.remember(args.name, output[:body])
|
|
92
|
-
check(args, directive:, body: output[:body], status:
|
|
90
|
+
check(args, directive:, body: output[:body], status: response_status(output))
|
|
93
91
|
curl[:close]
|
|
94
92
|
end
|
|
95
93
|
|
|
96
|
-
# The status in the
|
|
97
|
-
def
|
|
98
|
-
@lines[output[:
|
|
94
|
+
# The status in the comment above a response, which a comment written by hand may not have.
|
|
95
|
+
def response_status(output)
|
|
96
|
+
@lines[output[:comment]][RESPONSE_STATUS, 1]&.to_i if output[:comment]
|
|
99
97
|
end
|
|
100
98
|
|
|
101
99
|
# Run alone, only the endpoint that ran is checked. One without a response or a status hasn't run, so it has nothing
|
|
@@ -113,14 +111,15 @@ module Raindown
|
|
|
113
111
|
@execute && (output.nil? || output[:body].strip.empty?)
|
|
114
112
|
end
|
|
115
113
|
|
|
116
|
-
# Replaces
|
|
114
|
+
# Replaces everything from just after the curl block to the end of the output block, if there is one, like its
|
|
115
|
+
# comment.
|
|
117
116
|
# Secrets that a response echoes are written as their placeholders.
|
|
118
117
|
def write_output(curl:, output:, args:, directive:)
|
|
119
118
|
response = Curl.run(curl[:body], @variables)
|
|
120
119
|
body = @variables.redact(response.body)
|
|
121
120
|
@variables.remember(args.name, body)
|
|
122
121
|
check(args, directive:, body:, status: response.status)
|
|
123
|
-
replace_output(curl:, output:, block: [*output_block(body, response.language)
|
|
122
|
+
replace_output(curl:, output:, block: [response_comment(response), *output_block(body, response.language)])
|
|
124
123
|
rescue Curl::Error => e
|
|
125
124
|
message = @variables.redact(e.message)
|
|
126
125
|
# The one endpoint that was asked for fails for the caller to show, like in the terminal UI.
|
|
@@ -132,21 +131,13 @@ module Raindown
|
|
|
132
131
|
|
|
133
132
|
# Returns the index of the block's last line.
|
|
134
133
|
def replace_output(curl:, output:, block:)
|
|
135
|
-
|
|
136
|
-
start
|
|
137
|
-
|
|
138
|
-
@lines[start, output ? replaced_length(output) : 0] = block
|
|
134
|
+
start = curl[:close] + 1
|
|
135
|
+
@lines[start, output ? output[:close] - start + 1 : 0] = block
|
|
139
136
|
start + block.length - 1
|
|
140
137
|
end
|
|
141
138
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
length = output[:close] - output[:open] + 1
|
|
145
|
-
@lines[output[:close] + 1]&.match?(FETCHED) ? length + 1 : length
|
|
146
|
-
end
|
|
147
|
-
|
|
148
|
-
def fetched(response)
|
|
149
|
-
"<!-- raindown:fetched #{Date.today.iso8601} · #{response.status} · #{response.milliseconds} ms -->\n"
|
|
139
|
+
def response_comment(response)
|
|
140
|
+
"<!-- response #{response.status} · #{response.milliseconds} ms · #{Date.today.iso8601} -->\n"
|
|
150
141
|
end
|
|
151
142
|
|
|
152
143
|
# The fence is longer than any run of backticks in the body, so the body can't close it.
|
data/lib/raindown/node_blocks.rb
CHANGED
|
@@ -127,12 +127,7 @@ module Raindown
|
|
|
127
127
|
end
|
|
128
128
|
|
|
129
129
|
# A bare block holds the lines straight below it, up to the next blank line, with fenced code kept whole.
|
|
130
|
-
def bare_block(lines, open)
|
|
131
|
-
last = open
|
|
132
|
-
last = CodeBlocks.at(lines, last + 1)&.fetch(:close) || (last + 1) while lines[last + 1] && !lines[last + 1].strip.empty?
|
|
133
|
-
|
|
134
|
-
{ open:, close: nil, last: }
|
|
135
|
-
end
|
|
130
|
+
def bare_block(lines, open) = { open:, close: nil, last: CodeBlocks.last_before_blank(lines, open) }
|
|
136
131
|
|
|
137
132
|
# The end tag written the same way as the directive: in a comment, or in Antlers' delimiters.
|
|
138
133
|
def end_tag(lines, open, name, comment:)
|
|
@@ -35,7 +35,8 @@ module Raindown
|
|
|
35
35
|
private
|
|
36
36
|
|
|
37
37
|
def block_lines(lines, block)
|
|
38
|
-
|
|
38
|
+
context = { document: @content, metadata: @metadata, bare: @bare, written: written_lines(lines, block) }
|
|
39
|
+
markdown = Nodes.call(block[:node], :replace, **context).to_s.chomp
|
|
39
40
|
written = markdown.empty? ? [] : "#{markdown}\n".lines
|
|
40
41
|
return [line(lines[block[:open]]), *bare(written, block)] unless block[:close]
|
|
41
42
|
|
|
@@ -54,6 +55,11 @@ module Raindown
|
|
|
54
55
|
raise BlankLine, "#{name} writes blank lines, so it needs <!-- #{name}: --> and <!-- :#{name} --> around it"
|
|
55
56
|
end
|
|
56
57
|
|
|
58
|
+
# What the block's node wrote last time, between its directive and its end tag, or the next blank line.
|
|
59
|
+
def written_lines(lines, block)
|
|
60
|
+
lines[(block[:open] + 1)...(block[:close] || (block[:last] + 1))]
|
|
61
|
+
end
|
|
62
|
+
|
|
57
63
|
def line(line)
|
|
58
64
|
line.end_with?("\n") ? line : "#{line}\n"
|
|
59
65
|
end
|
data/lib/raindown/nodes.rb
CHANGED
|
@@ -6,6 +6,7 @@ require_relative 'js/js_nodes'
|
|
|
6
6
|
require_relative 'markdown_nodes'
|
|
7
7
|
require_relative 'node'
|
|
8
8
|
require_relative 'stat'
|
|
9
|
+
require_relative 'table/table'
|
|
9
10
|
require_relative 'toc'
|
|
10
11
|
|
|
11
12
|
module Raindown
|
|
@@ -13,11 +14,12 @@ module Raindown
|
|
|
13
14
|
# A project keeps them in a .raindown folder, which is loaded with Dirload, beside its JavaScript and Markdown
|
|
14
15
|
# Components, if any.
|
|
15
16
|
# A node writes Markdown with #replace, and HTML with #render if it has one. Either can take the keywords
|
|
16
|
-
# document: (the Markdown being rendered), metadata
|
|
17
|
+
# document: (the Markdown being rendered), metadata:, bare: and written: (the lines it wrote last time), and gets the
|
|
18
|
+
# ones it asks for.
|
|
17
19
|
module Nodes
|
|
18
20
|
FOLDER = '.raindown'
|
|
19
|
-
# Lowercase directives for Raindown's own nodes. A project's own TOC, Files or
|
|
20
|
-
BUILT_INS = { 'toc' => 'TOC', 'files' => 'Files', 'chart' => 'Chart' }.freeze
|
|
21
|
+
# Lowercase directives for Raindown's own nodes. A project's own TOC, Files, Chart or Table node replaces Raindown's.
|
|
22
|
+
BUILT_INS = { 'toc' => 'TOC', 'files' => 'Files', 'chart' => 'Chart', 'table' => 'Table' }.freeze
|
|
21
23
|
# A placeholder for a node's HTML, which stays as it is through Commonmarker and Antlers.
|
|
22
24
|
PLACEHOLDER = /<!-- raindown:node (\d+) -->/
|
|
23
25
|
|
data/lib/raindown/pages.rb
CHANGED
|
@@ -61,10 +61,12 @@ module Raindown
|
|
|
61
61
|
attr_reader :root
|
|
62
62
|
|
|
63
63
|
# Render options, like highlight_code: false, are for rendering each listed file's content.
|
|
64
|
-
|
|
64
|
+
# Eager reads every file's front matter straight away, for an app like Raindeer that needs all of it from the start.
|
|
65
|
+
def initialize(root:, metadata:, render_options: {}, eager: false)
|
|
65
66
|
@root = root
|
|
66
67
|
@render_options = render_options
|
|
67
68
|
@file_paths = metadata.file_types.values_at(*EXTENSIONS).flatten.compact
|
|
69
|
+
load_metadata if eager
|
|
68
70
|
end
|
|
69
71
|
|
|
70
72
|
# The files with any of the tags, like list(folder: 'basics') or list(tags: 'ruby'), sorted by order.
|
|
@@ -81,16 +83,16 @@ module Raindown
|
|
|
81
83
|
private
|
|
82
84
|
|
|
83
85
|
def metadata
|
|
84
|
-
|
|
86
|
+
load_metadata unless @metadata
|
|
85
87
|
@metadata
|
|
86
88
|
end
|
|
87
89
|
|
|
88
90
|
def tags
|
|
89
|
-
|
|
91
|
+
load_metadata unless @tags
|
|
90
92
|
@tags
|
|
91
93
|
end
|
|
92
94
|
|
|
93
|
-
def
|
|
95
|
+
def load_metadata
|
|
94
96
|
@metadata = {}
|
|
95
97
|
@tags = {}
|
|
96
98
|
@file_paths.each { |file_path| process(file_path:) }
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Raindown
|
|
4
|
+
# Rows of cells as a Markdown table, its first row the header, and back again. A | in a cell is escaped as \|, and
|
|
5
|
+
# a line break is written as a space, as a table's cells are one line. The TableFormatter lines up its columns.
|
|
6
|
+
module MarkdownTable
|
|
7
|
+
SEPARATOR = /\A\|?(\s*:?-+:?\s*\|)*\s*:?-+:?\s*\|?\z/
|
|
8
|
+
# A | that isn't escaped.
|
|
9
|
+
PIPE = /(?<!\\)\|/
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def format(rows)
|
|
14
|
+
header, *body = rows.map { |row| "| #{row.map { escape(it) }.join(' | ')} |" }
|
|
15
|
+
[header, "|#{Array.new(rows.first.length, '---').join('|')}|", *body].join("\n")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# The rows of the table in the lines, leaving out its separator, or none when they aren't a table.
|
|
19
|
+
def parse(lines)
|
|
20
|
+
return [] unless lines[1]&.strip&.match?(SEPARATOR)
|
|
21
|
+
|
|
22
|
+
[lines[0], *lines.drop(2)].take_while { it.strip.start_with?('|') }.map { cells(it.strip) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def cells(line)
|
|
26
|
+
line.delete_prefix('|').delete_suffix('|').split(PIPE, -1).map { it.strip.gsub('\|', '|') }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def escape(cell)
|
|
30
|
+
cell.gsub('|', '\|').gsub(/\s*\R\s*/, ' ')
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'csv'
|
|
4
|
+
require 'digest'
|
|
5
|
+
require 'json'
|
|
6
|
+
|
|
7
|
+
require_relative '../node'
|
|
8
|
+
require_relative 'markdown_table'
|
|
9
|
+
|
|
10
|
+
module Raindown
|
|
11
|
+
# The node behind table:, a CSV or TSV file relative to the project as a Markdown table, after a comment of how many
|
|
12
|
+
# rows it has and a hash of them, as they were when the table and the file last matched:
|
|
13
|
+
# table: 'data/team.csv'
|
|
14
|
+
# <!-- table 2 rows · 3f2a9c -->
|
|
15
|
+
# | name | role |
|
|
16
|
+
# |------|----------|
|
|
17
|
+
# | Ada | engineer |
|
|
18
|
+
# | Lin | design |
|
|
19
|
+
# Edit the table and its rows are written back to the file, or edit the file and the table's written again. When
|
|
20
|
+
# both changed, neither is, and the table's comment says so until they match again.
|
|
21
|
+
class Table < Node
|
|
22
|
+
COMMENT = /\A<!-- table (?:conflict|\d+ rows?) · (\h+) -->\z/
|
|
23
|
+
SEPARATORS = { '.csv' => ',', '.tsv' => "\t" }.freeze
|
|
24
|
+
|
|
25
|
+
def initialize(data)
|
|
26
|
+
@data = data
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Written is what it wrote last time, so it can tell which of the table and the file changed since.
|
|
30
|
+
def replace(written: [], metadata: NoMetadata)
|
|
31
|
+
path = File.expand_path(@data.to_s, metadata.respond_to?(:root) ? metadata.root : Dir.pwd)
|
|
32
|
+
return "Can't show #{@data.inspect} as a table: only CSV and TSV files can be" unless SEPARATORS.key?(File.extname(path).downcase)
|
|
33
|
+
return "Can't show #{@data.inspect} as a table: no such file" unless File.file?(path)
|
|
34
|
+
|
|
35
|
+
synced(path, written.map(&:chomp))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def synced(path, written)
|
|
41
|
+
file = read_rows(path)
|
|
42
|
+
synced_hash = written.first&.[](COMMENT, 1)
|
|
43
|
+
table = MarkdownTable.parse(written.drop(1))
|
|
44
|
+
return markdown(file) if synced_hash.nil? || table.empty? || [synced_hash, hash_rows(file)].include?(hash_rows(table))
|
|
45
|
+
return markdown(write_rows(path, table)) if hash_rows(file) == synced_hash
|
|
46
|
+
|
|
47
|
+
warn "raindown: #{@data} and its table both changed, so neither is written until they match"
|
|
48
|
+
["<!-- table conflict · #{synced_hash} -->", *written.drop(1)].join("\n")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def markdown(rows)
|
|
52
|
+
return '' if rows.empty?
|
|
53
|
+
|
|
54
|
+
count = rows.length - 1
|
|
55
|
+
"<!-- table #{count} row#{'s' unless count == 1} · #{hash_rows(rows)} -->\n#{MarkdownTable.format(rows)}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def read_rows(path)
|
|
59
|
+
rows = CSV.read(path, col_sep: SEPARATORS[File.extname(path).downcase], skip_blanks: true, encoding: Encoding::UTF_8)
|
|
60
|
+
width = rows.map(&:length).max.to_i
|
|
61
|
+
rows.map { |row| Array.new(width) { row[it].to_s.strip } }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def write_rows(path, rows)
|
|
65
|
+
File.write(path, rows.map { CSV.generate_line(it, col_sep: SEPARATORS[File.extname(path).downcase]) }.join)
|
|
66
|
+
rows
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def hash_rows(rows)
|
|
70
|
+
Digest::SHA256.hexdigest(JSON.generate(rows))[0, 6]
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'zlib'
|
|
4
|
+
|
|
5
|
+
module Raindown
|
|
6
|
+
module TUI
|
|
7
|
+
# Raindown's logo, raindown.dev's rain cloud, in the middle of the screen while the terminal UI loads: bumps and a
|
|
8
|
+
# rounded base of blocks, each its own blue, lighter at the top, with a few corrupted, and rain falling from under it
|
|
9
|
+
# to the bottom of the screen, fading and thinning out as it goes:
|
|
10
|
+
# ███████████████▞███▄██████████
|
|
11
|
+
# ██████████████████████████
|
|
12
|
+
# ╎╎,┊ ' , ┊ ┊ ' ┊' ╎,
|
|
13
|
+
# ╎ │ ││ , │ │ , ' ' ┊,
|
|
14
|
+
# It's drawn like the site's RainCloud, with the same seed, so it's the same cloud, in the nearest of the 256 colours
|
|
15
|
+
# most terminals have.
|
|
16
|
+
class Cloud
|
|
17
|
+
WIDTH = 50
|
|
18
|
+
ROWS = 19
|
|
19
|
+
SEED = 5
|
|
20
|
+
# Bumps as [column, row, radius], and the base under them, with its corners rounded. Rows are stretched, as
|
|
21
|
+
# characters are taller than they're wide.
|
|
22
|
+
BUMPS = [[25, 8, 11], [13, 12, 7.5], [37, 11, 8.5]].freeze
|
|
23
|
+
BASE_ROWS = 12..18
|
|
24
|
+
BASE_COLUMNS = 7..43
|
|
25
|
+
CORNER = 5
|
|
26
|
+
ROW_STRETCH = 1.7
|
|
27
|
+
SHADES = %w[█ ▓].freeze
|
|
28
|
+
CORRUPT = %w[▀ ▄ ▚ ▞ ▌ ▐].freeze
|
|
29
|
+
CORRUPTED = 0.015
|
|
30
|
+
RAIN = %w[│ ╎ ┊ ' ,].freeze
|
|
31
|
+
# The rain's blue, as hue, saturation and lightness, which fades in steps as it falls, and thins out.
|
|
32
|
+
RAIN_BLUE = [212, 100, 70].freeze
|
|
33
|
+
RAIN_STEPS = 8
|
|
34
|
+
FADE = 0.11
|
|
35
|
+
# The levels of red, green and blue in the 256 colours' cube, from 16 to 231, leaving out the greys after it, so
|
|
36
|
+
# the rain stays blue as it fades.
|
|
37
|
+
CUBE = [0, 95, 135, 175, 215, 255].freeze
|
|
38
|
+
XTERM = (16..231).to_h { [it, CUBE.values_at((it - 16) / 36, ((it - 16) / 6) % 6, (it - 16) % 6)] }.freeze
|
|
39
|
+
|
|
40
|
+
class << self
|
|
41
|
+
# Each cell inside the cloud, by its row and column, as its block and its blue's style.
|
|
42
|
+
def cells = drawing.first
|
|
43
|
+
|
|
44
|
+
# The columns it rains from.
|
|
45
|
+
def rainy = drawing.last
|
|
46
|
+
|
|
47
|
+
# The rain's style at each step, from just under the cloud to the bottom of the screen.
|
|
48
|
+
def rain_styles
|
|
49
|
+
@rain_styles ||= Array.new(RAIN_STEPS) { |step| style(rgb(*RAIN_BLUE).map { (it * (1 - (step * FADE))).round }) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Every blue it's drawn in, for the screen's colours.
|
|
53
|
+
def styles = (cells.values.map(&:last) + rain_styles).uniq
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
# Rolled in the site's order, a cell at a time, then the rainy columns.
|
|
58
|
+
def drawing
|
|
59
|
+
@drawing ||= begin
|
|
60
|
+
random = Random.new(SEED)
|
|
61
|
+
cells = ROWS.times.to_a.product(WIDTH.times.to_a).select { inside?(*it) }.to_h { [it, cell(it.first, random)] }
|
|
62
|
+
[cells, ((BASE_COLUMNS.begin + 1)..(BASE_COLUMNS.end - 1)).select { random.rand < 0.6 }]
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# A corrupted block now and then, and otherwise a shade, denser towards the bottom, with some noise.
|
|
67
|
+
def cell(row, random)
|
|
68
|
+
return [CORRUPT.sample(random:), blue(row, random)] if random.rand < CORRUPTED
|
|
69
|
+
|
|
70
|
+
depth = (row / ROWS.to_f) + random.rand(-0.25..0.25)
|
|
71
|
+
[SHADES[(1 - (depth * 2)).round.clamp(0, 1)], blue(row, random)]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# A random blue, lighter at the top of the cloud and darker at the bottom.
|
|
75
|
+
def blue(row, random)
|
|
76
|
+
light = (82 - (row * 2.4) + random.rand(-12..12)).round.clamp(25, 90)
|
|
77
|
+
style(rgb(random.rand(198..235), random.rand(65..100), light))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The style of the nearest of the 256 colours.
|
|
81
|
+
def style(rgb) = :"cloud_#{XTERM.min_by { |_, color| color.zip(rgb).sum { (it[0] - it[1])**2 } }.first}"
|
|
82
|
+
|
|
83
|
+
def rgb(hue, saturation, lightness)
|
|
84
|
+
saturation /= 100.0
|
|
85
|
+
lightness /= 100.0
|
|
86
|
+
chroma = saturation * [lightness, 1 - lightness].min
|
|
87
|
+
[0, 8, 4].map do |offset|
|
|
88
|
+
k = (offset + (hue / 30.0)) % 12
|
|
89
|
+
((lightness - (chroma * [[k - 3, 9 - k, 1].min, -1].max)) * 255).round
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def inside?(row, column)
|
|
94
|
+
BUMPS.any? { |x, y, radius| ((column - x)**2) + (((row - y) * ROW_STRETCH)**2) <= radius**2 } || base?(row, column)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def base?(row, column)
|
|
98
|
+
return false unless BASE_ROWS.cover?(row) && BASE_COLUMNS.cover?(column)
|
|
99
|
+
|
|
100
|
+
corner_row, corner_column = corner(row, column)
|
|
101
|
+
((column - corner_column)**2) + (((row - corner_row) * ROW_STRETCH)**2) <= CORNER**2
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# The nearest point to the cell that's a corner's radius in from the base's edges, where its corners curve in
|
|
105
|
+
# from, reaching at most halfway up the base.
|
|
106
|
+
def corner(row, column)
|
|
107
|
+
inset = [CORNER / ROW_STRETCH, (BASE_ROWS.end - BASE_ROWS.begin) / 2.0].min
|
|
108
|
+
[row.clamp(BASE_ROWS.begin + inset, BASE_ROWS.end - inset), column.clamp(BASE_COLUMNS.begin + CORNER, BASE_COLUMNS.end - CORNER)]
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
attr_reader :width, :height
|
|
113
|
+
|
|
114
|
+
def initialize(width:, height:)
|
|
115
|
+
@width = width
|
|
116
|
+
@height = height
|
|
117
|
+
@left = (width - WIDTH) / 2
|
|
118
|
+
@top = [(height - ROWS) / 3, 0].max
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Draws the cloud and its rain at the tick into a Scene's cells.
|
|
122
|
+
def draw(cells, tick)
|
|
123
|
+
self.class.cells.each { |(row, column), (block, style)| put(cells, row, column, block, style) }
|
|
124
|
+
rain(cells, tick)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
# Drops in the rainy columns, moving down a row a tick, fading and thinning out in steps below the cloud.
|
|
130
|
+
def rain(cells, tick)
|
|
131
|
+
falling = height - @top - ROWS
|
|
132
|
+
falling.times do |below|
|
|
133
|
+
step = below * RAIN_STEPS / falling
|
|
134
|
+
self.class.rainy.each { |column| drop(cells, column, below - tick, ROWS + below, step) }
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# The drop in the column at the row, if there's one there at the tick, from a checksum of where it is in the rain.
|
|
139
|
+
def drop(cells, column, falling, row, step)
|
|
140
|
+
drop = Zlib.crc32("#{column} #{falling}")
|
|
141
|
+
return unless (drop % 100) < 90 - (step * 9)
|
|
142
|
+
|
|
143
|
+
put(cells, row, column, RAIN[drop % RAIN.length], self.class.rain_styles[step])
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# The cell at the cloud's row and column, where it's on the screen.
|
|
147
|
+
def put(cells, row, column, char, style)
|
|
148
|
+
row += @top
|
|
149
|
+
column += @left
|
|
150
|
+
cells[row][column] = [char, style] if row.between?(0, height - 1) && column.between?(0, width - 1)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../../text'
|
|
4
|
+
require_relative '../waffle/waffle'
|
|
5
|
+
require_relative 'scene'
|
|
6
|
+
|
|
7
|
+
module Raindown
|
|
8
|
+
module TUI
|
|
9
|
+
# A Celebration over the whole screen, in place of the Layout: rain and fireworks, then a waffle popping out, among
|
|
10
|
+
# confetti, balloons and sparkles.
|
|
11
|
+
CelebrationRenderer = Struct.new(:celebration, :width, :height) do
|
|
12
|
+
include Text
|
|
13
|
+
include Waffle
|
|
14
|
+
|
|
15
|
+
def lines
|
|
16
|
+
return Scene.new(width:, height:).lines(celebration.frame) if celebration.raining?
|
|
17
|
+
|
|
18
|
+
waffle(Scene.new(width:, height:, animations: [Confetti, Balloons, Sparkles]).lines(celebration.popping), celebration.popping)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -7,14 +7,11 @@ require_relative 'bins'
|
|
|
7
7
|
require_relative 'feelings_code_box'
|
|
8
8
|
require_relative 'magnifier'
|
|
9
9
|
require_relative 'motion'
|
|
10
|
-
require_relative 'celebration/scene'
|
|
11
|
-
require_relative 'waffle/waffle'
|
|
12
10
|
|
|
13
11
|
module Raindown
|
|
14
12
|
module TUI
|
|
15
13
|
# Code feelings, right of the files: the methods as clusters of digits in a grid, with the selected one highlighted
|
|
16
14
|
# and its code below, then why it felt that way while that's typed, then the five bins and how many each holds.
|
|
17
|
-
# When the batch is refined, it rains over them, then there's a waffle:
|
|
18
15
|
# 0 0 2 2 2 5 5 9 9 9 9 1
|
|
19
16
|
# 1 3 3 7 7 7 6 4 4 0 8 8
|
|
20
17
|
# ╭─ lib/text.rb:62 · Text#segments_between ──────────── changed 6× ─╮
|
|
@@ -29,7 +26,6 @@ module Raindown
|
|
|
29
26
|
include FeelingsCodeBox
|
|
30
27
|
include Magnifier
|
|
31
28
|
include Motion
|
|
32
|
-
include Waffle
|
|
33
29
|
|
|
34
30
|
# The columns a cell takes, on a line of its own, its digit small in the middle, which is magnified in a stat's block
|
|
35
31
|
# digits across the cell and over the cells above and below it when the mouse is over its method.
|
|
@@ -66,7 +62,7 @@ module Raindown
|
|
|
66
62
|
return too_small if too_small?
|
|
67
63
|
|
|
68
64
|
cells = view.cells(columns, filled_rows)
|
|
69
|
-
return instead if view.code_methods.empty?
|
|
65
|
+
return instead if view.code_methods.empty?
|
|
70
66
|
|
|
71
67
|
below = [*code_box, *bins]
|
|
72
68
|
(grid(cells, below.length) + below).first(height)
|
|
@@ -76,20 +72,11 @@ module Raindown
|
|
|
76
72
|
|
|
77
73
|
def view = navigator.view
|
|
78
74
|
|
|
79
|
-
# Instead of the grid, that there's nothing to feel, as there are no projects or no methods
|
|
75
|
+
# Instead of the grid, that there's nothing to feel, as there are no projects or no methods.
|
|
80
76
|
def instead
|
|
81
77
|
return alert(NO_PROJECTS) if navigator.projects.empty?
|
|
82
|
-
return alert(["No functions or methods in #{File.basename(navigator.root)} to feel."]) if view.code_methods.empty?
|
|
83
78
|
|
|
84
|
-
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
# Rain and fireworks over the pane when the batch is refined, then a waffle popping out, among confetti, balloons and
|
|
88
|
-
# sparkles.
|
|
89
|
-
def celebration(celebration)
|
|
90
|
-
return Scene.new(width:, height:).lines(celebration.frame) if celebration.raining?
|
|
91
|
-
|
|
92
|
-
waffle(Scene.new(width:, height:, animations: [Confetti, Balloons, Sparkles]).lines(celebration.popping), celebration.popping)
|
|
79
|
+
alert(["No functions or methods in #{File.basename(navigator.root)} to feel."])
|
|
93
80
|
end
|
|
94
81
|
|
|
95
82
|
# The rows of the grid around the selected method's, between blank lines that leave the first and last rows room to
|
data/lib/raindown/tui/layout.rb
CHANGED
|
@@ -5,6 +5,7 @@ require_relative 'text'
|
|
|
5
5
|
require_relative 'title'
|
|
6
6
|
require_relative 'project_box'
|
|
7
7
|
require_relative 'search_box'
|
|
8
|
+
require_relative 'feelings/celebration/celebration_renderer'
|
|
8
9
|
|
|
9
10
|
module Raindown
|
|
10
11
|
module TUI
|
|
@@ -49,7 +50,7 @@ module Raindown
|
|
|
49
50
|
def lines
|
|
50
51
|
return [status].first(height) if height < CHROME_HEIGHT + 1
|
|
51
52
|
|
|
52
|
-
overlaid([title, *panes, status, button_bar])
|
|
53
|
+
celebration || overlaid([title, *panes, status, button_bar])
|
|
53
54
|
end
|
|
54
55
|
|
|
55
56
|
# A third of the width, within NAV_WIDTH, and never more than half of a narrow screen.
|
|
@@ -59,6 +60,12 @@ module Raindown
|
|
|
59
60
|
|
|
60
61
|
private
|
|
61
62
|
|
|
63
|
+
# Code feelings' celebration over the whole screen, while it's on.
|
|
64
|
+
def celebration
|
|
65
|
+
celebration = navigator.feelings.celebration
|
|
66
|
+
CelebrationRenderer.new(celebration, width, height).lines if celebration
|
|
67
|
+
end
|
|
68
|
+
|
|
62
69
|
# The project menu or Teleport, drawn over the lines while it's open.
|
|
63
70
|
def overlaid(lines)
|
|
64
71
|
return ProjectBox.new(navigator, width:, column: display_width(name)).apply(lines) if navigator.menu
|
data/lib/raindown/tui/project.rb
CHANGED
|
@@ -46,7 +46,7 @@ module Raindown
|
|
|
46
46
|
|
|
47
47
|
# A file's lines, headings and directives.
|
|
48
48
|
def outline(path)
|
|
49
|
-
document =
|
|
49
|
+
document = document(path)
|
|
50
50
|
Outline.new(lines: File.readlines(path, chomp: true), headings: document.headings, directives: document.directives)
|
|
51
51
|
end
|
|
52
52
|
|
|
@@ -66,9 +66,12 @@ module Raindown
|
|
|
66
66
|
# the ones whose snippet can't be read.
|
|
67
67
|
def endpoints
|
|
68
68
|
Projects.files(root).flat_map do |path|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
69
|
+
document = document(path)
|
|
70
|
+
# Most files have no endpoint, so they aren't parsed for one.
|
|
71
|
+
next [] unless document.markdown.include?('endpoint')
|
|
72
|
+
|
|
73
|
+
runnable = document.directives.select { it.endpoint? && it.writes }
|
|
74
|
+
runnable.filter_map { |directive| endpoint(path, document.markdown.lines, directive) }
|
|
72
75
|
rescue StandardError
|
|
73
76
|
[]
|
|
74
77
|
end
|
|
@@ -96,19 +99,28 @@ module Raindown
|
|
|
96
99
|
end
|
|
97
100
|
|
|
98
101
|
def charts_in(path)
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
NodeBlocks.find(lines, front_matter: FrontMatter.parse(content).first, bare:)
|
|
102
|
+
document = document(path)
|
|
103
|
+
lines = document.markdown.lines
|
|
104
|
+
# Its headings are only parsed when it has a chart, as most files don't.
|
|
105
|
+
NodeBlocks.find(lines, front_matter: FrontMatter.parse(document.markdown).first, bare: document.bare)
|
|
105
106
|
.select { it[:node].respond_to?(:card_lines) }
|
|
106
|
-
.map { |block| chart(block, path:, lines:, headings:) }
|
|
107
|
+
.map { |block| chart(block, path:, lines:, headings: document.headings) }
|
|
107
108
|
rescue StandardError => e
|
|
108
109
|
@file_errors << "#{path.delete_prefix(File.join(root, ''))}: #{e.message}"
|
|
109
110
|
[]
|
|
110
111
|
end
|
|
111
112
|
|
|
113
|
+
# The file's Document, read and parsed again only once the file's changed, so its endpoints, charts and outline
|
|
114
|
+
# share it, and finding them again only parses the files that changed.
|
|
115
|
+
def document(path)
|
|
116
|
+
stat = File.stat(path)
|
|
117
|
+
changed = [stat.mtime, stat.size]
|
|
118
|
+
cached_at, document = (@documents ||= {})[path]
|
|
119
|
+
return document if cached_at == changed
|
|
120
|
+
|
|
121
|
+
Document.read(path, bare: @bare).tap { @documents[path] = [changed, it] }
|
|
122
|
+
end
|
|
123
|
+
|
|
112
124
|
def drawn
|
|
113
125
|
pages = {}
|
|
114
126
|
@charts.map do |chart|
|
|
@@ -12,7 +12,7 @@ module Raindown
|
|
|
12
12
|
# │ GET https://httpbin.org/uuid [Run] │
|
|
13
13
|
# ╰──────────────────────────────────────────╯
|
|
14
14
|
#
|
|
15
|
-
#
|
|
15
|
+
# response 200 · 142 ms · 2026-10-26 · json
|
|
16
16
|
# {
|
|
17
17
|
# "uuid": "48024974-f1b1-431a-a0cf-5cac0099a1a5"
|
|
18
18
|
# }
|
|
@@ -73,8 +73,9 @@ module Raindown
|
|
|
73
73
|
return [segment(" #{view.error}", :error, width:)] if view.error
|
|
74
74
|
return [segment(' No response yet. X runs the request.', :heading, width:)] if view.response.empty?
|
|
75
75
|
|
|
76
|
-
|
|
77
|
-
[
|
|
76
|
+
# As its comment in the file reads, or just response when it has none, then its language.
|
|
77
|
+
heading = [view.comment || 'response', view.language].reject { it.to_s.empty? }.join(' · ')
|
|
78
|
+
[segment(" #{heading}", :heading, width:)]
|
|
78
79
|
end
|
|
79
80
|
end
|
|
80
81
|
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../cloud'
|
|
4
|
+
require_relative '../feelings/celebration/scene'
|
|
5
|
+
|
|
6
|
+
module Raindown
|
|
7
|
+
module TUI
|
|
8
|
+
# The Screen's first frames, which it's prepended to: Raindown's rain cloud over the whole screen while the Navigator's
|
|
9
|
+
# made in another thread, like while Rouge loads.
|
|
10
|
+
module Loading
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def start
|
|
14
|
+
super
|
|
15
|
+
loading = Thread.new(&@loading)
|
|
16
|
+
loading.report_on_exception = false
|
|
17
|
+
rain_until(loading)
|
|
18
|
+
@navigator = loading.value
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# The rain cloud, a frame each animation tick, until the thread's done.
|
|
22
|
+
def rain_until(thread)
|
|
23
|
+
scene = Scene.new(width: Curses.cols, height: Curses.lines, animations: [Cloud])
|
|
24
|
+
tick = 0
|
|
25
|
+
loop do
|
|
26
|
+
draw(scene.lines(tick))
|
|
27
|
+
break if thread.join(Screen::TICKS[:animation] / 1000.0)
|
|
28
|
+
|
|
29
|
+
tick += 1
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require 'curses'
|
|
4
4
|
|
|
5
5
|
require_relative 'input'
|
|
6
|
+
require_relative 'loading'
|
|
6
7
|
require_relative '../layout'
|
|
7
8
|
require_relative 'ncurses_shim'
|
|
8
9
|
require_relative 'quiet'
|
|
@@ -15,15 +16,16 @@ module Raindown
|
|
|
15
16
|
# Only this class draws on the terminal, so another library could draw the same Layout.
|
|
16
17
|
class Screen
|
|
17
18
|
prepend TerminalTitle
|
|
19
|
+
prepend Loading
|
|
18
20
|
|
|
19
21
|
# How often, in milliseconds, the screen's drawn, and the open file checked for changes, while no key is pressed, by
|
|
20
22
|
# the view's pace, from when the last frame started drawing: every half second while it's active, at 12 frames a
|
|
21
23
|
# second while it's reactive, just faster than Code feelings' digits tremble, and at 20 during an animation.
|
|
22
24
|
TICKS = { active: 500, reactive: 83, animation: 50 }.freeze
|
|
23
25
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
@
|
|
26
|
+
# Loading makes the Navigator, while the rain cloud's drawn.
|
|
27
|
+
def initialize(&loading)
|
|
28
|
+
@loading = loading
|
|
27
29
|
end
|
|
28
30
|
|
|
29
31
|
def run
|
|
@@ -89,8 +91,7 @@ module Raindown
|
|
|
89
91
|
Curses.close_screen
|
|
90
92
|
end
|
|
91
93
|
|
|
92
|
-
def draw
|
|
93
|
-
lines = Layout.new(@navigator, width: Curses.cols, height: Curses.lines).lines
|
|
94
|
+
def draw(lines = Layout.new(@navigator, width: Curses.cols, height: Curses.lines).lines)
|
|
94
95
|
@zones = Input.zones(lines)
|
|
95
96
|
window = Curses.stdscr
|
|
96
97
|
window.erase
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require 'curses'
|
|
4
4
|
|
|
5
|
+
require_relative '../cloud'
|
|
6
|
+
|
|
5
7
|
module Raindown
|
|
6
8
|
module TUI
|
|
7
9
|
# The curses attributes of a Layout's styles. Text is periwinkle where there are 256 colours, and the terminal's own
|
|
@@ -92,7 +94,7 @@ module Raindown
|
|
|
92
94
|
def colored
|
|
93
95
|
in_text_color.merge(
|
|
94
96
|
title: pair(:title) | Curses::A_BOLD, directive: pair(:directive), error: pair(:error) | Curses::A_BOLD,
|
|
95
|
-
button: pair(:button) | Curses::A_BOLD, **selections, **emphasis, **dividers, **code_tokens, **bright, **files,
|
|
97
|
+
button: pair(:button) | Curses::A_BOLD, **selections, **emphasis, **dividers, **code_tokens, **bright, **cloud, **files,
|
|
96
98
|
**unfocused
|
|
97
99
|
)
|
|
98
100
|
end
|
|
@@ -130,6 +132,8 @@ module Raindown
|
|
|
130
132
|
# The bright colours, bold, so they're bright in 8 colours too.
|
|
131
133
|
def bright = BRIGHT.keys.to_h { [it, pair(it) | Curses::A_BOLD] }
|
|
132
134
|
|
|
135
|
+
def cloud = Cloud.styles.to_h { [it, pair(it)] }
|
|
136
|
+
|
|
133
137
|
# The lines between the panes and beneath the title.
|
|
134
138
|
def dividers = { divider: pair(:divider) }
|
|
135
139
|
|
|
@@ -156,6 +160,7 @@ module Raindown
|
|
|
156
160
|
*(many ? METHODS : BASIC_METHODS).map { |name, color| [:"method_#{name}", color, -1] },
|
|
157
161
|
*(many ? CODE.merge(BRIGHT, FEELINGS, ACCENTS) : BASIC_CODE.merge(BASIC_BRIGHT, BASIC_FEELINGS, BASIC_ACCENTS))
|
|
158
162
|
.map { |name, color| [name, color, -1] },
|
|
163
|
+
*cloud_pairs,
|
|
159
164
|
[:text, text, -1],
|
|
160
165
|
[:selected, white, blues[:selected]], [:felt_selected, white, blues[:felt]], [:too_small, white, many ? DARK_RED : Curses::COLOR_RED],
|
|
161
166
|
[:selected_button, Curses::COLOR_YELLOW, blues[:selected]],
|
|
@@ -163,6 +168,13 @@ module Raindown
|
|
|
163
168
|
[:shine, white, -1]]
|
|
164
169
|
end
|
|
165
170
|
|
|
171
|
+
# The rain cloud's blues while the terminal UI loads, each the 256 colour its style's named after, or cyan with 8.
|
|
172
|
+
def cloud_pairs
|
|
173
|
+
return Cloud.styles.map { [it, Curses::COLOR_CYAN, -1] } if Curses.colors < 256
|
|
174
|
+
|
|
175
|
+
Cloud.styles.map { [it, it.to_s.delete_prefix('cloud_').to_i, -1] }
|
|
176
|
+
end
|
|
177
|
+
|
|
166
178
|
def pair(name)
|
|
167
179
|
Curses.color_pair(pairs.index { it.first == name } + 1)
|
|
168
180
|
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative '../../code_blocks'
|
|
3
4
|
require_relative '../events'
|
|
4
5
|
require_relative '../../workers/worker'
|
|
5
6
|
|
|
@@ -9,11 +10,11 @@ module Raindown
|
|
|
9
10
|
# Worker when it's opened, which scrolls.
|
|
10
11
|
# A key's next step, for the Navigator, is nil, an Open or Edit of its directive, or an Execute of it.
|
|
11
12
|
class EndpointView
|
|
12
|
-
# The
|
|
13
|
-
|
|
13
|
+
# The text of the comment above a response, like "response 200 · 142 ms · 2026-10-26".
|
|
14
|
+
COMMENT = /<!--\s*(.*?)\s*-->/
|
|
14
15
|
FENCE = /\A\s*`{3,}(\S*)/
|
|
15
16
|
|
|
16
|
-
attr_reader :endpoint, :response, :language, :
|
|
17
|
+
attr_reader :endpoint, :response, :language, :comment, :error
|
|
17
18
|
|
|
18
19
|
def initialize(endpoint, workers:)
|
|
19
20
|
@endpoint = endpoint
|
|
@@ -36,7 +37,7 @@ module Raindown
|
|
|
36
37
|
# Reads the response again, like after the request's run.
|
|
37
38
|
def load
|
|
38
39
|
lines = output_lines
|
|
39
|
-
@
|
|
40
|
+
@comment = lines.shift[COMMENT, 1] if lines.first&.match?(CodeBlocks::RESPONSE_COMMENT)
|
|
40
41
|
@language = lines.first.to_s[FENCE, 1]
|
|
41
42
|
@response = @language ? lines[1...-1] : lines
|
|
42
43
|
@error = nil
|
data/lib/raindown/tui.rb
CHANGED
|
@@ -27,12 +27,16 @@ module Raindown
|
|
|
27
27
|
# so two projects' nodes of the same name stay apart.
|
|
28
28
|
workers = Project.workers(root:, bare:, execute:)
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
30
|
+
saves = nil
|
|
31
|
+
Screen.new do
|
|
32
|
+
# Rouge loads here, rather than with Raindown, which doesn't highlight code outside the TUI, while the screen shows
|
|
33
|
+
# the rain cloud.
|
|
34
|
+
require 'rouge'
|
|
35
|
+
# Saved files are written back, like when the folder's watched.
|
|
36
|
+
require_relative 'tui/saves'
|
|
37
|
+
saves = Saves.new(root)
|
|
38
|
+
Navigator.new(root:, path: (path unless File.directory?(path)), workers:, saves:, runs_endpoints: execute)
|
|
39
|
+
end.run
|
|
36
40
|
true
|
|
37
41
|
ensure
|
|
38
42
|
saves&.stop
|
data/lib/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: raindown
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.13.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- maedi
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-11-
|
|
10
|
+
date: 2026-11-05 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: antlers
|
|
@@ -251,6 +251,8 @@ files:
|
|
|
251
251
|
- lib/raindown/rewriter.rb
|
|
252
252
|
- lib/raindown/server.rb
|
|
253
253
|
- lib/raindown/stat.rb
|
|
254
|
+
- lib/raindown/table/markdown_table.rb
|
|
255
|
+
- lib/raindown/table/table.rb
|
|
254
256
|
- lib/raindown/table_formatter.rb
|
|
255
257
|
- lib/raindown/toc.rb
|
|
256
258
|
- lib/raindown/tui.rb
|
|
@@ -262,6 +264,7 @@ files:
|
|
|
262
264
|
- lib/raindown/tui/blueprints/traced_code.rb
|
|
263
265
|
- lib/raindown/tui/blueprints/tracing.rb
|
|
264
266
|
- lib/raindown/tui/card.rb
|
|
267
|
+
- lib/raindown/tui/cloud.rb
|
|
265
268
|
- lib/raindown/tui/code_box.rb
|
|
266
269
|
- lib/raindown/tui/endpoint.rb
|
|
267
270
|
- lib/raindown/tui/endpoints.rb
|
|
@@ -269,6 +272,7 @@ files:
|
|
|
269
272
|
- lib/raindown/tui/feelings/batch.rb
|
|
270
273
|
- lib/raindown/tui/feelings/bins.rb
|
|
271
274
|
- lib/raindown/tui/feelings/celebration/celebration.rb
|
|
275
|
+
- lib/raindown/tui/feelings/celebration/celebration_renderer.rb
|
|
272
276
|
- lib/raindown/tui/feelings/celebration/fireworks.rb
|
|
273
277
|
- lib/raindown/tui/feelings/celebration/rain.rb
|
|
274
278
|
- lib/raindown/tui/feelings/celebration/scene.rb
|
|
@@ -308,6 +312,7 @@ files:
|
|
|
308
312
|
- lib/raindown/tui/saves.rb
|
|
309
313
|
- lib/raindown/tui/search_box.rb
|
|
310
314
|
- lib/raindown/tui/terminal/input.rb
|
|
315
|
+
- lib/raindown/tui/terminal/loading.rb
|
|
311
316
|
- lib/raindown/tui/terminal/ncurses_shim.rb
|
|
312
317
|
- lib/raindown/tui/terminal/quiet.rb
|
|
313
318
|
- lib/raindown/tui/terminal/screen.rb
|