raindown 0.6.0 → 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/document.rb +1 -5
- data/lib/raindown/endpoint/curl.rb +11 -7
- data/lib/raindown/node_blocks.rb +9 -6
- data/lib/raindown/nodes.rb +1 -0
- data/lib/raindown/stat.rb +53 -0
- data/lib/raindown/tui/events.rb +16 -1
- 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 +6 -5
- data/lib/raindown/tui/highlighter.rb +45 -0
- data/lib/raindown/tui/layout.rb +14 -7
- data/lib/raindown/tui/menu_overlay.rb +1 -1
- data/lib/raindown/tui/navigator/commands.rb +14 -1
- data/lib/raindown/tui/navigator/navigator.rb +42 -9
- data/lib/raindown/tui/navigator/search.rb +126 -0
- data/lib/raindown/tui/navigator/views.rb +6 -3
- data/lib/raindown/tui/renderers/dashboard_renderer.rb +4 -4
- data/lib/raindown/tui/renderers/endpoint_renderer.rb +14 -10
- data/lib/raindown/tui/renderers/outline_renderer.rb +4 -4
- data/lib/raindown/tui/renderers/renderer.rb +3 -0
- data/lib/raindown/tui/row.rb +4 -2
- data/lib/raindown/tui/rows.rb +2 -1
- data/lib/raindown/tui/search_overlay.rb +73 -0
- data/lib/raindown/tui/terminal/input.rb +42 -5
- data/lib/raindown/tui/terminal/screen.rb +12 -9
- data/lib/raindown/tui/terminal/styles.rb +78 -20
- data/lib/raindown/tui/text.rb +27 -0
- data/lib/raindown/tui/title.rb +1 -1
- data/lib/raindown/tui/views/dashboard_view.rb +6 -4
- data/lib/raindown/tui/views/endpoint_view.rb +2 -0
- data/lib/raindown/tui/views/list_view.rb +2 -0
- data/lib/raindown/tui/views/outline_view.rb +11 -2
- data/lib/raindown/tui/workers/project.rb +6 -6
- data/lib/raindown/tui.rb +2 -0
- data/lib/version.rb +1 -1
- metadata +33 -2
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Raindown
|
|
4
|
+
module TUI
|
|
5
|
+
# The waffle after the rain with a pat of butter, when a batch of code is refined, and the week's work is done:
|
|
6
|
+
# ╔═══╦═══╦═══╦═══╦═══╗
|
|
7
|
+
# ║ ║ ║ ║ ║ ║
|
|
8
|
+
# ╠═══╬═══╬═══╬═══╬═══╣
|
|
9
|
+
# ║ ║ ║███║ ║ ║
|
|
10
|
+
# ╠═══╬═══╬═══╬═══╬═══╣
|
|
11
|
+
# ║ ║ ║ ║ ║ ║
|
|
12
|
+
# ╚═══╩═══╩═══╩═══╩═══╝
|
|
13
|
+
module Waffle
|
|
14
|
+
EDGE = :code_number
|
|
15
|
+
BUTTER = :button
|
|
16
|
+
WAFFLE = [
|
|
17
|
+
[['╔═══╦═══╦═══╦═══╦═══╗', EDGE]],
|
|
18
|
+
[['║ ║ ║ ║ ║ ║', EDGE]],
|
|
19
|
+
[['╠═══╬═══╬═══╬═══╬═══╣', EDGE]],
|
|
20
|
+
[['║ ║ ║', EDGE], ['███', BUTTER], ['║ ║ ║', EDGE]],
|
|
21
|
+
[['╠═══╬═══╬═══╬═══╬═══╣', EDGE]],
|
|
22
|
+
[['║ ║ ║ ║ ║ ║', EDGE]],
|
|
23
|
+
[['╚═══╩═══╩═══╩═══╩═══╝', EDGE]],
|
|
24
|
+
[['100% refined', :feelings]],
|
|
25
|
+
[['The work is mysterious and important.', :feelings]]
|
|
26
|
+
].freeze
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def waffle
|
|
31
|
+
top = [(height - WAFFLE.length) / 2, 0].max
|
|
32
|
+
lines = [*Array.new(top, []), *WAFFLE, *Array.new(height, [])].first(height)
|
|
33
|
+
lines.map { centred(it) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The parts in the middle of the width.
|
|
37
|
+
def centred(parts)
|
|
38
|
+
left = [(width - parts.sum { display_width(it[0]) }) / 2, 0].max
|
|
39
|
+
padded([segment('', :blank, width: left), *parts.map { |text, style| segment(text, style, width: display_width(text)) }])
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'batch'
|
|
4
|
+
|
|
5
|
+
module Raindown
|
|
6
|
+
module TUI
|
|
7
|
+
# This week's Batch of a project's code to feel, picked to fill the grid when the view first opens and draws it,
|
|
8
|
+
# then kept through reloads, like after a method's edited or fixed.
|
|
9
|
+
class WeeklyBatch
|
|
10
|
+
attr_reader :batch
|
|
11
|
+
|
|
12
|
+
def initialize(log:)
|
|
13
|
+
@log = log
|
|
14
|
+
@batch = Batch.new(files: [], code_methods: [], log:, week: Batch.this_week)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# The methods read again, keeping the batch's files.
|
|
18
|
+
def read(every)
|
|
19
|
+
@every = every
|
|
20
|
+
@batch = batch.reading(every)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Picks the batch to fill the grid's cells, once they're known.
|
|
24
|
+
def fill(grid_cells)
|
|
25
|
+
return if @filled
|
|
26
|
+
|
|
27
|
+
@filled = true
|
|
28
|
+
@batch = Batch.pick(@every, log: @log, cells: grid_cells)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -58,8 +58,6 @@ module Raindown
|
|
|
58
58
|
path.delete_prefix(File.join(base, ''))
|
|
59
59
|
end
|
|
60
60
|
|
|
61
|
-
private
|
|
62
|
-
|
|
63
61
|
# A scope's own files, without those of the projects inside it, which are projects of their own.
|
|
64
62
|
def files
|
|
65
63
|
return @files unless scope
|
|
@@ -67,6 +65,8 @@ module Raindown
|
|
|
67
65
|
@files.select { |path| owner(path) == scope }
|
|
68
66
|
end
|
|
69
67
|
|
|
68
|
+
private
|
|
69
|
+
|
|
70
70
|
def owner(path)
|
|
71
71
|
[root, *@projects].select { path.start_with?(File.join(it, '')) }.max_by(&:length)
|
|
72
72
|
end
|
|
@@ -85,12 +85,13 @@ module Raindown
|
|
|
85
85
|
# A folder, and the tree inside it unless it's folded.
|
|
86
86
|
def folder_rows(name, children, prefix, depth)
|
|
87
87
|
folder = File.join(base, prefix, name)
|
|
88
|
-
|
|
88
|
+
expanded = @expanded.include?(folder)
|
|
89
89
|
project = @projects.include?(folder)
|
|
90
|
-
row = Row.new(text:
|
|
90
|
+
row = Row.new(text: [expanded ? '▾' : '▸', (PROJECT if project), "#{name}/"].compact.join(' '), depth:,
|
|
91
91
|
kind: project ? :project : :folder, target: folder, fold: -> { toggle(folder) })
|
|
92
|
+
return [row] unless expanded
|
|
92
93
|
|
|
93
|
-
[row, *
|
|
94
|
+
[row, *tree_rows(children, File.join(prefix, name), depth + 1)]
|
|
94
95
|
end
|
|
95
96
|
end
|
|
96
97
|
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Raindown
|
|
4
|
+
module TUI
|
|
5
|
+
# Code coloured with Rouge, which knows over 200 languages, as each line's [text, style] parts. Each part's style is
|
|
6
|
+
# the kind of token it is, like a keyword or a string, from the few code styles Styles has, so they work in any
|
|
7
|
+
# terminal. Code in a language Rouge doesn't know is a plain part a line.
|
|
8
|
+
module Highlighter
|
|
9
|
+
# Each kind of token's style, by the start of its name, like Literal.String.Double for a string, most specific first.
|
|
10
|
+
STYLES = [['Name.Function', :code_function], ['Name.Class', :code_constant], ['Name.Constant', :code_constant],
|
|
11
|
+
['Name.Builtin', :code_constant], ['Name.Label', :code_constant], ['Keyword', :code_keyword],
|
|
12
|
+
['Literal.String', :code_string], ['Literal.Number', :code_number], ['Comment', :code_comment]].freeze
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# The lines highlighted for a file's name, like shapes.rb, or a code fence's language, like json. Markdown
|
|
17
|
+
# highlights the code in its fences in their own languages, like an endpoint's request and response.
|
|
18
|
+
def lines(lines, filename: nil, language: nil)
|
|
19
|
+
lexer = lexer(filename:, language:)
|
|
20
|
+
return lines.map { [[it, :code]] } unless lexer
|
|
21
|
+
|
|
22
|
+
lexer.lex(lines.join("\n")).each_with_object([[]]) do |(token, text), highlighted|
|
|
23
|
+
text.split(/(\n)/).each do |piece|
|
|
24
|
+
next highlighted << [] if piece == "\n"
|
|
25
|
+
|
|
26
|
+
highlighted.last << [piece, style(token)] unless piece.empty?
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Rouge takes about a third of a second to load, so the TUI loads it when it starts, rather than with its code.
|
|
32
|
+
def lexer(filename:, language:)
|
|
33
|
+
require 'rouge'
|
|
34
|
+
lexer = Rouge::Lexer.find_fancy(language) unless language.to_s.empty?
|
|
35
|
+
lexer ||= Rouge::Lexer.guess_by_filename(filename).new if filename
|
|
36
|
+
lexer unless lexer.is_a?(Rouge::Lexers::PlainText)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def style(token)
|
|
40
|
+
name = token.qualname
|
|
41
|
+
STYLES.find { |prefix, _| name.start_with?(prefix) }&.last || :code
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
data/lib/raindown/tui/layout.rb
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
require_relative 'events'
|
|
4
4
|
require_relative 'text'
|
|
5
5
|
require_relative 'title'
|
|
6
|
+
require_relative 'menu_overlay'
|
|
7
|
+
require_relative 'search_overlay'
|
|
6
8
|
|
|
7
9
|
module Raindown
|
|
8
10
|
module TUI
|
|
@@ -44,11 +46,7 @@ module Raindown
|
|
|
44
46
|
def lines
|
|
45
47
|
return [status].first(height) if height < CHROME_HEIGHT + 1
|
|
46
48
|
|
|
47
|
-
|
|
48
|
-
return lines unless navigator.menu
|
|
49
|
-
|
|
50
|
-
require_relative 'menu_overlay'
|
|
51
|
-
MenuOverlay.new(navigator, width:, column: display_width(name)).apply(lines)
|
|
49
|
+
overlaid([title, *panes, status, button_bar])
|
|
52
50
|
end
|
|
53
51
|
|
|
54
52
|
# A third of the width, within NAV_WIDTH, and never more than half of a narrow screen.
|
|
@@ -58,6 +56,14 @@ module Raindown
|
|
|
58
56
|
|
|
59
57
|
private
|
|
60
58
|
|
|
59
|
+
# The project menu or the search, drawn over the lines while it's open.
|
|
60
|
+
def overlaid(lines)
|
|
61
|
+
return MenuOverlay.new(navigator, width:, column: display_width(name)).apply(lines) if navigator.menu
|
|
62
|
+
return SearchOverlay.new(navigator, width:).apply(lines) if navigator.searching?
|
|
63
|
+
|
|
64
|
+
lines
|
|
65
|
+
end
|
|
66
|
+
|
|
61
67
|
def body_height = height - CHROME_HEIGHT
|
|
62
68
|
|
|
63
69
|
# Below a blank line for spacing, the nav pane beside the main pane, each drawn by the renderer of what's in it.
|
|
@@ -68,8 +74,8 @@ module Raindown
|
|
|
68
74
|
[spacer, *Array.new(body_height - 1) { |index| [*nav[index], divider(main[index]), *main[index]] }]
|
|
69
75
|
end
|
|
70
76
|
|
|
71
|
-
# A
|
|
72
|
-
def spacer = [segment('', :blank, width: nav_width), divider([]),
|
|
77
|
+
# A line above the panes, for spacing, blank but for what the main pane's renderer puts in its side of it.
|
|
78
|
+
def spacer = [segment('', :blank, width: nav_width), divider([]), *view_renderer.spacer]
|
|
73
79
|
|
|
74
80
|
# The divider is on the background of the outline's row beside it, when that's highlighted, so they meet.
|
|
75
81
|
def divider(main_line)
|
|
@@ -122,3 +128,4 @@ require_relative 'renderers/list_renderer'
|
|
|
122
128
|
require_relative 'renderers/outline_renderer'
|
|
123
129
|
require_relative 'renderers/dashboard_renderer'
|
|
124
130
|
require_relative 'renderers/endpoint_renderer'
|
|
131
|
+
require_relative 'feelings/feelings_renderer'
|
|
@@ -5,7 +5,7 @@ require_relative 'text'
|
|
|
5
5
|
module Raindown
|
|
6
6
|
module TUI
|
|
7
7
|
# The project menu, drawn over the screen's lines in a box below the header's path:
|
|
8
|
-
# 🌧
|
|
8
|
+
# 🌧 Raindown ~/dev ▾ › Dashboard
|
|
9
9
|
# ┌──────────────────────┐
|
|
10
10
|
# │ All projects ✓ │
|
|
11
11
|
# │ ~/dev/raindown │
|
|
@@ -4,7 +4,8 @@ require_relative '../../actions'
|
|
|
4
4
|
|
|
5
5
|
module Raindown
|
|
6
6
|
module TUI
|
|
7
|
-
# What a Navigator does to the open file: run an endpoint, open the editor, write back and read it again
|
|
7
|
+
# What a Navigator does to the open file: run an endpoint, open the editor, write back and read it again,
|
|
8
|
+
# and to a feeling about some code: draw the page it was noted down in, or ask Claude Code to fix the code.
|
|
8
9
|
# Each sets the status, so the Screen shows how it went, and returns what the Screen should do next, if anything.
|
|
9
10
|
module Commands
|
|
10
11
|
def run(file, line, name) = Wait.new(message: "Running #{name}...", task: -> { run_endpoint(file, line) })
|
|
@@ -15,8 +16,20 @@ module Raindown
|
|
|
15
16
|
Run.new(command: Actions.edit_command(file, line:))
|
|
16
17
|
end
|
|
17
18
|
|
|
19
|
+
def felt(feeling) = info("Felt #{feeling.temper} about #{feeling.method.label}")
|
|
20
|
+
|
|
21
|
+
# Leaves the screen for Claude Code, asked to fix the method, and the view reads the code again after.
|
|
22
|
+
def fix_with_agent(feeling)
|
|
23
|
+
return error('Install Claude Code, the claude command, to fix code with X') unless claude?
|
|
24
|
+
|
|
25
|
+
@edited = feeling.method.path
|
|
26
|
+
Run.new(command: ['claude', feeling.prompt])
|
|
27
|
+
end
|
|
28
|
+
|
|
18
29
|
private
|
|
19
30
|
|
|
31
|
+
def claude? = ENV.fetch('PATH', '').split(File::PATH_SEPARATOR).any? { File.executable?(File.join(it, 'claude')) }
|
|
32
|
+
|
|
20
33
|
# Runs the endpoint selected in the outline, or listed below the files. One opened beside them runs itself.
|
|
21
34
|
def execute
|
|
22
35
|
target = selected&.target
|
|
@@ -8,6 +8,7 @@ require_relative '../events'
|
|
|
8
8
|
require_relative '../views/outline_view'
|
|
9
9
|
require_relative '../views/list_view'
|
|
10
10
|
require_relative 'project_menu'
|
|
11
|
+
require_relative 'search'
|
|
11
12
|
require_relative 'views'
|
|
12
13
|
require_relative '../workers/workers'
|
|
13
14
|
|
|
@@ -20,20 +21,26 @@ module Raindown
|
|
|
20
21
|
include Commands
|
|
21
22
|
include Views
|
|
22
23
|
include ProjectMenu
|
|
24
|
+
include Search
|
|
23
25
|
include Focus
|
|
24
26
|
|
|
25
27
|
# Keys of the events that move the cursor or the focus, and those that act on what's selected.
|
|
26
28
|
MOVES = { up: -1, down: 1 }.freeze
|
|
27
29
|
PANES = { tab: nil, left: :nav, right: :main }.freeze
|
|
28
30
|
ACTIONS = { execute: :execute, edit: :edit, write_back: :write_back, reload: :reload, tick: :refresh }.freeze
|
|
31
|
+
# Keys that open the search, the project menu, the Dashboard or Code feelings, from anywhere.
|
|
32
|
+
OPENERS = { search: :open_search, projects: :toggle_menu, dashboard: :toggle_dashboard, feelings: :open_feelings }.freeze
|
|
29
33
|
|
|
30
34
|
attr_reader :focus, :status
|
|
35
|
+
# What the mouse is over, which a click there would send, like a Click on a method's cells, or nil.
|
|
36
|
+
attr_reader :hovered
|
|
31
37
|
|
|
32
38
|
def initialize(root:, path: nil, workers: Workers.new(root:))
|
|
33
39
|
@workers = workers
|
|
34
40
|
@files = FileTree.new(root:, workers:)
|
|
35
41
|
@endpoints = Endpoints.new(workers:)
|
|
36
42
|
@dashboard = DashboardView.new(root:, workers:)
|
|
43
|
+
@feelings = FeelingsView.new(root:)
|
|
37
44
|
@nav = ListView.new { nav_rows }
|
|
38
45
|
first = Projects.files(root, nested: true).first
|
|
39
46
|
# The dashboard is the first page, unless a file is asked for, and "d" goes from it to the first file.
|
|
@@ -46,13 +53,17 @@ module Raindown
|
|
|
46
53
|
case event
|
|
47
54
|
when nil then nil
|
|
48
55
|
when Click then click(event)
|
|
56
|
+
when Type then type(event)
|
|
57
|
+
when Hover then hover(event)
|
|
49
58
|
else route(event)
|
|
50
59
|
end
|
|
51
60
|
end
|
|
52
61
|
|
|
53
|
-
# After the editor closes,
|
|
62
|
+
# After the editor closes, a Markdown file is written back, like a save while it's watched, and read again.
|
|
63
|
+
# Other files, like code that was felt, aren't written back, and the view reads them again.
|
|
54
64
|
def ran
|
|
55
|
-
write_back(@edited)
|
|
65
|
+
write_back(@edited) if @files.files.include?(@edited)
|
|
66
|
+
view.load
|
|
56
67
|
@endpoints.load
|
|
57
68
|
end
|
|
58
69
|
|
|
@@ -62,16 +73,34 @@ module Raindown
|
|
|
62
73
|
# The folder the files are shown from: the chosen project's, or the root's.
|
|
63
74
|
def base = @files.base
|
|
64
75
|
|
|
76
|
+
# While the search, or the view, like a feeling's note, takes what's typed, rather than keys' events.
|
|
77
|
+
def typing? = searching? || view.typing?
|
|
78
|
+
|
|
79
|
+
# While the view wants drawing faster than the ticks, like Code feelings' rain.
|
|
80
|
+
def animating? = view.animating?
|
|
81
|
+
|
|
65
82
|
# What's in a pane: the nav's ListView on the left, or the view on the right, which keeps its own cursor.
|
|
66
83
|
def content(pane) = pane == :nav ? @nav : view
|
|
67
84
|
|
|
68
85
|
private
|
|
69
86
|
|
|
87
|
+
def hover(hover)
|
|
88
|
+
@hovered = hover.target
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# A typed character goes to the search, or to the view while it's typing.
|
|
93
|
+
def type(event)
|
|
94
|
+
return type_query(query + event.text) if searching?
|
|
95
|
+
|
|
96
|
+
view.next_step(event)&.take(self) if view.typing?
|
|
97
|
+
end
|
|
98
|
+
|
|
70
99
|
def route(name)
|
|
71
100
|
return :quit if name == :quit
|
|
101
|
+
return search_event(name) if searching?
|
|
72
102
|
return menu_event(name) if menu
|
|
73
|
-
return
|
|
74
|
-
return toggle_dashboard if name == :dashboard
|
|
103
|
+
return send(OPENERS[name]) if OPENERS.key?(name)
|
|
75
104
|
return view.next_step(name)&.take(self) if @focus == :main && view.next_step?(name)
|
|
76
105
|
|
|
77
106
|
browser_event(name)
|
|
@@ -79,9 +108,10 @@ module Raindown
|
|
|
79
108
|
|
|
80
109
|
# A click moves the cursor where it's clicked, and does the click's action there, like a key, unless that would
|
|
81
110
|
# leave the terminal UI, like for the editor, so a stray click only moves the cursor.
|
|
82
|
-
# While the project menu
|
|
111
|
+
# While the project menu or the search is open, only its items are clicked.
|
|
83
112
|
def click(click)
|
|
84
113
|
return if menu && click.pane != :menu
|
|
114
|
+
return if searching? && click.pane != :search
|
|
85
115
|
|
|
86
116
|
point(click)
|
|
87
117
|
effect = route(click.action)
|
|
@@ -90,6 +120,7 @@ module Raindown
|
|
|
90
120
|
|
|
91
121
|
def point(click)
|
|
92
122
|
return @menu = click.index if click.pane == :menu
|
|
123
|
+
return @match = click.index if click.pane == :search
|
|
93
124
|
|
|
94
125
|
content(click.pane).cursor = click.index
|
|
95
126
|
@focus = click.pane
|
|
@@ -103,11 +134,13 @@ module Raindown
|
|
|
103
134
|
send(ACTIONS[name]) if ACTIONS.key?(name)
|
|
104
135
|
end
|
|
105
136
|
|
|
106
|
-
# The Dashboard,
|
|
107
|
-
# with a blank line between each section that has rows.
|
|
137
|
+
# The Dashboard and Code feelings, whose emoji every terminal draws two columns wide, then the files and the
|
|
138
|
+
# endpoints, with a blank line between each section that has rows.
|
|
108
139
|
def nav_rows
|
|
109
|
-
|
|
110
|
-
|
|
140
|
+
pages = { '📊 Dashboard' => dashboard, '🎭 Feelings' => feelings }.map do |text, view|
|
|
141
|
+
Row.new(text:, depth: 0, kind: :page, target: view, open: OpenView.new(view:))
|
|
142
|
+
end
|
|
143
|
+
[pages, @files.rows, @endpoints.rows].reject(&:empty?).flat_map { [Row::SEPARATOR, *it] }.drop(1)
|
|
111
144
|
end
|
|
112
145
|
|
|
113
146
|
def selected
|
|
@@ -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
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative '../views/dashboard_view'
|
|
4
4
|
require_relative '../views/endpoint_view'
|
|
5
|
+
require_relative '../feelings/feelings_view'
|
|
5
6
|
require_relative '../views/outline_view'
|
|
6
7
|
|
|
7
8
|
module Raindown
|
|
@@ -11,7 +12,7 @@ module Raindown
|
|
|
11
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,
|
|
12
13
|
# what's opened it in the files. It loads when it's opened, and again after a change.
|
|
13
14
|
module Views
|
|
14
|
-
attr_reader :view, :dashboard
|
|
15
|
+
attr_reader :view, :dashboard, :feelings
|
|
15
16
|
|
|
16
17
|
# The path of the file the view shows, if any.
|
|
17
18
|
def path = view.path
|
|
@@ -27,17 +28,19 @@ module Raindown
|
|
|
27
28
|
nil
|
|
28
29
|
end
|
|
29
30
|
|
|
30
|
-
# Opens the file in the outline, on the directive at the line.
|
|
31
|
+
# Opens the file in the outline, on the heading or directive at the line.
|
|
31
32
|
def open_at(path, line)
|
|
32
33
|
path = File.expand_path(path)
|
|
33
34
|
@files.reveal(path)
|
|
34
35
|
open_view(OutlineView.new(path, workers: @workers))
|
|
35
|
-
view.cursor = view.rows.index { it.kind
|
|
36
|
+
view.cursor = view.rows.index { %i[heading directive].include?(it.kind) && it.target.line == line } || 0
|
|
36
37
|
nil
|
|
37
38
|
end
|
|
38
39
|
|
|
39
40
|
private
|
|
40
41
|
|
|
42
|
+
def open_feelings = open_view(feelings)
|
|
43
|
+
|
|
41
44
|
# Switches between the dashboard and the view before it.
|
|
42
45
|
def toggle_dashboard
|
|
43
46
|
return open_view(@previous) if view == dashboard && @previous
|
|
@@ -15,14 +15,14 @@ module Raindown
|
|
|
15
15
|
# ╰─────────────────────────────╯ ╰────────────────────────────────╯
|
|
16
16
|
class DashboardRenderer < Renderer
|
|
17
17
|
BUTTONS = [['Tab', 'Pane', :tab], ['Enter', 'Open', :enter], ['e', 'Edit', :edit], ['r', 'Reload', :reload],
|
|
18
|
-
['q', 'Quit', :quit]].freeze
|
|
18
|
+
['/', 'Search', :search], ['q', 'Quit', :quit]].freeze
|
|
19
19
|
PROJECTS_BUTTON = ['p', 'Projects', :projects].freeze
|
|
20
20
|
|
|
21
21
|
# Room for the project menu's button, when there's more than one project.
|
|
22
22
|
def buttons
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
return BUTTONS unless navigator.projects?
|
|
24
|
+
|
|
25
|
+
BUTTONS.dup.insert(3, PROJECTS_BUTTON)
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
def page = 'Dashboard'
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative '../highlighter'
|
|
3
4
|
require_relative 'renderer'
|
|
4
5
|
require_relative 'outline_renderer'
|
|
5
6
|
|
|
@@ -8,7 +9,7 @@ module Raindown
|
|
|
8
9
|
# An endpoint opened from the files, right of them: its URL in a bar with rounded corners, and a button to run it,
|
|
9
10
|
# then its response:
|
|
10
11
|
# ╭──────────────────────────────────────────╮
|
|
11
|
-
# │ GET https://httpbin.org/uuid
|
|
12
|
+
# │ GET https://httpbin.org/uuid [Run] │
|
|
12
13
|
# ╰──────────────────────────────────────────╯
|
|
13
14
|
#
|
|
14
15
|
# Response · json · fetched 2026-10-26
|
|
@@ -16,7 +17,7 @@ module Raindown
|
|
|
16
17
|
# "uuid": "48024974-f1b1-431a-a0cf-5cac0099a1a5"
|
|
17
18
|
# }
|
|
18
19
|
class EndpointRenderer < Renderer
|
|
19
|
-
RUN = '[
|
|
20
|
+
RUN = '[Run]'
|
|
20
21
|
RUN_WIDTH = RUN.length
|
|
21
22
|
BUTTONS = OutlineRenderer::BUTTONS.reject { it.last == :write_back }.freeze
|
|
22
23
|
|
|
@@ -35,9 +36,12 @@ module Raindown
|
|
|
35
36
|
# A click on the response moves the focus to it, to scroll it.
|
|
36
37
|
def focus = Click.new(pane:, index: 0, action: :focus)
|
|
37
38
|
|
|
39
|
+
# The response's lines that fit, highlighted in the language of its code block, like json.
|
|
38
40
|
def response(view, lines)
|
|
39
|
-
|
|
40
|
-
Array.new([lines, 0].max)
|
|
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
|
|
41
45
|
end
|
|
42
46
|
|
|
43
47
|
# The bar is a column in from each side of the pane, with the URL and the button inside its border.
|
|
@@ -57,7 +61,7 @@ module Raindown
|
|
|
57
61
|
run_button, segment(' │', :border, width: 2)]
|
|
58
62
|
end
|
|
59
63
|
|
|
60
|
-
def run_button = segment(RUN, :
|
|
64
|
+
def run_button = segment(RUN, :button, width: RUN_WIDTH, event: Click.new(pane:, index: 0, action: :execute))
|
|
61
65
|
|
|
62
66
|
def edge(left, right, inner)
|
|
63
67
|
padded([margin, segment("#{left}#{'─' * (inner + 2)}#{right}", :border, width: inner + 4)])
|
|
@@ -66,11 +70,11 @@ module Raindown
|
|
|
66
70
|
def margin = segment('', :blank, width: 1)
|
|
67
71
|
|
|
68
72
|
def about(view)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
[segment(" #{
|
|
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:)]
|
|
74
78
|
end
|
|
75
79
|
end
|
|
76
80
|
end
|
|
@@ -7,14 +7,14 @@ module Raindown
|
|
|
7
7
|
# The open file's outline, right of the files: a row on each line, with its buttons.
|
|
8
8
|
class OutlineRenderer < ListRenderer
|
|
9
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
|
|
10
|
+
['w', 'Write back', :write_back], ['r', 'Reload', :reload], ['/', 'Search', :search], ['q', 'Quit', :quit]].freeze
|
|
11
11
|
|
|
12
12
|
def buttons = BUTTONS
|
|
13
13
|
|
|
14
14
|
def page
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
return 'No Markdown files' unless navigator.path
|
|
16
|
+
|
|
17
|
+
navigator.relative(navigator.path)
|
|
18
18
|
end
|
|
19
19
|
end
|
|
20
20
|
end
|
|
@@ -10,6 +10,9 @@ module Raindown
|
|
|
10
10
|
Renderer = Struct.new(:navigator, :pane, :width, :height) do
|
|
11
11
|
include Rows
|
|
12
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)]
|
|
13
16
|
end
|
|
14
17
|
end
|
|
15
18
|
end
|