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
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../events'
|
|
4
|
+
require_relative '../views/endpoint_view'
|
|
5
|
+
require_relative '../workers/worker'
|
|
6
|
+
|
|
7
|
+
module Raindown
|
|
8
|
+
module TUI
|
|
9
|
+
# Teleport: "/" opens a search of the Dashboard, Code feelings, every file, heading and directive in the files, and every endpoint,
|
|
10
|
+
# matched as it's typed by letters in order, so "stp" finds "## Setup". Up and down move between the matches,
|
|
11
|
+
# Enter or a click opens the one selected, and Esc closes it. Other keys and clicks do nothing while it's open.
|
|
12
|
+
module Search
|
|
13
|
+
# The most matches it shows.
|
|
14
|
+
LIMIT = 10
|
|
15
|
+
|
|
16
|
+
# A place to teleport to: its text, where it is, like "guides/setup.md:12", and the step that opens it.
|
|
17
|
+
Place = Data.define(:text, :where, :open) do
|
|
18
|
+
# How well the query matches, or nil when it doesn't: in the text alone, which ranks above a match that needs
|
|
19
|
+
# where it is too, like "setup notes" for "## Setup" in notes.md.
|
|
20
|
+
def score(query)
|
|
21
|
+
in_text = letters_score(text, query)
|
|
22
|
+
return in_text + query.length if in_text
|
|
23
|
+
|
|
24
|
+
letters_score("#{text} #{where}", query)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
# How well the query's letters appear in order in the text, ignoring case: higher for runs of them and for
|
|
30
|
+
# the starts of words, or nil when they don't all appear.
|
|
31
|
+
def letters_score(text, query)
|
|
32
|
+
haystack = text.downcase
|
|
33
|
+
last = -1
|
|
34
|
+
score = 0
|
|
35
|
+
query.downcase.each_char do |char|
|
|
36
|
+
index = haystack.index(char, last + 1)
|
|
37
|
+
return nil unless index
|
|
38
|
+
|
|
39
|
+
score += 1
|
|
40
|
+
score += 2 if index == last + 1
|
|
41
|
+
score += 2 if index.zero? || haystack[index - 1].match?(%r{[\s/#:._-]})
|
|
42
|
+
last = index
|
|
43
|
+
end
|
|
44
|
+
score
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# What's typed, while the search is open, and the selected match.
|
|
49
|
+
attr_reader :query, :match
|
|
50
|
+
|
|
51
|
+
def searching? = !query.nil?
|
|
52
|
+
|
|
53
|
+
# The places that match what's typed, best first, then in the order they're in the files.
|
|
54
|
+
def matches
|
|
55
|
+
@places.each_with_index.filter_map { |place, index| (score = place.score(query)) && [-score, index, place] }
|
|
56
|
+
.min(LIMIT).map(&:last)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def open_search
|
|
62
|
+
@places = places
|
|
63
|
+
@match = 0
|
|
64
|
+
@query = ''
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def search_event(name)
|
|
69
|
+
case name
|
|
70
|
+
when :up, :down then move_match(name == :up ? -1 : 1)
|
|
71
|
+
when :enter then teleport
|
|
72
|
+
when :escape then close_search
|
|
73
|
+
when :backspace then type_query(query[0...-1])
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def type_query(query)
|
|
78
|
+
@query = query
|
|
79
|
+
@match = 0
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def move_match(by)
|
|
84
|
+
@match = (@match + by).clamp(0, [matches.length - 1, 0].max)
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def teleport
|
|
89
|
+
place = matches[@match]
|
|
90
|
+
close_search
|
|
91
|
+
place&.open&.take(self)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def close_search
|
|
95
|
+
@query = nil
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def places
|
|
100
|
+
[Place.new(text: 'Dashboard', where: '', open: OpenView.new(view: dashboard)),
|
|
101
|
+
Place.new(text: 'Code feelings', where: '', open: OpenView.new(view: feelings)),
|
|
102
|
+
*@files.files.flat_map { file_places(it) },
|
|
103
|
+
*@endpoints.all.map { Place.new(text: it.text, where: "#{relative(it.path)}:#{it.line}", open: open_endpoint(it)) }]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# The file, then its headings and directives, in the order they're in it.
|
|
107
|
+
def file_places(path)
|
|
108
|
+
outline = @workers.for(path).call(:outline, path)
|
|
109
|
+
where = relative(path)
|
|
110
|
+
items = (outline.headings + outline.directives).sort_by(&:line)
|
|
111
|
+
[Place.new(text: where, where: '', open: Open.new(path:, line: nil)),
|
|
112
|
+
*items.map { Place.new(text: item_text(it), where: "#{where}:#{it.line}", open: Open.new(path:, line: it.line)) }]
|
|
113
|
+
rescue Worker::Error
|
|
114
|
+
[]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def item_text(item)
|
|
118
|
+
return "#{'#' * item.level} #{item.text}" if item.is_a?(Document::Heading)
|
|
119
|
+
|
|
120
|
+
"#{item.name}: #{item.args}".strip
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def open_endpoint(endpoint) = OpenView.new(view: EndpointView.new(endpoint, workers: @workers))
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../views/dashboard_view'
|
|
4
|
+
require_relative '../views/endpoint_view'
|
|
5
|
+
require_relative '../feelings/feelings_view'
|
|
6
|
+
require_relative '../views/outline_view'
|
|
7
|
+
|
|
8
|
+
module Raindown
|
|
9
|
+
module TUI
|
|
10
|
+
# Beside the files, a Navigator shows a view: the open file's Outline, the Dashboard of the project's charts,
|
|
11
|
+
# which is the first item in the files and "d" switches to, or an EndpointView of an endpoint listed below them.
|
|
12
|
+
# A view has the pane that draws it, a next step for the keys it takes while it has the focus, and a target,
|
|
13
|
+
# what's opened it in the files. It loads when it's opened, and again after a change.
|
|
14
|
+
module Views
|
|
15
|
+
attr_reader :view, :dashboard, :feelings
|
|
16
|
+
|
|
17
|
+
# The path of the file the view shows, if any.
|
|
18
|
+
def path = view.path
|
|
19
|
+
|
|
20
|
+
# Shows the view right of the files, with the focus, and the files' cursor on the row that opened it.
|
|
21
|
+
def open_view(view)
|
|
22
|
+
@previous = @view unless view == @view
|
|
23
|
+
@view = view
|
|
24
|
+
@focus = :main
|
|
25
|
+
@nav.cursor = @nav.rows.index { it.target == view.target } || 0
|
|
26
|
+
view.load
|
|
27
|
+
@status = view.status(self)
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Opens the file in the outline, on the heading or directive at the line.
|
|
32
|
+
def open_at(path, line)
|
|
33
|
+
path = File.expand_path(path)
|
|
34
|
+
@files.reveal(path)
|
|
35
|
+
open_view(OutlineView.new(path, workers: @workers))
|
|
36
|
+
view.cursor = view.rows.index { %i[heading directive].include?(it.kind) && it.target.line == line } || 0
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def open_feelings = open_view(feelings)
|
|
43
|
+
|
|
44
|
+
# Switches between the dashboard and the view before it.
|
|
45
|
+
def toggle_dashboard
|
|
46
|
+
return open_view(@previous) if view == dashboard && @previous
|
|
47
|
+
|
|
48
|
+
open_view(dashboard)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
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
|
+
['/', 'Search', :search], ['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
|
+
return BUTTONS unless navigator.projects?
|
|
24
|
+
|
|
25
|
+
BUTTONS.dup.insert(3, PROJECTS_BUTTON)
|
|
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,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../highlighter'
|
|
4
|
+
require_relative 'renderer'
|
|
5
|
+
require_relative 'outline_renderer'
|
|
6
|
+
|
|
7
|
+
module Raindown
|
|
8
|
+
module TUI
|
|
9
|
+
# An endpoint opened from the files, right of them: its URL in a bar with rounded corners, and a button to run it,
|
|
10
|
+
# then its response:
|
|
11
|
+
# ╭──────────────────────────────────────────╮
|
|
12
|
+
# │ GET https://httpbin.org/uuid [Run] │
|
|
13
|
+
# ╰──────────────────────────────────────────╯
|
|
14
|
+
#
|
|
15
|
+
# Response · json · fetched 2026-10-26
|
|
16
|
+
# {
|
|
17
|
+
# "uuid": "48024974-f1b1-431a-a0cf-5cac0099a1a5"
|
|
18
|
+
# }
|
|
19
|
+
class EndpointRenderer < Renderer
|
|
20
|
+
RUN = '[Run]'
|
|
21
|
+
RUN_WIDTH = RUN.length
|
|
22
|
+
BUTTONS = OutlineRenderer::BUTTONS.reject { it.last == :write_back }.freeze
|
|
23
|
+
|
|
24
|
+
def buttons = BUTTONS
|
|
25
|
+
def page = navigator.view.endpoint.text
|
|
26
|
+
|
|
27
|
+
def lines
|
|
28
|
+
view = navigator.view
|
|
29
|
+
top = [*url_bar(view.endpoint), [segment('', :blank, width:)], about(view)]
|
|
30
|
+
|
|
31
|
+
(top + response(view, height - top.length)).first(height)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
# A click on the response moves the focus to it, to scroll it.
|
|
37
|
+
def focus = Click.new(pane:, index: 0, action: :focus)
|
|
38
|
+
|
|
39
|
+
# The response's lines that fit, highlighted in the language of its code block, like json.
|
|
40
|
+
def response(view, lines)
|
|
41
|
+
code = Highlighter.lines(view.visible([lines, 0].max), language: view.language)
|
|
42
|
+
Array.new([lines, 0].max) do |line|
|
|
43
|
+
[segment(' ', :response, width: 1, event: focus), *code_segments(code[line] || [], width - 1, event: focus)]
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The bar is a column in from each side of the pane, with the URL and the button inside its border.
|
|
48
|
+
def url_bar(endpoint)
|
|
49
|
+
inner = [width - 6, 0].max
|
|
50
|
+
[edge('╭', '╮', inner), padded([margin, segment('│ ', :border, width: 2), *request(endpoint, inner)]),
|
|
51
|
+
edge('╰', '╯', inner)]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The method in its colour, then the URL, cut to what the method and button leave, then the button,
|
|
55
|
+
# and the border's side.
|
|
56
|
+
def request(endpoint, inner)
|
|
57
|
+
method_width = display_width(endpoint.method)
|
|
58
|
+
url_width = [inner - method_width - RUN_WIDTH - 1, 0].max
|
|
59
|
+
|
|
60
|
+
[segment(endpoint.method, endpoint.style, width: method_width), segment(" #{endpoint.url} ", :url, width: url_width + 1),
|
|
61
|
+
run_button, segment(' │', :border, width: 2)]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def run_button = segment(RUN, :button, width: RUN_WIDTH, event: Click.new(pane:, index: 0, action: :execute))
|
|
65
|
+
|
|
66
|
+
def edge(left, right, inner)
|
|
67
|
+
padded([margin, segment("#{left}#{'─' * (inner + 2)}#{right}", :border, width: inner + 4)])
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def margin = segment('', :blank, width: 1)
|
|
71
|
+
|
|
72
|
+
def about(view)
|
|
73
|
+
return [segment(" #{view.error}", :error, width:)] if view.error
|
|
74
|
+
return [segment(' No response yet. X runs the request.', :heading, width:)] if view.response.empty?
|
|
75
|
+
|
|
76
|
+
details = ['Response', view.language, ("fetched #{view.fetched}" if view.fetched)].reject { it.to_s.empty? }
|
|
77
|
+
[segment(" #{details.join(' · ')}", :heading, width:)]
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
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], ['/', 'Search', :search], ['q', 'Quit', :quit]].freeze
|
|
11
|
+
|
|
12
|
+
def buttons = BUTTONS
|
|
13
|
+
|
|
14
|
+
def page
|
|
15
|
+
return 'No Markdown files' unless navigator.path
|
|
16
|
+
|
|
17
|
+
navigator.relative(navigator.path)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
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
|
+
|
|
14
|
+
# Its side of the line above the panes, which is blank unless it has something to say there, like a progress.
|
|
15
|
+
def spacer = [segment('', :blank)]
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
data/lib/raindown/tui/row.rb
CHANGED
|
@@ -3,14 +3,23 @@
|
|
|
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
|
+
# Code is the text highlighted, as [text, style] parts, for a line of code, like a directive's body.
|
|
9
|
+
# Each optional field has a default, which is why initialize takes so many.
|
|
10
|
+
Row = Data.define(:text, :depth, :kind, :target, :buttons, :tag, :open, :fold, :code) do
|
|
11
|
+
def initialize(buttons: [], tag: nil, open: nil, fold: nil, code: nil, **) = super # rubocop:disable Metrics/ParameterLists
|
|
12
|
+
|
|
13
|
+
# A separator is space between a pane's sections, which the cursor skips and clicks don't reach.
|
|
14
|
+
def selectable? = kind != :separator
|
|
9
15
|
|
|
10
16
|
# The line the editor opens at for the row.
|
|
11
17
|
def line
|
|
12
18
|
target.is_a?(Integer) ? target : target.line
|
|
13
19
|
end
|
|
14
20
|
end
|
|
21
|
+
|
|
22
|
+
# Space between a pane's sections, like the Dashboard and the files.
|
|
23
|
+
Row::SEPARATOR = Row.new(text: '', depth: 0, kind: :separator, target: nil)
|
|
15
24
|
end
|
|
16
25
|
end
|
|
@@ -0,0 +1,58 @@
|
|
|
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, or highlighted when it's code, unless the row's selected.
|
|
33
|
+
def row_parts(row, style, selected)
|
|
34
|
+
lead = " #{Text::INDENT * row.depth}"
|
|
35
|
+
return [[lead, style], *row.code] if row.code && !selected
|
|
36
|
+
return [["#{lead}#{row.text}", style]] unless row.tag
|
|
37
|
+
|
|
38
|
+
[["#{lead}#{row.tag.method} ", selected ? style : row.tag.style], [row.text, style]]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The row's buttons, right of its text, unless they'd take more than half the pane.
|
|
42
|
+
def row_buttons(row, pane:, index:, selected:, cell_width:)
|
|
43
|
+
buttons = row.buttons.map do |label, event|
|
|
44
|
+
style = selected ? :"#{row_style(pane, row, true)}_button" : :button
|
|
45
|
+
segment(" #{label}", style, width: display_width(" #{label}"), event: Click.new(pane:, index:, action: event))
|
|
46
|
+
end
|
|
47
|
+
display_width(buttons.map(&:text).join) > cell_width / 2 ? [] : buttons
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The cursor's row is highlighted in the pane with the focus, and dimmer in the other, like the file a chart opened.
|
|
51
|
+
def row_style(pane, row, selected)
|
|
52
|
+
return row.kind unless selected
|
|
53
|
+
|
|
54
|
+
navigator.focus == pane ? :selected : :cursor
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'events'
|
|
4
|
+
require_relative 'text'
|
|
5
|
+
|
|
6
|
+
module Raindown
|
|
7
|
+
module TUI
|
|
8
|
+
# The search, drawn over the screen's lines in a box below the title: what's typed, then the places that match it,
|
|
9
|
+
# each with where it is, and the selected one highlighted:
|
|
10
|
+
# ┌─ Teleport ───────────────────────────────┐
|
|
11
|
+
# │ / stp▏ │
|
|
12
|
+
# │ ## Setup guides/setup.md:3 │
|
|
13
|
+
# │ ## Steps notes.md:12 │
|
|
14
|
+
# └──────────────────────────────────────────┘
|
|
15
|
+
# Each place is clicked to open it.
|
|
16
|
+
class SearchOverlay
|
|
17
|
+
include Text
|
|
18
|
+
|
|
19
|
+
# The widest the box is, with its border.
|
|
20
|
+
MAX_WIDTH = 72
|
|
21
|
+
|
|
22
|
+
def initialize(navigator, width:)
|
|
23
|
+
@navigator = navigator
|
|
24
|
+
@box_width = [width - 4, MAX_WIDTH].min
|
|
25
|
+
@column = (width - @box_width) / 2
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The lines with the box drawn over them, from the line below the title.
|
|
29
|
+
def apply(lines)
|
|
30
|
+
box.each_with_index do |segments, offset|
|
|
31
|
+
row = offset + 1
|
|
32
|
+
lines[row] = overlay_segments(lines[row], segments, column: @column) if lines[row]
|
|
33
|
+
end
|
|
34
|
+
lines
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
# The columns inside the border, "│ " and " │".
|
|
40
|
+
def inner = @box_width - 4
|
|
41
|
+
|
|
42
|
+
def box
|
|
43
|
+
[edge('┌─ Teleport ', '┐'), boxed(segment("/ #{@navigator.query}▏", :menu, width: inner)), *results, edge('└', '┘')]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def results
|
|
47
|
+
matches = @navigator.matches
|
|
48
|
+
return [boxed(segment('No matches', :info, width: inner))] if matches.empty?
|
|
49
|
+
|
|
50
|
+
matches.each_with_index.map { |place, index| place_line(place, index) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The place's text, then where it is, on the right, both highlighted when it's selected.
|
|
54
|
+
def place_line(place, index)
|
|
55
|
+
text_style = :menu
|
|
56
|
+
where_style = :body
|
|
57
|
+
text_style = where_style = :selected if @navigator.match == index
|
|
58
|
+
event = Click.new(pane: :search, index:, action: :enter)
|
|
59
|
+
where = " #{place.where}"
|
|
60
|
+
where_width = [display_width(where), inner / 2].min
|
|
61
|
+
|
|
62
|
+
boxed(segment(place.text, text_style, width: inner - where_width, event:),
|
|
63
|
+
segment(where, where_style, width: where_width, event:))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def boxed(*segments) = [segment('│ ', :border, width: 2), *segments, segment(' │', :border, width: 2)]
|
|
67
|
+
|
|
68
|
+
def edge(left, right)
|
|
69
|
+
[segment("#{left}#{'─' * (@box_width - display_width(left) - 1)}#{right}", :border, width: @box_width)]
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'curses'
|
|
4
|
+
require 'unicode/display_width'
|
|
5
|
+
|
|
6
|
+
require_relative '../events'
|
|
7
|
+
|
|
8
|
+
module Raindown
|
|
9
|
+
module TUI
|
|
10
|
+
# Turns curses' keys and clicks into a Navigator's events.
|
|
11
|
+
module Input
|
|
12
|
+
KEYS = {
|
|
13
|
+
Curses::KEY_UP => :up, 'k' => :up, Curses::KEY_DOWN => :down, 'j' => :down,
|
|
14
|
+
Curses::KEY_LEFT => :left, 'h' => :left, Curses::KEY_RIGHT => :right, 'l' => :right,
|
|
15
|
+
"\t" => :tab, Curses::KEY_BTAB => :tab, "\r" => :enter, "\n" => :enter, Curses::KEY_ENTER => :enter,
|
|
16
|
+
' ' => :toggle, 'x' => :execute, 'e' => :edit, 'w' => :write_back, 'r' => :reload, 'd' => :dashboard,
|
|
17
|
+
'p' => :projects, 'f' => :feelings, "\x14" => :celebrate, '/' => :search,
|
|
18
|
+
'1' => :bin1, '2' => :bin2, '3' => :bin3, '4' => :bin4, '5' => :bin5, '6' => :bin6, "\e" => :escape, 'q' => :quit
|
|
19
|
+
}.freeze
|
|
20
|
+
# While typing, like in the search, printable characters are typed, and only these keys do anything else.
|
|
21
|
+
TYPING_KEYS = {
|
|
22
|
+
Curses::KEY_UP => :up, Curses::KEY_DOWN => :down, "\r" => :enter, "\n" => :enter, Curses::KEY_ENTER => :enter,
|
|
23
|
+
"\e" => :escape, Curses::KEY_BACKSPACE => :backspace, "\x7F" => :backspace, "\b" => :backspace
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
# A key from getch, which is a string for a printable character and otherwise a number.
|
|
29
|
+
# Letters are the same with Shift, as the buttons show them as capitals, unless they're being typed.
|
|
30
|
+
def key_event(key, typing: false)
|
|
31
|
+
return typing_event(key) if typing
|
|
32
|
+
|
|
33
|
+
key = key.chr if key.is_a?(Integer) && key < 256
|
|
34
|
+
KEYS[key.is_a?(String) ? key.downcase : key]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def typing_event(key)
|
|
38
|
+
key = key.chr if key.is_a?(Integer) && key < 256
|
|
39
|
+
TYPING_KEYS.fetch(key) { Type.new(text: key) if key.is_a?(String) && key.match?(/\A[[:print:]]\z/) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The mouse buttons that click, which are the left button's. Scrolling does nothing, and since older ncurses,
|
|
43
|
+
# like macOS's, has no button for scrolling down, it can't be mistaken for a click.
|
|
44
|
+
CLICKS = Curses::BUTTON1_PRESSED | Curses::BUTTON1_CLICKED
|
|
45
|
+
|
|
46
|
+
# Moving the mouse, which terminals report once they're asked to, with any motion tracking.
|
|
47
|
+
MOTION = Curses::REPORT_MOUSE_POSITION
|
|
48
|
+
ANY_MOTION = "\e[?1003h"
|
|
49
|
+
NO_MOTION = "\e[?1003l"
|
|
50
|
+
|
|
51
|
+
# The terminal sends the app its mouse events, clicks and moves, so scrolling doesn't scroll the terminal either.
|
|
52
|
+
# ncurses only asks some terminals to report every move, so it's asked directly too, until untrack_mouse.
|
|
53
|
+
def track_mouse
|
|
54
|
+
Curses.mousemask(CLICKS | MOTION)
|
|
55
|
+
Curses.mouseinterval(0)
|
|
56
|
+
$stdout.write(ANY_MOTION)
|
|
57
|
+
$stdout.flush
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def untrack_mouse
|
|
61
|
+
$stdout.write(NO_MOTION)
|
|
62
|
+
$stdout.flush
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# A click sends the event of what's under it, and moving the mouse a Hover of it.
|
|
66
|
+
def mouse_event(event, zones)
|
|
67
|
+
target = click(zones, column: event.x, row: event.y)
|
|
68
|
+
return target if event.bstate.anybits?(CLICKS)
|
|
69
|
+
|
|
70
|
+
Hover.new(target:) if event.bstate.anybits?(MOTION)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Where each of a Layout's segments with an event is on the screen, as [row, columns, event].
|
|
74
|
+
def zones(lines)
|
|
75
|
+
lines.each_with_index.flat_map do |segments, row|
|
|
76
|
+
column = 0
|
|
77
|
+
segments.filter_map do |segment|
|
|
78
|
+
width = Unicode::DisplayWidth.of(segment.text)
|
|
79
|
+
zone = [row, column...(column + width), segment.event] if segment.event
|
|
80
|
+
column += width
|
|
81
|
+
zone
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def click(zones, column:, row:)
|
|
87
|
+
zones.find { |zone_row, columns, _event| zone_row == row && columns.cover?(column) }&.last
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|