raindown 0.7.0 → 0.8.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/nodes/endpoint_node.rb +3 -2
- data/lib/raindown/actions.rb +5 -3
- data/lib/raindown/bare_directives.rb +14 -27
- data/lib/raindown/blueprints.rb +255 -0
- data/lib/raindown/cli.rb +39 -54
- data/lib/raindown/config.rb +2 -3
- data/lib/raindown/directives.rb +30 -13
- data/lib/raindown/document.rb +21 -15
- data/lib/raindown/endpoint/endpoint_runner.rb +13 -5
- data/lib/raindown/node_blocks.rb +22 -16
- data/lib/raindown/node_injector.rb +7 -10
- data/lib/raindown/pages.rb +1 -1
- data/lib/raindown/pre_renderer.rb +18 -0
- data/lib/raindown/tui/blueprints/blueprints_renderer.rb +93 -0
- data/lib/raindown/tui/blueprints/blueprints_view.rb +146 -0
- data/lib/raindown/tui/blueprints/canvas.rb +58 -0
- data/lib/raindown/tui/blueprints/code_comments.rb +71 -0
- data/lib/raindown/tui/blueprints/filter.rb +77 -0
- data/lib/raindown/tui/blueprints/traced_code.rb +56 -0
- data/lib/raindown/tui/blueprints/tracing.rb +129 -0
- data/lib/raindown/tui/{feelings/code_box.rb → code_box.rb} +44 -30
- data/lib/raindown/tui/endpoints.rb +2 -2
- data/lib/raindown/tui/events.rb +3 -1
- data/lib/raindown/tui/feelings/bins.rb +5 -5
- data/lib/raindown/tui/feelings/{celebration.rb → celebration/celebration.rb} +5 -2
- data/lib/raindown/tui/feelings/{scene.rb → celebration/scene.rb} +4 -1
- data/lib/raindown/tui/feelings/cell_moves.rb +34 -0
- data/lib/raindown/tui/feelings/feelings_code_box.rb +40 -0
- data/lib/raindown/tui/feelings/feelings_log.rb +22 -19
- data/lib/raindown/tui/feelings/feelings_renderer.rb +94 -95
- data/lib/raindown/tui/feelings/feelings_view.rb +28 -32
- data/lib/raindown/tui/feelings/magnifier.rb +68 -0
- data/lib/raindown/tui/feelings/motion.rb +93 -0
- data/lib/raindown/tui/feelings/waffle/baked_waffle.rb +88 -0
- data/lib/raindown/tui/feelings/waffle/balloons.rb +81 -0
- data/lib/raindown/tui/feelings/waffle/confetti.rb +55 -0
- data/lib/raindown/tui/feelings/waffle/sparkles.rb +61 -0
- data/lib/raindown/tui/feelings/waffle/waffle.rb +62 -0
- data/lib/raindown/tui/feelings/weekly_batch.rb +24 -5
- data/lib/raindown/tui/layout.rb +25 -18
- data/lib/raindown/tui/navigator/commands.rb +32 -1
- data/lib/raindown/tui/navigator/mouse.rb +43 -0
- data/lib/raindown/tui/navigator/navigator.rb +40 -44
- data/lib/raindown/tui/navigator/{search.rb → teleport.rb} +14 -12
- data/lib/raindown/tui/navigator/views.rb +2 -3
- data/lib/raindown/tui/{workers/project.rb → project.rb} +33 -22
- data/lib/raindown/tui/{menu_overlay.rb → project_box.rb} +1 -1
- data/lib/raindown/tui/renderers/renderer.rb +1 -0
- data/lib/raindown/tui/saves.rb +22 -0
- data/lib/raindown/tui/{search_overlay.rb → search_box.rb} +27 -14
- data/lib/raindown/tui/terminal/input.rb +58 -7
- data/lib/raindown/tui/terminal/screen.rb +31 -26
- data/lib/raindown/tui/terminal/styles.rb +55 -28
- data/lib/raindown/tui/terminal/terminal_title.rb +31 -0
- data/lib/raindown/tui/text.rb +35 -12
- data/lib/raindown/tui/views/dashboard_view.rb +2 -2
- data/lib/raindown/tui/views/endpoint_view.rb +3 -3
- data/lib/raindown/tui/views/list_view.rb +1 -1
- data/lib/raindown/tui/views/outline_view.rb +5 -4
- data/lib/raindown/tui.rb +17 -7
- data/lib/raindown/watcher/changes.rb +33 -0
- data/lib/raindown/watcher/file_watcher.rb +58 -0
- data/lib/raindown/watcher/project.rb +47 -0
- data/lib/raindown/watcher/supervisor.rb +111 -0
- data/lib/raindown/{tui/workers → workers}/worker.rb +37 -29
- data/lib/raindown/workers/workers.rb +63 -0
- data/lib/raindown.rb +4 -4
- data/lib/version.rb +1 -1
- metadata +37 -17
- data/lib/raindown/project_worker.rb +0 -48
- data/lib/raindown/supervisor.rb +0 -160
- data/lib/raindown/tui/feelings/waffle.rb +0 -43
- data/lib/raindown/tui/workers/workers.rb +0 -53
- data/lib/raindown/watcher.rb +0 -57
- /data/lib/raindown/tui/feelings/{fireworks.rb → celebration/fireworks.rb} +0 -0
- /data/lib/raindown/tui/feelings/{rain.rb → celebration/rain.rb} +0 -0
|
@@ -4,13 +4,14 @@ require_relative '../file_tree'
|
|
|
4
4
|
require_relative 'commands'
|
|
5
5
|
require_relative '../endpoints'
|
|
6
6
|
require_relative 'focus'
|
|
7
|
+
require_relative 'mouse'
|
|
7
8
|
require_relative '../events'
|
|
8
9
|
require_relative '../views/outline_view'
|
|
9
10
|
require_relative '../views/list_view'
|
|
10
11
|
require_relative 'project_menu'
|
|
11
|
-
require_relative '
|
|
12
|
+
require_relative 'teleport'
|
|
12
13
|
require_relative 'views'
|
|
13
|
-
require_relative '../
|
|
14
|
+
require_relative '../project'
|
|
14
15
|
|
|
15
16
|
module Raindown
|
|
16
17
|
module TUI
|
|
@@ -21,26 +22,31 @@ module Raindown
|
|
|
21
22
|
include Commands
|
|
22
23
|
include Views
|
|
23
24
|
include ProjectMenu
|
|
24
|
-
include
|
|
25
|
+
include Teleport
|
|
25
26
|
include Focus
|
|
27
|
+
include Mouse
|
|
26
28
|
|
|
27
29
|
# Keys of the events that move the cursor or the focus, and those that act on what's selected.
|
|
28
30
|
MOVES = { up: -1, down: 1 }.freeze
|
|
29
31
|
PANES = { tab: nil, left: :nav, right: :main }.freeze
|
|
30
32
|
ACTIONS = { execute: :execute, edit: :edit, write_back: :write_back, reload: :reload, tick: :refresh }.freeze
|
|
31
|
-
# Keys that open
|
|
32
|
-
OPENERS = { search:
|
|
33
|
+
# Keys that open Teleport, the project menu, the Dashboard, Code feelings or Blueprints, from anywhere.
|
|
34
|
+
OPENERS = { search: -> { open_teleport }, projects: -> { toggle_menu }, dashboard: -> { toggle_dashboard },
|
|
35
|
+
feelings: -> { open_view(feelings) }, blueprints: -> { open_view(blueprints) } }.freeze
|
|
33
36
|
|
|
34
37
|
attr_reader :focus, :status
|
|
35
|
-
# What the mouse is over, which a click there would send, like a Click on a method's cells, or nil.
|
|
36
|
-
attr_reader :hovered
|
|
37
38
|
|
|
38
|
-
|
|
39
|
+
# Saves tells of the files saved in the folder, which are written back on the next tick, like when it's watched,
|
|
40
|
+
# running endpoints with no response as they are when it runs_endpoints, like with -x.
|
|
41
|
+
def initialize(root:, path: nil, workers: Project.workers(root:), saves: nil, runs_endpoints: false)
|
|
39
42
|
@workers = workers
|
|
43
|
+
@saves = saves
|
|
44
|
+
@runs_endpoints = runs_endpoints
|
|
40
45
|
@files = FileTree.new(root:, workers:)
|
|
41
46
|
@endpoints = Endpoints.new(workers:)
|
|
42
47
|
@dashboard = DashboardView.new(root:, workers:)
|
|
43
48
|
@feelings = FeelingsView.new(root:)
|
|
49
|
+
@blueprints = BlueprintsView.new(root:)
|
|
44
50
|
@nav = ListView.new { nav_rows }
|
|
45
51
|
first = Projects.files(root, nested: true).first
|
|
46
52
|
# The dashboard is the first page, unless a file is asked for, and "d" goes from it to the first file.
|
|
@@ -55,6 +61,7 @@ module Raindown
|
|
|
55
61
|
when Click then click(event)
|
|
56
62
|
when Type then type(event)
|
|
57
63
|
when Hover then hover(event)
|
|
64
|
+
when Drag then drag(event)
|
|
58
65
|
else route(event)
|
|
59
66
|
end
|
|
60
67
|
end
|
|
@@ -73,57 +80,39 @@ module Raindown
|
|
|
73
80
|
# The folder the files are shown from: the chosen project's, or the root's.
|
|
74
81
|
def base = @files.base
|
|
75
82
|
|
|
76
|
-
# While
|
|
77
|
-
def typing? =
|
|
83
|
+
# While Teleport, or the view, like a feeling's note, takes what's typed, rather than keys' events.
|
|
84
|
+
def typing? = teleporting? || view.typing?
|
|
78
85
|
|
|
79
|
-
#
|
|
80
|
-
def
|
|
86
|
+
# How often the screen's drawn, which is up to the view, like faster for Code feelings' rain.
|
|
87
|
+
def pace = view.pace(hovered)
|
|
81
88
|
|
|
82
89
|
# What's in a pane: the nav's ListView on the left, or the view on the right, which keeps its own cursor.
|
|
83
90
|
def content(pane) = pane == :nav ? @nav : view
|
|
84
91
|
|
|
85
92
|
private
|
|
86
93
|
|
|
87
|
-
|
|
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.
|
|
94
|
+
# A typed character goes to Teleport, or to the view while it's typing.
|
|
93
95
|
def type(event)
|
|
94
|
-
return type_query(query + event.text) if
|
|
96
|
+
return type_query(query + event.text) if teleporting?
|
|
95
97
|
|
|
96
98
|
view.next_step(event)&.take(self) if view.typing?
|
|
97
99
|
end
|
|
98
100
|
|
|
99
101
|
def route(name)
|
|
100
102
|
return :quit if name == :quit
|
|
101
|
-
return
|
|
103
|
+
return teleport_event(name) if teleporting?
|
|
102
104
|
return menu_event(name) if menu
|
|
103
|
-
return
|
|
105
|
+
return instance_exec(&OPENERS[name]) if opener?(name)
|
|
104
106
|
return view.next_step(name)&.take(self) if @focus == :main && view.next_step?(name)
|
|
105
107
|
|
|
106
108
|
browser_event(name)
|
|
107
109
|
end
|
|
108
110
|
|
|
109
|
-
#
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
def click(click)
|
|
113
|
-
return if menu && click.pane != :menu
|
|
114
|
-
return if searching? && click.pane != :search
|
|
115
|
-
|
|
116
|
-
point(click)
|
|
117
|
-
effect = route(click.action)
|
|
118
|
-
effect unless effect.is_a?(Run)
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
def point(click)
|
|
122
|
-
return @menu = click.index if click.pane == :menu
|
|
123
|
-
return @match = click.index if click.pane == :search
|
|
111
|
+
# Whether the key opens something from anywhere, unless the view's focused and keeps the key, like f in Blueprints.
|
|
112
|
+
def opener?(name)
|
|
113
|
+
return false if @focus == :main && view.respond_to?(:own_key?) && view.own_key?(name)
|
|
124
114
|
|
|
125
|
-
|
|
126
|
-
@focus = click.pane
|
|
115
|
+
OPENERS.key?(name)
|
|
127
116
|
end
|
|
128
117
|
|
|
129
118
|
def browser_event(name)
|
|
@@ -134,13 +123,20 @@ module Raindown
|
|
|
134
123
|
send(ACTIONS[name]) if ACTIONS.key?(name)
|
|
135
124
|
end
|
|
136
125
|
|
|
137
|
-
# The Dashboard
|
|
138
|
-
#
|
|
126
|
+
# The Dashboard, Code feelings and Blueprints, then the files and the endpoints, with a blank line between each section that
|
|
127
|
+
# has rows.
|
|
139
128
|
def nav_rows
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
129
|
+
rows = pages.map { |text, view| Row.new(text:, depth: 0, kind: :page, target: view, open: OpenView.new(view:)) }
|
|
130
|
+
[rows, @files.rows, @endpoints.rows].reject(&:empty?).flat_map { [Row::SEPARATOR, *it] }.drop(1)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# The pages above the files, by how they're shown, in the files and Teleport, with emoji every terminal draws two
|
|
134
|
+
# columns wide. Blueprints is listed once the project has some, from Claude Code or its code's comments.
|
|
135
|
+
def pages
|
|
136
|
+
pages = { '📊 Dashboard' => dashboard, '🎭 Feelings' => feelings }
|
|
137
|
+
return pages unless blueprints.any?
|
|
138
|
+
|
|
139
|
+
pages.merge('🌀 Blueprints' => blueprints)
|
|
144
140
|
end
|
|
145
141
|
|
|
146
142
|
def selected
|
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative '../events'
|
|
4
4
|
require_relative '../views/endpoint_view'
|
|
5
|
-
require_relative '
|
|
5
|
+
require_relative '../../workers/worker'
|
|
6
6
|
|
|
7
7
|
module Raindown
|
|
8
8
|
module TUI
|
|
9
9
|
# Teleport: "/" opens a search of the Dashboard, Code feelings, every file, heading and directive in the files, and every endpoint,
|
|
10
10
|
# matched as it's typed by letters in order, so "stp" finds "## Setup". Up and down move between the matches,
|
|
11
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
|
|
12
|
+
module Teleport
|
|
13
13
|
# The most matches it shows.
|
|
14
14
|
LIMIT = 10
|
|
15
15
|
|
|
@@ -45,10 +45,13 @@ module Raindown
|
|
|
45
45
|
end
|
|
46
46
|
end
|
|
47
47
|
|
|
48
|
-
# What's typed, while
|
|
48
|
+
# What's typed, while Teleport is open, and the selected match.
|
|
49
49
|
attr_reader :query, :match
|
|
50
50
|
|
|
51
|
-
def
|
|
51
|
+
def teleporting? = !query.nil?
|
|
52
|
+
|
|
53
|
+
# The first match the box lists, as it lists them all.
|
|
54
|
+
def first_match = 0
|
|
52
55
|
|
|
53
56
|
# The places that match what's typed, best first, then in the order they're in the files.
|
|
54
57
|
def matches
|
|
@@ -58,18 +61,18 @@ module Raindown
|
|
|
58
61
|
|
|
59
62
|
private
|
|
60
63
|
|
|
61
|
-
def
|
|
64
|
+
def open_teleport
|
|
62
65
|
@places = places
|
|
63
66
|
@match = 0
|
|
64
67
|
@query = ''
|
|
65
68
|
nil
|
|
66
69
|
end
|
|
67
70
|
|
|
68
|
-
def
|
|
71
|
+
def teleport_event(name)
|
|
69
72
|
case name
|
|
70
73
|
when :up, :down then move_match(name == :up ? -1 : 1)
|
|
71
74
|
when :enter then teleport
|
|
72
|
-
when :escape then
|
|
75
|
+
when :escape then close_teleport
|
|
73
76
|
when :backspace then type_query(query[0...-1])
|
|
74
77
|
end
|
|
75
78
|
end
|
|
@@ -87,18 +90,17 @@ module Raindown
|
|
|
87
90
|
|
|
88
91
|
def teleport
|
|
89
92
|
place = matches[@match]
|
|
90
|
-
|
|
93
|
+
close_teleport
|
|
91
94
|
place&.open&.take(self)
|
|
92
95
|
end
|
|
93
96
|
|
|
94
|
-
def
|
|
97
|
+
def close_teleport
|
|
95
98
|
@query = nil
|
|
96
99
|
nil
|
|
97
100
|
end
|
|
98
101
|
|
|
99
102
|
def places
|
|
100
|
-
[Place.new(text
|
|
101
|
-
Place.new(text: 'Code feelings', where: '', open: OpenView.new(view: feelings)),
|
|
103
|
+
[*pages.map { |text, view| Place.new(text:, where: '', open: OpenView.new(view:)) },
|
|
102
104
|
*@files.files.flat_map { file_places(it) },
|
|
103
105
|
*@endpoints.all.map { Place.new(text: it.text, where: "#{relative(it.path)}:#{it.line}", open: open_endpoint(it)) }]
|
|
104
106
|
end
|
|
@@ -110,7 +112,7 @@ module Raindown
|
|
|
110
112
|
items = (outline.headings + outline.directives).sort_by(&:line)
|
|
111
113
|
[Place.new(text: where, where: '', open: Open.new(path:, line: nil)),
|
|
112
114
|
*items.map { Place.new(text: item_text(it), where: "#{where}:#{it.line}", open: Open.new(path:, line: it.line)) }]
|
|
113
|
-
rescue Worker::Error
|
|
115
|
+
rescue Workers::Worker::Error
|
|
114
116
|
[]
|
|
115
117
|
end
|
|
116
118
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative '../views/dashboard_view'
|
|
4
|
+
require_relative '../blueprints/blueprints_view'
|
|
4
5
|
require_relative '../views/endpoint_view'
|
|
5
6
|
require_relative '../feelings/feelings_view'
|
|
6
7
|
require_relative '../views/outline_view'
|
|
@@ -12,7 +13,7 @@ module Raindown
|
|
|
12
13
|
# 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
14
|
# what's opened it in the files. It loads when it's opened, and again after a change.
|
|
14
15
|
module Views
|
|
15
|
-
attr_reader :view, :dashboard, :feelings
|
|
16
|
+
attr_reader :view, :dashboard, :feelings, :blueprints
|
|
16
17
|
|
|
17
18
|
# The path of the file the view shows, if any.
|
|
18
19
|
def path = view.path
|
|
@@ -39,8 +40,6 @@ module Raindown
|
|
|
39
40
|
|
|
40
41
|
private
|
|
41
42
|
|
|
42
|
-
def open_feelings = open_view(feelings)
|
|
43
|
-
|
|
44
43
|
# Switches between the dashboard and the view before it.
|
|
45
44
|
def toggle_dashboard
|
|
46
45
|
return open_view(@previous) if view == dashboard && @previous
|
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative '
|
|
4
|
-
require_relative '
|
|
5
|
-
require_relative '
|
|
6
|
-
require_relative '
|
|
7
|
-
require_relative '
|
|
8
|
-
require_relative '
|
|
9
|
-
require_relative '
|
|
10
|
-
require_relative '
|
|
11
|
-
require_relative '
|
|
12
|
-
require_relative '
|
|
13
|
-
require_relative '../
|
|
14
|
-
require_relative '
|
|
15
|
-
require_relative '
|
|
16
|
-
require_relative '
|
|
3
|
+
require_relative '../actions'
|
|
4
|
+
require_relative '../bare_directives'
|
|
5
|
+
require_relative '../chart/chart'
|
|
6
|
+
require_relative '../document'
|
|
7
|
+
require_relative '../front_matter'
|
|
8
|
+
require_relative '../node_blocks'
|
|
9
|
+
require_relative '../nodes'
|
|
10
|
+
require_relative '../pages'
|
|
11
|
+
require_relative '../projects'
|
|
12
|
+
require_relative '../workers/workers'
|
|
13
|
+
require_relative '../stat'
|
|
14
|
+
require_relative 'card'
|
|
15
|
+
require_relative 'endpoint'
|
|
16
|
+
require_relative '../code_blocks'
|
|
17
|
+
require_relative '../endpoint/curl'
|
|
17
18
|
|
|
18
19
|
module Raindown
|
|
19
20
|
module TUI
|
|
@@ -24,9 +25,17 @@ module Raindown
|
|
|
24
25
|
|
|
25
26
|
attr_reader :root
|
|
26
27
|
|
|
28
|
+
# Workers for the projects in the folder, each answering as a Project, which allows bare directives in any file,
|
|
29
|
+
# and runs endpoints with no response as it writes back, when asked, like with --bare and -x.
|
|
30
|
+
def self.workers(root:, bare: false, execute: false, **)
|
|
31
|
+
Workers.new(root:, **) { new(root: it, bare:, execute:) }
|
|
32
|
+
end
|
|
33
|
+
|
|
27
34
|
# The projects inside this one have workers of their own, so their files are left out.
|
|
28
|
-
def initialize(root:)
|
|
35
|
+
def initialize(root:, bare: false, execute: false)
|
|
29
36
|
@root = root
|
|
37
|
+
@bare = bare
|
|
38
|
+
@execute = execute
|
|
30
39
|
@load_errors = []
|
|
31
40
|
@file_errors = []
|
|
32
41
|
Nodes.load(File.join(root, Nodes::FOLDER))
|
|
@@ -36,7 +45,7 @@ module Raindown
|
|
|
36
45
|
|
|
37
46
|
# A file's lines, headings and directives.
|
|
38
47
|
def outline(path)
|
|
39
|
-
document = Document.read(path)
|
|
48
|
+
document = Document.read(path, bare: @bare)
|
|
40
49
|
Outline.new(lines: File.readlines(path, chomp: true), headings: document.headings, directives: document.directives)
|
|
41
50
|
end
|
|
42
51
|
|
|
@@ -52,23 +61,25 @@ module Raindown
|
|
|
52
61
|
{ cards: drawn, errors: @load_errors + @file_errors }
|
|
53
62
|
end
|
|
54
63
|
|
|
55
|
-
# Every endpoint in the project's files
|
|
64
|
+
# Every endpoint in the project's files that writes its response back, so it can run, by its request, leaving out
|
|
65
|
+
# the ones whose snippet can't be read.
|
|
56
66
|
def endpoints
|
|
57
67
|
Projects.files(root).flat_map do |path|
|
|
58
68
|
lines = File.readlines(path, encoding: Encoding::UTF_8)
|
|
59
|
-
Document.read(path).directives.select
|
|
69
|
+
runnable = Document.read(path, bare: @bare).directives.select { it.endpoint? && it.writes }
|
|
70
|
+
runnable.filter_map { |directive| endpoint(path, lines, directive) }
|
|
60
71
|
rescue StandardError
|
|
61
72
|
[]
|
|
62
73
|
end
|
|
63
74
|
end
|
|
64
75
|
|
|
65
76
|
def write_back(path)
|
|
66
|
-
Actions.write_back(path)
|
|
77
|
+
Actions.write_back(path, bare: @bare, execute: @execute)
|
|
67
78
|
end
|
|
68
79
|
|
|
69
80
|
# Runs the endpoint on the line, replacing its response.
|
|
70
81
|
def execute(path, line)
|
|
71
|
-
directive = Document.read(path).directives.find { it.line == line && it.endpoint? }
|
|
82
|
+
directive = Document.read(path, bare: @bare).directives.find { it.line == line && it.endpoint? }
|
|
72
83
|
raise Actions::NotAnEndpoint, "There's no endpoint on line #{line}" unless directive
|
|
73
84
|
|
|
74
85
|
Actions.execute(path, directive)
|
|
@@ -84,7 +95,7 @@ module Raindown
|
|
|
84
95
|
|
|
85
96
|
def charts_in(path)
|
|
86
97
|
content = File.read(path, encoding: Encoding::UTF_8)
|
|
87
|
-
bare = BareDirectives.enabled?(path:, content:)
|
|
98
|
+
bare = @bare || BareDirectives.enabled?(path:, content:)
|
|
88
99
|
lines = content.lines
|
|
89
100
|
headings = Document.parse(content, bare:).headings
|
|
90
101
|
|
|
@@ -106,7 +117,7 @@ module Raindown
|
|
|
106
117
|
|
|
107
118
|
def chart(block, path:, lines:, headings:)
|
|
108
119
|
line = block[:open] + 1
|
|
109
|
-
directive = lines[block[:open]]
|
|
120
|
+
directive = Directives.text(lines[block[:open]])
|
|
110
121
|
|
|
111
122
|
{ path:, line:, heading: headings.select { it.line < line }.last&.text, directive:, node: block[:node] }
|
|
112
123
|
end
|
|
@@ -7,6 +7,7 @@ require_relative '../text'
|
|
|
7
7
|
module Raindown
|
|
8
8
|
module TUI
|
|
9
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
|
+
# Like the Layout, it's made again for each draw, so it draws what the Navigator and its view hold then.
|
|
10
11
|
Renderer = Struct.new(:navigator, :pane, :width, :height) do
|
|
11
12
|
include Rows
|
|
12
13
|
include Text
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../watcher/changes'
|
|
4
|
+
|
|
5
|
+
module Raindown
|
|
6
|
+
module TUI
|
|
7
|
+
# The files saved, added or removed in the terminal UI's folder, like when it's watched, but kept until the Navigator
|
|
8
|
+
# takes them on its next tick, as Listen tells of them in a thread of its own.
|
|
9
|
+
class Saves
|
|
10
|
+
def initialize(root)
|
|
11
|
+
@queue = Thread::Queue.new
|
|
12
|
+
@listener = Watcher.listen(root) { |paths| paths.each { @queue << it } }
|
|
13
|
+
@listener.start
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# The paths saved since they were last taken, once each.
|
|
17
|
+
def take = Array.new(@queue.size) { @queue.pop }.uniq
|
|
18
|
+
|
|
19
|
+
def stop = @listener.stop
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -5,30 +5,35 @@ require_relative 'text'
|
|
|
5
5
|
|
|
6
6
|
module Raindown
|
|
7
7
|
module TUI
|
|
8
|
-
#
|
|
9
|
-
# each with where it is, and the selected one highlighted:
|
|
10
|
-
#
|
|
8
|
+
# A search in a box, like Teleport's, drawn over the screen's lines below the title: what's typed, then the places
|
|
9
|
+
# that match it, each with where it is, and the selected one highlighted:
|
|
10
|
+
# ┌──────────────── Teleport ────────────────┐
|
|
11
11
|
# │ / stp▏ │
|
|
12
12
|
# │ ## Setup guides/setup.md:3 │
|
|
13
13
|
# │ ## Steps notes.md:12 │
|
|
14
14
|
# └──────────────────────────────────────────┘
|
|
15
|
-
# Each place is clicked to open it.
|
|
16
|
-
|
|
15
|
+
# Each place is clicked to open it. Another search, like Blueprints' filter, is drawn the same, under its own title,
|
|
16
|
+
# from its query, its matches, each with its text and where it is, from the first it lists, and its selected match,
|
|
17
|
+
# clicked in its pane.
|
|
18
|
+
class SearchBox
|
|
17
19
|
include Text
|
|
18
20
|
|
|
19
21
|
# The widest the box is, with its border.
|
|
20
22
|
MAX_WIDTH = 72
|
|
21
23
|
|
|
22
|
-
def initialize(
|
|
23
|
-
@
|
|
24
|
+
def initialize(search, width:, title: 'Teleport', pane: :teleport, top: 1)
|
|
25
|
+
@search = search
|
|
26
|
+
@title = title
|
|
27
|
+
@pane = pane
|
|
28
|
+
@top = top
|
|
24
29
|
@box_width = [width - 4, MAX_WIDTH].min
|
|
25
30
|
@column = (width - @box_width) / 2
|
|
26
31
|
end
|
|
27
32
|
|
|
28
|
-
# The lines with the box drawn over them, from
|
|
33
|
+
# The lines with the box drawn over them, from its top line, below the title.
|
|
29
34
|
def apply(lines)
|
|
30
35
|
box.each_with_index do |segments, offset|
|
|
31
|
-
row = offset +
|
|
36
|
+
row = offset + @top
|
|
32
37
|
lines[row] = overlay_segments(lines[row], segments, column: @column) if lines[row]
|
|
33
38
|
end
|
|
34
39
|
lines
|
|
@@ -40,22 +45,23 @@ module Raindown
|
|
|
40
45
|
def inner = @box_width - 4
|
|
41
46
|
|
|
42
47
|
def box
|
|
43
|
-
[
|
|
48
|
+
[title_edge, boxed(segment("/ #{@search.query}▏", :menu, width: inner)), *results, edge('└', '┘')]
|
|
44
49
|
end
|
|
45
50
|
|
|
46
51
|
def results
|
|
47
|
-
matches = @
|
|
52
|
+
matches = @search.matches
|
|
48
53
|
return [boxed(segment('No matches', :info, width: inner))] if matches.empty?
|
|
49
54
|
|
|
50
|
-
|
|
55
|
+
first = @search.first_match
|
|
56
|
+
matches.each_with_index.map { |place, index| place_line(place, first + index) }
|
|
51
57
|
end
|
|
52
58
|
|
|
53
59
|
# The place's text, then where it is, on the right, both highlighted when it's selected.
|
|
54
60
|
def place_line(place, index)
|
|
55
61
|
text_style = :menu
|
|
56
62
|
where_style = :body
|
|
57
|
-
text_style = where_style = :selected if @
|
|
58
|
-
event = Click.new(pane:
|
|
63
|
+
text_style = where_style = :selected if @search.match == index
|
|
64
|
+
event = Click.new(pane: @pane, index:, action: :enter)
|
|
59
65
|
where = " #{place.where}"
|
|
60
66
|
where_width = [display_width(where), inner / 2].min
|
|
61
67
|
|
|
@@ -65,6 +71,13 @@ module Raindown
|
|
|
65
71
|
|
|
66
72
|
def boxed(*segments) = [segment('│ ', :border, width: 2), *segments, segment(' │', :border, width: 2)]
|
|
67
73
|
|
|
74
|
+
# The box's top, with its title in the middle.
|
|
75
|
+
def title_edge
|
|
76
|
+
title = " #{@title} "
|
|
77
|
+
left = (@box_width - 2 - display_width(title)) / 2
|
|
78
|
+
edge("┌#{'─' * left}#{title}", '┐')
|
|
79
|
+
end
|
|
80
|
+
|
|
68
81
|
def edge(left, right)
|
|
69
82
|
[segment("#{left}#{'─' * (@box_width - display_width(left) - 1)}#{right}", :border, width: @box_width)]
|
|
70
83
|
end
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'curses'
|
|
4
|
-
require 'unicode/display_width'
|
|
5
4
|
|
|
6
5
|
require_relative '../events'
|
|
6
|
+
require_relative '../text'
|
|
7
|
+
require_relative 'quiet'
|
|
7
8
|
|
|
8
9
|
module Raindown
|
|
9
10
|
module TUI
|
|
@@ -12,12 +13,14 @@ module Raindown
|
|
|
12
13
|
KEYS = {
|
|
13
14
|
Curses::KEY_UP => :up, 'k' => :up, Curses::KEY_DOWN => :down, 'j' => :down,
|
|
14
15
|
Curses::KEY_LEFT => :left, 'h' => :left, Curses::KEY_RIGHT => :right, 'l' => :right,
|
|
16
|
+
Curses::KEY_SLEFT => :scroll_left, Curses::KEY_SRIGHT => :scroll_right, Curses::KEY_SR => :scroll_up,
|
|
17
|
+
Curses::KEY_SF => :scroll_down,
|
|
15
18
|
"\t" => :tab, Curses::KEY_BTAB => :tab, "\r" => :enter, "\n" => :enter, Curses::KEY_ENTER => :enter,
|
|
16
19
|
' ' => :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,
|
|
20
|
+
'p' => :projects, 'f' => :feelings, 'b' => :blueprints, "\x14" => :celebrate, '/' => :search,
|
|
21
|
+
'1' => :bin1, '2' => :bin2, '3' => :bin3, '4' => :bin4, '5' => :bin5, "\e" => :escape, 'q' => :quit
|
|
19
22
|
}.freeze
|
|
20
|
-
# While typing, like in
|
|
23
|
+
# While typing, like in Teleport, printable characters are typed, and only these keys do anything else.
|
|
21
24
|
TYPING_KEYS = {
|
|
22
25
|
Curses::KEY_UP => :up, Curses::KEY_DOWN => :down, "\r" => :enter, "\n" => :enter, Curses::KEY_ENTER => :enter,
|
|
23
26
|
"\e" => :escape, Curses::KEY_BACKSPACE => :backspace, "\x7F" => :backspace, "\b" => :backspace
|
|
@@ -25,6 +28,24 @@ module Raindown
|
|
|
25
28
|
|
|
26
29
|
module_function
|
|
27
30
|
|
|
31
|
+
# The next event, or :tick when no key is pressed within the timeout, in milliseconds, or nil for keys without one,
|
|
32
|
+
# like a resize, which clears the screen to draw it again.
|
|
33
|
+
def read(timeout, zones:, typing:)
|
|
34
|
+
Curses.stdscr.timeout = timeout
|
|
35
|
+
key = Curses.stdscr.getch
|
|
36
|
+
return :tick if key.nil?
|
|
37
|
+
return mouse(zones) if key == Curses::KEY_MOUSE
|
|
38
|
+
|
|
39
|
+
Curses.stdscr.clear if key == Curses::KEY_RESIZE
|
|
40
|
+
key_event(key, typing:)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The curses gem warns the first time it makes a MouseEvent, that it's "undefining the allocator" of the class.
|
|
44
|
+
def mouse(zones)
|
|
45
|
+
event = Quiet.quietly { Curses.getmouse }
|
|
46
|
+
mouse_event(event, zones) if event
|
|
47
|
+
end
|
|
48
|
+
|
|
28
49
|
# A key from getch, which is a string for a printable character and otherwise a number.
|
|
29
50
|
# Letters are the same with Shift, as the buttons show them as capitals, unless they're being typed.
|
|
30
51
|
def key_event(key, typing: false)
|
|
@@ -43,6 +64,10 @@ module Raindown
|
|
|
43
64
|
# like macOS's, has no button for scrolling down, it can't be mistaken for a click.
|
|
44
65
|
CLICKS = Curses::BUTTON1_PRESSED | Curses::BUTTON1_CLICKED
|
|
45
66
|
|
|
67
|
+
# Letting go of the button, which older ncurses, like macOS's, also reports for each move while it's held, as it
|
|
68
|
+
# doesn't know the moves terminals send then.
|
|
69
|
+
RELEASE = Curses::BUTTON1_RELEASED
|
|
70
|
+
|
|
46
71
|
# Moving the mouse, which terminals report once they're asked to, with any motion tracking.
|
|
47
72
|
MOTION = Curses::REPORT_MOUSE_POSITION
|
|
48
73
|
ANY_MOTION = "\e[?1003h"
|
|
@@ -51,7 +76,7 @@ module Raindown
|
|
|
51
76
|
# The terminal sends the app its mouse events, clicks and moves, so scrolling doesn't scroll the terminal either.
|
|
52
77
|
# ncurses only asks some terminals to report every move, so it's asked directly too, until untrack_mouse.
|
|
53
78
|
def track_mouse
|
|
54
|
-
Curses.mousemask(CLICKS | MOTION)
|
|
79
|
+
Curses.mousemask(CLICKS | RELEASE | MOTION)
|
|
55
80
|
Curses.mouseinterval(0)
|
|
56
81
|
$stdout.write(ANY_MOTION)
|
|
57
82
|
$stdout.flush
|
|
@@ -62,20 +87,46 @@ module Raindown
|
|
|
62
87
|
$stdout.flush
|
|
63
88
|
end
|
|
64
89
|
|
|
65
|
-
# A click sends the event of what's under it, and moving the mouse a Hover of it
|
|
90
|
+
# A click sends the event of what's under it, and moving the mouse a Hover of it, or a Drag by how far it's moved
|
|
91
|
+
# since the last event while the button's held. Terminals and ncurses versions report that in their own ways: a
|
|
92
|
+
# move the button's held for, a move after a press, until the release, or with older ncurses and some terminals,
|
|
93
|
+
# a release somewhere else than the last event, for each move. A release where the mouse already is, which ends a
|
|
94
|
+
# click or a drag, does nothing.
|
|
66
95
|
def mouse_event(event, zones)
|
|
96
|
+
from = @last
|
|
97
|
+
@last = [event.x, event.y]
|
|
98
|
+
held = hold(event.bstate)
|
|
99
|
+
return drag(from, event) if from && dragged?(event, held:, moved: from != @last)
|
|
100
|
+
return if event.bstate.anybits?(RELEASE)
|
|
101
|
+
|
|
67
102
|
target = click(zones, column: event.x, row: event.y)
|
|
68
103
|
return target if event.bstate.anybits?(CLICKS)
|
|
69
104
|
|
|
70
105
|
Hover.new(target:) if event.bstate.anybits?(MOTION)
|
|
71
106
|
end
|
|
72
107
|
|
|
108
|
+
# Whether the button was held before this event, which holds it from a press until a release.
|
|
109
|
+
def hold(bstate)
|
|
110
|
+
held = @held
|
|
111
|
+
@held = bstate.anybits?(CLICKS) || (held && !bstate.anybits?(RELEASE))
|
|
112
|
+
held
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def drag(from, event) = Drag.new(columns: event.x - from[0], rows: event.y - from[1])
|
|
116
|
+
|
|
117
|
+
def dragged?(event, held:, moved:)
|
|
118
|
+
bstate = event.bstate
|
|
119
|
+
return held || bstate.anybits?(Curses::BUTTON1_PRESSED) if bstate.anybits?(MOTION)
|
|
120
|
+
|
|
121
|
+
moved && bstate.anybits?(RELEASE)
|
|
122
|
+
end
|
|
123
|
+
|
|
73
124
|
# Where each of a Layout's segments with an event is on the screen, as [row, columns, event].
|
|
74
125
|
def zones(lines)
|
|
75
126
|
lines.each_with_index.flat_map do |segments, row|
|
|
76
127
|
column = 0
|
|
77
128
|
segments.filter_map do |segment|
|
|
78
|
-
width =
|
|
129
|
+
width = Text.display_width(segment.text)
|
|
79
130
|
zone = [row, column...(column + width), segment.event] if segment.event
|
|
80
131
|
column += width
|
|
81
132
|
zone
|