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
|
@@ -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
|
|
@@ -11,27 +11,26 @@ module Raindown
|
|
|
11
11
|
Curses::KEY_UP => :up, 'k' => :up, Curses::KEY_DOWN => :down, 'j' => :down,
|
|
12
12
|
Curses::KEY_LEFT => :left, 'h' => :left, Curses::KEY_RIGHT => :right, 'l' => :right,
|
|
13
13
|
"\t" => :tab, Curses::KEY_BTAB => :tab, "\r" => :enter, "\n" => :enter, Curses::KEY_ENTER => :enter,
|
|
14
|
-
' ' => :toggle, 'x' => :execute, 'e' => :edit, 'w' => :write_back, 'r' => :reload, '
|
|
14
|
+
' ' => :toggle, 'x' => :execute, 'e' => :edit, 'w' => :write_back, 'r' => :reload, 'd' => :dashboard,
|
|
15
|
+
'p' => :projects, "\e" => :escape, 'q' => :quit
|
|
15
16
|
}.freeze
|
|
16
17
|
|
|
17
18
|
module_function
|
|
18
19
|
|
|
19
20
|
# A key from getch, which is a string for a printable character and otherwise a number.
|
|
21
|
+
# Letters are the same with Shift, as the buttons show them as capitals.
|
|
20
22
|
def key_event(key)
|
|
21
23
|
key = key.chr if key.is_a?(Integer) && key < 256
|
|
22
|
-
KEYS[key]
|
|
24
|
+
KEYS[key.is_a?(String) ? key.downcase : key]
|
|
23
25
|
end
|
|
24
26
|
|
|
25
|
-
#
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
end
|
|
27
|
+
# The mouse buttons that click, which are the left button's. Scrolling does nothing, and since older ncurses,
|
|
28
|
+
# like macOS's, has no button for scrolling down, it can't be mistaken for a click.
|
|
29
|
+
CLICKS = Curses::BUTTON1_PRESSED | Curses::BUTTON1_CLICKED
|
|
29
30
|
|
|
30
|
-
#
|
|
31
|
-
def
|
|
32
|
-
|
|
33
|
-
buttons[Curses::BUTTON5_PRESSED] = :down if Curses.const_defined?(:BUTTON5_PRESSED)
|
|
34
|
-
end
|
|
31
|
+
# A click sends the event of what's under it.
|
|
32
|
+
def mouse_event(event, zones)
|
|
33
|
+
click(zones, column: event.x, row: event.y) if event.bstate.anybits?(CLICKS)
|
|
35
34
|
end
|
|
36
35
|
|
|
37
36
|
# Where each of a Layout's segments with an event is on the screen, as [row, columns, event].
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'curses'
|
|
4
|
+
|
|
5
|
+
module Raindown
|
|
6
|
+
module TUI
|
|
7
|
+
# ncurses before 6.1, like macOS's, repeats a character with the terminal's rep capability by writing one byte of it,
|
|
8
|
+
# so a run of █ in a bar chart draws as ?. Terminals with their own terminfo entries, like Ghostty's and kitty's,
|
|
9
|
+
# have rep, while the system's entries from those ncurses versions don't. So with old ncurses, a terminal whose
|
|
10
|
+
# entry isn't the system's starts curses as xterm-256color, which they draw the same, and TERM is put back after,
|
|
11
|
+
# for the commands run from the screen, like the editor.
|
|
12
|
+
module NcursesShim
|
|
13
|
+
FALLBACK = 'xterm-256color'
|
|
14
|
+
SYSTEM_TERMINFO = %w[/usr/share/terminfo /usr/lib/terminfo /lib/terminfo].freeze
|
|
15
|
+
FIXED_VERSION = [6, 1].freeze
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
def starting(version: Curses::VERSION)
|
|
20
|
+
term = ENV.fetch('TERM', nil)
|
|
21
|
+
ENV['TERM'] = FALLBACK if fallback?(term, version)
|
|
22
|
+
yield
|
|
23
|
+
ensure
|
|
24
|
+
ENV['TERM'] = term
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def fallback?(term, version)
|
|
28
|
+
return false if term.nil? || term.empty? || term == FALLBACK
|
|
29
|
+
|
|
30
|
+
old?(version) && !system_entry?(term)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def old?(version)
|
|
34
|
+
numbers = version.to_s[/ncurses (\d+)\.(\d+)/, 0]&.scan(/\d+/)&.map(&:to_i)
|
|
35
|
+
numbers ? (numbers <=> FIXED_VERSION).negative? : false
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Entries are in a folder named by their first letter, or on macOS, its hex code, like 78/xterm-256color.
|
|
39
|
+
def system_entry?(term)
|
|
40
|
+
SYSTEM_TERMINFO.any? do |folder|
|
|
41
|
+
[term[0], term[0].ord.to_s(16)].any? { File.exist?(File.join(folder, it, term)) }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'stringio'
|
|
4
|
+
|
|
5
|
+
module Raindown
|
|
6
|
+
module TUI
|
|
7
|
+
# Keeps text off the screen while it's up, since it would be written over it, and scroll it from the bottom line.
|
|
8
|
+
module Quiet
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# What's written to stderr while the block runs, like a warning, is written once it's done.
|
|
12
|
+
def holding_stderr
|
|
13
|
+
stderr = $stderr
|
|
14
|
+
$stderr = StringIO.new
|
|
15
|
+
yield
|
|
16
|
+
ensure
|
|
17
|
+
held = $stderr.string
|
|
18
|
+
$stderr = stderr
|
|
19
|
+
$stderr.write(held) unless held.empty?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Ruby's warnings are off while the block runs.
|
|
23
|
+
def quietly
|
|
24
|
+
verbose = $VERBOSE
|
|
25
|
+
$VERBOSE = nil
|
|
26
|
+
yield
|
|
27
|
+
ensure
|
|
28
|
+
$VERBOSE = verbose
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
require 'curses'
|
|
4
4
|
|
|
5
5
|
require_relative 'input'
|
|
6
|
-
require_relative 'layout'
|
|
6
|
+
require_relative '../layout'
|
|
7
|
+
require_relative 'quiet'
|
|
8
|
+
require_relative 'ncurses_shim'
|
|
7
9
|
require_relative 'styles'
|
|
8
10
|
|
|
9
11
|
module Raindown
|
|
@@ -20,14 +22,20 @@ module Raindown
|
|
|
20
22
|
end
|
|
21
23
|
|
|
22
24
|
def run
|
|
25
|
+
Quiet.holding_stderr { loop_screen }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def loop_screen
|
|
23
31
|
start
|
|
24
32
|
|
|
25
33
|
loop do
|
|
26
34
|
draw
|
|
27
|
-
effect = @navigator.
|
|
35
|
+
effect = @navigator.next_step(read)
|
|
28
36
|
break if effect == :quit
|
|
29
37
|
|
|
30
|
-
perform(
|
|
38
|
+
perform(effect)
|
|
31
39
|
end
|
|
32
40
|
rescue Interrupt
|
|
33
41
|
nil
|
|
@@ -35,23 +43,18 @@ module Raindown
|
|
|
35
43
|
stop
|
|
36
44
|
end
|
|
37
45
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
@navigator.ran
|
|
45
|
-
when :wait
|
|
46
|
-
message, task = args
|
|
47
|
-
show(message)
|
|
48
|
-
task.call
|
|
46
|
+
# Runs a command, like the editor, then tells the Navigator, or shows a message while a slow task runs.
|
|
47
|
+
def perform(effect)
|
|
48
|
+
case effect
|
|
49
|
+
in Run(command:) then suspend { system(*command) }.then { @navigator.ran }
|
|
50
|
+
in Wait(message:, task:) then show(message).then { task.call }
|
|
51
|
+
else nil
|
|
49
52
|
end
|
|
50
53
|
end
|
|
51
54
|
|
|
52
55
|
def start
|
|
53
56
|
ENV['ESCDELAY'] ||= '25'
|
|
54
|
-
Curses.init_screen
|
|
57
|
+
NcursesShim.starting { Curses.init_screen }
|
|
55
58
|
@styles = Styles.start
|
|
56
59
|
Curses.noecho
|
|
57
60
|
Curses.cbreak
|
|
@@ -59,7 +62,8 @@ module Raindown
|
|
|
59
62
|
Curses.curs_set(0)
|
|
60
63
|
Curses.stdscr.keypad(true)
|
|
61
64
|
Curses.stdscr.timeout = TICK
|
|
62
|
-
|
|
65
|
+
# The terminal sends the app its mouse events, so scrolling doesn't scroll the terminal either.
|
|
66
|
+
Curses.mousemask(Input::CLICKS)
|
|
63
67
|
Curses.mouseinterval(0)
|
|
64
68
|
end
|
|
65
69
|
|
|
@@ -80,9 +84,19 @@ module Raindown
|
|
|
80
84
|
segments.each { |segment| paint(window, segment) }
|
|
81
85
|
end
|
|
82
86
|
|
|
87
|
+
repaint_title(lines.first, window)
|
|
83
88
|
window.refresh
|
|
84
89
|
end
|
|
85
90
|
|
|
91
|
+
# The title's rain cloud is one column to ncurses, and two in terminals like iTerm and kitty, which would put
|
|
92
|
+
# the rest of the title a column out when ncurses changes part of it. So when it changes, the screen is
|
|
93
|
+
# repainted, and the title is written in one go.
|
|
94
|
+
def repaint_title(title, window)
|
|
95
|
+
text = title&.map(&:text)&.join
|
|
96
|
+
window.redraw if @title && text != @title
|
|
97
|
+
@title = text
|
|
98
|
+
end
|
|
99
|
+
|
|
86
100
|
def paint(window, segment)
|
|
87
101
|
window.attron(@styles.fetch(segment.style, Curses::A_NORMAL)) { window.addstr(segment.text) }
|
|
88
102
|
end
|
|
@@ -102,8 +116,9 @@ module Raindown
|
|
|
102
116
|
Input.key_event(key)
|
|
103
117
|
end
|
|
104
118
|
|
|
119
|
+
# The curses gem warns the first time it makes a MouseEvent, that it's "undefining the allocator" of the class.
|
|
105
120
|
def mouse
|
|
106
|
-
event = Curses.getmouse
|
|
121
|
+
event = Quiet.quietly { Curses.getmouse }
|
|
107
122
|
Input.mouse_event(event, @zones) if event
|
|
108
123
|
end
|
|
109
124
|
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'curses'
|
|
4
|
+
|
|
5
|
+
module Raindown
|
|
6
|
+
module TUI
|
|
7
|
+
# The curses attributes of a Layout's styles. The title is white on blue, and the selected row white on a lighter
|
|
8
|
+
# blue, or on dark grey in the panes without the focus, while other colours are on the terminal's own background.
|
|
9
|
+
# With only 8 colours, there's no dark grey, so those rows are underlined, and without colours,
|
|
10
|
+
# the rest are in reverse.
|
|
11
|
+
module Styles
|
|
12
|
+
COLORS = { directive: Curses::COLOR_CYAN, button: Curses::COLOR_YELLOW, error: Curses::COLOR_RED,
|
|
13
|
+
folder: Curses::COLOR_BLUE }.freeze
|
|
14
|
+
# The colours of endpoints' HTTP methods, like GET in green, from the 256 colours most terminals have,
|
|
15
|
+
# which are the same in every theme, unlike the 8 they all do, where green can look yellow.
|
|
16
|
+
METHODS = { get: 40, post: 33, delete: 196, put: 220, patch: 171, head: 44, options: 44 }.freeze
|
|
17
|
+
BASIC_METHODS = { get: Curses::COLOR_GREEN, post: Curses::COLOR_BLUE, delete: Curses::COLOR_RED,
|
|
18
|
+
put: Curses::COLOR_YELLOW, patch: Curses::COLOR_MAGENTA, head: Curses::COLOR_CYAN,
|
|
19
|
+
options: Curses::COLOR_CYAN }.freeze
|
|
20
|
+
# The title's and the selected row's blues, from the 256 colours most terminals have, or the 8 they all do.
|
|
21
|
+
BLUES = { title: 25, selected: 33 }.freeze
|
|
22
|
+
BASIC_BLUES = { title: Curses::COLOR_BLUE, selected: Curses::COLOR_CYAN }.freeze
|
|
23
|
+
BRIGHT_WHITE = 15
|
|
24
|
+
DARK_GREY = 238
|
|
25
|
+
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
# Sets up the colours, once the screen has started, and returns the attribute of each style.
|
|
29
|
+
def start
|
|
30
|
+
return plain unless Curses.has_colors?
|
|
31
|
+
|
|
32
|
+
start_colors
|
|
33
|
+
colored
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def plain
|
|
37
|
+
{ title: Curses::A_REVERSE | Curses::A_BOLD, selected: Curses::A_REVERSE, selected_button: Curses::A_REVERSE,
|
|
38
|
+
cursor: Curses::A_UNDERLINE, cursor_button: Curses::A_UNDERLINE | Curses::A_BOLD, heading: Curses::A_BOLD,
|
|
39
|
+
body: Curses::A_DIM, error: Curses::A_BOLD, button: Curses::A_BOLD, folder: Curses::A_BOLD, project: Curses::A_BOLD, dashboard: Curses::A_BOLD,
|
|
40
|
+
key: Curses::A_REVERSE, border: Curses::A_DIM, run: Curses::A_REVERSE | Curses::A_BOLD, divider: Curses::A_BOLD,
|
|
41
|
+
selected_divider: Curses::A_REVERSE, cursor_divider: Curses::A_BOLD }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def colored
|
|
45
|
+
plain.merge(
|
|
46
|
+
title: pair(:title) | Curses::A_BOLD, selected: pair(:selected) | white_bold,
|
|
47
|
+
selected_button: pair(:selected_button) | Curses::A_BOLD, directive: pair(:directive),
|
|
48
|
+
error: pair(:error) | Curses::A_BOLD, button: pair(:button) | Curses::A_BOLD, divider: pair(:divider),
|
|
49
|
+
selected_divider: pair(:selected_divider), run: pair(:run) | Curses::A_BOLD, **files, **unfocused
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def files
|
|
54
|
+
{ folder: pair(:folder) | Curses::A_BOLD, project: pair(:directive) | Curses::A_BOLD,
|
|
55
|
+
dashboard: pair(:directive) | Curses::A_BOLD, **METHODS.keys.to_h { [:"method_#{it}", pair(:"method_#{it}") | Curses::A_BOLD] } }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The selected rows of the panes without the focus, on dark grey where there's one.
|
|
59
|
+
def unfocused
|
|
60
|
+
return { cursor_button: pair(:button) | Curses::A_BOLD, cursor_divider: pair(:divider) } unless Curses.colors >= 256
|
|
61
|
+
|
|
62
|
+
{ cursor: pair(:cursor), cursor_button: pair(:cursor_button) | Curses::A_BOLD, cursor_divider: pair(:cursor_divider) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Each colour pair as [name, foreground, background], where -1 is the terminal's own background.
|
|
66
|
+
def pairs
|
|
67
|
+
many = Curses.colors >= 256
|
|
68
|
+
blues = many ? BLUES : BASIC_BLUES
|
|
69
|
+
white = many ? BRIGHT_WHITE : Curses::COLOR_WHITE
|
|
70
|
+
|
|
71
|
+
[*COLORS.map { |name, color| [name, color, -1] },
|
|
72
|
+
*(many ? METHODS : BASIC_METHODS).map { |name, color| [:"method_#{name}", color, -1] },
|
|
73
|
+
[:title, white, blues[:title]],
|
|
74
|
+
[:selected, white, blues[:selected]], [:selected_button, Curses::COLOR_YELLOW, blues[:selected]],
|
|
75
|
+
[:cursor, white, DARK_GREY], [:cursor_button, Curses::COLOR_YELLOW, DARK_GREY], [:divider, blues[:title], -1],
|
|
76
|
+
[:selected_divider, blues[:title], blues[:selected]], [:cursor_divider, blues[:title], DARK_GREY],
|
|
77
|
+
[:run, white, Curses::COLOR_GREEN]]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def pair(name)
|
|
81
|
+
Curses.color_pair(pairs.index { it.first == name } + 1)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The 8 colour white is grey, which bold brightens.
|
|
85
|
+
def white_bold
|
|
86
|
+
Curses.colors >= 256 ? Curses::A_NORMAL : Curses::A_BOLD
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def start_colors
|
|
90
|
+
Curses.start_color
|
|
91
|
+
Curses.use_default_colors
|
|
92
|
+
pairs.each.with_index(1) { |(_name, foreground, background), index| Curses.init_pair(index, foreground, background) }
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|