raindown 0.5.0 → 0.6.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/lib/raindown/actions.rb +1 -1
  3. data/lib/raindown/bare_directives.rb +14 -3
  4. data/lib/raindown/cli.rb +2 -2
  5. data/lib/raindown/document.rb +18 -7
  6. data/lib/raindown/endpoint/curl.rb +15 -0
  7. data/lib/raindown/endpoint/endpoint_runner.rb +1 -1
  8. data/lib/raindown/node_injector.rb +1 -1
  9. data/lib/raindown/projects.rb +8 -4
  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 +35 -0
  14. data/lib/raindown/tui/file_tree.rb +53 -11
  15. data/lib/raindown/tui/layout.rb +60 -78
  16. data/lib/raindown/tui/menu_overlay.rb +59 -0
  17. data/lib/raindown/tui/navigator/commands.rb +84 -0
  18. data/lib/raindown/tui/navigator/focus.rb +25 -0
  19. data/lib/raindown/tui/navigator/navigator.rb +127 -0
  20. data/lib/raindown/tui/navigator/project_menu.rb +61 -0
  21. data/lib/raindown/tui/navigator/views.rb +49 -0
  22. data/lib/raindown/tui/renderers/dashboard_renderer.rb +102 -0
  23. data/lib/raindown/tui/renderers/endpoint_renderer.rb +77 -0
  24. data/lib/raindown/tui/renderers/list_renderer.rb +15 -0
  25. data/lib/raindown/tui/renderers/outline_renderer.rb +21 -0
  26. data/lib/raindown/tui/renderers/renderer.rb +15 -0
  27. data/lib/raindown/tui/row.rb +10 -3
  28. data/lib/raindown/tui/rows.rb +57 -0
  29. data/lib/raindown/tui/{input.rb → terminal/input.rb} +10 -11
  30. data/lib/raindown/tui/terminal/ncurses_shim.rb +46 -0
  31. data/lib/raindown/tui/terminal/quiet.rb +32 -0
  32. data/lib/raindown/tui/{screen.rb → terminal/screen.rb} +32 -17
  33. data/lib/raindown/tui/terminal/styles.rb +96 -0
  34. data/lib/raindown/tui/text.rb +83 -0
  35. data/lib/raindown/tui/title.rb +29 -0
  36. data/lib/raindown/tui/views/dashboard_view.rb +145 -0
  37. data/lib/raindown/tui/views/endpoint_view.rb +75 -0
  38. data/lib/raindown/tui/{pane.rb → views/list_view.rb} +9 -8
  39. data/lib/raindown/tui/{outline.rb → views/outline_view.rb} +39 -20
  40. data/lib/raindown/tui/workers/project.rb +122 -0
  41. data/lib/raindown/tui/workers/worker.rb +124 -0
  42. data/lib/raindown/tui/workers/workers.rb +53 -0
  43. data/lib/raindown/tui.rb +8 -5
  44. data/lib/raindown.rb +2 -0
  45. data/lib/version.rb +1 -1
  46. metadata +32 -9
  47. data/lib/raindown/tui/commands.rb +0 -70
  48. data/lib/raindown/tui/navigator.rb +0 -123
  49. data/lib/raindown/tui/styles.rb +0 -36
@@ -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,19 @@ 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
+ Screen.new(Navigator.new(root:, path: (path unless File.directory?(path)), workers:)).run
26
+ ensure
27
+ workers&.stop
25
28
  end
26
29
  end
27
30
  end
data/lib/raindown.rb CHANGED
@@ -47,6 +47,8 @@ module Raindown
47
47
  # Directives like "toc:" export to HTML as bold or comments depending on hide_directives config.
48
48
  # Code blocks are highlighted, unless highlight_code is false for a site that highlights code in the browser.
49
49
  def render(markdown:, metadata: NoMetadata, bare: false, hide_directives: false, highlight_code: true)
50
+ bare ||= BareDirectives.front_matter?(markdown)
51
+
50
52
  highlighting(highlight_code) do
51
53
  Fragments.collect do
52
54
  # Components' HTML goes back in, then their headings get unique ids, before nodes like the TOC see it.
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.0'
4
+ VERSION = '0.6.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.0
4
+ version: 0.6.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-27 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: antlers
@@ -216,16 +216,39 @@ files:
216
216
  - lib/raindown/table_formatter.rb
217
217
  - lib/raindown/toc.rb
218
218
  - lib/raindown/tui.rb
219
- - lib/raindown/tui/commands.rb
219
+ - lib/raindown/tui/card.rb
220
+ - lib/raindown/tui/endpoint.rb
221
+ - lib/raindown/tui/endpoints.rb
222
+ - lib/raindown/tui/events.rb
220
223
  - lib/raindown/tui/file_tree.rb
221
- - lib/raindown/tui/input.rb
222
224
  - lib/raindown/tui/layout.rb
223
- - lib/raindown/tui/navigator.rb
224
- - lib/raindown/tui/outline.rb
225
- - lib/raindown/tui/pane.rb
225
+ - lib/raindown/tui/menu_overlay.rb
226
+ - lib/raindown/tui/navigator/commands.rb
227
+ - lib/raindown/tui/navigator/focus.rb
228
+ - lib/raindown/tui/navigator/navigator.rb
229
+ - lib/raindown/tui/navigator/project_menu.rb
230
+ - lib/raindown/tui/navigator/views.rb
231
+ - lib/raindown/tui/renderers/dashboard_renderer.rb
232
+ - lib/raindown/tui/renderers/endpoint_renderer.rb
233
+ - lib/raindown/tui/renderers/list_renderer.rb
234
+ - lib/raindown/tui/renderers/outline_renderer.rb
235
+ - lib/raindown/tui/renderers/renderer.rb
226
236
  - lib/raindown/tui/row.rb
227
- - lib/raindown/tui/screen.rb
228
- - lib/raindown/tui/styles.rb
237
+ - lib/raindown/tui/rows.rb
238
+ - lib/raindown/tui/terminal/input.rb
239
+ - lib/raindown/tui/terminal/ncurses_shim.rb
240
+ - lib/raindown/tui/terminal/quiet.rb
241
+ - lib/raindown/tui/terminal/screen.rb
242
+ - lib/raindown/tui/terminal/styles.rb
243
+ - lib/raindown/tui/text.rb
244
+ - lib/raindown/tui/title.rb
245
+ - lib/raindown/tui/views/dashboard_view.rb
246
+ - lib/raindown/tui/views/endpoint_view.rb
247
+ - lib/raindown/tui/views/list_view.rb
248
+ - lib/raindown/tui/views/outline_view.rb
249
+ - lib/raindown/tui/workers/project.rb
250
+ - lib/raindown/tui/workers/worker.rb
251
+ - lib/raindown/tui/workers/workers.rb
229
252
  - lib/raindown/variables.rb
230
253
  - lib/raindown/watcher.rb
231
254
  - lib/version.rb
@@ -1,70 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative '../actions'
4
-
5
- module Raindown
6
- module TUI
7
- # What a Navigator does to the open file: run an endpoint, open the editor, write back and read it again.
8
- # Each sets the status, so the Screen shows how it went, and returns what the Screen should do next, if anything.
9
- module Commands
10
- private
11
-
12
- def execute
13
- directive = selected(:outline)&.target if @focus == :outline
14
- return error('Select an endpoint to run') unless directive.is_a?(Document::Directive) && directive.endpoint?
15
-
16
- [:wait, "Running the endpoint on line #{directive.line}...", -> { run_endpoint(directive) }]
17
- end
18
-
19
- def run_endpoint(directive)
20
- changed = Actions.execute(path, directive)
21
- @outline.read
22
- info(changed ? "Ran the endpoint on line #{directive.line}" : 'The response is the same')
23
- rescue StandardError => e
24
- error(e.message)
25
- end
26
-
27
- # In the files pane, the selected file opens at its top, and in the outline, the open file at the selected line.
28
- def edit
29
- row = selected
30
- show(row.target) if @focus == :files && row&.kind == :file
31
- return error('Open a file to edit') unless path
32
-
33
- line = @focus == :outline && row ? row.line : 1
34
- [:run, Actions.edit_command(path, line:)]
35
- end
36
-
37
- def write_back
38
- return error('Open a file to write back') unless path
39
-
40
- changed = Actions.write_back(path)
41
- @outline.read
42
- info(changed ? "Updated #{relative(path)}" : "#{relative(path)} is up to date")
43
- rescue StandardError => e
44
- error(e.message)
45
- end
46
-
47
- def reload
48
- @files.load
49
- @outline&.read if path && File.exist?(path)
50
- info('Reloaded')
51
- end
52
-
53
- # Reads the file again when it's changed, like when it's saved in an editor or a watcher writes back to it.
54
- def refresh
55
- @outline.read if @outline&.changed?
56
- nil
57
- end
58
-
59
- def info(message)
60
- @status = [:info, message]
61
- nil
62
- end
63
-
64
- def error(message)
65
- @status = [:error, message]
66
- nil
67
- end
68
- end
69
- end
70
- end
@@ -1,123 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative 'file_tree'
4
- require_relative 'commands'
5
- require_relative 'outline'
6
- require_relative 'pane'
7
-
8
- module Raindown
9
- module TUI
10
- # What the terminal UI shows and does, in plain Ruby, with no terminal: a FileTree of the project's Markdown files,
11
- # beside the open file's Outline. The Screen draws it and sends it events, like :down or
12
- # [:press, :outline, 3, :execute], and handle returns what the Screen should do next:
13
- # nil draw again
14
- # :quit stop
15
- # [:run, command] leave the screen to run a command, like the editor, then call ran
16
- # [:wait, message, task] show the message while the task runs, like an endpoint's request
17
- class Navigator
18
- include Commands
19
-
20
- # Keys of the events that move the cursor or the focus, and those that act on what's selected.
21
- MOVES = { up: -1, down: 1 }.freeze
22
- PANES = { tab: nil, left: :files, right: :outline }.freeze
23
- ACTIONS = { execute: :execute, edit: :edit, write_back: :write_back, reload: :reload, tick: :refresh }.freeze
24
-
25
- attr_reader :focus, :status
26
-
27
- def initialize(root:, path: nil)
28
- @files = FileTree.new(root:)
29
- @focus = :files
30
- @panes = { files: Pane.new { @files.rows }, outline: Pane.new { @outline ? @outline.rows : [] } }
31
- @status = [:info, 'Tab switches panes, Enter opens and folds, q quits']
32
- first = @files.rows.find { it.kind == :file }&.target
33
- show(path || first) if path || first
34
- end
35
-
36
- def handle(event)
37
- name, *args = event
38
- return :quit if name == :quit
39
- return move(MOVES[name]) if MOVES.key?(name)
40
- return switch(PANES[name] || other_pane) if PANES.key?(name)
41
- return select(*args) if name == :select
42
- return press(*args) if name == :press
43
- return activate(name) if %i[enter toggle].include?(name)
44
-
45
- send(ACTIONS[name]) if ACTIONS.key?(name)
46
- end
47
-
48
- # After the editor closes, the file is written back, like a save while it's watched, and read again.
49
- def ran
50
- write_back
51
- end
52
-
53
- def root = @files.root
54
- def path = @outline&.path
55
- def relative(path) = @files.relative(path)
56
-
57
- def rows(pane) = @panes[pane].rows
58
- def cursor(pane) = @panes[pane].cursor
59
- def visible(pane, height) = @panes[pane].visible(height)
60
-
61
- private
62
-
63
- def show(path)
64
- @outline = Outline.new(File.expand_path(path))
65
- @panes[:outline].reset
66
- end
67
-
68
- def other_pane
69
- @focus == :files ? :outline : :files
70
- end
71
-
72
- def move(by)
73
- @panes[@focus].move(by)
74
- nil
75
- end
76
-
77
- def switch(pane)
78
- @focus = pane
79
- nil
80
- end
81
-
82
- def select(pane, index)
83
- @focus = pane
84
- @panes[pane].cursor = index
85
- nil
86
- end
87
-
88
- def press(pane, index, event)
89
- select(pane, index)
90
- handle(event)
91
- end
92
-
93
- def selected(pane = @focus)
94
- @panes[pane].selected
95
- end
96
-
97
- # Enter opens what's selected, or folds it, and Space only folds.
98
- # A line of output, or a directive without any, opens in the editor.
99
- def activate(name)
100
- row = selected
101
- return edit if name == :enter && editable?(row)
102
-
103
- case row&.kind
104
- when :folder then @files.toggle(row.target)
105
- when :file then open_file(row.target) if name == :enter
106
- when :heading then @outline.fold(row.target)
107
- when :directive then @outline.expand(row.target) if row.target.output
108
- end
109
- nil
110
- end
111
-
112
- def editable?(row)
113
- row&.kind == :output || (row&.kind == :directive && !row.target.output)
114
- end
115
-
116
- def open_file(path)
117
- show(path)
118
- @focus = :outline
119
- info("Opened #{relative(path)}")
120
- end
121
- end
122
- end
123
- end
@@ -1,36 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'curses'
4
-
5
- module Raindown
6
- module TUI
7
- # The curses attributes of a Layout's styles, in colour on the terminal's own background when it has colours.
8
- module Styles
9
- COLORS = { directive: Curses::COLOR_CYAN, button: Curses::COLOR_YELLOW, error: Curses::COLOR_RED,
10
- folder: Curses::COLOR_BLUE }.freeze
11
-
12
- module_function
13
-
14
- # Sets up the colours, once the screen has started, and returns the attribute of each style.
15
- def start
16
- colors = Curses.has_colors?
17
- start_colors if colors
18
- color = ->(name) { colors ? Curses.color_pair(COLORS.keys.index(name) + 1) : Curses::A_NORMAL }
19
-
20
- {
21
- title: Curses::A_REVERSE | Curses::A_BOLD, selected: Curses::A_REVERSE, cursor: Curses::A_UNDERLINE,
22
- heading: Curses::A_BOLD, directive: color[:directive], output: Curses::A_DIM,
23
- error: color[:error] | Curses::A_BOLD, button: color[:button] | Curses::A_BOLD,
24
- selected_button: color[:button] | Curses::A_REVERSE, folder: color[:folder] | Curses::A_BOLD,
25
- key: Curses::A_REVERSE, border: Curses::A_DIM
26
- }
27
- end
28
-
29
- def start_colors
30
- Curses.start_color
31
- Curses.use_default_colors
32
- COLORS.each_value.with_index(1) { |color, pair| Curses.init_pair(pair, color, -1) }
33
- end
34
- end
35
- end
36
- end