raindown 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/raindown/cli.rb +2 -2
- data/lib/raindown/document.rb +15 -4
- data/lib/raindown/endpoint/curl.rb +15 -0
- data/lib/raindown/projects.rb +8 -4
- data/lib/raindown/tui/card.rb +56 -0
- data/lib/raindown/tui/endpoint.rb +16 -0
- data/lib/raindown/tui/endpoints.rb +40 -0
- data/lib/raindown/tui/events.rb +35 -0
- data/lib/raindown/tui/file_tree.rb +53 -11
- data/lib/raindown/tui/layout.rb +60 -78
- data/lib/raindown/tui/menu_overlay.rb +59 -0
- data/lib/raindown/tui/navigator/commands.rb +84 -0
- data/lib/raindown/tui/navigator/focus.rb +25 -0
- data/lib/raindown/tui/navigator/navigator.rb +127 -0
- data/lib/raindown/tui/navigator/project_menu.rb +61 -0
- data/lib/raindown/tui/navigator/views.rb +49 -0
- data/lib/raindown/tui/renderers/dashboard_renderer.rb +102 -0
- data/lib/raindown/tui/renderers/endpoint_renderer.rb +77 -0
- data/lib/raindown/tui/renderers/list_renderer.rb +15 -0
- data/lib/raindown/tui/renderers/outline_renderer.rb +21 -0
- data/lib/raindown/tui/renderers/renderer.rb +15 -0
- data/lib/raindown/tui/row.rb +10 -3
- data/lib/raindown/tui/rows.rb +57 -0
- data/lib/raindown/tui/{input.rb → terminal/input.rb} +10 -11
- data/lib/raindown/tui/terminal/ncurses_shim.rb +46 -0
- data/lib/raindown/tui/terminal/quiet.rb +32 -0
- data/lib/raindown/tui/{screen.rb → terminal/screen.rb} +32 -17
- data/lib/raindown/tui/terminal/styles.rb +96 -0
- data/lib/raindown/tui/text.rb +83 -0
- data/lib/raindown/tui/title.rb +29 -0
- data/lib/raindown/tui/views/dashboard_view.rb +145 -0
- data/lib/raindown/tui/views/endpoint_view.rb +75 -0
- data/lib/raindown/tui/{pane.rb → views/list_view.rb} +9 -8
- data/lib/raindown/tui/{outline.rb → views/outline_view.rb} +39 -20
- data/lib/raindown/tui/workers/project.rb +122 -0
- data/lib/raindown/tui/workers/worker.rb +124 -0
- data/lib/raindown/tui/workers/workers.rb +53 -0
- data/lib/raindown/tui.rb +8 -5
- data/lib/version.rb +1 -1
- metadata +32 -9
- data/lib/raindown/tui/commands.rb +0 -70
- data/lib/raindown/tui/navigator.rb +0 -123
- data/lib/raindown/tui/styles.rb +0 -36
|
@@ -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
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'renderer'
|
|
4
|
+
|
|
5
|
+
module Raindown
|
|
6
|
+
module TUI
|
|
7
|
+
# The dashboard, right of the files: a card for each of the projects' charts, as wide as its chart,
|
|
8
|
+
# with as many in a row as fit. With more than one project, each project's charts are under its path.
|
|
9
|
+
# A card has its file and heading, and its directive, both cut to the chart's width, then the chart,
|
|
10
|
+
# in a border with rounded corners:
|
|
11
|
+
# ╭─────────────────────────────╮ ╭────────────────────────────────╮
|
|
12
|
+
# │ raindown.rd › Charts │ │ raindown.rd › Commits │
|
|
13
|
+
# │ chart: 'data/downloads.csv' │ │ chart: 'data/commits.csv' min… │
|
|
14
|
+
# │ Jun ██████▊ 640 │ │ c2f98fc -18 █│███▊ +96 │
|
|
15
|
+
# ╰─────────────────────────────╯ ╰────────────────────────────────╯
|
|
16
|
+
class DashboardRenderer < Renderer
|
|
17
|
+
BUTTONS = [['Tab', 'Pane', :tab], ['Enter', 'Open', :enter], ['e', 'Edit', :edit], ['r', 'Reload', :reload],
|
|
18
|
+
['q', 'Quit', :quit]].freeze
|
|
19
|
+
PROJECTS_BUTTON = ['p', 'Projects', :projects].freeze
|
|
20
|
+
|
|
21
|
+
# Room for the project menu's button, when there's more than one project.
|
|
22
|
+
def buttons
|
|
23
|
+
if navigator.projects? then BUTTONS.dup.insert(3, PROJECTS_BUTTON)
|
|
24
|
+
else BUTTONS
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def page = 'Dashboard'
|
|
29
|
+
|
|
30
|
+
def lines
|
|
31
|
+
cards = dashboard.cards
|
|
32
|
+
return blank(height, " No charts in #{File.basename(navigator.root)} yet, like <{ chart: '3 5 2 8' }>") if cards.empty?
|
|
33
|
+
|
|
34
|
+
rows = dashboard.rows(width)
|
|
35
|
+
@headings = headings(rows, cards)
|
|
36
|
+
grid(rows, cards)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def dashboard = navigator.dashboard
|
|
42
|
+
|
|
43
|
+
# Each project's path above its first row of cards, by the row's index, when there's more than one project.
|
|
44
|
+
def headings(rows, cards)
|
|
45
|
+
firsts = rows.each_index.chunk_while { |a, b| cards[rows[a].first].project == cards[rows[b].first].project }.map(&:first)
|
|
46
|
+
return {} if firsts.one?
|
|
47
|
+
|
|
48
|
+
firsts.to_h { [it, cards[rows[it].first].project] }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The rows of cards from the first that's scrolled to, each card's lines side by side, with a line between rows.
|
|
52
|
+
def grid(rows, cards)
|
|
53
|
+
heights = rows.each_with_index.map { |row, index| row_height(row, cards) + (@headings.key?(index) ? 1 : 0) }
|
|
54
|
+
first = dashboard.scroll(heights, height)
|
|
55
|
+
lines = (first...rows.length).flat_map { |index| row_lines(rows[index], cards, heading: @headings[index]) }
|
|
56
|
+
|
|
57
|
+
(lines + blank(height)).first(height)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The tallest card's lines, in its border, and one line below it.
|
|
61
|
+
def row_height(row, cards)
|
|
62
|
+
row.map { cards[it].box_height }.max + 1
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def row_line(row, cards, line)
|
|
66
|
+
segments = [segment('', :blank, width: 1)]
|
|
67
|
+
|
|
68
|
+
row.each_with_index do |index, column|
|
|
69
|
+
segments << segment('', :blank, width: DashboardView::GAP) unless column.zero?
|
|
70
|
+
segments.concat(card_line(cards[index], index, line))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
padded(segments)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# A card opens chart source when it's clicked, like Enter, but not the space below a card shorter than its row.
|
|
77
|
+
# The selected card's title is highlighted, like a selected row.
|
|
78
|
+
def card_line(card, index, line)
|
|
79
|
+
title_style = dashboard.cursor == index ? row_style(pane, nil, true) : :heading
|
|
80
|
+
event = Click.new(pane:, index:, action: :enter) if line < card.box_height
|
|
81
|
+
box_width = card.box_width(within: dashboard.card_widths(width))
|
|
82
|
+
card.format_line(line, box_width:, title_style:).map do |text, style, part_width|
|
|
83
|
+
segment(text, style, width: part_width, event:)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# A row of cards' lines, under its project's heading when it's the project's first row.
|
|
88
|
+
def row_lines(row, cards, heading:)
|
|
89
|
+
[*([heading_line(heading)] if heading), *Array.new(row_height(row, cards)) { |line| row_line(row, cards, line) }]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# A project's path from the home folder, above its charts.
|
|
93
|
+
def heading_line(project)
|
|
94
|
+
[segment(" #{from_home(project)}", :project)]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def blank(count, message = '')
|
|
98
|
+
[[segment(message, :info)], *Array.new([count - 1, 0].max) { [segment('', :blank)] }].first(count)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'renderer'
|
|
4
|
+
require_relative 'outline_renderer'
|
|
5
|
+
|
|
6
|
+
module Raindown
|
|
7
|
+
module TUI
|
|
8
|
+
# An endpoint opened from the files, right of them: its URL in a bar with rounded corners, and a button to run it,
|
|
9
|
+
# then its response:
|
|
10
|
+
# ╭──────────────────────────────────────────╮
|
|
11
|
+
# │ GET https://httpbin.org/uuid [ Run ] │
|
|
12
|
+
# ╰──────────────────────────────────────────╯
|
|
13
|
+
#
|
|
14
|
+
# Response · json · fetched 2026-10-26
|
|
15
|
+
# {
|
|
16
|
+
# "uuid": "48024974-f1b1-431a-a0cf-5cac0099a1a5"
|
|
17
|
+
# }
|
|
18
|
+
class EndpointRenderer < Renderer
|
|
19
|
+
RUN = '[ Run ]'
|
|
20
|
+
RUN_WIDTH = RUN.length
|
|
21
|
+
BUTTONS = OutlineRenderer::BUTTONS.reject { it.last == :write_back }.freeze
|
|
22
|
+
|
|
23
|
+
def buttons = BUTTONS
|
|
24
|
+
def page = navigator.view.endpoint.text
|
|
25
|
+
|
|
26
|
+
def lines
|
|
27
|
+
view = navigator.view
|
|
28
|
+
top = [*url_bar(view.endpoint), [segment('', :blank, width:)], about(view)]
|
|
29
|
+
|
|
30
|
+
(top + response(view, height - top.length)).first(height)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# A click on the response moves the focus to it, to scroll it.
|
|
36
|
+
def focus = Click.new(pane:, index: 0, action: :focus)
|
|
37
|
+
|
|
38
|
+
def response(view, lines)
|
|
39
|
+
texts = view.visible([lines, 0].max)
|
|
40
|
+
Array.new([lines, 0].max) { [segment(texts[it] ? " #{texts[it]}" : '', :response, width:, event: focus)] }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The bar is a column in from each side of the pane, with the URL and the button inside its border.
|
|
44
|
+
def url_bar(endpoint)
|
|
45
|
+
inner = [width - 6, 0].max
|
|
46
|
+
[edge('╭', '╮', inner), padded([margin, segment('│ ', :border, width: 2), *request(endpoint, inner)]),
|
|
47
|
+
edge('╰', '╯', inner)]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The method in its colour, then the URL, cut to what the method and button leave, then the button,
|
|
51
|
+
# and the border's side.
|
|
52
|
+
def request(endpoint, inner)
|
|
53
|
+
method_width = display_width(endpoint.method)
|
|
54
|
+
url_width = [inner - method_width - RUN_WIDTH - 1, 0].max
|
|
55
|
+
|
|
56
|
+
[segment(endpoint.method, endpoint.style, width: method_width), segment(" #{endpoint.url} ", :url, width: url_width + 1),
|
|
57
|
+
run_button, segment(' │', :border, width: 2)]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def run_button = segment(RUN, :run, width: RUN_WIDTH, event: Click.new(pane:, index: 0, action: :execute))
|
|
61
|
+
|
|
62
|
+
def edge(left, right, inner)
|
|
63
|
+
padded([margin, segment("#{left}#{'─' * (inner + 2)}#{right}", :border, width: inner + 4)])
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def margin = segment('', :blank, width: 1)
|
|
67
|
+
|
|
68
|
+
def about(view)
|
|
69
|
+
text = if view.error then view.error
|
|
70
|
+
elsif view.response.empty? then 'No response yet. X runs the request.'
|
|
71
|
+
else ['Response', view.language, ("fetched #{view.fetched}" if view.fetched)].reject { it.to_s.empty? }.join(' · ')
|
|
72
|
+
end
|
|
73
|
+
[segment(" #{text}", view.error ? :error : :heading, width:)]
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'renderer'
|
|
4
|
+
|
|
5
|
+
module Raindown
|
|
6
|
+
module TUI
|
|
7
|
+
# A ListView's rows in its pane, like the files in the nav or a file's outline, each with its buttons.
|
|
8
|
+
class ListRenderer < Renderer
|
|
9
|
+
def lines
|
|
10
|
+
list = navigator.content(pane).visible(height)
|
|
11
|
+
Array.new(height) { |index| cell(pane, *list, index, width) }
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'list_renderer'
|
|
4
|
+
|
|
5
|
+
module Raindown
|
|
6
|
+
module TUI
|
|
7
|
+
# The open file's outline, right of the files: a row on each line, with its buttons.
|
|
8
|
+
class OutlineRenderer < ListRenderer
|
|
9
|
+
BUTTONS = [['Tab', 'Pane', :tab], ['Enter', 'Open', :enter], ['x', 'Run', :execute], ['e', 'Edit', :edit],
|
|
10
|
+
['w', 'Write back', :write_back], ['r', 'Reload', :reload], ['q', 'Quit', :quit]].freeze
|
|
11
|
+
|
|
12
|
+
def buttons = BUTTONS
|
|
13
|
+
|
|
14
|
+
def page
|
|
15
|
+
if navigator.path then navigator.relative(navigator.path)
|
|
16
|
+
else 'No Markdown files'
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../events'
|
|
4
|
+
require_relative '../rows'
|
|
5
|
+
require_relative '../text'
|
|
6
|
+
|
|
7
|
+
module Raindown
|
|
8
|
+
module TUI
|
|
9
|
+
# Draws what's in a pane, :nav or :main, as lines of the pane's width and height, which the Layout puts in it.
|
|
10
|
+
Renderer = Struct.new(:navigator, :pane, :width, :height) do
|
|
11
|
+
include Rows
|
|
12
|
+
include Text
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
data/lib/raindown/tui/row.rb
CHANGED
|
@@ -3,14 +3,21 @@
|
|
|
3
3
|
module Raindown
|
|
4
4
|
module TUI
|
|
5
5
|
# A line of a pane. Target is what it's about: a folder or file path, a Heading or Directive, or a line number.
|
|
6
|
-
# Buttons are [label, event] pairs, like ['[Run]', :execute].
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
# Buttons are [label, event] pairs, like ['[Run]', :execute], and a tag is an Endpoint, whose method is before its text.
|
|
7
|
+
# Open is the Navigator's next step on Enter, like an Open of a file, and fold, a block that folds or expands the row.
|
|
8
|
+
Row = Data.define(:text, :depth, :kind, :target, :buttons, :tag, :open, :fold) do
|
|
9
|
+
def initialize(buttons: [], tag: nil, open: nil, fold: nil, **) = super
|
|
10
|
+
|
|
11
|
+
# A separator is space between a pane's sections, which the cursor skips and clicks don't reach.
|
|
12
|
+
def selectable? = kind != :separator
|
|
9
13
|
|
|
10
14
|
# The line the editor opens at for the row.
|
|
11
15
|
def line
|
|
12
16
|
target.is_a?(Integer) ? target : target.line
|
|
13
17
|
end
|
|
14
18
|
end
|
|
19
|
+
|
|
20
|
+
# Space between a pane's sections, like the Dashboard and the files.
|
|
21
|
+
Row::SEPARATOR = Row.new(text: '', depth: 0, kind: :separator, target: nil)
|
|
15
22
|
end
|
|
16
23
|
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Raindown
|
|
4
|
+
module TUI
|
|
5
|
+
# A ListRenderer's rows, like the files and the outline, each a line of the pane's width: a row's text, after its tag,
|
|
6
|
+
# like an endpoint's method, then its buttons.
|
|
7
|
+
module Rows
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
# A row as its text, then its buttons, or blank below its last row.
|
|
11
|
+
def cell(pane, rows, offset, index, cell_width)
|
|
12
|
+
row = rows[index]
|
|
13
|
+
return [segment('', :blank, width: cell_width)] unless row&.selectable?
|
|
14
|
+
|
|
15
|
+
index += offset
|
|
16
|
+
selected = navigator.content(pane).cursor == index
|
|
17
|
+
buttons = row_buttons(row, pane:, index:, selected:, cell_width:)
|
|
18
|
+
text_width = cell_width - display_width(buttons.map(&:text).join)
|
|
19
|
+
parts = row_parts(row, row_style(pane, row, selected), selected)
|
|
20
|
+
|
|
21
|
+
[*spread_segments(parts, text_width, event: Click.new(pane:, index:, action: :enter)), *buttons]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# A line's segments, then blank to the edge.
|
|
25
|
+
def padded(segments) = [*segments, segment('', :blank, width: [width - segments.sum { display_width(it.text) }, 0].max)]
|
|
26
|
+
|
|
27
|
+
# [text, style] parts side by side in the width, each a segment.
|
|
28
|
+
def spread_segments(parts, width, event: nil)
|
|
29
|
+
parts.zip(spread(parts.map(&:first), width)).map { |(text, style), part| segment(text, style, width: part, event:) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The row's text, after its tag in the tag's style, unless the row's selected.
|
|
33
|
+
def row_parts(row, style, selected)
|
|
34
|
+
lead = " #{Text::INDENT * row.depth}"
|
|
35
|
+
return [["#{lead}#{row.text}", style]] unless row.tag
|
|
36
|
+
|
|
37
|
+
[["#{lead}#{row.tag.method} ", selected ? style : row.tag.style], [row.text, style]]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The row's buttons, right of its text, unless they'd take more than half the pane.
|
|
41
|
+
def row_buttons(row, pane:, index:, selected:, cell_width:)
|
|
42
|
+
buttons = row.buttons.map do |label, event|
|
|
43
|
+
style = selected ? :"#{row_style(pane, row, true)}_button" : :button
|
|
44
|
+
segment(" #{label}", style, width: display_width(" #{label}"), event: Click.new(pane:, index:, action: event))
|
|
45
|
+
end
|
|
46
|
+
display_width(buttons.map(&:text).join) > cell_width / 2 ? [] : buttons
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The cursor's row is highlighted in the pane with the focus, and dimmer in the other, like the file a chart opened.
|
|
50
|
+
def row_style(pane, row, selected)
|
|
51
|
+
return row.kind unless selected
|
|
52
|
+
|
|
53
|
+
navigator.focus == pane ? :selected : :cursor
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|