raindown 0.4.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 +59 -17
- data/lib/raindown/document.rb +148 -0
- data/lib/raindown/endpoint/endpoint_runner.rb +15 -5
- data/lib/raindown/node_blocks.rb +22 -12
- 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/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 +7 -23
- data/lib/version.rb +1 -1
- metadata +32 -2
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'project_worker'
|
|
4
|
+
require_relative 'projects'
|
|
5
|
+
|
|
6
|
+
module Raindown
|
|
7
|
+
# Watches every Raindown project in a directory, like ~/dev, and writes back to each Markdown file as it's saved.
|
|
8
|
+
#
|
|
9
|
+
# Each project's nodes load in a process of its own, forked once the shared code has loaded, so starting a project
|
|
10
|
+
# costs almost nothing and two projects can have nodes of the same name. One listener watches the directory and sends
|
|
11
|
+
# each changed file to its project's process. Without fork, as on Windows, the projects share this process.
|
|
12
|
+
class Supervisor
|
|
13
|
+
# Watching skips these folders as they hold dependencies and builds, which Projects ignores too.
|
|
14
|
+
IGNORE = %r{(?:\A|/)(?:#{Projects::IGNORED.join('|')})/}
|
|
15
|
+
|
|
16
|
+
attr_reader :directory, :workers
|
|
17
|
+
|
|
18
|
+
def initialize(directory:, bare: false, execute: false)
|
|
19
|
+
@directory = File.expand_path(directory)
|
|
20
|
+
@bare = bare
|
|
21
|
+
@execute = execute
|
|
22
|
+
@workers = {}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def start
|
|
26
|
+
require 'listen'
|
|
27
|
+
$stdout.sync = true
|
|
28
|
+
preload
|
|
29
|
+
|
|
30
|
+
Projects.roots(directory).each { worker(it) }
|
|
31
|
+
puts "Watching #{workers.size} #{workers.size == 1 ? 'project' : 'projects'} in #{directory} for changes..."
|
|
32
|
+
|
|
33
|
+
trap_interrupt
|
|
34
|
+
listener.start
|
|
35
|
+
sleep
|
|
36
|
+
ensure
|
|
37
|
+
stop
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Sends each changed file to its project, starting a worker for a new project, or restarting one when its nodes change.
|
|
41
|
+
def handle(paths)
|
|
42
|
+
paths.each do |path|
|
|
43
|
+
root = Projects.root(path) || next
|
|
44
|
+
|
|
45
|
+
if Projects.node?(path, root:)
|
|
46
|
+
restart(root)
|
|
47
|
+
elsif Projects.markdown?(path, root:)
|
|
48
|
+
worker(root).update(path)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def stop
|
|
54
|
+
workers.each_value(&:stop)
|
|
55
|
+
workers.clear
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def listener
|
|
61
|
+
Listen.to(directory, ignore: IGNORE) { |modified, added, _removed| handle(modified + added) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def worker(root)
|
|
65
|
+
workers[root] ||= begin
|
|
66
|
+
worker = ProjectWorker.new(root:, name: name(root), bare: @bare, execute: @execute)
|
|
67
|
+
fork? ? ForkedWorker.new(worker).tap(&:start) : worker.tap(&:start)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Without fork the projects share this process, where nodes can't be unloaded.
|
|
72
|
+
def restart(root)
|
|
73
|
+
return warn("[#{name(root)}] Restart raindown to load the node changes") unless fork?
|
|
74
|
+
|
|
75
|
+
worker = workers.delete(root) || return
|
|
76
|
+
worker.stop
|
|
77
|
+
puts "[#{worker.name}] Reloading nodes"
|
|
78
|
+
worker(root)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# A project in the directory is named by its path from there, and the project the directory is in by its folder.
|
|
82
|
+
def name(root)
|
|
83
|
+
return File.basename(root) unless root.start_with?(File.join(directory, ''))
|
|
84
|
+
|
|
85
|
+
root.delete_prefix(File.join(directory, ''))
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Loaded before forking, so every project's process shares them instead of loading its own.
|
|
89
|
+
def preload
|
|
90
|
+
return unless fork?
|
|
91
|
+
|
|
92
|
+
require 'lownode'
|
|
93
|
+
require 'dirload'
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def fork?
|
|
97
|
+
Process.respond_to?(:fork)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Ctrl+C reaches every process in the terminal, so the workers stop with the watcher.
|
|
101
|
+
def trap_interrupt
|
|
102
|
+
trap('INT') { exit }
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# A project's worker in a forked process, which reads the files to update from a pipe until the pipe closes.
|
|
107
|
+
# The process ends with exit! as it's forked mid-call, so exit would run the watcher's ensure and stop its siblings.
|
|
108
|
+
class ForkedWorker
|
|
109
|
+
def initialize(worker)
|
|
110
|
+
@worker = worker
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def name
|
|
114
|
+
@worker.name
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def start
|
|
118
|
+
@writer&.close
|
|
119
|
+
reader, @writer = IO.pipe
|
|
120
|
+
@pid = fork do
|
|
121
|
+
@writer.close
|
|
122
|
+
trap('INT') { exit!(0) }
|
|
123
|
+
@worker.start
|
|
124
|
+
while (path = reader.gets)
|
|
125
|
+
@worker.update(path.chomp)
|
|
126
|
+
end
|
|
127
|
+
exit!(0)
|
|
128
|
+
end
|
|
129
|
+
reader.close
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# A worker that stopped, such as from a crash, starts again for the next file.
|
|
133
|
+
def update(path, restarted: false)
|
|
134
|
+
start unless alive?
|
|
135
|
+
@writer.puts(path)
|
|
136
|
+
@writer.flush
|
|
137
|
+
rescue Errno::EPIPE
|
|
138
|
+
raise if restarted
|
|
139
|
+
|
|
140
|
+
start
|
|
141
|
+
update(path, restarted: true)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def stop
|
|
145
|
+
@writer&.close
|
|
146
|
+
Process.kill('TERM', @pid) if alive?
|
|
147
|
+
Process.wait(@pid)
|
|
148
|
+
rescue Errno::ECHILD, Errno::ESRCH
|
|
149
|
+
nil
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
private
|
|
153
|
+
|
|
154
|
+
def alive?
|
|
155
|
+
@pid && Process.waitpid(@pid, Process::WNOHANG).nil?
|
|
156
|
+
rescue Errno::ECHILD
|
|
157
|
+
false
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
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
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../projects'
|
|
4
|
+
require_relative 'row'
|
|
5
|
+
|
|
6
|
+
module Raindown
|
|
7
|
+
module TUI
|
|
8
|
+
# The files pane: a project's Markdown files as a tree, with folders before files, where folders fold.
|
|
9
|
+
class FileTree
|
|
10
|
+
attr_reader :root
|
|
11
|
+
|
|
12
|
+
def initialize(root:)
|
|
13
|
+
@root = root
|
|
14
|
+
@folded = Set.new
|
|
15
|
+
load
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def load
|
|
19
|
+
@files = Projects.files(root)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def rows
|
|
23
|
+
tree = {}
|
|
24
|
+
@files.each { |path| relative(path).split('/').reduce(tree) { |node, part| node[part] ||= {} } }
|
|
25
|
+
tree_rows(tree, '', 0)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def toggle(folder)
|
|
29
|
+
@folded.delete?(folder) || @folded.add(folder)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def relative(path)
|
|
33
|
+
path.delete_prefix(File.join(root, ''))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def tree_rows(tree, prefix, depth)
|
|
39
|
+
folders, files = tree.partition { |_name, children| children.any? }
|
|
40
|
+
|
|
41
|
+
folders.sort.flat_map { |name, children| folder_rows(name, children, prefix, depth) } +
|
|
42
|
+
files.sort.map { |name, _| Row.new(text: name, depth:, kind: :file, target: File.join(root, prefix, name)) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# A folder, and the tree inside it unless it's folded.
|
|
46
|
+
def folder_rows(name, children, prefix, depth)
|
|
47
|
+
folder = File.join(root, prefix, name)
|
|
48
|
+
folded = @folded.include?(folder)
|
|
49
|
+
row = Row.new(text: "#{folded ? '▸' : '▾'} #{name}/", depth:, kind: :folder, target: folder)
|
|
50
|
+
|
|
51
|
+
[row, *(tree_rows(children, File.join(prefix, name), depth + 1) unless folded)]
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'curses'
|
|
4
|
+
require 'unicode/display_width'
|
|
5
|
+
|
|
6
|
+
module Raindown
|
|
7
|
+
module TUI
|
|
8
|
+
# Turns curses' keys and clicks into a Navigator's events.
|
|
9
|
+
module Input
|
|
10
|
+
KEYS = {
|
|
11
|
+
Curses::KEY_UP => :up, 'k' => :up, Curses::KEY_DOWN => :down, 'j' => :down,
|
|
12
|
+
Curses::KEY_LEFT => :left, 'h' => :left, Curses::KEY_RIGHT => :right, 'l' => :right,
|
|
13
|
+
"\t" => :tab, Curses::KEY_BTAB => :tab, "\r" => :enter, "\n" => :enter, Curses::KEY_ENTER => :enter,
|
|
14
|
+
' ' => :toggle, 'x' => :execute, 'e' => :edit, 'w' => :write_back, 'r' => :reload, 'q' => :quit
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# A key from getch, which is a string for a printable character and otherwise a number.
|
|
20
|
+
def key_event(key)
|
|
21
|
+
key = key.chr if key.is_a?(Integer) && key < 256
|
|
22
|
+
KEYS[key]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# A scroll of the wheel moves the cursor, and a click sends the event of what's under it.
|
|
26
|
+
def mouse_event(event, zones)
|
|
27
|
+
wheel.find { |button, _| event.bstate.anybits?(button) }&.last || click(zones, column: event.x, row: event.y)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Older ncurses has no fifth button, which is the wheel scrolling down.
|
|
31
|
+
def wheel
|
|
32
|
+
{ Curses::BUTTON4_PRESSED => :up }.tap do |buttons|
|
|
33
|
+
buttons[Curses::BUTTON5_PRESSED] = :down if Curses.const_defined?(:BUTTON5_PRESSED)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Where each of a Layout's segments with an event is on the screen, as [row, columns, event].
|
|
38
|
+
def zones(lines)
|
|
39
|
+
lines.each_with_index.flat_map do |segments, row|
|
|
40
|
+
column = 0
|
|
41
|
+
segments.filter_map do |segment|
|
|
42
|
+
width = Unicode::DisplayWidth.of(segment.text)
|
|
43
|
+
zone = [row, column...(column + width), segment.event] if segment.event
|
|
44
|
+
column += width
|
|
45
|
+
zone
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def click(zones, column:, row:)
|
|
51
|
+
zones.find { |zone_row, columns, _event| zone_row == row && columns.cover?(column) }&.last
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'unicode/display_width'
|
|
4
|
+
|
|
5
|
+
module Raindown
|
|
6
|
+
module TUI
|
|
7
|
+
# The screen as lines of styled segments, worked out from a Navigator with no terminal, so the Screen only paints them.
|
|
8
|
+
# A title, the files pane beside the outline, a status line and a bar of buttons:
|
|
9
|
+
# raindown docs guides/setup.md
|
|
10
|
+
# ▾ guides/ │ # Setup
|
|
11
|
+
# setup.md │ toc: [+3]
|
|
12
|
+
# README.md │ ## Install
|
|
13
|
+
# Opened guides/setup.md
|
|
14
|
+
# Tab Pane Enter Open x Run e Edit w Write back r Reload q Quit
|
|
15
|
+
# Each line is exactly the width, in display columns. A segment with an event is clicked to send it.
|
|
16
|
+
class Layout
|
|
17
|
+
Segment = Data.define(:text, :style, :event)
|
|
18
|
+
|
|
19
|
+
BUTTONS = [['Tab', 'Pane', :tab], ['Enter', 'Open', :enter], ['x', 'Run', :execute], ['e', 'Edit', :edit],
|
|
20
|
+
['w', 'Write back', :write_back], ['r', 'Reload', :reload], ['q', 'Quit', :quit]].freeze
|
|
21
|
+
FILES_WIDTH = (16..32)
|
|
22
|
+
INDENT = ' '
|
|
23
|
+
# The title, status and button lines.
|
|
24
|
+
CHROME_HEIGHT = 3
|
|
25
|
+
|
|
26
|
+
attr_reader :navigator, :width, :height
|
|
27
|
+
|
|
28
|
+
def initialize(navigator, width:, height:)
|
|
29
|
+
@navigator = navigator
|
|
30
|
+
@width = width
|
|
31
|
+
@height = height
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def lines
|
|
35
|
+
return [status].first(height) if height < CHROME_HEIGHT + 1
|
|
36
|
+
|
|
37
|
+
[title, *panes, status, button_bar]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def files_width
|
|
41
|
+
(width / 3).clamp(FILES_WIDTH.begin, [FILES_WIDTH.end, width / 2].min)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def title
|
|
47
|
+
path = navigator.path ? navigator.relative(navigator.path) : 'No Markdown files'
|
|
48
|
+
[segment(" raindown #{File.basename(navigator.root)} #{path}", :title)]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def panes
|
|
52
|
+
pane_height = height - CHROME_HEIGHT
|
|
53
|
+
files = navigator.visible(:files, pane_height)
|
|
54
|
+
outline = navigator.visible(:outline, pane_height)
|
|
55
|
+
|
|
56
|
+
Array.new(pane_height) do |index|
|
|
57
|
+
[*cell(:files, *files, index, files_width), Segment.new(text: '│', style: :border, event: nil),
|
|
58
|
+
*cell(:outline, *outline, index, width - files_width - 1)]
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# A pane's row as its text, then its buttons, or blank below its last row.
|
|
63
|
+
def cell(pane, rows, offset, index, cell_width)
|
|
64
|
+
row = rows[index]
|
|
65
|
+
return [segment('', :blank, width: cell_width)] unless row
|
|
66
|
+
|
|
67
|
+
index += offset
|
|
68
|
+
selected = navigator.cursor(pane) == index
|
|
69
|
+
buttons = row_buttons(row, pane:, index:, selected:, cell_width:)
|
|
70
|
+
text_width = cell_width - display_width(buttons.map(&:text).join)
|
|
71
|
+
text = " #{INDENT * row.depth}#{row.text}"
|
|
72
|
+
|
|
73
|
+
[segment(text, row_style(pane, row, selected), width: text_width, event: [:select, pane, index]), *buttons]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The row's buttons, right of its text, unless they'd take more than half the pane.
|
|
77
|
+
def row_buttons(row, pane:, index:, selected:, cell_width:)
|
|
78
|
+
buttons = row.buttons.map do |label, event|
|
|
79
|
+
Segment.new(text: " #{label}", style: selected ? :selected_button : :button, event: [:press, pane, index, event])
|
|
80
|
+
end
|
|
81
|
+
display_width(buttons.map(&:text).join) > cell_width / 2 ? [] : buttons
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def row_style(pane, row, selected)
|
|
85
|
+
return row.kind unless selected
|
|
86
|
+
|
|
87
|
+
navigator.focus == pane ? :selected : :cursor
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def status
|
|
91
|
+
kind, message = navigator.status
|
|
92
|
+
[segment(" #{message}", kind)]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# As many buttons as fit, each clicked by its key or its label.
|
|
96
|
+
def button_bar
|
|
97
|
+
segments = []
|
|
98
|
+
used = 0
|
|
99
|
+
|
|
100
|
+
BUTTONS.each do |key, label, event|
|
|
101
|
+
pair = [Segment.new(text: " #{key} ", style: :key, event:), Segment.new(text: "#{label} ", style: :bar, event:)]
|
|
102
|
+
pair_width = display_width(pair.map(&:text).join)
|
|
103
|
+
break if used + pair_width > width
|
|
104
|
+
|
|
105
|
+
segments.concat(pair)
|
|
106
|
+
used += pair_width
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
[*segments, segment('', :bar, width: width - used)]
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Text cut or padded to the width, with a cut shown by an ellipsis.
|
|
113
|
+
def segment(text, style, width: self.width, event: nil)
|
|
114
|
+
Segment.new(text: fit(printable(text), width), style:, event:)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def fit(text, width)
|
|
118
|
+
return '' if width <= 0
|
|
119
|
+
return text + (' ' * (width - display_width(text))) if display_width(text) <= width
|
|
120
|
+
|
|
121
|
+
cut = +''
|
|
122
|
+
cut_width = 0
|
|
123
|
+
text.each_char do |char|
|
|
124
|
+
break if cut_width + display_width(char) > width - 1
|
|
125
|
+
|
|
126
|
+
cut << char
|
|
127
|
+
cut_width += display_width(char)
|
|
128
|
+
end
|
|
129
|
+
"#{cut}…#{' ' * (width - 1 - cut_width)}"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Tabs and control characters, like in a directive's output, would move the terminal's cursor.
|
|
133
|
+
def printable(text)
|
|
134
|
+
text.gsub("\t", INDENT).gsub(/[[:cntrl:]]/, '')
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def display_width(text)
|
|
138
|
+
Unicode::DisplayWidth.of(text)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -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
|