raindown 0.3.0 → 0.5.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/actions.rb +54 -0
- data/lib/raindown/cli.rb +99 -15
- data/lib/raindown/directives.rb +2 -2
- data/lib/raindown/document.rb +148 -0
- data/lib/raindown/endpoint/endpoint_runner.rb +15 -5
- data/lib/raindown/node_blocks.rb +29 -18
- data/lib/raindown/project_worker.rb +48 -0
- data/lib/raindown/projects.rb +71 -0
- data/lib/raindown/supervisor.rb +160 -0
- data/lib/raindown/toc.rb +10 -5
- data/lib/raindown/tui/commands.rb +70 -0
- data/lib/raindown/tui/file_tree.rb +55 -0
- data/lib/raindown/tui/input.rb +55 -0
- data/lib/raindown/tui/layout.rb +142 -0
- data/lib/raindown/tui/navigator.rb +123 -0
- data/lib/raindown/tui/outline.rb +114 -0
- data/lib/raindown/tui/pane.rb +41 -0
- data/lib/raindown/tui/row.rb +16 -0
- data/lib/raindown/tui/screen.rb +131 -0
- data/lib/raindown/tui/styles.rb +36 -0
- data/lib/raindown/tui.rb +27 -0
- data/lib/raindown/watcher.rb +16 -18
- data/lib/raindown.rb +0 -3
- data/lib/version.rb +1 -1
- metadata +36 -6
|
@@ -0,0 +1,123 @@
|
|
|
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
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../document'
|
|
4
|
+
require_relative 'row'
|
|
5
|
+
|
|
6
|
+
module Raindown
|
|
7
|
+
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
|
|
11
|
+
attr_reader :path, :error
|
|
12
|
+
|
|
13
|
+
def initialize(path)
|
|
14
|
+
@path = path
|
|
15
|
+
@folded = Set.new
|
|
16
|
+
@expanded = Set.new
|
|
17
|
+
read
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Reads the file again, keeping what's folded.
|
|
21
|
+
def read
|
|
22
|
+
@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.
|
|
26
|
+
@error = nil
|
|
27
|
+
rescue StandardError => e
|
|
28
|
+
@error = e.message
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def changed?
|
|
32
|
+
File.exist?(path) && File.mtime(path) != @mtime
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def fold(heading)
|
|
36
|
+
@folded.delete?(heading.line) || @folded.add(heading.line)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def expand(directive)
|
|
40
|
+
@expanded.delete?(directive.line) || @expanded.add(directive.line)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def rows
|
|
44
|
+
return [Row.new(text: error, depth: 0, kind: :error, target: 1)] if error
|
|
45
|
+
|
|
46
|
+
sections.flat_map do |heading, items|
|
|
47
|
+
next items.flat_map { directive_rows(it, depth: 0) } unless heading
|
|
48
|
+
|
|
49
|
+
depth = heading.level - min_level + 1
|
|
50
|
+
[heading_row(heading), *(items.flat_map { directive_rows(it, depth:) } unless @folded.include?(heading.line))]
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# Each heading with the directives below it, leaving out the headings in a folded heading's section.
|
|
57
|
+
# Directives before the first heading come first, with no heading.
|
|
58
|
+
def sections
|
|
59
|
+
sections = [[nil, []]]
|
|
60
|
+
hidden = nil
|
|
61
|
+
|
|
62
|
+
items.each do |item|
|
|
63
|
+
heading = item.is_a?(Document::Heading)
|
|
64
|
+
next if hidden && !(heading && item.level <= hidden)
|
|
65
|
+
|
|
66
|
+
hidden = (item.level if @folded.include?(item.line)) if heading
|
|
67
|
+
heading ? sections << [item, []] : sections.last.last << item
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
sections
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Headings a directive wrote are part of its output, so they show when it's expanded rather than on their own.
|
|
74
|
+
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) } }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def min_level
|
|
80
|
+
@document.headings.map(&:level).min || 1
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def heading_row(heading)
|
|
84
|
+
folded = @folded.include?(heading.line) ? ' …' : ''
|
|
85
|
+
Row.new(text: "#{'#' * heading.level} #{heading.text}#{folded}", depth: heading.level - min_level, kind: :heading,
|
|
86
|
+
target: heading)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def directive_rows(directive, depth:)
|
|
90
|
+
expanded = @expanded.include?(directive.line)
|
|
91
|
+
row = Row.new(text: directive_text(directive), depth:, kind: :directive, target: directive,
|
|
92
|
+
buttons: buttons(directive, expanded:))
|
|
93
|
+
return [row] unless expanded
|
|
94
|
+
|
|
95
|
+
[row, *directive.output.map do |number|
|
|
96
|
+
Row.new(text: @lines[number - 1].to_s, depth: depth + 1, kind: :output, target: number)
|
|
97
|
+
end]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def buttons(directive, expanded:)
|
|
101
|
+
buttons = []
|
|
102
|
+
buttons << [expanded ? '[-]' : "[+#{directive.output.size}]", :toggle] if directive.output
|
|
103
|
+
buttons << ['[Run]', :execute] if directive.endpoint?
|
|
104
|
+
buttons
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def directive_text(directive)
|
|
108
|
+
return ":#{directive.name}" unless directive.writes
|
|
109
|
+
|
|
110
|
+
[directive.name, ':', (" #{directive.args}" unless directive.args.empty?)].join
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Raindown
|
|
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
|
|
7
|
+
attr_writer :cursor
|
|
8
|
+
|
|
9
|
+
def initialize(&rows)
|
|
10
|
+
@rows = rows
|
|
11
|
+
@cursor = 0
|
|
12
|
+
@offset = 0
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def rows = @rows.call
|
|
16
|
+
def selected = rows[cursor]
|
|
17
|
+
|
|
18
|
+
# The cursor stays on a row when there are fewer rows than before.
|
|
19
|
+
def cursor
|
|
20
|
+
@cursor.clamp(0, [rows.length - 1, 0].max)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
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
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The rows that fit in the height, scrolled so the cursor's row is one of them, and the first one's index.
|
|
33
|
+
def visible(height)
|
|
34
|
+
cursor = self.cursor
|
|
35
|
+
@offset = @offset.clamp([cursor - height + 1, 0].max, cursor)
|
|
36
|
+
|
|
37
|
+
[rows[@offset, height] || [], @offset]
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Raindown
|
|
4
|
+
module TUI
|
|
5
|
+
# A line of a pane. Target is what it's about: a folder or file path, a Heading or Directive, or a line number.
|
|
6
|
+
# Buttons are [label, event] pairs, like ['[Run]', :execute].
|
|
7
|
+
Row = Data.define(:text, :depth, :kind, :target, :buttons) do
|
|
8
|
+
def initialize(buttons: [], **) = super
|
|
9
|
+
|
|
10
|
+
# The line the editor opens at for the row.
|
|
11
|
+
def line
|
|
12
|
+
target.is_a?(Integer) ? target : target.line
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'curses'
|
|
4
|
+
|
|
5
|
+
require_relative 'input'
|
|
6
|
+
require_relative 'layout'
|
|
7
|
+
require_relative 'styles'
|
|
8
|
+
|
|
9
|
+
module Raindown
|
|
10
|
+
module TUI
|
|
11
|
+
# Paints a Navigator's Layout with curses, and sends the Navigator the events of keys and clicks, read with Input.
|
|
12
|
+
# Only this class draws on the terminal, so another library could draw the same Layout.
|
|
13
|
+
class Screen
|
|
14
|
+
# How often, in milliseconds, the open file is checked for changes while no key is pressed.
|
|
15
|
+
TICK = 500
|
|
16
|
+
|
|
17
|
+
def initialize(navigator)
|
|
18
|
+
@navigator = navigator
|
|
19
|
+
@zones = []
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def run
|
|
23
|
+
start
|
|
24
|
+
|
|
25
|
+
loop do
|
|
26
|
+
draw
|
|
27
|
+
effect = @navigator.handle(read)
|
|
28
|
+
break if effect == :quit
|
|
29
|
+
|
|
30
|
+
perform(*effect) if effect.is_a?(Array)
|
|
31
|
+
end
|
|
32
|
+
rescue Interrupt
|
|
33
|
+
nil
|
|
34
|
+
ensure
|
|
35
|
+
stop
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def perform(name, *args)
|
|
41
|
+
case name
|
|
42
|
+
when :run
|
|
43
|
+
suspend { system(*args.first) }
|
|
44
|
+
@navigator.ran
|
|
45
|
+
when :wait
|
|
46
|
+
message, task = args
|
|
47
|
+
show(message)
|
|
48
|
+
task.call
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def start
|
|
53
|
+
ENV['ESCDELAY'] ||= '25'
|
|
54
|
+
Curses.init_screen
|
|
55
|
+
@styles = Styles.start
|
|
56
|
+
Curses.noecho
|
|
57
|
+
Curses.cbreak
|
|
58
|
+
Curses.nonl
|
|
59
|
+
Curses.curs_set(0)
|
|
60
|
+
Curses.stdscr.keypad(true)
|
|
61
|
+
Curses.stdscr.timeout = TICK
|
|
62
|
+
Curses.mousemask(Curses::BUTTON1_PRESSED | Curses::BUTTON1_CLICKED | Input.wheel.keys.sum)
|
|
63
|
+
Curses.mouseinterval(0)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def stop
|
|
67
|
+
Curses.close_screen
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def draw
|
|
71
|
+
lines = Layout.new(@navigator, width: Curses.cols, height: Curses.lines).lines
|
|
72
|
+
@zones = Input.zones(lines)
|
|
73
|
+
window = Curses.stdscr
|
|
74
|
+
window.erase
|
|
75
|
+
|
|
76
|
+
lines.each_with_index do |segments, row|
|
|
77
|
+
window.setpos(row, 0)
|
|
78
|
+
# Writing the bottom-right corner would scroll the screen, so the last line stops a column short.
|
|
79
|
+
segments = last_line(segments) if row == Curses.lines - 1
|
|
80
|
+
segments.each { |segment| paint(window, segment) }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
window.refresh
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def paint(window, segment)
|
|
87
|
+
window.attron(@styles.fetch(segment.style, Curses::A_NORMAL)) { window.addstr(segment.text) }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def last_line(segments)
|
|
91
|
+
*rest, last = segments
|
|
92
|
+
[*rest, last.with(text: last.text[0...-1])]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The next event, or :tick when no key is pressed in time, or nil for keys without one, like a resize.
|
|
96
|
+
def read
|
|
97
|
+
key = Curses.stdscr.getch
|
|
98
|
+
return :tick if key.nil?
|
|
99
|
+
return mouse if key == Curses::KEY_MOUSE
|
|
100
|
+
|
|
101
|
+
Curses.stdscr.clear if key == Curses::KEY_RESIZE
|
|
102
|
+
Input.key_event(key)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def mouse
|
|
106
|
+
event = Curses.getmouse
|
|
107
|
+
Input.mouse_event(event, @zones) if event
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Shows a message in the status line while something slow runs, like an endpoint's request.
|
|
111
|
+
def show(message)
|
|
112
|
+
window = Curses.stdscr
|
|
113
|
+
window.setpos(Curses.lines - 2, 0)
|
|
114
|
+
window.clrtoeol
|
|
115
|
+
window.addstr(" #{message}"[0, Curses.cols])
|
|
116
|
+
window.refresh
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Leaves the screen to the command, like the editor, and comes back to it after.
|
|
120
|
+
def suspend
|
|
121
|
+
Curses.def_prog_mode
|
|
122
|
+
Curses.close_screen
|
|
123
|
+
yield
|
|
124
|
+
ensure
|
|
125
|
+
Curses.reset_prog_mode
|
|
126
|
+
Curses.stdscr.clear
|
|
127
|
+
Curses.refresh
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
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
|
data/lib/raindown/tui.rb
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'nodes'
|
|
4
|
+
require_relative 'projects'
|
|
5
|
+
require_relative 'tui/navigator'
|
|
6
|
+
|
|
7
|
+
module Raindown
|
|
8
|
+
# The terminal UI, started with "raindown tui": a project's Markdown files beside the open file's outline,
|
|
9
|
+
# where directives fold, endpoints run and the editor opens at a line. Curses only loads here,
|
|
10
|
+
# so watching and rendering start without it.
|
|
11
|
+
module TUI
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# PATH is a file to open, or a directory. Its project is the one it's in, or else the directory itself.
|
|
15
|
+
def start(path)
|
|
16
|
+
require_relative 'tui/screen'
|
|
17
|
+
# Without a terminal, curses' own error is "Error opening terminal: unknown".
|
|
18
|
+
abort('raindown tui needs a terminal') unless $stdin.tty? && $stdout.tty?
|
|
19
|
+
|
|
20
|
+
path = File.expand_path(path)
|
|
21
|
+
root = Projects.root(path) || (File.directory?(path) ? path : File.dirname(path))
|
|
22
|
+
Nodes.load(File.join(root, Nodes::FOLDER))
|
|
23
|
+
|
|
24
|
+
Screen.new(Navigator.new(root:, path: (path unless File.directory?(path)))).run
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
data/lib/raindown/watcher.rb
CHANGED
|
@@ -4,12 +4,12 @@ require_relative '../raindown'
|
|
|
4
4
|
require_relative 'rewriter'
|
|
5
5
|
|
|
6
6
|
module Raindown
|
|
7
|
+
# Watches one file and writes back to it as it's saved, like its TOC, tables and endpoint responses.
|
|
7
8
|
class Watcher
|
|
8
|
-
attr_reader :path, :
|
|
9
|
+
attr_reader :path, :bare, :execute
|
|
9
10
|
|
|
10
|
-
def initialize(path:,
|
|
11
|
+
def initialize(path:, bare: false, execute: false)
|
|
11
12
|
@path = path
|
|
12
|
-
@output = output || default_output
|
|
13
13
|
@bare = bare
|
|
14
14
|
@execute = execute
|
|
15
15
|
end
|
|
@@ -19,9 +19,8 @@ module Raindown
|
|
|
19
19
|
|
|
20
20
|
$stdout.sync = true
|
|
21
21
|
|
|
22
|
-
rewrite
|
|
23
|
-
|
|
24
|
-
puts "Watching #{path} for changes (writing to #{output})..."
|
|
22
|
+
safely { rewrite }
|
|
23
|
+
puts "Watching #{path} for changes..."
|
|
25
24
|
|
|
26
25
|
listener.start
|
|
27
26
|
sleep
|
|
@@ -35,25 +34,24 @@ module Raindown
|
|
|
35
34
|
|
|
36
35
|
Listen.to(directory) do |modified, added, _removed|
|
|
37
36
|
next unless (modified + added).any? { |f| File.basename(f) == filename }
|
|
38
|
-
# Formatting saves the file again, and that change triggers the render.
|
|
39
|
-
next if rewrite
|
|
40
37
|
|
|
41
|
-
|
|
42
|
-
puts "
|
|
38
|
+
# Writing back saves the file again, and that save changes nothing, so it ends there.
|
|
39
|
+
safely { puts "Updated #{path}" if rewrite }
|
|
43
40
|
end
|
|
44
41
|
end
|
|
45
42
|
|
|
46
|
-
#
|
|
47
|
-
def
|
|
48
|
-
|
|
49
|
-
|
|
43
|
+
# An error in the file is shown rather than stopping the watcher, so it can be fixed and saved again.
|
|
44
|
+
def safely
|
|
45
|
+
yield
|
|
46
|
+
rescue StandardError => e
|
|
47
|
+
raise if ENV['RAINDOWN_DEBUG']
|
|
50
48
|
|
|
51
|
-
|
|
52
|
-
File.write(output, Raindown.render_file(path:, bare:, metadata: Pages.for(path)))
|
|
49
|
+
warn "raindown: #{path}: #{e.message}"
|
|
53
50
|
end
|
|
54
51
|
|
|
55
|
-
|
|
56
|
-
|
|
52
|
+
# The files' metadata is loaded again each time, so new and renamed files are listed.
|
|
53
|
+
def rewrite
|
|
54
|
+
Rewriter.update(path:, bare:, execute:, metadata: Pages.for(path))
|
|
57
55
|
end
|
|
58
56
|
end
|
|
59
57
|
end
|
data/lib/raindown.rb
CHANGED
|
@@ -35,9 +35,6 @@ module Raindown
|
|
|
35
35
|
def self.list(**) = []
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
-
# Headings with an id, which are the ones a table of contents links to.
|
|
39
|
-
HEADINGS = 'h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]'
|
|
40
|
-
|
|
41
38
|
module_function
|
|
42
39
|
|
|
43
40
|
# Directives show in bold and code is highlighted, unless the file's project changes them in .raindown/config.yml.
|
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.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- maedi
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-10-
|
|
10
|
+
date: 2026-10-26 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: antlers
|
|
@@ -51,6 +51,20 @@ dependencies:
|
|
|
51
51
|
- - ">="
|
|
52
52
|
- !ruby/object:Gem::Version
|
|
53
53
|
version: '0'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: curses
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '1.7'
|
|
61
|
+
type: :runtime
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '1.7'
|
|
54
68
|
- !ruby/object:Gem::Dependency
|
|
55
69
|
name: dirload
|
|
56
70
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -80,21 +94,21 @@ dependencies:
|
|
|
80
94
|
- !ruby/object:Gem::Version
|
|
81
95
|
version: '0'
|
|
82
96
|
- !ruby/object:Gem::Dependency
|
|
83
|
-
name:
|
|
97
|
+
name: lowkey
|
|
84
98
|
requirement: !ruby/object:Gem::Requirement
|
|
85
99
|
requirements:
|
|
86
100
|
- - ">="
|
|
87
101
|
- !ruby/object:Gem::Version
|
|
88
|
-
version:
|
|
102
|
+
version: 0.7.0
|
|
89
103
|
type: :runtime
|
|
90
104
|
prerelease: false
|
|
91
105
|
version_requirements: !ruby/object:Gem::Requirement
|
|
92
106
|
requirements:
|
|
93
107
|
- - ">="
|
|
94
108
|
- !ruby/object:Gem::Version
|
|
95
|
-
version:
|
|
109
|
+
version: 0.7.0
|
|
96
110
|
- !ruby/object:Gem::Dependency
|
|
97
|
-
name:
|
|
111
|
+
name: lownode
|
|
98
112
|
requirement: !ruby/object:Gem::Requirement
|
|
99
113
|
requirements:
|
|
100
114
|
- - ">="
|
|
@@ -167,6 +181,7 @@ files:
|
|
|
167
181
|
- lib/nodes/endpoint_node.rb
|
|
168
182
|
- lib/nodes/list_node.rb
|
|
169
183
|
- lib/raindown.rb
|
|
184
|
+
- lib/raindown/actions.rb
|
|
170
185
|
- lib/raindown/bare_directives.rb
|
|
171
186
|
- lib/raindown/chart/bars.rb
|
|
172
187
|
- lib/raindown/chart/chart.rb
|
|
@@ -180,6 +195,7 @@ files:
|
|
|
180
195
|
- lib/raindown/code_blocks.rb
|
|
181
196
|
- lib/raindown/config.rb
|
|
182
197
|
- lib/raindown/directives.rb
|
|
198
|
+
- lib/raindown/document.rb
|
|
183
199
|
- lib/raindown/endpoint/curl.rb
|
|
184
200
|
- lib/raindown/endpoint/endpoint_runner.rb
|
|
185
201
|
- lib/raindown/files.rb
|
|
@@ -193,9 +209,23 @@ files:
|
|
|
193
209
|
- lib/raindown/page.rb
|
|
194
210
|
- lib/raindown/pages.rb
|
|
195
211
|
- lib/raindown/pre_renderer.rb
|
|
212
|
+
- lib/raindown/project_worker.rb
|
|
213
|
+
- lib/raindown/projects.rb
|
|
196
214
|
- lib/raindown/rewriter.rb
|
|
215
|
+
- lib/raindown/supervisor.rb
|
|
197
216
|
- lib/raindown/table_formatter.rb
|
|
198
217
|
- lib/raindown/toc.rb
|
|
218
|
+
- lib/raindown/tui.rb
|
|
219
|
+
- lib/raindown/tui/commands.rb
|
|
220
|
+
- lib/raindown/tui/file_tree.rb
|
|
221
|
+
- lib/raindown/tui/input.rb
|
|
222
|
+
- lib/raindown/tui/layout.rb
|
|
223
|
+
- lib/raindown/tui/navigator.rb
|
|
224
|
+
- lib/raindown/tui/outline.rb
|
|
225
|
+
- lib/raindown/tui/pane.rb
|
|
226
|
+
- lib/raindown/tui/row.rb
|
|
227
|
+
- lib/raindown/tui/screen.rb
|
|
228
|
+
- lib/raindown/tui/styles.rb
|
|
199
229
|
- lib/raindown/variables.rb
|
|
200
230
|
- lib/raindown/watcher.rb
|
|
201
231
|
- lib/version.rb
|