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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/lib/raindown/cli.rb +2 -2
  3. data/lib/raindown/document.rb +15 -4
  4. data/lib/raindown/endpoint/curl.rb +15 -0
  5. data/lib/raindown/projects.rb +8 -4
  6. data/lib/raindown/tui/card.rb +56 -0
  7. data/lib/raindown/tui/endpoint.rb +16 -0
  8. data/lib/raindown/tui/endpoints.rb +40 -0
  9. data/lib/raindown/tui/events.rb +35 -0
  10. data/lib/raindown/tui/file_tree.rb +53 -11
  11. data/lib/raindown/tui/layout.rb +60 -78
  12. data/lib/raindown/tui/menu_overlay.rb +59 -0
  13. data/lib/raindown/tui/navigator/commands.rb +84 -0
  14. data/lib/raindown/tui/navigator/focus.rb +25 -0
  15. data/lib/raindown/tui/navigator/navigator.rb +127 -0
  16. data/lib/raindown/tui/navigator/project_menu.rb +61 -0
  17. data/lib/raindown/tui/navigator/views.rb +49 -0
  18. data/lib/raindown/tui/renderers/dashboard_renderer.rb +102 -0
  19. data/lib/raindown/tui/renderers/endpoint_renderer.rb +77 -0
  20. data/lib/raindown/tui/renderers/list_renderer.rb +15 -0
  21. data/lib/raindown/tui/renderers/outline_renderer.rb +21 -0
  22. data/lib/raindown/tui/renderers/renderer.rb +15 -0
  23. data/lib/raindown/tui/row.rb +10 -3
  24. data/lib/raindown/tui/rows.rb +57 -0
  25. data/lib/raindown/tui/{input.rb → terminal/input.rb} +10 -11
  26. data/lib/raindown/tui/terminal/ncurses_shim.rb +46 -0
  27. data/lib/raindown/tui/terminal/quiet.rb +32 -0
  28. data/lib/raindown/tui/{screen.rb → terminal/screen.rb} +32 -17
  29. data/lib/raindown/tui/terminal/styles.rb +96 -0
  30. data/lib/raindown/tui/text.rb +83 -0
  31. data/lib/raindown/tui/title.rb +29 -0
  32. data/lib/raindown/tui/views/dashboard_view.rb +145 -0
  33. data/lib/raindown/tui/views/endpoint_view.rb +75 -0
  34. data/lib/raindown/tui/{pane.rb → views/list_view.rb} +9 -8
  35. data/lib/raindown/tui/{outline.rb → views/outline_view.rb} +39 -20
  36. data/lib/raindown/tui/workers/project.rb +122 -0
  37. data/lib/raindown/tui/workers/worker.rb +124 -0
  38. data/lib/raindown/tui/workers/workers.rb +53 -0
  39. data/lib/raindown/tui.rb +8 -5
  40. data/lib/version.rb +1 -1
  41. metadata +32 -9
  42. data/lib/raindown/tui/commands.rb +0 -70
  43. data/lib/raindown/tui/navigator.rb +0 -123
  44. data/lib/raindown/tui/styles.rb +0 -36
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9e35dfde9371c5f805bff304124c0abe22ca89254f14c5c70d2996e14d0dbb0f
4
- data.tar.gz: 76774aa6b06fec8b50e0929b9ac86259511fb1ab9417ce48fcfa56afd20c90cf
3
+ metadata.gz: c9a61e7a842dcd5dc4cc5f6d004db388173ef2d5c48af1a50e32fea6c317b678
4
+ data.tar.gz: 7bbae724e020d48a3f095ba1cc8e1c45b9ee5dd483daf0af5f8edd4333d83205
5
5
  SHA512:
6
- metadata.gz: 454c35bfd6c74cc2c125d8229ffe971cb178fb40f4d87affad0fc5d345301fd4d5a0e32a15852f05eb35231e0054b2a5b55ff2a4eb7253cc85aef17f4d17e178
7
- data.tar.gz: 33b41ed491f7cc8238f3c3d99b2d72e2b0197dce29146dd14bdc704dd0ea6fa64788c97ed5d60bda60ef4602e9e6dedb2d22b02a1f8886a5c4592a522d96886e
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
- and open the editor at a line
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
@@ -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
- output = (output.begin + 1)..(output.end + 1) if output
145
- Directive.new(name:, args:, line: span.begin + 1, end_line: span.end + 1, output:, writes:)
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'
@@ -35,14 +35,14 @@ module Raindown
35
35
  folder && File.dirname(folder)
36
36
  end
37
37
 
38
- # The project's Markdown files, sorted. Ignored folders and the projects inside it are left out,
39
- # since those projects have nodes of their own.
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)) || File.directory?(File.join(path, Nodes::FOLDER)))
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 files pane: a project's Markdown files as a tree, with folders before files, where folders fold.
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
- @folded = Set.new
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
- @files.each { |path| relative(path).split('/').reduce(tree) { |node, part| node[part] ||= {} } }
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
- @folded.delete?(folder) || @folded.add(folder)
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(root, ''))
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, _| Row.new(text: name, depth:, kind: :file, target: File.join(root, prefix, 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(root, prefix, name)
48
- folded = @folded.include?(folder)
49
- row = Row.new(text: "#{folded ? '▸' : '▾'} #{name}/", depth:, kind: :folder, target: folder)
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
@@ -1,25 +1,35 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'unicode/display_width'
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 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
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
- Segment = Data.define(:text, :style, :event)
26
+ include Text
27
+ include Title
18
28
 
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 = ' '
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
- def files_width
41
- (width / 3).clamp(FILES_WIDTH.begin, [FILES_WIDTH.end, width / 2].min)
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 title
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
- pane_height = height - CHROME_HEIGHT
53
- files = navigator.visible(:files, pane_height)
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(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
68
+ [spacer, *Array.new(body_height - 1) { |index| [*nav[index], divider(main[index]), *main[index]] }]
60
69
  end
61
70
 
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
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
- 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
+ # 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
- # 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
80
+ def view_renderer
81
+ @view_renderer ||= renderer_in(:main, width - nav_width - 1)
82
82
  end
83
83
 
84
- def row_style(pane, row, selected)
85
- return row.kind unless selected
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
- BUTTONS.each do |key, label, event|
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