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.
- checksums.yaml +4 -4
- data/lib/raindown/actions.rb +1 -1
- data/lib/raindown/bare_directives.rb +14 -3
- data/lib/raindown/cli.rb +2 -2
- data/lib/raindown/document.rb +18 -7
- data/lib/raindown/endpoint/curl.rb +15 -0
- data/lib/raindown/endpoint/endpoint_runner.rb +1 -1
- data/lib/raindown/node_injector.rb +1 -1
- 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/raindown.rb +2 -0
- 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
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
|
|
@@ -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
|