raindown 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/lib/raindown/actions.rb +1 -1
  3. data/lib/raindown/bare_directives.rb +14 -3
  4. data/lib/raindown/cli.rb +2 -2
  5. data/lib/raindown/document.rb +18 -7
  6. data/lib/raindown/endpoint/curl.rb +15 -0
  7. data/lib/raindown/endpoint/endpoint_runner.rb +1 -1
  8. data/lib/raindown/node_injector.rb +1 -1
  9. data/lib/raindown/projects.rb +8 -4
  10. data/lib/raindown/tui/card.rb +56 -0
  11. data/lib/raindown/tui/endpoint.rb +16 -0
  12. data/lib/raindown/tui/endpoints.rb +40 -0
  13. data/lib/raindown/tui/events.rb +35 -0
  14. data/lib/raindown/tui/file_tree.rb +53 -11
  15. data/lib/raindown/tui/layout.rb +60 -78
  16. data/lib/raindown/tui/menu_overlay.rb +59 -0
  17. data/lib/raindown/tui/navigator/commands.rb +84 -0
  18. data/lib/raindown/tui/navigator/focus.rb +25 -0
  19. data/lib/raindown/tui/navigator/navigator.rb +127 -0
  20. data/lib/raindown/tui/navigator/project_menu.rb +61 -0
  21. data/lib/raindown/tui/navigator/views.rb +49 -0
  22. data/lib/raindown/tui/renderers/dashboard_renderer.rb +102 -0
  23. data/lib/raindown/tui/renderers/endpoint_renderer.rb +77 -0
  24. data/lib/raindown/tui/renderers/list_renderer.rb +15 -0
  25. data/lib/raindown/tui/renderers/outline_renderer.rb +21 -0
  26. data/lib/raindown/tui/renderers/renderer.rb +15 -0
  27. data/lib/raindown/tui/row.rb +10 -3
  28. data/lib/raindown/tui/rows.rb +57 -0
  29. data/lib/raindown/tui/{input.rb → terminal/input.rb} +10 -11
  30. data/lib/raindown/tui/terminal/ncurses_shim.rb +46 -0
  31. data/lib/raindown/tui/terminal/quiet.rb +32 -0
  32. data/lib/raindown/tui/{screen.rb → terminal/screen.rb} +32 -17
  33. data/lib/raindown/tui/terminal/styles.rb +96 -0
  34. data/lib/raindown/tui/text.rb +83 -0
  35. data/lib/raindown/tui/title.rb +29 -0
  36. data/lib/raindown/tui/views/dashboard_view.rb +145 -0
  37. data/lib/raindown/tui/views/endpoint_view.rb +75 -0
  38. data/lib/raindown/tui/{pane.rb → views/list_view.rb} +9 -8
  39. data/lib/raindown/tui/{outline.rb → views/outline_view.rb} +39 -20
  40. data/lib/raindown/tui/workers/project.rb +122 -0
  41. data/lib/raindown/tui/workers/worker.rb +124 -0
  42. data/lib/raindown/tui/workers/workers.rb +53 -0
  43. data/lib/raindown/tui.rb +8 -5
  44. data/lib/raindown.rb +2 -0
  45. data/lib/version.rb +1 -1
  46. metadata +32 -9
  47. data/lib/raindown/tui/commands.rb +0 -70
  48. data/lib/raindown/tui/navigator.rb +0 -123
  49. data/lib/raindown/tui/styles.rb +0 -36
@@ -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
@@ -0,0 +1,84 @@
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
+ def run(file, line, name) = Wait.new(message: "Running #{name}...", task: -> { run_endpoint(file, line) })
11
+
12
+ # Opens the editor at the file's line, and writes the file back after it closes.
13
+ def edit_at(file, line)
14
+ @edited = file
15
+ Run.new(command: Actions.edit_command(file, line:))
16
+ end
17
+
18
+ private
19
+
20
+ # Runs the endpoint selected in the outline, or listed below the files. One opened beside them runs itself.
21
+ def execute
22
+ target = selected&.target
23
+ return run(target.path, target.line, target.text) if target.is_a?(Endpoint)
24
+ return error('Select an endpoint to run') unless target.is_a?(Document::Directive) && target.endpoint?
25
+
26
+ run(path, target.line, "the endpoint on line #{target.line}")
27
+ end
28
+
29
+ def run_endpoint(file, line)
30
+ changed = @workers.for(file).call(:execute, file, line)
31
+ view.load
32
+ info(changed ? "Ran the endpoint on line #{line}" : 'The response is the same')
33
+ rescue StandardError => e
34
+ error(e.message)
35
+ end
36
+
37
+ # In the nav pane, the selected file opens at its top, and in the outline, the open file at the selected line.
38
+ def edit
39
+ row = selected
40
+ return error('Select a file to edit') if @focus == :nav && row&.kind != :file
41
+
42
+ return edit_at(row.target, 1) if @focus == :nav
43
+ return error('Open a file to edit') unless path
44
+
45
+ edit_at(path, row ? row.line : 1)
46
+ end
47
+
48
+ def write_back(file = path)
49
+ return error('Open a file to write back') unless file
50
+
51
+ changed = @workers.for(file).call(:write_back, file)
52
+ view.load
53
+ info(changed ? "Updated #{relative(file)}" : "#{relative(file)} is up to date")
54
+ rescue StandardError => e
55
+ error(e.message)
56
+ end
57
+
58
+ # The workers start again too, loading their projects' nodes again, like when one of them has changed.
59
+ def reload
60
+ @workers.restart
61
+ @files.load
62
+ @endpoints.load
63
+ view.load
64
+ info('Reloaded')
65
+ end
66
+
67
+ # Reads the file again when it's changed, like when it's saved in an editor or a watcher writes back to it.
68
+ def refresh
69
+ view.load if view.changed?
70
+ nil
71
+ end
72
+
73
+ def info(message)
74
+ @status = [:info, message]
75
+ nil
76
+ end
77
+
78
+ def error(message)
79
+ @status = [:error, message]
80
+ nil
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raindown
4
+ module TUI
5
+ # How a Navigator's focus and cursor move: Tab or left and right between the files and the view,
6
+ # and up and down in the pane with the focus.
7
+ module Focus
8
+ private
9
+
10
+ def other_pane
11
+ @focus == :nav ? :main : :nav
12
+ end
13
+
14
+ def move(by)
15
+ content(@focus).move(by)
16
+ nil
17
+ end
18
+
19
+ def switch(pane)
20
+ @focus = pane
21
+ nil
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../file_tree'
4
+ require_relative 'commands'
5
+ require_relative '../endpoints'
6
+ require_relative 'focus'
7
+ require_relative '../events'
8
+ require_relative '../views/outline_view'
9
+ require_relative '../views/list_view'
10
+ require_relative 'project_menu'
11
+ require_relative 'views'
12
+ require_relative '../workers/workers'
13
+
14
+ module Raindown
15
+ module TUI
16
+ # What the terminal UI shows and does, in plain Ruby, with no terminal: a FileTree of the project's Markdown files,
17
+ # beside the open file's Outline, or the Dashboard of its charts. The Screen draws it and sends it events, keys like
18
+ # :down, or a Click, and next_step returns what the Screen does next: nil to draw again, :quit to stop, or a Run or Wait.
19
+ class Navigator
20
+ include Commands
21
+ include Views
22
+ include ProjectMenu
23
+ include Focus
24
+
25
+ # Keys of the events that move the cursor or the focus, and those that act on what's selected.
26
+ MOVES = { up: -1, down: 1 }.freeze
27
+ PANES = { tab: nil, left: :nav, right: :main }.freeze
28
+ ACTIONS = { execute: :execute, edit: :edit, write_back: :write_back, reload: :reload, tick: :refresh }.freeze
29
+
30
+ attr_reader :focus, :status
31
+
32
+ def initialize(root:, path: nil, workers: Workers.new(root:))
33
+ @workers = workers
34
+ @files = FileTree.new(root:, workers:)
35
+ @endpoints = Endpoints.new(workers:)
36
+ @dashboard = DashboardView.new(root:, workers:)
37
+ @nav = ListView.new { nav_rows }
38
+ first = Projects.files(root, nested: true).first
39
+ # The dashboard is the first page, unless a file is asked for, and "d" goes from it to the first file.
40
+ @view = OutlineView.new(first, workers:) if first
41
+ path ? open_at(path, nil) : open_view(dashboard)
42
+ end
43
+
44
+ # An event nothing has, like a mouse button's release, does nothing.
45
+ def next_step(event)
46
+ case event
47
+ when nil then nil
48
+ when Click then click(event)
49
+ else route(event)
50
+ end
51
+ end
52
+
53
+ # After the editor closes, the file is written back, like a save while it's watched, and read again.
54
+ def ran
55
+ write_back(@edited)
56
+ @endpoints.load
57
+ end
58
+
59
+ def root = @files.root
60
+ def relative(path) = @files.relative(path)
61
+
62
+ # The folder the files are shown from: the chosen project's, or the root's.
63
+ def base = @files.base
64
+
65
+ # What's in a pane: the nav's ListView on the left, or the view on the right, which keeps its own cursor.
66
+ def content(pane) = pane == :nav ? @nav : view
67
+
68
+ private
69
+
70
+ def route(name)
71
+ return :quit if name == :quit
72
+ return menu_event(name) if menu
73
+ return toggle_menu if name == :projects
74
+ return toggle_dashboard if name == :dashboard
75
+ return view.next_step(name)&.take(self) if @focus == :main && view.next_step?(name)
76
+
77
+ browser_event(name)
78
+ end
79
+
80
+ # A click moves the cursor where it's clicked, and does the click's action there, like a key, unless that would
81
+ # leave the terminal UI, like for the editor, so a stray click only moves the cursor.
82
+ # While the project menu's open, only its projects are clicked.
83
+ def click(click)
84
+ return if menu && click.pane != :menu
85
+
86
+ point(click)
87
+ effect = route(click.action)
88
+ effect unless effect.is_a?(Run)
89
+ end
90
+
91
+ def point(click)
92
+ return @menu = click.index if click.pane == :menu
93
+
94
+ content(click.pane).cursor = click.index
95
+ @focus = click.pane
96
+ end
97
+
98
+ def browser_event(name)
99
+ return move(MOVES[name]) if MOVES.key?(name)
100
+ return switch(PANES[name] || other_pane) if PANES.key?(name)
101
+ return activate(name) if %i[enter toggle].include?(name)
102
+
103
+ send(ACTIONS[name]) if ACTIONS.key?(name)
104
+ end
105
+
106
+ # The Dashboard, which every terminal draws two columns wide, then the files and the endpoints,
107
+ # with a blank line between each section that has rows.
108
+ def nav_rows
109
+ dashboard_row = Row.new(text: '📊 Dashboard', depth: 0, kind: :dashboard, target: dashboard, open: OpenView.new(view: dashboard))
110
+ [[dashboard_row], @files.rows, @endpoints.rows].reject(&:empty?).flat_map { [Row::SEPARATOR, *it] }.drop(1)
111
+ end
112
+
113
+ def selected
114
+ content(@focus).selected
115
+ end
116
+
117
+ # Enter opens what's selected, or folds it, and Space only folds.
118
+ def activate(name)
119
+ row = selected
120
+ return row.open.take(self) if name == :enter && row&.open
121
+
122
+ row&.fold&.call
123
+ nil
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raindown
4
+ module TUI
5
+ # The list of projects that drops down from the header's path, with "p" or a click, when there's more than one.
6
+ # Choosing one narrows the files and the dashboard to its own, and "All projects" shows every project's again.
7
+ # It stays open until a project's chosen, with Enter or a click, while up and down move its cursor.
8
+ # Esc or "p" close it without choosing, and other keys and clicks do nothing while it's open.
9
+ module ProjectMenu
10
+ # The menu's cursor, while it's open.
11
+ attr_reader :menu
12
+
13
+ # The project the files and the dashboard are narrowed to, or nil for every project.
14
+ attr_reader :project
15
+
16
+ def projects = @workers.roots
17
+
18
+ # The menu's items: nil for all the projects, then each project's root.
19
+ def menu_items = [nil, *projects]
20
+
21
+ def projects? = projects.length > 1
22
+
23
+ private
24
+
25
+ def toggle_menu
26
+ return info("There's one project here") unless projects?
27
+
28
+ @menu = @menu ? nil : menu_items.index(project) || 0
29
+ nil
30
+ end
31
+
32
+ def menu_event(name)
33
+ case name
34
+ when :up, :down then move_menu(name == :up ? -1 : 1)
35
+ when :enter then choose(menu_items[@menu])
36
+ when :escape, :projects then close_menu
37
+ end
38
+ end
39
+
40
+ def move_menu(by)
41
+ @menu = (@menu + by).clamp(0, menu_items.length - 1)
42
+ nil
43
+ end
44
+
45
+ def close_menu
46
+ @menu = nil
47
+ nil
48
+ end
49
+
50
+ # Shows the project's dashboard, or every project's.
51
+ def choose(project)
52
+ @menu = nil
53
+ @project = project
54
+ @files.scope = project
55
+ @endpoints.project = project
56
+ dashboard.project = project
57
+ open_view(dashboard)
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../views/dashboard_view'
4
+ require_relative '../views/endpoint_view'
5
+ require_relative '../views/outline_view'
6
+
7
+ module Raindown
8
+ module TUI
9
+ # Beside the files, a Navigator shows a view: the open file's Outline, the Dashboard of the project's charts,
10
+ # which is the first item in the files and "d" switches to, or an EndpointView of an endpoint listed below them.
11
+ # A view has the pane that draws it, a next step for the keys it takes while it has the focus, and a target,
12
+ # what's opened it in the files. It loads when it's opened, and again after a change.
13
+ module Views
14
+ attr_reader :view, :dashboard
15
+
16
+ # The path of the file the view shows, if any.
17
+ def path = view.path
18
+
19
+ # Shows the view right of the files, with the focus, and the files' cursor on the row that opened it.
20
+ def open_view(view)
21
+ @previous = @view unless view == @view
22
+ @view = view
23
+ @focus = :main
24
+ @nav.cursor = @nav.rows.index { it.target == view.target } || 0
25
+ view.load
26
+ @status = view.status(self)
27
+ nil
28
+ end
29
+
30
+ # Opens the file in the outline, on the directive at the line.
31
+ def open_at(path, line)
32
+ path = File.expand_path(path)
33
+ @files.reveal(path)
34
+ open_view(OutlineView.new(path, workers: @workers))
35
+ view.cursor = view.rows.index { it.kind == :directive && it.target.line == line } || 0
36
+ nil
37
+ end
38
+
39
+ private
40
+
41
+ # Switches between the dashboard and the view before it.
42
+ def toggle_dashboard
43
+ return open_view(@previous) if view == dashboard && @previous
44
+
45
+ open_view(dashboard)
46
+ end
47
+ end
48
+ end
49
+ end