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,18 +4,22 @@ require 'curses'
|
|
|
4
4
|
|
|
5
5
|
require_relative 'input'
|
|
6
6
|
require_relative '../layout'
|
|
7
|
-
require_relative 'quiet'
|
|
8
7
|
require_relative 'ncurses_shim'
|
|
8
|
+
require_relative 'quiet'
|
|
9
9
|
require_relative 'styles'
|
|
10
|
+
require_relative 'terminal_title'
|
|
10
11
|
|
|
11
12
|
module Raindown
|
|
12
13
|
module TUI
|
|
13
14
|
# Paints a Navigator's Layout with curses, and sends the Navigator the events of keys and clicks, read with Input.
|
|
14
15
|
# Only this class draws on the terminal, so another library could draw the same Layout.
|
|
15
16
|
class Screen
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
prepend TerminalTitle
|
|
18
|
+
|
|
19
|
+
# How often, in milliseconds, the screen's drawn, and the open file checked for changes, while no key is pressed, by
|
|
20
|
+
# the view's pace, from when the last frame started drawing: every half second while it's active, at 12 frames a
|
|
21
|
+
# second while it's reactive, just faster than Code feelings' digits tremble, and at 20 during an animation.
|
|
22
|
+
TICKS = { active: 500, reactive: 83, animation: 50 }.freeze
|
|
19
23
|
|
|
20
24
|
def initialize(navigator)
|
|
21
25
|
@navigator = navigator
|
|
@@ -32,11 +36,9 @@ module Raindown
|
|
|
32
36
|
start
|
|
33
37
|
|
|
34
38
|
loop do
|
|
39
|
+
drawn_at = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
|
|
35
40
|
draw
|
|
36
|
-
|
|
37
|
-
break if effect == :quit
|
|
38
|
-
|
|
39
|
-
perform(effect)
|
|
41
|
+
break if take_events(drawn_at) == :quit
|
|
40
42
|
end
|
|
41
43
|
rescue Interrupt
|
|
42
44
|
nil
|
|
@@ -44,6 +46,23 @@ module Raindown
|
|
|
44
46
|
stop
|
|
45
47
|
end
|
|
46
48
|
|
|
49
|
+
# The Navigator takes the next event, then any others already waiting, like the mouse's many moves, before the
|
|
50
|
+
# screen's drawn again, so drawing doesn't fall further and further behind them.
|
|
51
|
+
def take_events(drawn_at)
|
|
52
|
+
event = Input.read(wait(drawn_at), zones: @zones, typing: @navigator.typing?)
|
|
53
|
+
loop do
|
|
54
|
+
effect = @navigator.next_step(event)
|
|
55
|
+
return effect if effect == :quit
|
|
56
|
+
|
|
57
|
+
perform(effect)
|
|
58
|
+
event = Input.read(0, zones: @zones, typing: @navigator.typing?)
|
|
59
|
+
return if event == :tick
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The milliseconds left of the pace's tick since the frame started drawing, so drawing doesn't slow the pace.
|
|
64
|
+
def wait(drawn_at) = [TICKS.fetch(@navigator.pace) + drawn_at - Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond), 0].max
|
|
65
|
+
|
|
47
66
|
# Runs a command, like the editor, then tells the Navigator, or shows a message while a slow task runs.
|
|
48
67
|
def perform(effect)
|
|
49
68
|
case effect
|
|
@@ -105,23 +124,6 @@ module Raindown
|
|
|
105
124
|
[*rest, last.with(text: last.text[0...-1])]
|
|
106
125
|
end
|
|
107
126
|
|
|
108
|
-
# The next event, or :tick when no key is pressed in time, or nil for keys without one, like a resize.
|
|
109
|
-
def read
|
|
110
|
-
Curses.stdscr.timeout = TICKS.fetch(@navigator.animating?)
|
|
111
|
-
key = Curses.stdscr.getch
|
|
112
|
-
return :tick if key.nil?
|
|
113
|
-
return mouse if key == Curses::KEY_MOUSE
|
|
114
|
-
|
|
115
|
-
Curses.stdscr.clear if key == Curses::KEY_RESIZE
|
|
116
|
-
Input.key_event(key, typing: @navigator.typing?)
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
# The curses gem warns the first time it makes a MouseEvent, that it's "undefining the allocator" of the class.
|
|
120
|
-
def mouse
|
|
121
|
-
event = Quiet.quietly { Curses.getmouse }
|
|
122
|
-
Input.mouse_event(event, @zones) if event
|
|
123
|
-
end
|
|
124
|
-
|
|
125
127
|
# Shows a message in the status line while something slow runs, like an endpoint's request.
|
|
126
128
|
def show(message)
|
|
127
129
|
window = Curses.stdscr
|
|
@@ -132,13 +134,16 @@ module Raindown
|
|
|
132
134
|
end
|
|
133
135
|
|
|
134
136
|
# Leaves the screen to the command, like the editor, and comes back to it after. The command isn't sent the
|
|
135
|
-
# mouse's moves meanwhile.
|
|
137
|
+
# mouse's moves meanwhile. Ctrl+C, like to leave Claude Code, goes to the TUI too, which waits for the command
|
|
138
|
+
# rather than quitting, while the command gets Ctrl+C as usual.
|
|
136
139
|
def suspend
|
|
140
|
+
interrupt = trap('INT') { nil }
|
|
137
141
|
Curses.def_prog_mode
|
|
138
142
|
Input.untrack_mouse
|
|
139
143
|
Curses.close_screen
|
|
140
144
|
yield
|
|
141
145
|
ensure
|
|
146
|
+
trap('INT', interrupt)
|
|
142
147
|
Curses.reset_prog_mode
|
|
143
148
|
Input.track_mouse
|
|
144
149
|
Curses.stdscr.clear
|
|
@@ -5,7 +5,8 @@ require 'curses'
|
|
|
5
5
|
module Raindown
|
|
6
6
|
module TUI
|
|
7
7
|
# The curses attributes of a Layout's styles. Text is periwinkle where there are 256 colours, and the terminal's own
|
|
8
|
-
# colour otherwise,
|
|
8
|
+
# colour otherwise, while the title, headings, and the lines between the panes and beneath the title are cornflower
|
|
9
|
+
# blue. The selected
|
|
9
10
|
# row is white on blue, or on dark grey in the panes without the focus, while other colours are on the terminal's own
|
|
10
11
|
# background.
|
|
11
12
|
# With only 8 colours, there's no dark grey, so those rows are underlined, and without colours,
|
|
@@ -13,23 +14,31 @@ module Raindown
|
|
|
13
14
|
module Styles
|
|
14
15
|
COLORS = { button: Curses::COLOR_YELLOW, error: Curses::COLOR_RED }.freeze
|
|
15
16
|
# Directives, like charts' on the Dashboard, in purple, and the folders, projects and pages in the files, the title,
|
|
16
|
-
# and headings, like the outline's and the Dashboard cards', in cornflower blue,
|
|
17
|
+
# and headings, like the outline's and the Dashboard cards', in cornflower blue, and the waffle's syrup in brown,
|
|
18
|
+
# from the 256 colours most terminals
|
|
17
19
|
# have, or magenta and blue with 8.
|
|
18
|
-
|
|
20
|
+
CORNFLOWER = 111
|
|
21
|
+
ACCENTS = { directive: 141, folder: CORNFLOWER, title: CORNFLOWER, heading: CORNFLOWER, syrup: 130 }.freeze
|
|
19
22
|
BASIC_ACCENTS = { directive: Curses::COLOR_MAGENTA, folder: Curses::COLOR_BLUE, title: Curses::COLOR_BLUE,
|
|
20
|
-
heading: Curses::COLOR_BLUE }.freeze
|
|
23
|
+
heading: Curses::COLOR_BLUE, syrup: Curses::COLOR_YELLOW }.freeze
|
|
24
|
+
|
|
25
|
+
# The green of GET, and of a selected method in Code feelings that's already been felt.
|
|
26
|
+
GREEN = 34
|
|
27
|
+
|
|
21
28
|
# The colours of endpoints' HTTP methods, like GET in green, from the 256 colours most terminals have,
|
|
22
29
|
# which are the same in every theme, unlike the 8 they all do, where green can look yellow.
|
|
23
|
-
METHODS = { get:
|
|
30
|
+
METHODS = { get: GREEN, post: 33, delete: 196, put: 220, patch: 171, head: 44, options: 44 }.freeze
|
|
24
31
|
BASIC_METHODS = { get: Curses::COLOR_GREEN, post: Curses::COLOR_BLUE, delete: Curses::COLOR_RED,
|
|
25
32
|
put: Curses::COLOR_YELLOW, patch: Curses::COLOR_MAGENTA, head: Curses::COLOR_CYAN,
|
|
26
33
|
options: Curses::COLOR_CYAN }.freeze
|
|
34
|
+
|
|
27
35
|
# Highlighted code's colours, by the kind of token, from the 256 colours most terminals have, or the 8 they all do,
|
|
28
36
|
# where comments are dim instead of grey.
|
|
29
37
|
CODE = { code_keyword: 204, code_string: 114, code_number: 179, code_function: 75, code_constant: 117,
|
|
30
38
|
code_comment: 244 }.freeze
|
|
31
39
|
BASIC_CODE = { code_keyword: Curses::COLOR_MAGENTA, code_string: Curses::COLOR_GREEN, code_number: Curses::COLOR_YELLOW,
|
|
32
40
|
code_function: Curses::COLOR_BLUE, code_constant: Curses::COLOR_CYAN, code_comment: Curses::COLOR_WHITE }.freeze
|
|
41
|
+
|
|
33
42
|
# Code feelings' bright colours, for its rain and fireworks, from the 256 colours most terminals have, or
|
|
34
43
|
# the 8 they all do.
|
|
35
44
|
BRIGHT = { bright_red: 203, bright_orange: 215, bright_yellow: 227, bright_green: 120, bright_cyan: 87,
|
|
@@ -37,17 +46,26 @@ module Raindown
|
|
|
37
46
|
BASIC_BRIGHT = { bright_red: Curses::COLOR_RED, bright_orange: Curses::COLOR_YELLOW, bright_yellow: Curses::COLOR_YELLOW,
|
|
38
47
|
bright_green: Curses::COLOR_GREEN, bright_cyan: Curses::COLOR_CYAN, bright_blue: Curses::COLOR_BLUE,
|
|
39
48
|
bright_purple: Curses::COLOR_MAGENTA, bright_pink: Curses::COLOR_MAGENTA }.freeze
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
49
|
+
|
|
50
|
+
# The selected row's blue, the green of a selected method in Code feelings that's already been felt, and the
|
|
51
|
+
# cornflower blue of the lines between the panes and beneath the title, from the 256 colours most terminals have,
|
|
52
|
+
# or the 8 they all do.
|
|
53
|
+
BLUES = { selected: 33, felt: GREEN, line: CORNFLOWER }.freeze
|
|
54
|
+
BASIC_BLUES = { selected: Curses::COLOR_CYAN, felt: Curses::COLOR_GREEN, line: Curses::COLOR_BLUE }.freeze
|
|
55
|
+
|
|
43
56
|
# Text that has no colour of its own, from the 256 colours most terminals have, or the terminal's own with 8.
|
|
44
57
|
PERIWINKLE = 147
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
58
|
+
|
|
59
|
+
# Code feelings' text and lines, in lavender, or magenta in 8 colours, and the purple its magnified digits take turns
|
|
60
|
+
# with, or blue.
|
|
61
|
+
FEELINGS = { feelings: 183, feelings_purple: 135 }.freeze
|
|
62
|
+
BASIC_FEELINGS = { feelings: Curses::COLOR_MAGENTA, feelings_purple: Curses::COLOR_BLUE }.freeze
|
|
48
63
|
BRIGHT_WHITE = 15
|
|
49
64
|
DARK_GREY = 238
|
|
50
65
|
|
|
66
|
+
# Code feelings' red when the screen's too small for it, or the 8 colours' red.
|
|
67
|
+
DARK_RED = 88
|
|
68
|
+
|
|
51
69
|
module_function
|
|
52
70
|
|
|
53
71
|
# Sets up the colours, once the screen has started, and returns the attribute of each style.
|
|
@@ -61,24 +79,32 @@ module Raindown
|
|
|
61
79
|
# Without colours, a style that isn't here is the terminal's own.
|
|
62
80
|
def plain
|
|
63
81
|
Hash.new(Curses::A_NORMAL).merge(
|
|
64
|
-
title: Curses::A_REVERSE | Curses::A_BOLD, selected: Curses::A_REVERSE,
|
|
82
|
+
title: Curses::A_REVERSE | Curses::A_BOLD, selected: Curses::A_REVERSE, too_small: Curses::A_REVERSE,
|
|
83
|
+
feelings_selected: Curses::A_REVERSE | Curses::A_BOLD, felt_selected: Curses::A_REVERSE | Curses::A_BOLD,
|
|
84
|
+
selected_button: Curses::A_REVERSE,
|
|
65
85
|
cursor: Curses::A_UNDERLINE, cursor_button: Curses::A_UNDERLINE | Curses::A_BOLD, heading: Curses::A_BOLD,
|
|
66
86
|
body: Curses::A_DIM, error: Curses::A_BOLD, button: Curses::A_BOLD, folder: Curses::A_BOLD, project: Curses::A_BOLD, page: Curses::A_BOLD,
|
|
67
87
|
key: Curses::A_REVERSE, border: Curses::A_DIM, divider: Curses::A_BOLD,
|
|
68
|
-
selected_divider: Curses::A_REVERSE, cursor_divider: Curses::A_BOLD,
|
|
69
88
|
code_keyword: Curses::A_BOLD, code_comment: Curses::A_DIM
|
|
70
89
|
)
|
|
71
90
|
end
|
|
72
91
|
|
|
73
92
|
def colored
|
|
74
93
|
in_text_color.merge(
|
|
75
|
-
title: pair(:title) | Curses::A_BOLD,
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
**dividers, **code_tokens, **bright, **files, **unfocused
|
|
94
|
+
title: pair(:title) | Curses::A_BOLD, directive: pair(:directive), error: pair(:error) | Curses::A_BOLD,
|
|
95
|
+
button: pair(:button) | Curses::A_BOLD, **selections, **emphasis, **dividers, **code_tokens, **bright, **files,
|
|
96
|
+
**unfocused
|
|
79
97
|
)
|
|
80
98
|
end
|
|
81
99
|
|
|
100
|
+
# The selected row, its buttons, Code feelings' selected method, bold, on green when it's already been felt, and its
|
|
101
|
+
# red when the screen's too small for it.
|
|
102
|
+
def selections
|
|
103
|
+
{ selected: pair(:selected) | white_bold, feelings_selected: pair(:selected) | Curses::A_BOLD,
|
|
104
|
+
felt_selected: pair(:felt_selected) | Curses::A_BOLD,
|
|
105
|
+
too_small: pair(:too_small) | white_bold, selected_button: pair(:selected_button) | Curses::A_BOLD }
|
|
106
|
+
end
|
|
107
|
+
|
|
82
108
|
# The styles without a colour of their own, or that are only bold, dim or the like, in the text's colour, which is
|
|
83
109
|
# also any style that isn't here.
|
|
84
110
|
def in_text_color
|
|
@@ -86,11 +112,11 @@ module Raindown
|
|
|
86
112
|
Hash.new(text).merge(plain.transform_values { it | text })
|
|
87
113
|
end
|
|
88
114
|
|
|
89
|
-
# Headings, and Code feelings' text and lines,
|
|
90
|
-
#
|
|
115
|
+
# Headings, the waffle's butter's shine and its syrup, and Code feelings' text and lines, bold around the method the
|
|
116
|
+
# mouse is over, and its purple.
|
|
91
117
|
def emphasis
|
|
92
|
-
|
|
93
|
-
|
|
118
|
+
{ heading: pair(:heading) | Curses::A_BOLD, feelings: pair(:feelings), nearby: pair(:feelings) | Curses::A_BOLD,
|
|
119
|
+
feelings_purple: pair(:feelings_purple), shine: pair(:shine) | Curses::A_BOLD, syrup: pair(:syrup) }
|
|
94
120
|
end
|
|
95
121
|
|
|
96
122
|
# Highlighted code's tokens, like keywords and comments, with comments dim in 8 colours, where there's no grey.
|
|
@@ -103,8 +129,8 @@ module Raindown
|
|
|
103
129
|
# The bright colours, bold, so they're bright in 8 colours too.
|
|
104
130
|
def bright = BRIGHT.keys.to_h { [it, pair(it) | Curses::A_BOLD] }
|
|
105
131
|
|
|
106
|
-
# The
|
|
107
|
-
def dividers = { divider: pair(:divider)
|
|
132
|
+
# The lines between the panes and beneath the title.
|
|
133
|
+
def dividers = { divider: pair(:divider) }
|
|
108
134
|
|
|
109
135
|
def files
|
|
110
136
|
{ folder: pair(:folder) | Curses::A_BOLD, project: pair(:folder) | Curses::A_BOLD,
|
|
@@ -113,9 +139,9 @@ module Raindown
|
|
|
113
139
|
|
|
114
140
|
# The selected rows of the panes without the focus, on dark grey where there's one.
|
|
115
141
|
def unfocused
|
|
116
|
-
return { cursor_button: pair(:button) | Curses::A_BOLD
|
|
142
|
+
return { cursor_button: pair(:button) | Curses::A_BOLD } unless Curses.colors >= 256
|
|
117
143
|
|
|
118
|
-
{ cursor: pair(:cursor), cursor_button: pair(:cursor_button) | Curses::A_BOLD
|
|
144
|
+
{ cursor: pair(:cursor), cursor_button: pair(:cursor_button) | Curses::A_BOLD }
|
|
119
145
|
end
|
|
120
146
|
|
|
121
147
|
# Each colour pair as [name, foreground, background], where -1 is the terminal's own background.
|
|
@@ -130,9 +156,10 @@ module Raindown
|
|
|
130
156
|
*(many ? CODE.merge(BRIGHT, FEELINGS, ACCENTS) : BASIC_CODE.merge(BASIC_BRIGHT, BASIC_FEELINGS, BASIC_ACCENTS))
|
|
131
157
|
.map { |name, color| [name, color, -1] },
|
|
132
158
|
[:text, text, -1],
|
|
133
|
-
[:selected, white, blues[:selected]], [:
|
|
134
|
-
[:
|
|
135
|
-
[:
|
|
159
|
+
[:selected, white, blues[:selected]], [:felt_selected, white, blues[:felt]], [:too_small, white, many ? DARK_RED : Curses::COLOR_RED],
|
|
160
|
+
[:selected_button, Curses::COLOR_YELLOW, blues[:selected]],
|
|
161
|
+
[:cursor, white, DARK_GREY], [:cursor_button, Curses::COLOR_YELLOW, DARK_GREY], [:divider, blues[:line], -1],
|
|
162
|
+
[:shine, white, -1]]
|
|
136
163
|
end
|
|
137
164
|
|
|
138
165
|
def pair(name)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Raindown
|
|
4
|
+
module TUI
|
|
5
|
+
# The terminal's title while the Screen's open, which it's prepended to: blank, rather than the command's name,
|
|
6
|
+
# saving the title it had, then putting it back, which terminals that can't ignore. It's a space, as terminals like
|
|
7
|
+
# Ghostty take an empty title to mean their own.
|
|
8
|
+
module TerminalTitle
|
|
9
|
+
BLANK_TITLE = "\e]2; \a"
|
|
10
|
+
SAVE_TITLE = "\e[22;2t"
|
|
11
|
+
RESTORE_TITLE = "\e[23;2t"
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def start
|
|
16
|
+
super
|
|
17
|
+
terminal("#{SAVE_TITLE}#{BLANK_TITLE}")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def stop
|
|
21
|
+
terminal(RESTORE_TITLE)
|
|
22
|
+
super
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def terminal(sequence)
|
|
26
|
+
$stdout.write(sequence)
|
|
27
|
+
$stdout.flush
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
data/lib/raindown/tui/text.rb
CHANGED
|
@@ -8,6 +8,16 @@ module Raindown
|
|
|
8
8
|
module Text
|
|
9
9
|
Segment = Data.define(:text, :style, :event)
|
|
10
10
|
INDENT = ' '
|
|
11
|
+
WIDTHS = 10_000
|
|
12
|
+
|
|
13
|
+
# Printable ASCII, like most code and labels, is a column a character, without asking Unicode, and other text,
|
|
14
|
+
# like boxes' borders and magnified digits, is the same every frame, so its width's remembered, up to WIDTHS texts.
|
|
15
|
+
def self.display_width(text)
|
|
16
|
+
return text.length if text.match?(/\A[ -~]*\z/)
|
|
17
|
+
|
|
18
|
+
@widths = {} if @widths.nil? || @widths.size >= WIDTHS
|
|
19
|
+
@widths[text] ||= Unicode::DisplayWidth.of(text)
|
|
20
|
+
end
|
|
11
21
|
|
|
12
22
|
private
|
|
13
23
|
|
|
@@ -27,7 +37,9 @@ module Raindown
|
|
|
27
37
|
# Text cut or padded to the width, with a cut shown by an ellipsis.
|
|
28
38
|
def fit(text, width)
|
|
29
39
|
return '' if width <= 0
|
|
30
|
-
|
|
40
|
+
|
|
41
|
+
text_width = display_width(text)
|
|
42
|
+
return text + (' ' * (width - text_width)) if text_width <= width
|
|
31
43
|
|
|
32
44
|
cut = +''
|
|
33
45
|
cut_width = 0
|
|
@@ -83,28 +95,39 @@ module Raindown
|
|
|
83
95
|
[*segments_between(line, 0, column), *segments, *segments_between(line, column + width, Float::INFINITY)]
|
|
84
96
|
end
|
|
85
97
|
|
|
86
|
-
# The parts of the line's segments between two columns, where a wide character cut in two leaves spaces.
|
|
98
|
+
# The parts of the line's segments between two columns, where a wide character cut in two leaves spaces. A segment
|
|
99
|
+
# wholly between them, or wholly outside, is kept or left out whole, without going through its characters.
|
|
87
100
|
def segments_between(line, from, to)
|
|
88
101
|
column = 0
|
|
89
102
|
line.filter_map do |segment|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
segment.with(text:) unless text.empty?
|
|
103
|
+
start = column
|
|
104
|
+
column += display_width(segment.text)
|
|
105
|
+
next if segment.text.empty? || column <= from || start >= to
|
|
106
|
+
next segment if start >= from && column <= to
|
|
107
|
+
|
|
108
|
+
segment.with(text: chars_between(segment.text, start, from, to))
|
|
97
109
|
end
|
|
98
110
|
end
|
|
99
111
|
|
|
112
|
+
# The text's characters between two columns, from the column it starts at, where a wide character cut in two
|
|
113
|
+
# leaves spaces.
|
|
114
|
+
def chars_between(text, column, from, to)
|
|
115
|
+
text.each_char.map do |char|
|
|
116
|
+
start = column
|
|
117
|
+
column += display_width(char)
|
|
118
|
+
overlap = [column, to].min - [start, from].max
|
|
119
|
+
overlap == display_width(char) ? char : ' ' * [overlap, 0].max
|
|
120
|
+
end.join
|
|
121
|
+
end
|
|
122
|
+
|
|
100
123
|
# Tabs and control characters, like in a directive's output, would move the terminal's cursor.
|
|
101
124
|
def printable(text)
|
|
125
|
+
return text unless text.match?(/[[:cntrl:]]/)
|
|
126
|
+
|
|
102
127
|
text.gsub("\t", INDENT).gsub(/[[:cntrl:]]/, '')
|
|
103
128
|
end
|
|
104
129
|
|
|
105
|
-
def display_width(text)
|
|
106
|
-
Unicode::DisplayWidth.of(text)
|
|
107
|
-
end
|
|
130
|
+
def display_width(text) = Text.display_width(text)
|
|
108
131
|
end
|
|
109
132
|
end
|
|
110
133
|
end
|
|
@@ -32,7 +32,7 @@ module Raindown
|
|
|
32
32
|
def path = nil
|
|
33
33
|
def changed? = false
|
|
34
34
|
def typing? = false
|
|
35
|
-
def
|
|
35
|
+
def pace(_hovered) = :active
|
|
36
36
|
|
|
37
37
|
def status(_navigator)
|
|
38
38
|
found = "#{charts.length} #{charts.length == 1 ? 'chart' : 'charts'} in #{File.basename(project || root)}"
|
|
@@ -111,7 +111,7 @@ module Raindown
|
|
|
111
111
|
answer = worker.call(:charts, find:)
|
|
112
112
|
@cards.concat(answer[:cards])
|
|
113
113
|
@errors.concat(answer[:errors])
|
|
114
|
-
rescue Worker::Error => e
|
|
114
|
+
rescue Workers::Worker::Error => e
|
|
115
115
|
@errors << "#{File.basename(worker.root)}: #{e.message}"
|
|
116
116
|
end
|
|
117
117
|
@cards.sort_by! { [it.project, it.path, it.line] }
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative '../events'
|
|
4
|
-
require_relative '
|
|
4
|
+
require_relative '../../workers/worker'
|
|
5
5
|
|
|
6
6
|
module Raindown
|
|
7
7
|
module TUI
|
|
@@ -27,7 +27,7 @@ module Raindown
|
|
|
27
27
|
def status(_navigator) = [:info, endpoint.text]
|
|
28
28
|
def changed? = false
|
|
29
29
|
def typing? = false
|
|
30
|
-
def
|
|
30
|
+
def pace(_hovered) = :active
|
|
31
31
|
def next_step?(key) = %i[up down enter edit execute].include?(key)
|
|
32
32
|
|
|
33
33
|
# Clicks on the response only move the focus to it.
|
|
@@ -40,7 +40,7 @@ module Raindown
|
|
|
40
40
|
@language = lines.first.to_s[FENCE, 1]
|
|
41
41
|
@response = @language ? lines[1...-1] : lines
|
|
42
42
|
@error = nil
|
|
43
|
-
rescue Worker::Error => e
|
|
43
|
+
rescue Workers::Worker::Error => e
|
|
44
44
|
@response = []
|
|
45
45
|
@error = e.message
|
|
46
46
|
end
|
|
@@ -5,7 +5,7 @@ require_relative '../events'
|
|
|
5
5
|
require_relative 'list_view'
|
|
6
6
|
require_relative '../highlighter'
|
|
7
7
|
require_relative '../row'
|
|
8
|
-
require_relative '
|
|
8
|
+
require_relative '../../workers/worker'
|
|
9
9
|
|
|
10
10
|
module Raindown
|
|
11
11
|
module TUI
|
|
@@ -37,7 +37,7 @@ module Raindown
|
|
|
37
37
|
@directives = outline.directives
|
|
38
38
|
@highlighted = {}
|
|
39
39
|
@error = nil
|
|
40
|
-
rescue Worker::Error, SystemCallError => e
|
|
40
|
+
rescue Workers::Worker::Error, SystemCallError => e
|
|
41
41
|
@error = e.message
|
|
42
42
|
end
|
|
43
43
|
|
|
@@ -128,12 +128,13 @@ module Raindown
|
|
|
128
128
|
def buttons(directive, expanded:)
|
|
129
129
|
buttons = []
|
|
130
130
|
buttons << [expanded ? '[-]' : "[+#{directive.body.size}]", :toggle] if directive.body
|
|
131
|
-
buttons << ['[Run]', :execute] if directive.endpoint?
|
|
131
|
+
buttons << ['[Run]', :execute] if directive.endpoint? && directive.writes
|
|
132
132
|
buttons
|
|
133
133
|
end
|
|
134
134
|
|
|
135
|
+
# A self-closing directive, like <{ :toc }>, by its name after a colon, and the rest by theirs before one.
|
|
135
136
|
def directive_text(directive)
|
|
136
|
-
return ":#{directive.name}"
|
|
137
|
+
return ":#{directive.name}" if !directive.writes && directive.line == directive.end_line
|
|
137
138
|
|
|
138
139
|
[directive.name, ':', (" #{directive.args}" unless directive.args.empty?)].join
|
|
139
140
|
end
|
data/lib/raindown/tui.rb
CHANGED
|
@@ -4,28 +4,38 @@ require_relative 'projects'
|
|
|
4
4
|
require_relative 'tui/navigator/navigator'
|
|
5
5
|
|
|
6
6
|
module Raindown
|
|
7
|
-
# The terminal UI, started with "raindown
|
|
7
|
+
# The terminal UI, started with "raindown": a project's Markdown files beside the open file's outline,
|
|
8
8
|
# where directives fold, endpoints run and the editor opens at a line. Curses only loads here,
|
|
9
9
|
# so watching and rendering start without it.
|
|
10
10
|
module TUI
|
|
11
11
|
module_function
|
|
12
12
|
|
|
13
|
-
# PATH is a file to open, or a directory. Its project is the one it's in, or else the directory itself.
|
|
14
|
-
|
|
13
|
+
# PATH is a file to open, or a directory. Its project is the one it's in, or else the directory itself. It returns
|
|
14
|
+
# true once it's quit. Without a terminal, like in a script or a service, it says so, once curses has loaded, and returns false, to be
|
|
15
|
+
# watched instead, as curses' own error would be "Error opening terminal: unknown". Bare and execute, like --bare and
|
|
16
|
+
# -x, allow bare directives in any file and run endpoints with no response as it writes back.
|
|
17
|
+
def start(path, bare: false, execute: false)
|
|
15
18
|
require_relative 'tui/terminal/screen'
|
|
16
|
-
|
|
17
|
-
|
|
19
|
+
unless $stdin.tty? && $stdout.tty?
|
|
20
|
+
warn("raindown needs a terminal, so it's watching instead")
|
|
21
|
+
return false
|
|
22
|
+
end
|
|
18
23
|
|
|
19
24
|
path = File.expand_path(path)
|
|
20
25
|
root = Projects.root(path) || (File.directory?(path) ? path : File.dirname(path))
|
|
21
26
|
# The projects inside it are shown too. Each loads its own nodes in its worker's process, like when watching,
|
|
22
27
|
# so two projects' nodes of the same name stay apart.
|
|
23
|
-
workers =
|
|
28
|
+
workers = Project.workers(root:, bare:, execute:)
|
|
24
29
|
|
|
25
30
|
# Rouge loads here, rather than with Raindown, which doesn't highlight code outside the TUI.
|
|
26
31
|
require 'rouge'
|
|
27
|
-
|
|
32
|
+
# Saved files are written back, like when the folder's watched.
|
|
33
|
+
require_relative 'tui/saves'
|
|
34
|
+
saves = Saves.new(root)
|
|
35
|
+
Screen.new(Navigator.new(root:, path: (path unless File.directory?(path)), workers:, saves:, runs_endpoints: execute)).run
|
|
36
|
+
true
|
|
28
37
|
ensure
|
|
38
|
+
saves&.stop
|
|
29
39
|
workers&.stop
|
|
30
40
|
end
|
|
31
41
|
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../projects'
|
|
4
|
+
|
|
5
|
+
module Raindown
|
|
6
|
+
module Watcher
|
|
7
|
+
# Listening skips these folders as they hold dependencies and builds, which Projects ignores too.
|
|
8
|
+
IGNORE = %r{(?:\A|/)(?:#{Projects::IGNORED.join('|')})/}
|
|
9
|
+
|
|
10
|
+
# A change to a project's nodes, or to one of its Markdown files, which is written back when it's been saved.
|
|
11
|
+
Change = Data.define(:root, :path, :nodes) do
|
|
12
|
+
def nodes? = nodes
|
|
13
|
+
def saved? = !nodes && File.exist?(path)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# A listener, still to start, that gives the block the paths saved, added or removed in the directory.
|
|
17
|
+
def self.listen(directory, &)
|
|
18
|
+
require 'listen'
|
|
19
|
+
Listen.to(directory, ignore: IGNORE) { |modified, added, removed| yield(modified + added + removed) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# The changes among the paths, by the project each is in, the nearest one above it, or else the root, when there is
|
|
23
|
+
# one, leaving out files that are neither nodes nor Markdown, or are in ignored folders.
|
|
24
|
+
def self.changes(paths, root: nil)
|
|
25
|
+
paths.filter_map do |path|
|
|
26
|
+
project = Projects.root(path) || root || next
|
|
27
|
+
next Change.new(root: project, path:, nodes: true) if Projects.node?(path, root: project)
|
|
28
|
+
|
|
29
|
+
Change.new(root: project, path:, nodes: false) if Projects.markdown?(path, root: project)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../../raindown'
|
|
4
|
+
require_relative '../rewriter'
|
|
5
|
+
require_relative 'changes'
|
|
6
|
+
|
|
7
|
+
module Raindown
|
|
8
|
+
module Watcher
|
|
9
|
+
# Watches one file and writes back to it as it's saved, like its TOC, tables and endpoint responses.
|
|
10
|
+
class FileWatcher
|
|
11
|
+
attr_reader :path, :bare, :execute
|
|
12
|
+
|
|
13
|
+
def initialize(path:, bare: false, execute: false)
|
|
14
|
+
@path = path
|
|
15
|
+
@bare = bare
|
|
16
|
+
@execute = execute
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def start
|
|
20
|
+
$stdout.sync = true
|
|
21
|
+
|
|
22
|
+
safely { rewrite }
|
|
23
|
+
puts "Watching #{path} for changes..."
|
|
24
|
+
|
|
25
|
+
listener.start
|
|
26
|
+
sleep
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def listener
|
|
32
|
+
directory = File.dirname(File.expand_path(path))
|
|
33
|
+
filename = File.basename(path)
|
|
34
|
+
|
|
35
|
+
Watcher.listen(directory) do |paths|
|
|
36
|
+
next unless paths.any? { File.basename(it) == filename && File.exist?(it) }
|
|
37
|
+
|
|
38
|
+
# Writing back saves the file again, and that save changes nothing, so it ends there.
|
|
39
|
+
safely { puts "Updated #{path}" if rewrite }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# An error in the file is shown rather than stopping the watcher, so it can be fixed and saved again.
|
|
44
|
+
def safely
|
|
45
|
+
yield
|
|
46
|
+
rescue StandardError => e
|
|
47
|
+
raise if ENV['RAINDOWN_DEBUG']
|
|
48
|
+
|
|
49
|
+
warn "raindown: #{path}: #{e.message}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The files' metadata is loaded again each time, so new and renamed files are listed.
|
|
53
|
+
def rewrite
|
|
54
|
+
Rewriter.update(path:, bare:, execute:, metadata: Pages.for(path))
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|