raindown 0.5.1 → 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.
- checksums.yaml +4 -4
- data/lib/raindown/cli.rb +2 -2
- data/lib/raindown/document.rb +15 -4
- data/lib/raindown/endpoint/curl.rb +15 -0
- data/lib/raindown/projects.rb +8 -4
- data/lib/raindown/tui/card.rb +56 -0
- data/lib/raindown/tui/endpoint.rb +16 -0
- data/lib/raindown/tui/endpoints.rb +40 -0
- data/lib/raindown/tui/events.rb +35 -0
- data/lib/raindown/tui/file_tree.rb +53 -11
- data/lib/raindown/tui/layout.rb +60 -78
- data/lib/raindown/tui/menu_overlay.rb +59 -0
- data/lib/raindown/tui/navigator/commands.rb +84 -0
- data/lib/raindown/tui/navigator/focus.rb +25 -0
- data/lib/raindown/tui/navigator/navigator.rb +127 -0
- data/lib/raindown/tui/navigator/project_menu.rb +61 -0
- data/lib/raindown/tui/navigator/views.rb +49 -0
- data/lib/raindown/tui/renderers/dashboard_renderer.rb +102 -0
- data/lib/raindown/tui/renderers/endpoint_renderer.rb +77 -0
- data/lib/raindown/tui/renderers/list_renderer.rb +15 -0
- data/lib/raindown/tui/renderers/outline_renderer.rb +21 -0
- data/lib/raindown/tui/renderers/renderer.rb +15 -0
- data/lib/raindown/tui/row.rb +10 -3
- data/lib/raindown/tui/rows.rb +57 -0
- data/lib/raindown/tui/{input.rb → terminal/input.rb} +10 -11
- data/lib/raindown/tui/terminal/ncurses_shim.rb +46 -0
- data/lib/raindown/tui/terminal/quiet.rb +32 -0
- data/lib/raindown/tui/{screen.rb → terminal/screen.rb} +32 -17
- data/lib/raindown/tui/terminal/styles.rb +96 -0
- data/lib/raindown/tui/text.rb +83 -0
- data/lib/raindown/tui/title.rb +29 -0
- data/lib/raindown/tui/views/dashboard_view.rb +145 -0
- data/lib/raindown/tui/views/endpoint_view.rb +75 -0
- data/lib/raindown/tui/{pane.rb → views/list_view.rb} +9 -8
- data/lib/raindown/tui/{outline.rb → views/outline_view.rb} +39 -20
- data/lib/raindown/tui/workers/project.rb +122 -0
- data/lib/raindown/tui/workers/worker.rb +124 -0
- data/lib/raindown/tui/workers/workers.rb +53 -0
- data/lib/raindown/tui.rb +8 -5
- data/lib/version.rb +1 -1
- metadata +32 -9
- data/lib/raindown/tui/commands.rb +0 -70
- data/lib/raindown/tui/navigator.rb +0 -123
- data/lib/raindown/tui/styles.rb +0 -36
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c9a61e7a842dcd5dc4cc5f6d004db388173ef2d5c48af1a50e32fea6c317b678
|
|
4
|
+
data.tar.gz: 7bbae724e020d48a3f095ba1cc8e1c45b9ee5dd483daf0af5f8edd4333d83205
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b8f312582ede3900be43490c52c54ce3df6292764c04c177f54510e397a4814c9a9184cbb0710113055bb0dd140f00ced0bcf309f59583d34485a900579ac64b
|
|
7
|
+
data.tar.gz: 932d51981cd38660a402a40bbf9b0015da35baf62bad04152cdfdc262287f9593643daf66d2b60b6282e4df8a3de553a9ebba1793b37bec0972c145ea275c5a0
|
data/lib/raindown/cli.rb
CHANGED
|
@@ -23,8 +23,8 @@ module Raindown
|
|
|
23
23
|
|
|
24
24
|
Commands:
|
|
25
25
|
init [DIRECTORY] Create a .raindown folder for the project's config and nodes (default: current directory)
|
|
26
|
-
tui [PATH] Browse a project's files and outlines in the terminal, fold directives, run endpoints
|
|
27
|
-
|
|
26
|
+
tui [PATH] Browse a project's files and outlines in the terminal, fold directives, run endpoints,
|
|
27
|
+
open the editor at a line and see a dashboard of the project's charts
|
|
28
28
|
|
|
29
29
|
Options:
|
|
30
30
|
--html [OUTPUT] Render FILE to HTML once, writing to OUTPUT or else printing to stdout
|
data/lib/raindown/document.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'commonmarker'
|
|
4
4
|
|
|
5
|
+
require_relative '../raindown'
|
|
5
6
|
require_relative 'bare_directives'
|
|
6
7
|
require_relative 'code_blocks'
|
|
7
8
|
require_relative 'endpoint/endpoint_runner'
|
|
@@ -17,9 +18,10 @@ module Raindown
|
|
|
17
18
|
|
|
18
19
|
# A content directive: a node, like toc: or a project's Changelog:, or an endpoint.
|
|
19
20
|
# It spans line to end_line, its end tag, or the last line of a bare block.
|
|
21
|
+
# Body is the lines inside it, between it and its end tag, like an endpoint's request and response.
|
|
20
22
|
# Output is the lines Raindown writes back, like a node's Markdown or an endpoint's response, and is nil
|
|
21
23
|
# when nothing's written yet. A self-closing directive like <{ :toc }> only renders, so it never writes.
|
|
22
|
-
Directive = Data.define(:name, :args, :line, :end_line, :output, :writes) do
|
|
24
|
+
Directive = Data.define(:name, :args, :line, :end_line, :body, :output, :writes) do
|
|
23
25
|
def endpoint? = name == 'endpoint'
|
|
24
26
|
end
|
|
25
27
|
|
|
@@ -27,6 +29,7 @@ module Raindown
|
|
|
27
29
|
TEXT_NODES = %i[text code].freeze
|
|
28
30
|
BREAK_NODES = %i[softbreak linebreak].freeze
|
|
29
31
|
ENDPOINT_END_TAG = /\A {0,3}<\{\s*:endpoint\s*\}>[ \t]*$/
|
|
32
|
+
END_TAG = /\A {0,3}<\{\s*:[\w:]+\s*\}>[ \t]*$/
|
|
30
33
|
|
|
31
34
|
def self.parse(markdown, bare: false)
|
|
32
35
|
new(markdown:, bare:)
|
|
@@ -139,10 +142,18 @@ module Raindown
|
|
|
139
142
|
first..last if first <= last
|
|
140
143
|
end
|
|
141
144
|
|
|
142
|
-
# From a range of indexes to line numbers.
|
|
145
|
+
# From a range of indexes to line numbers. The body is the lines after the directive, up to its end tag,
|
|
146
|
+
# or to the end of a bare block, which has none.
|
|
143
147
|
def directive(name, args, span, output:, writes:)
|
|
144
|
-
|
|
145
|
-
|
|
148
|
+
end_tag = span.end > span.begin && @lines[span.end].match?(END_TAG)
|
|
149
|
+
body = trimmed(span.begin + 1, end_tag ? span.end - 1 : span.end)
|
|
150
|
+
|
|
151
|
+
Directive.new(name:, args:, line: span.begin + 1, end_line: span.end + 1, body: lines(body), output: lines(output),
|
|
152
|
+
writes:)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def lines(range)
|
|
156
|
+
(range.begin + 1)..(range.end + 1) if range
|
|
146
157
|
end
|
|
147
158
|
end
|
|
148
159
|
end
|
|
@@ -10,6 +10,7 @@ module Raindown
|
|
|
10
10
|
# are allowed: nothing that reads or writes local files, loads a config, or uses another protocol.
|
|
11
11
|
module Curl
|
|
12
12
|
class Error < StandardError; end
|
|
13
|
+
Request = Data.define(:method, :url)
|
|
13
14
|
|
|
14
15
|
TIMEOUT = '30'
|
|
15
16
|
# --disable must come first and skips ~/.curlrc. The protocol options stop redirects to file:// and the like.
|
|
@@ -64,6 +65,20 @@ module Raindown
|
|
|
64
65
|
raise Error, 'curl is not installed'
|
|
65
66
|
end
|
|
66
67
|
|
|
68
|
+
# The snippet's method and URL, like GET https://httpbin.org/uuid, or nil when it can't be read.
|
|
69
|
+
# A snippet that sends data is a POST, unless it says otherwise, as with curl.
|
|
70
|
+
def request(snippet)
|
|
71
|
+
args = arguments(snippet)
|
|
72
|
+
url = args.find { it.match?(URL) }
|
|
73
|
+
method = args[args.index('--request') + 1] if args.include?('--request')
|
|
74
|
+
method ||= 'HEAD' if args.include?('--head')
|
|
75
|
+
method ||= 'POST' if args.intersect?(VALUE_CHECKS.keys.grep(/data|json/))
|
|
76
|
+
|
|
77
|
+
Request.new(method: (method || 'GET').upcase, url:) if url
|
|
78
|
+
rescue Error
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
|
|
67
82
|
def arguments(snippet)
|
|
68
83
|
words = Shellwords.split(snippet.gsub(/\\\r?\n/, ' ').strip.delete_prefix('$ '))
|
|
69
84
|
raise Error, 'Endpoint snippets must start with curl' unless words.first == 'curl'
|
data/lib/raindown/projects.rb
CHANGED
|
@@ -35,14 +35,14 @@ module Raindown
|
|
|
35
35
|
folder && File.dirname(folder)
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
-
# The project's Markdown files, sorted
|
|
39
|
-
# since
|
|
40
|
-
def files(root)
|
|
38
|
+
# The project's Markdown files, sorted, leaving out ignored folders. The projects inside it are left out too,
|
|
39
|
+
# since they have nodes of their own, unless they're asked for, like by the terminal UI, which lists them all.
|
|
40
|
+
def files(root, nested: false)
|
|
41
41
|
files = []
|
|
42
42
|
|
|
43
43
|
Find.find(root) do |path|
|
|
44
44
|
if File.directory?(path)
|
|
45
|
-
Find.prune if path != root && (ignored?(File.basename(path)) ||
|
|
45
|
+
Find.prune if path != root && (ignored?(File.basename(path)) || (!nested && project?(path)))
|
|
46
46
|
elsif markdown?(path, root:)
|
|
47
47
|
files << path
|
|
48
48
|
end
|
|
@@ -64,6 +64,10 @@ module Raindown
|
|
|
64
64
|
File.expand_path(path).start_with?(File.join(root, Nodes::FOLDER, '')) && %w[.rb .rbx].include?(File.extname(path))
|
|
65
65
|
end
|
|
66
66
|
|
|
67
|
+
def project?(folder)
|
|
68
|
+
File.directory?(File.join(folder, Nodes::FOLDER))
|
|
69
|
+
end
|
|
70
|
+
|
|
67
71
|
def ignored?(folder)
|
|
68
72
|
folder.start_with?('.') || IGNORED.include?(folder)
|
|
69
73
|
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'unicode/display_width'
|
|
4
|
+
|
|
5
|
+
module Raindown
|
|
6
|
+
module TUI
|
|
7
|
+
# A chart on the dashboard, drawn from its data by its project's Worker: a title of its file, from its project,
|
|
8
|
+
# and the heading above it, then its directive, then the chart, each line with its Layout style,
|
|
9
|
+
# in a border with rounded corners. It's as wide as the chart, so the title and directive are cut to fit it:
|
|
10
|
+
# ╭───────────────────────────────────────╮
|
|
11
|
+
# │ markdown.md › Charts │
|
|
12
|
+
# │ chart: 'data/downloads.csv' width: 30 │
|
|
13
|
+
# │ Jun ██████▊ 640 │
|
|
14
|
+
# ╰───────────────────────────────────────╯
|
|
15
|
+
class Card < Data.define(:project, :path, :line, :heading, :directive, :chart)
|
|
16
|
+
# The border's columns either side of a card's lines, "│ " and " │", and its lines above and below them.
|
|
17
|
+
BORDER_WIDTH = 4
|
|
18
|
+
BORDER_HEIGHT = 2
|
|
19
|
+
|
|
20
|
+
def title = [path.delete_prefix(File.join(project, '')), heading].compact.join(' › ')
|
|
21
|
+
|
|
22
|
+
def lines = parts.map(&:first)
|
|
23
|
+
|
|
24
|
+
# Each line, with its style.
|
|
25
|
+
def parts = [[title, :heading], [directive, :directive], *chart.map { [it, :chart] }]
|
|
26
|
+
|
|
27
|
+
# As wide as its chart.
|
|
28
|
+
def width = chart.map { Unicode::DisplayWidth.of(it) }.max || 0
|
|
29
|
+
|
|
30
|
+
def height = lines.length
|
|
31
|
+
|
|
32
|
+
# With its border, within a range of widths, like a pane's.
|
|
33
|
+
def box_width(within: nil) = within ? (width + BORDER_WIDTH).clamp(within) : width + BORDER_WIDTH
|
|
34
|
+
def box_height = height + BORDER_HEIGHT
|
|
35
|
+
|
|
36
|
+
# A line of the card in its border, as [text, style, width] parts, for a card the box width wide.
|
|
37
|
+
# The border's top is line 0, and below its bottom, the lines are blank, for a card shorter than its row.
|
|
38
|
+
# The title's style is title_style, like when the card's selected.
|
|
39
|
+
def format_line(line, box_width:, title_style: :heading)
|
|
40
|
+
width = [box_width - BORDER_WIDTH, 0].max
|
|
41
|
+
return [border(width, '╭', '╮')] if line.zero?
|
|
42
|
+
return [border(width, '╰', '╯')] if line == height + 1
|
|
43
|
+
return [['', :blank, width + BORDER_WIDTH]] if line > height + 1
|
|
44
|
+
|
|
45
|
+
text, style = parts[line - 1]
|
|
46
|
+
[['│ ', :border, 2], [text, style == :heading ? title_style : style, width], [' │', :border, 2]]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def border(width, left, right)
|
|
52
|
+
["#{left}#{'─' * (width + 2)}#{right}", :border, width + BORDER_WIDTH]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Raindown
|
|
4
|
+
module TUI
|
|
5
|
+
# An endpoint in a project's files, listed in the nav pane by its request, like "GET httpbin.org/uuid",
|
|
6
|
+
# with its method in its style, like GET in :method_get.
|
|
7
|
+
Endpoint = Data.define(:project, :path, :line, :method, :url) do
|
|
8
|
+
def text = "#{method} #{address}"
|
|
9
|
+
|
|
10
|
+
# The URL without its scheme.
|
|
11
|
+
def address = url.sub(%r{\Ahttps?://}i, '')
|
|
12
|
+
|
|
13
|
+
def style = :"method_#{method.downcase}"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'endpoint'
|
|
4
|
+
require_relative 'views/endpoint_view'
|
|
5
|
+
require_relative 'row'
|
|
6
|
+
require_relative 'workers/worker'
|
|
7
|
+
|
|
8
|
+
module Raindown
|
|
9
|
+
module TUI
|
|
10
|
+
# Every endpoint in the projects' files, found by each project's Worker, listed below the files.
|
|
11
|
+
# Like the dashboard, it can be narrowed to a project's own.
|
|
12
|
+
class Endpoints
|
|
13
|
+
# The project the endpoints are narrowed to, or nil for every project's.
|
|
14
|
+
attr_accessor :project
|
|
15
|
+
|
|
16
|
+
def initialize(workers:)
|
|
17
|
+
@workers = workers
|
|
18
|
+
load
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Finds the endpoints again, like after a file's changed.
|
|
22
|
+
def load
|
|
23
|
+
@endpoints = @workers.each.flat_map do |worker|
|
|
24
|
+
worker.call(:endpoints)
|
|
25
|
+
rescue Worker::Error
|
|
26
|
+
[]
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def all = project ? @endpoints.select { it.project == project } : @endpoints
|
|
31
|
+
|
|
32
|
+
def rows
|
|
33
|
+
all.map do
|
|
34
|
+
Row.new(text: it.address, tag: it, depth: 0, kind: :endpoint, target: it,
|
|
35
|
+
open: OpenView.new(view: EndpointView.new(it, workers: @workers)))
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Raindown
|
|
4
|
+
module TUI
|
|
5
|
+
# A click on the screen: the cursor moves to the pane's row, or the dashboard's card or the menu's item,
|
|
6
|
+
# then the action's done there, like :enter, or a button's, like :toggle or :execute.
|
|
7
|
+
# Keys are their events' names, like :down or :enter.
|
|
8
|
+
Click = Data.define(:pane, :index, :action)
|
|
9
|
+
|
|
10
|
+
# What the Screen does after an event, other than drawing again, or stopping for :quit.
|
|
11
|
+
# Leaves the screen for a command, like the editor, then tells the Navigator it ran.
|
|
12
|
+
Run = Data.define(:command)
|
|
13
|
+
|
|
14
|
+
# Shows the message while the task runs, like an endpoint's request.
|
|
15
|
+
Wait = Data.define(:message, :task)
|
|
16
|
+
|
|
17
|
+
# Steps a row or a view asks of the Navigator, which each takes on it, returning what the Screen does next: to open
|
|
18
|
+
# a view right of the files, or a file in the outline at a directive, to edit a file at a line, or to run an endpoint.
|
|
19
|
+
OpenView = Data.define(:view) do
|
|
20
|
+
def take(navigator) = navigator.open_view(view)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
Open = Data.define(:path, :line) do
|
|
24
|
+
def take(navigator) = navigator.open_at(path, line)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
Edit = Data.define(:path, :line) do
|
|
28
|
+
def take(navigator) = navigator.edit_at(path, line)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
Execute = Data.define(:path, :line, :name) do
|
|
32
|
+
def take(navigator) = navigator.run(path, line, name)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -1,52 +1,94 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative '../projects'
|
|
4
|
+
require_relative 'events'
|
|
5
|
+
require_relative 'views/outline_view'
|
|
4
6
|
require_relative 'row'
|
|
5
7
|
|
|
6
8
|
module Raindown
|
|
7
9
|
module TUI
|
|
8
|
-
# The
|
|
10
|
+
# The nav pane's files: a project's Markdown files as a tree, with folders before files,
|
|
11
|
+
# including the files of the projects inside it, whose folders are marked with Raindown's cloud.
|
|
12
|
+
# Folders start folded, and open with Enter, or when a file in them opens.
|
|
13
|
+
# A scope narrows it to one project's own files, from the project's folder, like when it's chosen in the header.
|
|
9
14
|
class FileTree
|
|
10
15
|
attr_reader :root
|
|
16
|
+
# The project the files are narrowed to, or nil for all of them.
|
|
17
|
+
attr_accessor :scope
|
|
11
18
|
|
|
12
|
-
def initialize(root:)
|
|
19
|
+
def initialize(root:, workers:)
|
|
13
20
|
@root = root
|
|
14
|
-
@
|
|
21
|
+
@workers = workers
|
|
22
|
+
@expanded = Set.new
|
|
15
23
|
load
|
|
16
24
|
end
|
|
17
25
|
|
|
26
|
+
# The rain cloud without its emoji variation selector, which every terminal draws one column wide, so it lines up
|
|
27
|
+
# the files with the panes beside them. With the selector, as in the title, some terminals draw it two.
|
|
28
|
+
PROJECT = '🌧'
|
|
29
|
+
|
|
18
30
|
def load
|
|
19
|
-
@files = Projects.files(root)
|
|
31
|
+
@files = Projects.files(root, nested: true)
|
|
32
|
+
@projects = Projects.roots(root).to_set
|
|
20
33
|
end
|
|
21
34
|
|
|
35
|
+
# The folder the tree starts from: the scope's, or the root's.
|
|
36
|
+
def base = scope || root
|
|
37
|
+
|
|
22
38
|
def rows
|
|
23
39
|
tree = {}
|
|
24
|
-
|
|
40
|
+
files.each { |path| relative(path).split('/').reduce(tree) { |node, part| node[part] ||= {} } }
|
|
25
41
|
tree_rows(tree, '', 0)
|
|
26
42
|
end
|
|
27
43
|
|
|
28
44
|
def toggle(folder)
|
|
29
|
-
@
|
|
45
|
+
@expanded.delete?(folder) || @expanded.add(folder)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Opens the folders the file is in, so its row is shown.
|
|
49
|
+
def reveal(path)
|
|
50
|
+
folder = File.dirname(path)
|
|
51
|
+
while folder.start_with?(File.join(root, ''))
|
|
52
|
+
@expanded.add(folder)
|
|
53
|
+
folder = File.dirname(folder)
|
|
54
|
+
end
|
|
30
55
|
end
|
|
31
56
|
|
|
32
57
|
def relative(path)
|
|
33
|
-
path.delete_prefix(File.join(
|
|
58
|
+
path.delete_prefix(File.join(base, ''))
|
|
34
59
|
end
|
|
35
60
|
|
|
36
61
|
private
|
|
37
62
|
|
|
63
|
+
# A scope's own files, without those of the projects inside it, which are projects of their own.
|
|
64
|
+
def files
|
|
65
|
+
return @files unless scope
|
|
66
|
+
|
|
67
|
+
@files.select { |path| owner(path) == scope }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def owner(path)
|
|
71
|
+
[root, *@projects].select { path.start_with?(File.join(it, '')) }.max_by(&:length)
|
|
72
|
+
end
|
|
73
|
+
|
|
38
74
|
def tree_rows(tree, prefix, depth)
|
|
39
75
|
folders, files = tree.partition { |_name, children| children.any? }
|
|
40
76
|
|
|
41
77
|
folders.sort.flat_map { |name, children| folder_rows(name, children, prefix, depth) } +
|
|
42
|
-
files.sort.map { |name, _|
|
|
78
|
+
files.sort.map { |name, _| file_row(name, File.join(base, prefix, name), depth) }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def file_row(name, path, depth)
|
|
82
|
+
Row.new(text: name, depth:, kind: :file, target: path, open: OpenView.new(view: OutlineView.new(path, workers: @workers)))
|
|
43
83
|
end
|
|
44
84
|
|
|
45
85
|
# A folder, and the tree inside it unless it's folded.
|
|
46
86
|
def folder_rows(name, children, prefix, depth)
|
|
47
|
-
folder = File.join(
|
|
48
|
-
folded =
|
|
49
|
-
|
|
87
|
+
folder = File.join(base, prefix, name)
|
|
88
|
+
folded = !@expanded.include?(folder)
|
|
89
|
+
project = @projects.include?(folder)
|
|
90
|
+
row = Row.new(text: "#{folded ? '▸' : '▾'} #{"#{PROJECT} " if project}#{name}/", depth:,
|
|
91
|
+
kind: project ? :project : :folder, target: folder, fold: -> { toggle(folder) })
|
|
50
92
|
|
|
51
93
|
[row, *(tree_rows(children, File.join(prefix, name), depth + 1) unless folded)]
|
|
52
94
|
end
|
data/lib/raindown/tui/layout.rb
CHANGED
|
@@ -1,25 +1,35 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
require_relative 'events'
|
|
4
|
+
require_relative 'text'
|
|
5
|
+
require_relative 'title'
|
|
4
6
|
|
|
5
7
|
module Raindown
|
|
6
8
|
module TUI
|
|
7
9
|
# The screen as lines of styled segments, worked out from a Navigator with no terminal, so the Screen only paints them.
|
|
8
|
-
# A
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
10
|
+
# A Layout and its Renderers are the display code, made again for each draw: they lay out and render what the
|
|
11
|
+
# Navigator and its views hold. The Layout draws the title, the status and the buttons around two panes, nav and main,
|
|
12
|
+
# and a Renderer draws what's in each pane: the nav's ListRenderer, and the open view's renderer, an OutlineRenderer,
|
|
13
|
+
# DashboardRenderer or EndpointRenderer, which gives the buttons and page too:
|
|
14
|
+
# ┌─ Layout ──────────────────────────────────────────────────┐
|
|
15
|
+
# │ Raindown ~/docs › guides/setup.md │
|
|
16
|
+
# │ ┌─ nav pane ──────┐ ┌─ main pane ───────────────────────┐ │
|
|
17
|
+
# │ │ Dashboard ▏ # Setup │ │
|
|
18
|
+
# │ │ ▾ guides/ ▏ toc: [+3] │ │
|
|
19
|
+
# │ │ setup.md ▏ ## Install │ │
|
|
20
|
+
# │ └─ ListRenderer ──┘ └─ view.renderer ───────────────────┘ │
|
|
21
|
+
# │ Opened guides/setup.md │
|
|
22
|
+
# │ Tab Pane Enter Open X Run E dit W rite back Q uit │
|
|
23
|
+
# └───────────────────────────────────────────────────────────┘
|
|
15
24
|
# Each line is exactly the width, in display columns. A segment with an event is clicked to send it.
|
|
16
25
|
class Layout
|
|
17
|
-
|
|
26
|
+
include Text
|
|
27
|
+
include Title
|
|
18
28
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
29
|
+
NAV_WIDTH = (16..32)
|
|
30
|
+
# The line between the nav and main panes, at its column's left edge, unlike │ in the middle,
|
|
31
|
+
# so a highlighted file's row runs up to it.
|
|
32
|
+
DIVIDER = '▏'
|
|
23
33
|
# The title, status and button lines.
|
|
24
34
|
CHROME_HEIGHT = 3
|
|
25
35
|
|
|
@@ -34,57 +44,45 @@ module Raindown
|
|
|
34
44
|
def lines
|
|
35
45
|
return [status].first(height) if height < CHROME_HEIGHT + 1
|
|
36
46
|
|
|
37
|
-
[title, *panes, status, button_bar]
|
|
47
|
+
lines = [title, *panes, status, button_bar]
|
|
48
|
+
return lines unless navigator.menu
|
|
49
|
+
|
|
50
|
+
require_relative 'menu_overlay'
|
|
51
|
+
MenuOverlay.new(navigator, width:, column: display_width(name)).apply(lines)
|
|
38
52
|
end
|
|
39
53
|
|
|
40
|
-
|
|
41
|
-
|
|
54
|
+
# A third of the width, within NAV_WIDTH, and never more than half of a narrow screen.
|
|
55
|
+
def nav_width
|
|
56
|
+
[(width / 3).clamp(NAV_WIDTH), width / 2].min
|
|
42
57
|
end
|
|
43
58
|
|
|
44
59
|
private
|
|
45
60
|
|
|
46
|
-
def
|
|
47
|
-
path = navigator.path ? navigator.relative(navigator.path) : 'No Markdown files'
|
|
48
|
-
[segment(" raindown #{File.basename(navigator.root)} #{path}", :title)]
|
|
49
|
-
end
|
|
61
|
+
def body_height = height - CHROME_HEIGHT
|
|
50
62
|
|
|
63
|
+
# Below a blank line for spacing, the nav pane beside the main pane, each drawn by the renderer of what's in it.
|
|
51
64
|
def panes
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
outline = navigator.visible(:outline, pane_height)
|
|
65
|
+
nav = renderer_in(:nav, nav_width).lines
|
|
66
|
+
main = view_renderer.lines
|
|
55
67
|
|
|
56
|
-
Array.new(
|
|
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
|
|
68
|
+
[spacer, *Array.new(body_height - 1) { |index| [*nav[index], divider(main[index]), *main[index]] }]
|
|
60
69
|
end
|
|
61
70
|
|
|
62
|
-
# A
|
|
63
|
-
def
|
|
64
|
-
row = rows[index]
|
|
65
|
-
return [segment('', :blank, width: cell_width)] unless row
|
|
71
|
+
# A blank line above the panes, for spacing.
|
|
72
|
+
def spacer = [segment('', :blank, width: nav_width), divider([]), segment('', :blank, width: width - nav_width - 1)]
|
|
66
73
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
+
# The divider is on the background of the outline's row beside it, when that's highlighted, so they meet.
|
|
75
|
+
def divider(main_line)
|
|
76
|
+
style = { selected: :selected_divider, cursor: :cursor_divider }.fetch(main_line.first&.style, :divider)
|
|
77
|
+
segment(DIVIDER, style, width: 1)
|
|
74
78
|
end
|
|
75
79
|
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
80
|
+
def view_renderer
|
|
81
|
+
@view_renderer ||= renderer_in(:main, width - nav_width - 1)
|
|
82
82
|
end
|
|
83
83
|
|
|
84
|
-
def
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
navigator.focus == pane ? :selected : :cursor
|
|
84
|
+
def renderer_in(pane, width)
|
|
85
|
+
navigator.content(pane).renderer.new(navigator:, pane:, width:, height: body_height - 1)
|
|
88
86
|
end
|
|
89
87
|
|
|
90
88
|
def status
|
|
@@ -92,12 +90,20 @@ module Raindown
|
|
|
92
90
|
[segment(" #{message}", kind)]
|
|
93
91
|
end
|
|
94
92
|
|
|
93
|
+
# The first letter of a label is stylized as a keyboard key like "(E)dit".
|
|
94
|
+
def shown(key, label)
|
|
95
|
+
return [key, label] unless key.length == 1
|
|
96
|
+
|
|
97
|
+
[key.upcase, label.downcase.start_with?(key) ? label[1..] : label]
|
|
98
|
+
end
|
|
99
|
+
|
|
95
100
|
# As many buttons as fit, each clicked by its key or its label.
|
|
96
101
|
def button_bar
|
|
97
102
|
segments = []
|
|
98
103
|
used = 0
|
|
99
104
|
|
|
100
|
-
|
|
105
|
+
view_renderer.buttons.each do |key, label, event|
|
|
106
|
+
key, label = shown(key, label)
|
|
101
107
|
pair = [Segment.new(text: " #{key} ", style: :key, event:), Segment.new(text: "#{label} ", style: :bar, event:)]
|
|
102
108
|
pair_width = display_width(pair.map(&:text).join)
|
|
103
109
|
break if used + pair_width > width
|
|
@@ -108,35 +114,11 @@ module Raindown
|
|
|
108
114
|
|
|
109
115
|
[*segments, segment('', :bar, width: width - used)]
|
|
110
116
|
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
117
|
end
|
|
141
118
|
end
|
|
142
119
|
end
|
|
120
|
+
|
|
121
|
+
require_relative 'renderers/list_renderer'
|
|
122
|
+
require_relative 'renderers/outline_renderer'
|
|
123
|
+
require_relative 'renderers/dashboard_renderer'
|
|
124
|
+
require_relative 'renderers/endpoint_renderer'
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'text'
|
|
4
|
+
|
|
5
|
+
module Raindown
|
|
6
|
+
module TUI
|
|
7
|
+
# The project menu, drawn over the screen's lines in a box below the header's path:
|
|
8
|
+
# 🌧 Raindown ~/dev ▾ › Dashboard
|
|
9
|
+
# ┌──────────────────────┐
|
|
10
|
+
# │ All projects ✓ │
|
|
11
|
+
# │ ~/dev/raindown │
|
|
12
|
+
# └──────────────────────┘
|
|
13
|
+
# Each item is clicked to choose it.
|
|
14
|
+
class MenuOverlay
|
|
15
|
+
include Text
|
|
16
|
+
|
|
17
|
+
def initialize(navigator, width:, column:)
|
|
18
|
+
@navigator = navigator
|
|
19
|
+
@width = width
|
|
20
|
+
@column = column
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The lines with the box drawn over them, from the line below the header.
|
|
24
|
+
def apply(lines)
|
|
25
|
+
box.each_with_index.map { |segments, offset| [offset + 1, segments] }.each do |row, segments|
|
|
26
|
+
lines[row] = overlay_segments(lines[row], segments, column: @column) if lines[row]
|
|
27
|
+
end
|
|
28
|
+
lines
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def items
|
|
34
|
+
@navigator.menu_items.map { |project| project ? from_home(project) : 'All projects' }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def inner_width
|
|
38
|
+
[items.map { display_width(it) }.max + 4, @width - @column - 3].min
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def box
|
|
42
|
+
top = segment("┌#{'─' * (inner_width + 2)}┐", :border)
|
|
43
|
+
bottom = segment("└#{'─' * (inner_width + 2)}┘", :border)
|
|
44
|
+
[[top], *items.each_with_index.map { |item, index| item_line(item, index) }, [bottom]]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def item_line(item, index)
|
|
48
|
+
chosen = @navigator.menu_items[index] == @navigator.project ? '✓' : ' '
|
|
49
|
+
style = @navigator.menu == index ? :selected : :menu
|
|
50
|
+
text = fit("#{fit(item, inner_width - 2)} #{chosen}", inner_width)
|
|
51
|
+
[segment('│ ', :border), segment(text, style, Click.new(pane: :menu, index:, action: :enter)), segment(' │', :border)]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def segment(text, style, event = nil)
|
|
55
|
+
Text::Segment.new(text:, style:, event:)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|