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.
Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/lib/nodes/endpoint_node.rb +3 -2
  3. data/lib/raindown/actions.rb +5 -3
  4. data/lib/raindown/bare_directives.rb +14 -27
  5. data/lib/raindown/blueprints.rb +255 -0
  6. data/lib/raindown/cli.rb +39 -54
  7. data/lib/raindown/config.rb +2 -3
  8. data/lib/raindown/directives.rb +30 -13
  9. data/lib/raindown/document.rb +21 -15
  10. data/lib/raindown/endpoint/endpoint_runner.rb +13 -5
  11. data/lib/raindown/node_blocks.rb +22 -16
  12. data/lib/raindown/node_injector.rb +7 -10
  13. data/lib/raindown/pages.rb +1 -1
  14. data/lib/raindown/pre_renderer.rb +18 -0
  15. data/lib/raindown/tui/blueprints/blueprints_renderer.rb +93 -0
  16. data/lib/raindown/tui/blueprints/blueprints_view.rb +146 -0
  17. data/lib/raindown/tui/blueprints/canvas.rb +58 -0
  18. data/lib/raindown/tui/blueprints/code_comments.rb +71 -0
  19. data/lib/raindown/tui/blueprints/filter.rb +77 -0
  20. data/lib/raindown/tui/blueprints/traced_code.rb +56 -0
  21. data/lib/raindown/tui/blueprints/tracing.rb +129 -0
  22. data/lib/raindown/tui/{feelings/code_box.rb → code_box.rb} +44 -30
  23. data/lib/raindown/tui/endpoints.rb +2 -2
  24. data/lib/raindown/tui/events.rb +3 -1
  25. data/lib/raindown/tui/feelings/bins.rb +5 -5
  26. data/lib/raindown/tui/feelings/{celebration.rb → celebration/celebration.rb} +5 -2
  27. data/lib/raindown/tui/feelings/{scene.rb → celebration/scene.rb} +4 -1
  28. data/lib/raindown/tui/feelings/cell_moves.rb +34 -0
  29. data/lib/raindown/tui/feelings/feelings_code_box.rb +40 -0
  30. data/lib/raindown/tui/feelings/feelings_log.rb +22 -19
  31. data/lib/raindown/tui/feelings/feelings_renderer.rb +94 -95
  32. data/lib/raindown/tui/feelings/feelings_view.rb +28 -32
  33. data/lib/raindown/tui/feelings/magnifier.rb +68 -0
  34. data/lib/raindown/tui/feelings/motion.rb +93 -0
  35. data/lib/raindown/tui/feelings/waffle/baked_waffle.rb +88 -0
  36. data/lib/raindown/tui/feelings/waffle/balloons.rb +81 -0
  37. data/lib/raindown/tui/feelings/waffle/confetti.rb +55 -0
  38. data/lib/raindown/tui/feelings/waffle/sparkles.rb +61 -0
  39. data/lib/raindown/tui/feelings/waffle/waffle.rb +62 -0
  40. data/lib/raindown/tui/feelings/weekly_batch.rb +24 -5
  41. data/lib/raindown/tui/layout.rb +25 -18
  42. data/lib/raindown/tui/navigator/commands.rb +32 -1
  43. data/lib/raindown/tui/navigator/mouse.rb +43 -0
  44. data/lib/raindown/tui/navigator/navigator.rb +40 -44
  45. data/lib/raindown/tui/navigator/{search.rb → teleport.rb} +14 -12
  46. data/lib/raindown/tui/navigator/views.rb +2 -3
  47. data/lib/raindown/tui/{workers/project.rb → project.rb} +33 -22
  48. data/lib/raindown/tui/{menu_overlay.rb → project_box.rb} +1 -1
  49. data/lib/raindown/tui/renderers/renderer.rb +1 -0
  50. data/lib/raindown/tui/saves.rb +22 -0
  51. data/lib/raindown/tui/{search_overlay.rb → search_box.rb} +27 -14
  52. data/lib/raindown/tui/terminal/input.rb +58 -7
  53. data/lib/raindown/tui/terminal/screen.rb +31 -26
  54. data/lib/raindown/tui/terminal/styles.rb +55 -28
  55. data/lib/raindown/tui/terminal/terminal_title.rb +31 -0
  56. data/lib/raindown/tui/text.rb +35 -12
  57. data/lib/raindown/tui/views/dashboard_view.rb +2 -2
  58. data/lib/raindown/tui/views/endpoint_view.rb +3 -3
  59. data/lib/raindown/tui/views/list_view.rb +1 -1
  60. data/lib/raindown/tui/views/outline_view.rb +5 -4
  61. data/lib/raindown/tui.rb +17 -7
  62. data/lib/raindown/watcher/changes.rb +33 -0
  63. data/lib/raindown/watcher/file_watcher.rb +58 -0
  64. data/lib/raindown/watcher/project.rb +47 -0
  65. data/lib/raindown/watcher/supervisor.rb +111 -0
  66. data/lib/raindown/{tui/workers → workers}/worker.rb +37 -29
  67. data/lib/raindown/workers/workers.rb +63 -0
  68. data/lib/raindown.rb +4 -4
  69. data/lib/version.rb +1 -1
  70. metadata +37 -17
  71. data/lib/raindown/project_worker.rb +0 -48
  72. data/lib/raindown/supervisor.rb +0 -160
  73. data/lib/raindown/tui/feelings/waffle.rb +0 -43
  74. data/lib/raindown/tui/workers/workers.rb +0 -53
  75. data/lib/raindown/watcher.rb +0 -57
  76. /data/lib/raindown/tui/feelings/{fireworks.rb → celebration/fireworks.rb} +0 -0
  77. /data/lib/raindown/tui/feelings/{rain.rb → celebration/rain.rb} +0 -0
@@ -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. A self-closing directive like <{ :toc }> only renders, so it never writes.
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
- ENDPOINT_END_TAG = /\A {0,3}<\{\s*:endpoint\s*\}>[ \t]*$/
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: !span[:self_closing])
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 (args = endpoint_args(@lines[index]))
98
- directives << endpoint(index, args)
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 = BareDirectives.wrap(line) || line if @bare
110
- line[EndpointRunner::DIRECTIVE, 1]&.strip
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 if @lines[open].match?(EndpointRunner::DIRECTIVE)
125
+ last = end_tag(after: last) || last unless Directives.bare?(@lines[open])
120
126
 
121
- directive('endpoint', args, open..last, output:, writes: true)
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?(ENDPOINT_END_TAG)
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 block below it,
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
- # <{ endpoint: :x }>
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
- # <{ :endpoint }>
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
- line = BareDirectives.wrap(line) || line if @bare
59
- line[DIRECTIVE, 1]&.split
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.
@@ -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
- # With delimiters the block ends with the node's end tag, and without them at the next blank line:
14
- # <{ Changelog: limit=3 }> Changelog: limit=3
15
- # - **1.2.0** Bare directives
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
- # <{ :Changelog }>
19
- # A self-closing <{ :Changelog }> or bare :Changelog that doesn't end a block renders the node without writing it.
20
- # toc: and files: are Raindown's own nodes. A name only starts a block when it's a loaded node,
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 bare block.
39
- # A self-closing node is a block of one line with self_closing: true.
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 then end_tag(lines, index, found[2]).then { |close| { open: index, close:, last: close, **span } }
103
- when :bare_open then { **bare_block(lines, index), **span }
104
- else { open: index, close: nil, last: index, self_closing: true, **span }
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
- def end_tag(lines, open, name)
130
- tag = /\A {0,3}<\{\s*:#{Regexp.escape(name)}\s*\}>[ \t]*$/
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, "<{ #{name}: }> on line #{open + 1} needs <{ :#{name} }>" unless close
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
- # Self-closing nodes only render, so they're left alone.
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
- .reject { |block| block[:self_closing] }
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 separated by a blank line.
46
- def delimited(lines, block, written)
47
- result = [line(lines[block[:open]])]
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 <{ #{name}: }> and <{ :#{name} }> around it"
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)
@@ -33,7 +33,7 @@ module Raindown
33
33
 
34
34
  attr_reader :root
35
35
 
36
- # Render options, like hide_directives: true, are for rendering each listed file's content.
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