raindown 0.5.1 → 0.7.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/lib/raindown/chart/chart.rb +12 -1
  3. data/lib/raindown/cli.rb +2 -2
  4. data/lib/raindown/document.rb +16 -9
  5. data/lib/raindown/endpoint/curl.rb +19 -0
  6. data/lib/raindown/node_blocks.rb +9 -6
  7. data/lib/raindown/nodes.rb +1 -0
  8. data/lib/raindown/projects.rb +8 -4
  9. data/lib/raindown/stat.rb +53 -0
  10. data/lib/raindown/tui/card.rb +56 -0
  11. data/lib/raindown/tui/endpoint.rb +16 -0
  12. data/lib/raindown/tui/endpoints.rb +40 -0
  13. data/lib/raindown/tui/events.rb +50 -0
  14. data/lib/raindown/tui/feelings/batch.rb +47 -0
  15. data/lib/raindown/tui/feelings/bins.rb +67 -0
  16. data/lib/raindown/tui/feelings/celebration.rb +25 -0
  17. data/lib/raindown/tui/feelings/code_box.rb +129 -0
  18. data/lib/raindown/tui/feelings/code_methods.rb +134 -0
  19. data/lib/raindown/tui/feelings/feelings_log.rb +133 -0
  20. data/lib/raindown/tui/feelings/feelings_renderer.rb +184 -0
  21. data/lib/raindown/tui/feelings/feelings_view.rb +161 -0
  22. data/lib/raindown/tui/feelings/fireworks.rb +75 -0
  23. data/lib/raindown/tui/feelings/rain.rb +66 -0
  24. data/lib/raindown/tui/feelings/scene.rb +37 -0
  25. data/lib/raindown/tui/feelings/waffle.rb +43 -0
  26. data/lib/raindown/tui/feelings/weekly_batch.rb +32 -0
  27. data/lib/raindown/tui/file_tree.rb +55 -12
  28. data/lib/raindown/tui/highlighter.rb +45 -0
  29. data/lib/raindown/tui/layout.rb +66 -77
  30. data/lib/raindown/tui/menu_overlay.rb +59 -0
  31. data/lib/raindown/tui/navigator/commands.rb +97 -0
  32. data/lib/raindown/tui/navigator/focus.rb +25 -0
  33. data/lib/raindown/tui/navigator/navigator.rb +160 -0
  34. data/lib/raindown/tui/navigator/project_menu.rb +61 -0
  35. data/lib/raindown/tui/navigator/search.rb +126 -0
  36. data/lib/raindown/tui/navigator/views.rb +52 -0
  37. data/lib/raindown/tui/renderers/dashboard_renderer.rb +102 -0
  38. data/lib/raindown/tui/renderers/endpoint_renderer.rb +81 -0
  39. data/lib/raindown/tui/renderers/list_renderer.rb +15 -0
  40. data/lib/raindown/tui/renderers/outline_renderer.rb +21 -0
  41. data/lib/raindown/tui/renderers/renderer.rb +18 -0
  42. data/lib/raindown/tui/row.rb +12 -3
  43. data/lib/raindown/tui/rows.rb +58 -0
  44. data/lib/raindown/tui/search_overlay.rb +73 -0
  45. data/lib/raindown/tui/terminal/input.rb +91 -0
  46. data/lib/raindown/tui/terminal/ncurses_shim.rb +46 -0
  47. data/lib/raindown/tui/terminal/quiet.rb +32 -0
  48. data/lib/raindown/tui/{screen.rb → terminal/screen.rb} +42 -24
  49. data/lib/raindown/tui/terminal/styles.rb +154 -0
  50. data/lib/raindown/tui/text.rb +110 -0
  51. data/lib/raindown/tui/title.rb +29 -0
  52. data/lib/raindown/tui/views/dashboard_view.rb +147 -0
  53. data/lib/raindown/tui/views/endpoint_view.rb +77 -0
  54. data/lib/raindown/tui/{pane.rb → views/list_view.rb} +11 -8
  55. data/lib/raindown/tui/{outline.rb → views/outline_view.rb} +48 -20
  56. data/lib/raindown/tui/workers/project.rb +122 -0
  57. data/lib/raindown/tui/workers/worker.rb +124 -0
  58. data/lib/raindown/tui/workers/workers.rb +53 -0
  59. data/lib/raindown/tui.rb +10 -5
  60. data/lib/version.rb +1 -1
  61. metadata +63 -9
  62. data/lib/raindown/tui/commands.rb +0 -70
  63. data/lib/raindown/tui/input.rb +0 -55
  64. data/lib/raindown/tui/navigator.rb +0 -123
  65. data/lib/raindown/tui/styles.rb +0 -36
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../events'
4
+ require_relative '../workers/worker'
5
+
6
+ module Raindown
7
+ module TUI
8
+ # An endpoint opened from the files, beside them: its request, and the response its file has, read by its project's
9
+ # Worker when it's opened, which scrolls.
10
+ # A key's next step, for the Navigator, is nil, an Open or Edit of its directive, or an Execute of it.
11
+ class EndpointView
12
+ # The date a response was fetched, on the line after it.
13
+ FETCHED = /raindown:fetched (\S+)/
14
+ FENCE = /\A\s*`{3,}(\S*)/
15
+
16
+ attr_reader :endpoint, :response, :language, :fetched, :error
17
+
18
+ def initialize(endpoint, workers:)
19
+ @endpoint = endpoint
20
+ @workers = workers
21
+ @offset = 0
22
+ end
23
+
24
+ def renderer = EndpointRenderer
25
+ def target = endpoint
26
+ def path = endpoint.path
27
+ def status(_navigator) = [:info, endpoint.text]
28
+ def changed? = false
29
+ def typing? = false
30
+ def animating? = false
31
+ def next_step?(key) = %i[up down enter edit execute].include?(key)
32
+
33
+ # Clicks on the response only move the focus to it.
34
+ def cursor=(_index); end
35
+
36
+ # Reads the response again, like after the request's run.
37
+ def load
38
+ lines = output_lines
39
+ @fetched = lines.pop[FETCHED, 1] if lines.last&.match?(FETCHED)
40
+ @language = lines.first.to_s[FENCE, 1]
41
+ @response = @language ? lines[1...-1] : lines
42
+ @error = nil
43
+ rescue Worker::Error => e
44
+ @response = []
45
+ @error = e.message
46
+ end
47
+
48
+ def next_step(key)
49
+ case key
50
+ when :up, :down then scroll(key == :up ? -1 : 1)
51
+ when :enter then Open.new(path: endpoint.path, line: endpoint.line)
52
+ when :edit then Edit.new(path: endpoint.path, line: endpoint.line)
53
+ when :execute then Execute.new(path: endpoint.path, line: endpoint.line, name: endpoint.text)
54
+ end
55
+ end
56
+
57
+ # The response's lines that fit in the height, from where it's scrolled to.
58
+ def visible(height)
59
+ @offset = @offset.clamp(0, [response.length - height, 0].max)
60
+ response[@offset, height] || []
61
+ end
62
+
63
+ private
64
+
65
+ def output_lines
66
+ outline = @workers.for(path).call(:outline, path)
67
+ directive = outline.directives.find { it.endpoint? && it.line == endpoint.line }
68
+ directive&.output ? outline.lines[(directive.output.begin - 1)...directive.output.end] : []
69
+ end
70
+
71
+ def scroll(by)
72
+ @offset += by
73
+ nil
74
+ end
75
+ end
76
+ end
77
+ end
@@ -2,8 +2,8 @@
2
2
 
3
3
  module Raindown
4
4
  module TUI
5
- # A pane's cursor and how far it's scrolled, over rows that can change between draws, like when a folder folds.
6
- class Pane
5
+ # Rows with a cursor, and how far they're scrolled, which can change between draws, like when a folder folds.
6
+ class ListView
7
7
  attr_writer :cursor
8
8
 
9
9
  def initialize(&rows)
@@ -12,6 +12,10 @@ module Raindown
12
12
  @offset = 0
13
13
  end
14
14
 
15
+ def renderer = ListRenderer
16
+ def typing? = false
17
+ def animating? = false
18
+
15
19
  def rows = @rows.call
16
20
  def selected = rows[cursor]
17
21
 
@@ -20,13 +24,12 @@ module Raindown
20
24
  @cursor.clamp(0, [rows.length - 1, 0].max)
21
25
  end
22
26
 
27
+ # Moves past rows that can't be selected, like separators, and stays at the first or last row rather than moving off them.
23
28
  def move(by)
24
- @cursor = (cursor + by).clamp(0, [rows.length - 1, 0].max)
25
- end
26
-
27
- def reset
28
- @cursor = 0
29
- @offset = 0
29
+ rows = self.rows
30
+ index = cursor + by
31
+ index += by while index.between?(0, rows.length - 1) && !rows[index].selectable?
32
+ @cursor = index if index.between?(0, rows.length - 1)
30
33
  end
31
34
 
32
35
  # The rows that fit in the height, scrolled so the cursor's row is one of them, and the first one's index.
@@ -1,30 +1,43 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative '../document'
4
- require_relative 'row'
3
+ require_relative '../../document'
4
+ require_relative '../events'
5
+ require_relative 'list_view'
6
+ require_relative '../highlighter'
7
+ require_relative '../row'
8
+ require_relative '../workers/worker'
5
9
 
6
10
  module Raindown
7
11
  module TUI
8
- # The outline pane: a file's headings and directives, read from its Document. Headings fold the section below them,
9
- # and a directive's output, the lines Raindown wrote back, expands below it. Both start folded away.
10
- class Outline
12
+ # The outline: a file's headings and directives, read by its project's Worker. Headings fold the section below them,
13
+ # and a directive's body expands below it, like the lines a node wrote back or an endpoint's request and response.
14
+ # Both start folded away. It's read when it's opened, and it's a ListView, with its own cursor.
15
+ class OutlineView < ListView
11
16
  attr_reader :path, :error
12
17
 
13
- def initialize(path)
18
+ def initialize(path, workers:)
19
+ super()
14
20
  @path = path
21
+ @workers = workers
15
22
  @folded = Set.new
16
23
  @expanded = Set.new
17
- read
18
24
  end
19
25
 
26
+ def renderer = OutlineRenderer
27
+ def target = path
28
+ def next_step?(_key) = false
29
+ def status(navigator) = [:info, "Opened #{navigator.relative(path)}"]
30
+
20
31
  # Reads the file again, keeping what's folded.
21
- def read
32
+ def load
22
33
  @mtime = File.mtime(path)
23
- @document = Document.read(path)
24
- @lines = File.readlines(path, chomp: true)
25
- @document.directives # Finds an error in the file, like a missing end tag, now rather than while drawing.
34
+ outline = @workers.for(path).call(:outline, path)
35
+ @lines = outline.lines
36
+ @headings = outline.headings
37
+ @directives = outline.directives
38
+ @highlighted = {}
26
39
  @error = nil
27
- rescue StandardError => e
40
+ rescue Worker::Error, SystemCallError => e
28
41
  @error = e.message
29
42
  end
30
43
 
@@ -72,34 +85,49 @@ module Raindown
72
85
 
73
86
  # Headings a directive wrote are part of its output, so they show when it's expanded rather than on their own.
74
87
  def items
75
- outputs = @document.directives.filter_map(&:output)
76
- @document.outline.reject { |item| item.is_a?(Document::Heading) && outputs.any? { it.cover?(item.line) } }
88
+ outputs = @directives.filter_map(&:output)
89
+ (@headings + @directives).sort_by(&:line)
90
+ .reject { |item| item.is_a?(Document::Heading) && outputs.any? { it.cover?(item.line) } }
77
91
  end
78
92
 
79
93
  def min_level
80
- @document.headings.map(&:level).min || 1
94
+ @headings.map(&:level).min || 1
81
95
  end
82
96
 
83
97
  def heading_row(heading)
84
98
  folded = @folded.include?(heading.line) ? ' …' : ''
85
99
  Row.new(text: "#{'#' * heading.level} #{heading.text}#{folded}", depth: heading.level - min_level, kind: :heading,
86
- target: heading)
100
+ target: heading, fold: -> { fold(heading) })
87
101
  end
88
102
 
89
103
  def directive_rows(directive, depth:)
90
104
  expanded = @expanded.include?(directive.line)
91
105
  row = Row.new(text: directive_text(directive), depth:, kind: :directive, target: directive,
92
- buttons: buttons(directive, expanded:))
106
+ buttons: buttons(directive, expanded:), **directive_action(directive))
93
107
  return [row] unless expanded
94
108
 
95
- [row, *directive.output.map do |number|
96
- Row.new(text: @lines[number - 1].to_s, depth: depth + 1, kind: :output, target: number)
109
+ code = highlighted(directive)
110
+ [row, *directive.body.each_with_index.map do |number, index|
111
+ Row.new(text: @lines[number - 1].to_s, depth: depth + 1, kind: :body, target: number, open: Edit.new(path:, line: number),
112
+ code: code[index])
97
113
  end]
98
114
  end
99
115
 
116
+ # The directive's body highlighted as Markdown, which highlights its code blocks in their languages, once a read.
117
+ def highlighted(directive)
118
+ @highlighted[directive.line] ||= Highlighter.lines(directive.body.map { @lines[it - 1].to_s }, language: 'markdown')
119
+ end
120
+
121
+ # A directive with a body expands it, and one without opens in the editor, like a line of its body.
122
+ def directive_action(directive)
123
+ if directive.body then { fold: -> { expand(directive) } }
124
+ else { open: Edit.new(path:, line: directive.line) }
125
+ end
126
+ end
127
+
100
128
  def buttons(directive, expanded:)
101
129
  buttons = []
102
- buttons << [expanded ? '[-]' : "[+#{directive.output.size}]", :toggle] if directive.output
130
+ buttons << [expanded ? '[-]' : "[+#{directive.body.size}]", :toggle] if directive.body
103
131
  buttons << ['[Run]', :execute] if directive.endpoint?
104
132
  buttons
105
133
  end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../actions'
4
+ require_relative '../../bare_directives'
5
+ require_relative '../../chart/chart'
6
+ require_relative '../../document'
7
+ require_relative '../../front_matter'
8
+ require_relative '../../node_blocks'
9
+ require_relative '../../nodes'
10
+ require_relative '../../pages'
11
+ require_relative '../../projects'
12
+ require_relative '../../stat'
13
+ require_relative '../card'
14
+ require_relative '../endpoint'
15
+ require_relative '../../code_blocks'
16
+ require_relative '../../endpoint/curl'
17
+
18
+ module Raindown
19
+ module TUI
20
+ # What the terminal UI asks of a project, answered in the project's Worker with its own nodes loaded,
21
+ # so two projects' nodes of the same name stay apart. Answers are plain data, which a Worker sends back through a pipe.
22
+ class Project
23
+ Outline = Data.define(:lines, :headings, :directives)
24
+
25
+ attr_reader :root
26
+
27
+ # The projects inside this one have workers of their own, so their files are left out.
28
+ def initialize(root:)
29
+ @root = root
30
+ @load_errors = []
31
+ @file_errors = []
32
+ Nodes.load(File.join(root, Nodes::FOLDER))
33
+ rescue StandardError, ScriptError => e
34
+ @load_errors << "#{File.basename(root)}/#{Nodes::FOLDER}: #{e.message}"
35
+ end
36
+
37
+ # A file's lines, headings and directives.
38
+ def outline(path)
39
+ document = Document.read(path)
40
+ Outline.new(lines: File.readlines(path, chomp: true), headings: document.headings, directives: document.directives)
41
+ end
42
+
43
+ # Every chart and stat in the project's files, or any node that draws a card, drawn from its data, and the files
44
+ # that couldn't be read.
45
+ # The charts are found again when asked, and otherwise drawn again, so they follow changes to their data.
46
+ def charts(find: false)
47
+ if find || @charts.nil?
48
+ @file_errors = []
49
+ @charts = Projects.files(root).flat_map { |path| charts_in(path) }
50
+ end
51
+
52
+ { cards: drawn, errors: @load_errors + @file_errors }
53
+ end
54
+
55
+ # Every endpoint in the project's files, by its request, leaving out the ones whose snippet can't be read.
56
+ def endpoints
57
+ Projects.files(root).flat_map do |path|
58
+ lines = File.readlines(path, encoding: Encoding::UTF_8)
59
+ Document.read(path).directives.select(&:endpoint?).filter_map { |directive| endpoint(path, lines, directive) }
60
+ rescue StandardError
61
+ []
62
+ end
63
+ end
64
+
65
+ def write_back(path)
66
+ Actions.write_back(path)
67
+ end
68
+
69
+ # Runs the endpoint on the line, replacing its response.
70
+ def execute(path, line)
71
+ directive = Document.read(path).directives.find { it.line == line && it.endpoint? }
72
+ raise Actions::NotAnEndpoint, "There's no endpoint on line #{line}" unless directive
73
+
74
+ Actions.execute(path, directive)
75
+ end
76
+
77
+ private
78
+
79
+ def endpoint(path, lines, directive)
80
+ snippet = CodeBlocks.endpoint(lines, directive.line).first&.dig(:body)
81
+ request = Curl.request(snippet.to_s)
82
+ Endpoint.new(project: root, path:, line: directive.line, **request.to_h) if request
83
+ end
84
+
85
+ def charts_in(path)
86
+ content = File.read(path, encoding: Encoding::UTF_8)
87
+ bare = BareDirectives.enabled?(path:, content:)
88
+ lines = content.lines
89
+ headings = Document.parse(content, bare:).headings
90
+
91
+ NodeBlocks.find(lines, front_matter: FrontMatter.parse(content).first, bare:)
92
+ .select { it[:node].respond_to?(:card_lines) }
93
+ .map { |block| chart(block, path:, lines:, headings:) }
94
+ rescue StandardError => e
95
+ @file_errors << "#{path.delete_prefix(File.join(root, ''))}: #{e.message}"
96
+ []
97
+ end
98
+
99
+ def drawn
100
+ pages = {}
101
+ @charts.map do |chart|
102
+ lines = lines(chart, pages[File.dirname(chart[:path])] ||= Pages.for(chart[:path]))
103
+ Card.new(project: root, chart: lines, **chart.except(:node))
104
+ end
105
+ end
106
+
107
+ def chart(block, path:, lines:, headings:)
108
+ line = block[:open] + 1
109
+ directive = lines[block[:open]].strip.delete_prefix('<{').delete_suffix('}>').strip
110
+
111
+ { path:, line:, heading: headings.select { it.line < line }.last&.text, directive:, node: block[:node] }
112
+ end
113
+
114
+ # Draws the chart or stat as its Markdown would be written back, with the files its project lists.
115
+ def lines(chart, metadata)
116
+ chart[:node].card_lines(metadata:)
117
+ rescue StandardError => e
118
+ ["Can't chart: #{e.message}"]
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'project'
4
+
5
+ module Raindown
6
+ module TUI
7
+ # A Project in a process of its own, forked so it loads the project's nodes apart from other projects', like watching.
8
+ # Requests and answers go through two pipes, as Marshal data, one at a time. Without fork, like on Windows,
9
+ # or with fork off, like in specs, the Project answers in this process instead.
10
+ # The process ends with exit!, since it's forked mid-call, so the terminal UI's ensure doesn't run in it.
11
+ class Worker
12
+ class Error < StandardError; end
13
+
14
+ class << self
15
+ attr_writer :fork
16
+
17
+ def fork?
18
+ @fork.nil? ? Process.respond_to?(:fork) : @fork
19
+ end
20
+
21
+ # Every worker's pipes, which a new worker closes the others of, since a worker holding another's pipe open
22
+ # would keep that worker from seeing it close.
23
+ def pipes
24
+ @pipes ||= []
25
+ end
26
+ end
27
+
28
+ attr_reader :root
29
+
30
+ def initialize(root:, fork: self.class.fork?)
31
+ @root = root
32
+ @fork = fork
33
+ end
34
+
35
+ # The Project's answer, or a Worker::Error with its error's message. A worker that stopped starts again.
36
+ def call(name, *args, **options)
37
+ return local(name, args, options) unless @fork
38
+
39
+ start unless alive?
40
+ write(@requests, [name, args, options])
41
+ status, value = read(@answers) || raise(Error, "The worker for #{File.basename(root)} stopped")
42
+ status == :ok ? value : raise(Error, value)
43
+ end
44
+
45
+ def stop
46
+ [@requests, @answers].compact.each { it.close unless it.closed? }
47
+ return unless @pid
48
+
49
+ Process.kill('TERM', @pid)
50
+ Process.wait(@pid)
51
+ rescue Errno::ECHILD, Errno::ESRCH
52
+ nil
53
+ ensure
54
+ @pid = nil
55
+ end
56
+
57
+ private
58
+
59
+ def local(name, args, options)
60
+ @project ||= Project.new(root:)
61
+ @project.public_send(name, *args, **options)
62
+ rescue StandardError => e
63
+ raise Error, e.message
64
+ end
65
+
66
+ def start
67
+ stop
68
+ requests, @requests = IO.pipe
69
+ @answers, answers = IO.pipe
70
+ self.class.pipes.reject!(&:closed?)
71
+ self.class.pipes.push(@requests, @answers)
72
+ @pid = fork do
73
+ self.class.pipes.each { it.close unless it.closed? }
74
+ serve(requests, answers)
75
+ end
76
+ requests.close
77
+ answers.close
78
+ end
79
+
80
+ # Answers until the terminal UI closes its end. The screen is the terminal UI's, so nothing is written to it,
81
+ # and Ctrl+C, which reaches every process in the terminal, is the terminal UI's to handle.
82
+ def serve(requests, answers)
83
+ trap('INT', 'IGNORE')
84
+ $stdout.reopen(File::NULL)
85
+ $stderr.reopen(File::NULL)
86
+ project = Project.new(root:)
87
+
88
+ while (request = read(requests))
89
+ write(answers, answer(project, *request))
90
+ end
91
+ ensure
92
+ exit!(0)
93
+ end
94
+
95
+ def answer(project, name, args, options)
96
+ [:ok, project.public_send(name, *args, **options)]
97
+ rescue StandardError, ScriptError => e
98
+ [:error, e.message]
99
+ end
100
+
101
+ def alive?
102
+ @pid && Process.waitpid(@pid, Process::WNOHANG).nil?
103
+ rescue Errno::ECHILD
104
+ false
105
+ end
106
+
107
+ # A message is its length, then its Marshal data.
108
+ def write(io, message)
109
+ data = Marshal.dump(message)
110
+ io.write([data.bytesize].pack('N'), data)
111
+ io.flush
112
+ rescue Errno::EPIPE, IOError
113
+ raise Error, "The worker for #{File.basename(root)} stopped"
114
+ end
115
+
116
+ def read(io)
117
+ length = io.read(4)&.unpack1('N')
118
+ Marshal.load(io.read(length)) if length # rubocop:disable Security/MarshalLoad -- from this process's own fork
119
+ rescue IOError
120
+ nil
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../projects'
4
+ require_relative 'worker'
5
+
6
+ module Raindown
7
+ module TUI
8
+ # A Worker for each project in the terminal UI's folder: the folder's own, and every project inside it.
9
+ # A file's requests go to the worker of the project it's in, the nearest one above it, even when it's new.
10
+ # Workers start when they're first asked something.
11
+ class Workers
12
+ attr_reader :root, :roots
13
+
14
+ def initialize(root:, fork: Worker.fork?)
15
+ @root = root
16
+ @fork = fork
17
+ @workers = {}
18
+ find_roots
19
+ end
20
+
21
+ def for(path)
22
+ worker(Projects.root(path) || root)
23
+ end
24
+
25
+ # A worker for each project, as they were found when the workers last started.
26
+ def each(&)
27
+ roots.map { worker(it) }.each(&)
28
+ end
29
+
30
+ # Stops the workers, so the next requests start them again, loading their projects' nodes again,
31
+ # and finds the projects again, like ones added since.
32
+ def restart
33
+ stop
34
+ @workers.clear
35
+ find_roots
36
+ end
37
+
38
+ def stop
39
+ @workers.each_value(&:stop)
40
+ end
41
+
42
+ private
43
+
44
+ def worker(project)
45
+ @workers[project] ||= Worker.new(root: project, fork: @fork)
46
+ end
47
+
48
+ def find_roots
49
+ @roots = [root, *Projects.roots(root)].uniq.sort
50
+ end
51
+ end
52
+ end
53
+ end
data/lib/raindown/tui.rb CHANGED
@@ -1,8 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'nodes'
4
3
  require_relative 'projects'
5
- require_relative 'tui/navigator'
4
+ require_relative 'tui/navigator/navigator'
6
5
 
7
6
  module Raindown
8
7
  # The terminal UI, started with "raindown tui": a project's Markdown files beside the open file's outline,
@@ -13,15 +12,21 @@ module Raindown
13
12
 
14
13
  # PATH is a file to open, or a directory. Its project is the one it's in, or else the directory itself.
15
14
  def start(path)
16
- require_relative 'tui/screen'
15
+ require_relative 'tui/terminal/screen'
17
16
  # Without a terminal, curses' own error is "Error opening terminal: unknown".
18
17
  abort('raindown tui needs a terminal') unless $stdin.tty? && $stdout.tty?
19
18
 
20
19
  path = File.expand_path(path)
21
20
  root = Projects.root(path) || (File.directory?(path) ? path : File.dirname(path))
22
- Nodes.load(File.join(root, Nodes::FOLDER))
21
+ # The projects inside it are shown too. Each loads its own nodes in its worker's process, like when watching,
22
+ # so two projects' nodes of the same name stay apart.
23
+ workers = Workers.new(root:)
23
24
 
24
- Screen.new(Navigator.new(root:, path: (path unless File.directory?(path)))).run
25
+ # Rouge loads here, rather than with Raindown, which doesn't highlight code outside the TUI.
26
+ require 'rouge'
27
+ Screen.new(Navigator.new(root:, path: (path unless File.directory?(path)), workers:)).run
28
+ ensure
29
+ workers&.stop
25
30
  end
26
31
  end
27
32
  end
data/lib/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Raindown
4
- VERSION = '0.5.1'
4
+ VERSION = '0.7.0'
5
5
  end
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.5.1
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - maedi
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-10-26 00:00:00.000000000 Z
10
+ date: 2026-10-28 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: antlers
@@ -135,6 +135,20 @@ dependencies:
135
135
  - - ">="
136
136
  - !ruby/object:Gem::Version
137
137
  version: '0'
138
+ - !ruby/object:Gem::Dependency
139
+ name: rouge
140
+ requirement: !ruby/object:Gem::Requirement
141
+ requirements:
142
+ - - "~>"
143
+ - !ruby/object:Gem::Version
144
+ version: '5.1'
145
+ type: :runtime
146
+ prerelease: false
147
+ version_requirements: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - "~>"
150
+ - !ruby/object:Gem::Version
151
+ version: '5.1'
138
152
  - !ruby/object:Gem::Dependency
139
153
  name: trees
140
154
  requirement: !ruby/object:Gem::Requirement
@@ -212,20 +226,60 @@ files:
212
226
  - lib/raindown/project_worker.rb
213
227
  - lib/raindown/projects.rb
214
228
  - lib/raindown/rewriter.rb
229
+ - lib/raindown/stat.rb
215
230
  - lib/raindown/supervisor.rb
216
231
  - lib/raindown/table_formatter.rb
217
232
  - lib/raindown/toc.rb
218
233
  - lib/raindown/tui.rb
219
- - lib/raindown/tui/commands.rb
234
+ - lib/raindown/tui/card.rb
235
+ - lib/raindown/tui/endpoint.rb
236
+ - lib/raindown/tui/endpoints.rb
237
+ - lib/raindown/tui/events.rb
238
+ - lib/raindown/tui/feelings/batch.rb
239
+ - lib/raindown/tui/feelings/bins.rb
240
+ - lib/raindown/tui/feelings/celebration.rb
241
+ - lib/raindown/tui/feelings/code_box.rb
242
+ - lib/raindown/tui/feelings/code_methods.rb
243
+ - lib/raindown/tui/feelings/feelings_log.rb
244
+ - lib/raindown/tui/feelings/feelings_renderer.rb
245
+ - lib/raindown/tui/feelings/feelings_view.rb
246
+ - lib/raindown/tui/feelings/fireworks.rb
247
+ - lib/raindown/tui/feelings/rain.rb
248
+ - lib/raindown/tui/feelings/scene.rb
249
+ - lib/raindown/tui/feelings/waffle.rb
250
+ - lib/raindown/tui/feelings/weekly_batch.rb
220
251
  - lib/raindown/tui/file_tree.rb
221
- - lib/raindown/tui/input.rb
252
+ - lib/raindown/tui/highlighter.rb
222
253
  - lib/raindown/tui/layout.rb
223
- - lib/raindown/tui/navigator.rb
224
- - lib/raindown/tui/outline.rb
225
- - lib/raindown/tui/pane.rb
254
+ - lib/raindown/tui/menu_overlay.rb
255
+ - lib/raindown/tui/navigator/commands.rb
256
+ - lib/raindown/tui/navigator/focus.rb
257
+ - lib/raindown/tui/navigator/navigator.rb
258
+ - lib/raindown/tui/navigator/project_menu.rb
259
+ - lib/raindown/tui/navigator/search.rb
260
+ - lib/raindown/tui/navigator/views.rb
261
+ - lib/raindown/tui/renderers/dashboard_renderer.rb
262
+ - lib/raindown/tui/renderers/endpoint_renderer.rb
263
+ - lib/raindown/tui/renderers/list_renderer.rb
264
+ - lib/raindown/tui/renderers/outline_renderer.rb
265
+ - lib/raindown/tui/renderers/renderer.rb
226
266
  - lib/raindown/tui/row.rb
227
- - lib/raindown/tui/screen.rb
228
- - lib/raindown/tui/styles.rb
267
+ - lib/raindown/tui/rows.rb
268
+ - lib/raindown/tui/search_overlay.rb
269
+ - lib/raindown/tui/terminal/input.rb
270
+ - lib/raindown/tui/terminal/ncurses_shim.rb
271
+ - lib/raindown/tui/terminal/quiet.rb
272
+ - lib/raindown/tui/terminal/screen.rb
273
+ - lib/raindown/tui/terminal/styles.rb
274
+ - lib/raindown/tui/text.rb
275
+ - lib/raindown/tui/title.rb
276
+ - lib/raindown/tui/views/dashboard_view.rb
277
+ - lib/raindown/tui/views/endpoint_view.rb
278
+ - lib/raindown/tui/views/list_view.rb
279
+ - lib/raindown/tui/views/outline_view.rb
280
+ - lib/raindown/tui/workers/project.rb
281
+ - lib/raindown/tui/workers/worker.rb
282
+ - lib/raindown/tui/workers/workers.rb
229
283
  - lib/raindown/variables.rb
230
284
  - lib/raindown/watcher.rb
231
285
  - lib/version.rb