raindown 0.5.1 → 0.7.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/chart/chart.rb +12 -1
- data/lib/raindown/cli.rb +2 -2
- data/lib/raindown/document.rb +16 -9
- data/lib/raindown/endpoint/curl.rb +19 -0
- data/lib/raindown/node_blocks.rb +9 -6
- data/lib/raindown/nodes.rb +1 -0
- data/lib/raindown/projects.rb +8 -4
- data/lib/raindown/stat.rb +53 -0
- 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 +50 -0
- data/lib/raindown/tui/feelings/batch.rb +47 -0
- data/lib/raindown/tui/feelings/bins.rb +67 -0
- data/lib/raindown/tui/feelings/celebration.rb +25 -0
- data/lib/raindown/tui/feelings/code_box.rb +129 -0
- data/lib/raindown/tui/feelings/code_methods.rb +134 -0
- data/lib/raindown/tui/feelings/feelings_log.rb +133 -0
- data/lib/raindown/tui/feelings/feelings_renderer.rb +184 -0
- data/lib/raindown/tui/feelings/feelings_view.rb +161 -0
- data/lib/raindown/tui/feelings/fireworks.rb +75 -0
- data/lib/raindown/tui/feelings/rain.rb +66 -0
- data/lib/raindown/tui/feelings/scene.rb +37 -0
- data/lib/raindown/tui/feelings/waffle.rb +43 -0
- data/lib/raindown/tui/feelings/weekly_batch.rb +32 -0
- data/lib/raindown/tui/file_tree.rb +55 -12
- data/lib/raindown/tui/highlighter.rb +45 -0
- data/lib/raindown/tui/layout.rb +66 -77
- data/lib/raindown/tui/menu_overlay.rb +59 -0
- data/lib/raindown/tui/navigator/commands.rb +97 -0
- data/lib/raindown/tui/navigator/focus.rb +25 -0
- data/lib/raindown/tui/navigator/navigator.rb +160 -0
- data/lib/raindown/tui/navigator/project_menu.rb +61 -0
- data/lib/raindown/tui/navigator/search.rb +126 -0
- data/lib/raindown/tui/navigator/views.rb +52 -0
- data/lib/raindown/tui/renderers/dashboard_renderer.rb +102 -0
- data/lib/raindown/tui/renderers/endpoint_renderer.rb +81 -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 +18 -0
- data/lib/raindown/tui/row.rb +12 -3
- data/lib/raindown/tui/rows.rb +58 -0
- data/lib/raindown/tui/search_overlay.rb +73 -0
- data/lib/raindown/tui/terminal/input.rb +91 -0
- 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} +42 -24
- data/lib/raindown/tui/terminal/styles.rb +154 -0
- data/lib/raindown/tui/text.rb +110 -0
- data/lib/raindown/tui/title.rb +29 -0
- data/lib/raindown/tui/views/dashboard_view.rb +147 -0
- data/lib/raindown/tui/views/endpoint_view.rb +77 -0
- data/lib/raindown/tui/{pane.rb → views/list_view.rb} +11 -8
- data/lib/raindown/tui/{outline.rb → views/outline_view.rb} +48 -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 +10 -5
- data/lib/version.rb +1 -1
- metadata +63 -9
- data/lib/raindown/tui/commands.rb +0 -70
- data/lib/raindown/tui/input.rb +0 -55
- 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,37 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
require_relative 'events'
|
|
4
|
+
require_relative 'text'
|
|
5
|
+
require_relative 'title'
|
|
6
|
+
require_relative 'menu_overlay'
|
|
7
|
+
require_relative 'search_overlay'
|
|
4
8
|
|
|
5
9
|
module Raindown
|
|
6
10
|
module TUI
|
|
7
11
|
# 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
|
-
#
|
|
12
|
+
# A Layout and its Renderers are the display code, made again for each draw: they lay out and render what the
|
|
13
|
+
# Navigator and its views hold. The Layout draws the title, the status and the buttons around two panes, nav and main,
|
|
14
|
+
# and a Renderer draws what's in each pane: the nav's ListRenderer, and the open view's renderer, an OutlineRenderer,
|
|
15
|
+
# DashboardRenderer or EndpointRenderer, which gives the buttons and page too:
|
|
16
|
+
# ┌─ Layout ──────────────────────────────────────────────────┐
|
|
17
|
+
# │ Raindown ~/docs › guides/setup.md │
|
|
18
|
+
# │ ┌─ nav pane ──────┐ ┌─ main pane ───────────────────────┐ │
|
|
19
|
+
# │ │ Dashboard ▏ # Setup │ │
|
|
20
|
+
# │ │ ▾ guides/ ▏ toc: [+3] │ │
|
|
21
|
+
# │ │ setup.md ▏ ## Install │ │
|
|
22
|
+
# │ └─ ListRenderer ──┘ └─ view.renderer ───────────────────┘ │
|
|
23
|
+
# │ Opened guides/setup.md │
|
|
24
|
+
# │ Tab Pane Enter Open X Run E dit W rite back Q uit │
|
|
25
|
+
# └───────────────────────────────────────────────────────────┘
|
|
15
26
|
# Each line is exactly the width, in display columns. A segment with an event is clicked to send it.
|
|
16
27
|
class Layout
|
|
17
|
-
|
|
28
|
+
include Text
|
|
29
|
+
include Title
|
|
18
30
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
31
|
+
NAV_WIDTH = (16..32)
|
|
32
|
+
# The line between the nav and main panes, at its column's left edge, unlike │ in the middle,
|
|
33
|
+
# so a highlighted file's row runs up to it.
|
|
34
|
+
DIVIDER = '▏'
|
|
23
35
|
# The title, status and button lines.
|
|
24
36
|
CHROME_HEIGHT = 3
|
|
25
37
|
|
|
@@ -34,57 +46,49 @@ module Raindown
|
|
|
34
46
|
def lines
|
|
35
47
|
return [status].first(height) if height < CHROME_HEIGHT + 1
|
|
36
48
|
|
|
37
|
-
[title, *panes, status, button_bar]
|
|
49
|
+
overlaid([title, *panes, status, button_bar])
|
|
38
50
|
end
|
|
39
51
|
|
|
40
|
-
|
|
41
|
-
|
|
52
|
+
# A third of the width, within NAV_WIDTH, and never more than half of a narrow screen.
|
|
53
|
+
def nav_width
|
|
54
|
+
[(width / 3).clamp(NAV_WIDTH), width / 2].min
|
|
42
55
|
end
|
|
43
56
|
|
|
44
57
|
private
|
|
45
58
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
59
|
+
# The project menu or the search, drawn over the lines while it's open.
|
|
60
|
+
def overlaid(lines)
|
|
61
|
+
return MenuOverlay.new(navigator, width:, column: display_width(name)).apply(lines) if navigator.menu
|
|
62
|
+
return SearchOverlay.new(navigator, width:).apply(lines) if navigator.searching?
|
|
63
|
+
|
|
64
|
+
lines
|
|
49
65
|
end
|
|
50
66
|
|
|
67
|
+
def body_height = height - CHROME_HEIGHT
|
|
68
|
+
|
|
69
|
+
# Below a blank line for spacing, the nav pane beside the main pane, each drawn by the renderer of what's in it.
|
|
51
70
|
def panes
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
outline = navigator.visible(:outline, pane_height)
|
|
71
|
+
nav = renderer_in(:nav, nav_width).lines
|
|
72
|
+
main = view_renderer.lines
|
|
55
73
|
|
|
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
|
|
74
|
+
[spacer, *Array.new(body_height - 1) { |index| [*nav[index], divider(main[index]), *main[index]] }]
|
|
60
75
|
end
|
|
61
76
|
|
|
62
|
-
# A
|
|
63
|
-
def
|
|
64
|
-
row = rows[index]
|
|
65
|
-
return [segment('', :blank, width: cell_width)] unless row
|
|
77
|
+
# A line above the panes, for spacing, blank but for what the main pane's renderer puts in its side of it.
|
|
78
|
+
def spacer = [segment('', :blank, width: nav_width), divider([]), *view_renderer.spacer]
|
|
66
79
|
|
|
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]
|
|
80
|
+
# The divider is on the background of the outline's row beside it, when that's highlighted, so they meet.
|
|
81
|
+
def divider(main_line)
|
|
82
|
+
style = { selected: :selected_divider, cursor: :cursor_divider }.fetch(main_line.first&.style, :divider)
|
|
83
|
+
segment(DIVIDER, style, width: 1)
|
|
74
84
|
end
|
|
75
85
|
|
|
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
|
|
86
|
+
def view_renderer
|
|
87
|
+
@view_renderer ||= renderer_in(:main, width - nav_width - 1)
|
|
82
88
|
end
|
|
83
89
|
|
|
84
|
-
def
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
navigator.focus == pane ? :selected : :cursor
|
|
90
|
+
def renderer_in(pane, width)
|
|
91
|
+
navigator.content(pane).renderer.new(navigator:, pane:, width:, height: body_height - 1)
|
|
88
92
|
end
|
|
89
93
|
|
|
90
94
|
def status
|
|
@@ -92,12 +96,20 @@ module Raindown
|
|
|
92
96
|
[segment(" #{message}", kind)]
|
|
93
97
|
end
|
|
94
98
|
|
|
99
|
+
# The first letter of a label is stylized as a keyboard key like "(E)dit".
|
|
100
|
+
def shown(key, label)
|
|
101
|
+
return [key, label] unless key.length == 1
|
|
102
|
+
|
|
103
|
+
[key.upcase, label.downcase.start_with?(key) ? label[1..] : label]
|
|
104
|
+
end
|
|
105
|
+
|
|
95
106
|
# As many buttons as fit, each clicked by its key or its label.
|
|
96
107
|
def button_bar
|
|
97
108
|
segments = []
|
|
98
109
|
used = 0
|
|
99
110
|
|
|
100
|
-
|
|
111
|
+
view_renderer.buttons.each do |key, label, event|
|
|
112
|
+
key, label = shown(key, label)
|
|
101
113
|
pair = [Segment.new(text: " #{key} ", style: :key, event:), Segment.new(text: "#{label} ", style: :bar, event:)]
|
|
102
114
|
pair_width = display_width(pair.map(&:text).join)
|
|
103
115
|
break if used + pair_width > width
|
|
@@ -108,35 +120,12 @@ module Raindown
|
|
|
108
120
|
|
|
109
121
|
[*segments, segment('', :bar, width: width - used)]
|
|
110
122
|
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
123
|
end
|
|
141
124
|
end
|
|
142
125
|
end
|
|
126
|
+
|
|
127
|
+
require_relative 'renderers/list_renderer'
|
|
128
|
+
require_relative 'renderers/outline_renderer'
|
|
129
|
+
require_relative 'renderers/dashboard_renderer'
|
|
130
|
+
require_relative 'renderers/endpoint_renderer'
|
|
131
|
+
require_relative 'feelings/feelings_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,97 @@
|
|
|
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
|
+
# and to a feeling about some code: draw the page it was noted down in, or ask Claude Code to fix the code.
|
|
9
|
+
# Each sets the status, so the Screen shows how it went, and returns what the Screen should do next, if anything.
|
|
10
|
+
module Commands
|
|
11
|
+
def run(file, line, name) = Wait.new(message: "Running #{name}...", task: -> { run_endpoint(file, line) })
|
|
12
|
+
|
|
13
|
+
# Opens the editor at the file's line, and writes the file back after it closes.
|
|
14
|
+
def edit_at(file, line)
|
|
15
|
+
@edited = file
|
|
16
|
+
Run.new(command: Actions.edit_command(file, line:))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def felt(feeling) = info("Felt #{feeling.temper} about #{feeling.method.label}")
|
|
20
|
+
|
|
21
|
+
# Leaves the screen for Claude Code, asked to fix the method, and the view reads the code again after.
|
|
22
|
+
def fix_with_agent(feeling)
|
|
23
|
+
return error('Install Claude Code, the claude command, to fix code with X') unless claude?
|
|
24
|
+
|
|
25
|
+
@edited = feeling.method.path
|
|
26
|
+
Run.new(command: ['claude', feeling.prompt])
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def claude? = ENV.fetch('PATH', '').split(File::PATH_SEPARATOR).any? { File.executable?(File.join(it, 'claude')) }
|
|
32
|
+
|
|
33
|
+
# Runs the endpoint selected in the outline, or listed below the files. One opened beside them runs itself.
|
|
34
|
+
def execute
|
|
35
|
+
target = selected&.target
|
|
36
|
+
return run(target.path, target.line, target.text) if target.is_a?(Endpoint)
|
|
37
|
+
return error('Select an endpoint to run') unless target.is_a?(Document::Directive) && target.endpoint?
|
|
38
|
+
|
|
39
|
+
run(path, target.line, "the endpoint on line #{target.line}")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def run_endpoint(file, line)
|
|
43
|
+
changed = @workers.for(file).call(:execute, file, line)
|
|
44
|
+
view.load
|
|
45
|
+
info(changed ? "Ran the endpoint on line #{line}" : 'The response is the same')
|
|
46
|
+
rescue StandardError => e
|
|
47
|
+
error(e.message)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# In the nav pane, the selected file opens at its top, and in the outline, the open file at the selected line.
|
|
51
|
+
def edit
|
|
52
|
+
row = selected
|
|
53
|
+
return error('Select a file to edit') if @focus == :nav && row&.kind != :file
|
|
54
|
+
|
|
55
|
+
return edit_at(row.target, 1) if @focus == :nav
|
|
56
|
+
return error('Open a file to edit') unless path
|
|
57
|
+
|
|
58
|
+
edit_at(path, row ? row.line : 1)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def write_back(file = path)
|
|
62
|
+
return error('Open a file to write back') unless file
|
|
63
|
+
|
|
64
|
+
changed = @workers.for(file).call(:write_back, file)
|
|
65
|
+
view.load
|
|
66
|
+
info(changed ? "Updated #{relative(file)}" : "#{relative(file)} is up to date")
|
|
67
|
+
rescue StandardError => e
|
|
68
|
+
error(e.message)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The workers start again too, loading their projects' nodes again, like when one of them has changed.
|
|
72
|
+
def reload
|
|
73
|
+
@workers.restart
|
|
74
|
+
@files.load
|
|
75
|
+
@endpoints.load
|
|
76
|
+
view.load
|
|
77
|
+
info('Reloaded')
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Reads the file again when it's changed, like when it's saved in an editor or a watcher writes back to it.
|
|
81
|
+
def refresh
|
|
82
|
+
view.load if view.changed?
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def info(message)
|
|
87
|
+
@status = [:info, message]
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def error(message)
|
|
92
|
+
@status = [:error, message]
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
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,160 @@
|
|
|
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 'search'
|
|
12
|
+
require_relative 'views'
|
|
13
|
+
require_relative '../workers/workers'
|
|
14
|
+
|
|
15
|
+
module Raindown
|
|
16
|
+
module TUI
|
|
17
|
+
# What the terminal UI shows and does, in plain Ruby, with no terminal: a FileTree of the project's Markdown files,
|
|
18
|
+
# beside the open file's Outline, or the Dashboard of its charts. The Screen draws it and sends it events, keys like
|
|
19
|
+
# :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.
|
|
20
|
+
class Navigator
|
|
21
|
+
include Commands
|
|
22
|
+
include Views
|
|
23
|
+
include ProjectMenu
|
|
24
|
+
include Search
|
|
25
|
+
include Focus
|
|
26
|
+
|
|
27
|
+
# Keys of the events that move the cursor or the focus, and those that act on what's selected.
|
|
28
|
+
MOVES = { up: -1, down: 1 }.freeze
|
|
29
|
+
PANES = { tab: nil, left: :nav, right: :main }.freeze
|
|
30
|
+
ACTIONS = { execute: :execute, edit: :edit, write_back: :write_back, reload: :reload, tick: :refresh }.freeze
|
|
31
|
+
# Keys that open the search, the project menu, the Dashboard or Code feelings, from anywhere.
|
|
32
|
+
OPENERS = { search: :open_search, projects: :toggle_menu, dashboard: :toggle_dashboard, feelings: :open_feelings }.freeze
|
|
33
|
+
|
|
34
|
+
attr_reader :focus, :status
|
|
35
|
+
# What the mouse is over, which a click there would send, like a Click on a method's cells, or nil.
|
|
36
|
+
attr_reader :hovered
|
|
37
|
+
|
|
38
|
+
def initialize(root:, path: nil, workers: Workers.new(root:))
|
|
39
|
+
@workers = workers
|
|
40
|
+
@files = FileTree.new(root:, workers:)
|
|
41
|
+
@endpoints = Endpoints.new(workers:)
|
|
42
|
+
@dashboard = DashboardView.new(root:, workers:)
|
|
43
|
+
@feelings = FeelingsView.new(root:)
|
|
44
|
+
@nav = ListView.new { nav_rows }
|
|
45
|
+
first = Projects.files(root, nested: true).first
|
|
46
|
+
# The dashboard is the first page, unless a file is asked for, and "d" goes from it to the first file.
|
|
47
|
+
@view = OutlineView.new(first, workers:) if first
|
|
48
|
+
path ? open_at(path, nil) : open_view(dashboard)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# An event nothing has, like a mouse button's release, does nothing.
|
|
52
|
+
def next_step(event)
|
|
53
|
+
case event
|
|
54
|
+
when nil then nil
|
|
55
|
+
when Click then click(event)
|
|
56
|
+
when Type then type(event)
|
|
57
|
+
when Hover then hover(event)
|
|
58
|
+
else route(event)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# After the editor closes, a Markdown file is written back, like a save while it's watched, and read again.
|
|
63
|
+
# Other files, like code that was felt, aren't written back, and the view reads them again.
|
|
64
|
+
def ran
|
|
65
|
+
write_back(@edited) if @files.files.include?(@edited)
|
|
66
|
+
view.load
|
|
67
|
+
@endpoints.load
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def root = @files.root
|
|
71
|
+
def relative(path) = @files.relative(path)
|
|
72
|
+
|
|
73
|
+
# The folder the files are shown from: the chosen project's, or the root's.
|
|
74
|
+
def base = @files.base
|
|
75
|
+
|
|
76
|
+
# While the search, or the view, like a feeling's note, takes what's typed, rather than keys' events.
|
|
77
|
+
def typing? = searching? || view.typing?
|
|
78
|
+
|
|
79
|
+
# While the view wants drawing faster than the ticks, like Code feelings' rain.
|
|
80
|
+
def animating? = view.animating?
|
|
81
|
+
|
|
82
|
+
# What's in a pane: the nav's ListView on the left, or the view on the right, which keeps its own cursor.
|
|
83
|
+
def content(pane) = pane == :nav ? @nav : view
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def hover(hover)
|
|
88
|
+
@hovered = hover.target
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# A typed character goes to the search, or to the view while it's typing.
|
|
93
|
+
def type(event)
|
|
94
|
+
return type_query(query + event.text) if searching?
|
|
95
|
+
|
|
96
|
+
view.next_step(event)&.take(self) if view.typing?
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def route(name)
|
|
100
|
+
return :quit if name == :quit
|
|
101
|
+
return search_event(name) if searching?
|
|
102
|
+
return menu_event(name) if menu
|
|
103
|
+
return send(OPENERS[name]) if OPENERS.key?(name)
|
|
104
|
+
return view.next_step(name)&.take(self) if @focus == :main && view.next_step?(name)
|
|
105
|
+
|
|
106
|
+
browser_event(name)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# A click moves the cursor where it's clicked, and does the click's action there, like a key, unless that would
|
|
110
|
+
# leave the terminal UI, like for the editor, so a stray click only moves the cursor.
|
|
111
|
+
# While the project menu or the search is open, only its items are clicked.
|
|
112
|
+
def click(click)
|
|
113
|
+
return if menu && click.pane != :menu
|
|
114
|
+
return if searching? && click.pane != :search
|
|
115
|
+
|
|
116
|
+
point(click)
|
|
117
|
+
effect = route(click.action)
|
|
118
|
+
effect unless effect.is_a?(Run)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def point(click)
|
|
122
|
+
return @menu = click.index if click.pane == :menu
|
|
123
|
+
return @match = click.index if click.pane == :search
|
|
124
|
+
|
|
125
|
+
content(click.pane).cursor = click.index
|
|
126
|
+
@focus = click.pane
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def browser_event(name)
|
|
130
|
+
return move(MOVES[name]) if MOVES.key?(name)
|
|
131
|
+
return switch(PANES[name] || other_pane) if PANES.key?(name)
|
|
132
|
+
return activate(name) if %i[enter toggle].include?(name)
|
|
133
|
+
|
|
134
|
+
send(ACTIONS[name]) if ACTIONS.key?(name)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# The Dashboard and Code feelings, whose emoji every terminal draws two columns wide, then the files and the
|
|
138
|
+
# endpoints, with a blank line between each section that has rows.
|
|
139
|
+
def nav_rows
|
|
140
|
+
pages = { '📊 Dashboard' => dashboard, '🎭 Feelings' => feelings }.map do |text, view|
|
|
141
|
+
Row.new(text:, depth: 0, kind: :page, target: view, open: OpenView.new(view:))
|
|
142
|
+
end
|
|
143
|
+
[pages, @files.rows, @endpoints.rows].reject(&:empty?).flat_map { [Row::SEPARATOR, *it] }.drop(1)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def selected
|
|
147
|
+
content(@focus).selected
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Enter opens what's selected, or folds it, and Space only folds.
|
|
151
|
+
def activate(name)
|
|
152
|
+
row = selected
|
|
153
|
+
return row.open.take(self) if name == :enter && row&.open
|
|
154
|
+
|
|
155
|
+
row&.fold&.call
|
|
156
|
+
nil
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
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
|