raindown 0.6.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/chart/chart.rb +12 -1
- 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 +22 -20
- data/lib/raindown/endpoint/curl.rb +11 -7
- data/lib/raindown/endpoint/endpoint_runner.rb +13 -5
- data/lib/raindown/node_blocks.rb +31 -22
- data/lib/raindown/node_injector.rb +7 -10
- data/lib/raindown/nodes.rb +1 -0
- data/lib/raindown/pages.rb +1 -1
- data/lib/raindown/pre_renderer.rb +18 -0
- data/lib/raindown/stat.rb +53 -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/code_box.rb +143 -0
- data/lib/raindown/tui/endpoints.rb +2 -2
- data/lib/raindown/tui/events.rb +18 -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/celebration.rb +28 -0
- data/lib/raindown/tui/feelings/celebration/fireworks.rb +75 -0
- data/lib/raindown/tui/feelings/celebration/rain.rb +66 -0
- data/lib/raindown/tui/feelings/celebration/scene.rb +40 -0
- data/lib/raindown/tui/feelings/cell_moves.rb +34 -0
- data/lib/raindown/tui/feelings/code_methods.rb +134 -0
- data/lib/raindown/tui/feelings/feelings_code_box.rb +40 -0
- data/lib/raindown/tui/feelings/feelings_log.rb +136 -0
- data/lib/raindown/tui/feelings/feelings_renderer.rb +183 -0
- data/lib/raindown/tui/feelings/feelings_view.rb +157 -0
- 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 +51 -0
- data/lib/raindown/tui/file_tree.rb +6 -5
- data/lib/raindown/tui/highlighter.rb +45 -0
- data/lib/raindown/tui/layout.rb +32 -18
- data/lib/raindown/tui/navigator/commands.rb +46 -2
- data/lib/raindown/tui/navigator/mouse.rb +43 -0
- data/lib/raindown/tui/navigator/navigator.rb +54 -25
- data/lib/raindown/tui/navigator/teleport.rb +128 -0
- data/lib/raindown/tui/navigator/views.rb +5 -3
- data/lib/raindown/tui/{workers/project.rb → project.rb} +38 -27
- data/lib/raindown/tui/{menu_overlay.rb → project_box.rb} +2 -2
- 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 +4 -0
- data/lib/raindown/tui/row.rb +4 -2
- data/lib/raindown/tui/rows.rb +2 -1
- data/lib/raindown/tui/saves.rb +22 -0
- data/lib/raindown/tui/search_box.rb +86 -0
- data/lib/raindown/tui/terminal/input.rb +95 -7
- data/lib/raindown/tui/terminal/screen.rb +37 -29
- data/lib/raindown/tui/terminal/styles.rb +111 -26
- data/lib/raindown/tui/terminal/terminal_title.rb +31 -0
- data/lib/raindown/tui/text.rb +62 -12
- data/lib/raindown/tui/title.rb +1 -1
- data/lib/raindown/tui/views/dashboard_view.rb +7 -5
- data/lib/raindown/tui/views/endpoint_view.rb +4 -2
- data/lib/raindown/tui/views/list_view.rb +2 -0
- data/lib/raindown/tui/views/outline_view.rb +16 -6
- data/lib/raindown/tui.rb +19 -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 +60 -9
- data/lib/raindown/project_worker.rb +0 -48
- data/lib/raindown/supervisor.rb +0 -160
- data/lib/raindown/tui/workers/workers.rb +0 -53
- data/lib/raindown/watcher.rb +0 -57
|
@@ -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
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'events'
|
|
4
|
+
require_relative 'text'
|
|
5
|
+
|
|
6
|
+
module Raindown
|
|
7
|
+
module TUI
|
|
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
|
+
# │ / stp▏ │
|
|
12
|
+
# │ ## Setup guides/setup.md:3 │
|
|
13
|
+
# │ ## Steps notes.md:12 │
|
|
14
|
+
# └──────────────────────────────────────────┘
|
|
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
|
|
19
|
+
include Text
|
|
20
|
+
|
|
21
|
+
# The widest the box is, with its border.
|
|
22
|
+
MAX_WIDTH = 72
|
|
23
|
+
|
|
24
|
+
def initialize(search, width:, title: 'Teleport', pane: :teleport, top: 1)
|
|
25
|
+
@search = search
|
|
26
|
+
@title = title
|
|
27
|
+
@pane = pane
|
|
28
|
+
@top = top
|
|
29
|
+
@box_width = [width - 4, MAX_WIDTH].min
|
|
30
|
+
@column = (width - @box_width) / 2
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The lines with the box drawn over them, from its top line, below the title.
|
|
34
|
+
def apply(lines)
|
|
35
|
+
box.each_with_index do |segments, offset|
|
|
36
|
+
row = offset + @top
|
|
37
|
+
lines[row] = overlay_segments(lines[row], segments, column: @column) if lines[row]
|
|
38
|
+
end
|
|
39
|
+
lines
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
# The columns inside the border, "│ " and " │".
|
|
45
|
+
def inner = @box_width - 4
|
|
46
|
+
|
|
47
|
+
def box
|
|
48
|
+
[title_edge, boxed(segment("/ #{@search.query}▏", :menu, width: inner)), *results, edge('└', '┘')]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def results
|
|
52
|
+
matches = @search.matches
|
|
53
|
+
return [boxed(segment('No matches', :info, width: inner))] if matches.empty?
|
|
54
|
+
|
|
55
|
+
first = @search.first_match
|
|
56
|
+
matches.each_with_index.map { |place, index| place_line(place, first + index) }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The place's text, then where it is, on the right, both highlighted when it's selected.
|
|
60
|
+
def place_line(place, index)
|
|
61
|
+
text_style = :menu
|
|
62
|
+
where_style = :body
|
|
63
|
+
text_style = where_style = :selected if @search.match == index
|
|
64
|
+
event = Click.new(pane: @pane, index:, action: :enter)
|
|
65
|
+
where = " #{place.where}"
|
|
66
|
+
where_width = [display_width(where), inner / 2].min
|
|
67
|
+
|
|
68
|
+
boxed(segment(place.text, text_style, width: inner - where_width, event:),
|
|
69
|
+
segment(where, where_style, width: where_width, event:))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def boxed(*segments) = [segment('│ ', :border, width: 2), *segments, segment(' │', :border, width: 2)]
|
|
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
|
+
|
|
81
|
+
def edge(left, right)
|
|
82
|
+
[segment("#{left}#{'─' * (@box_width - display_width(left) - 1)}#{right}", :border, width: @box_width)]
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'curses'
|
|
4
|
-
|
|
4
|
+
|
|
5
|
+
require_relative '../events'
|
|
6
|
+
require_relative '../text'
|
|
7
|
+
require_relative 'quiet'
|
|
5
8
|
|
|
6
9
|
module Raindown
|
|
7
10
|
module TUI
|
|
@@ -10,27 +13,112 @@ module Raindown
|
|
|
10
13
|
KEYS = {
|
|
11
14
|
Curses::KEY_UP => :up, 'k' => :up, Curses::KEY_DOWN => :down, 'j' => :down,
|
|
12
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,
|
|
13
18
|
"\t" => :tab, Curses::KEY_BTAB => :tab, "\r" => :enter, "\n" => :enter, Curses::KEY_ENTER => :enter,
|
|
14
19
|
' ' => :toggle, 'x' => :execute, 'e' => :edit, 'w' => :write_back, 'r' => :reload, 'd' => :dashboard,
|
|
15
|
-
'p' => :projects, "\
|
|
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
|
|
22
|
+
}.freeze
|
|
23
|
+
# While typing, like in Teleport, printable characters are typed, and only these keys do anything else.
|
|
24
|
+
TYPING_KEYS = {
|
|
25
|
+
Curses::KEY_UP => :up, Curses::KEY_DOWN => :down, "\r" => :enter, "\n" => :enter, Curses::KEY_ENTER => :enter,
|
|
26
|
+
"\e" => :escape, Curses::KEY_BACKSPACE => :backspace, "\x7F" => :backspace, "\b" => :backspace
|
|
16
27
|
}.freeze
|
|
17
28
|
|
|
18
29
|
module_function
|
|
19
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
|
+
|
|
20
49
|
# A key from getch, which is a string for a printable character and otherwise a number.
|
|
21
|
-
# Letters are the same with Shift, as the buttons show them as capitals.
|
|
22
|
-
def key_event(key)
|
|
50
|
+
# Letters are the same with Shift, as the buttons show them as capitals, unless they're being typed.
|
|
51
|
+
def key_event(key, typing: false)
|
|
52
|
+
return typing_event(key) if typing
|
|
53
|
+
|
|
23
54
|
key = key.chr if key.is_a?(Integer) && key < 256
|
|
24
55
|
KEYS[key.is_a?(String) ? key.downcase : key]
|
|
25
56
|
end
|
|
26
57
|
|
|
58
|
+
def typing_event(key)
|
|
59
|
+
key = key.chr if key.is_a?(Integer) && key < 256
|
|
60
|
+
TYPING_KEYS.fetch(key) { Type.new(text: key) if key.is_a?(String) && key.match?(/\A[[:print:]]\z/) }
|
|
61
|
+
end
|
|
62
|
+
|
|
27
63
|
# The mouse buttons that click, which are the left button's. Scrolling does nothing, and since older ncurses,
|
|
28
64
|
# like macOS's, has no button for scrolling down, it can't be mistaken for a click.
|
|
29
65
|
CLICKS = Curses::BUTTON1_PRESSED | Curses::BUTTON1_CLICKED
|
|
30
66
|
|
|
31
|
-
#
|
|
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
|
+
|
|
71
|
+
# Moving the mouse, which terminals report once they're asked to, with any motion tracking.
|
|
72
|
+
MOTION = Curses::REPORT_MOUSE_POSITION
|
|
73
|
+
ANY_MOTION = "\e[?1003h"
|
|
74
|
+
NO_MOTION = "\e[?1003l"
|
|
75
|
+
|
|
76
|
+
# The terminal sends the app its mouse events, clicks and moves, so scrolling doesn't scroll the terminal either.
|
|
77
|
+
# ncurses only asks some terminals to report every move, so it's asked directly too, until untrack_mouse.
|
|
78
|
+
def track_mouse
|
|
79
|
+
Curses.mousemask(CLICKS | RELEASE | MOTION)
|
|
80
|
+
Curses.mouseinterval(0)
|
|
81
|
+
$stdout.write(ANY_MOTION)
|
|
82
|
+
$stdout.flush
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def untrack_mouse
|
|
86
|
+
$stdout.write(NO_MOTION)
|
|
87
|
+
$stdout.flush
|
|
88
|
+
end
|
|
89
|
+
|
|
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.
|
|
32
95
|
def mouse_event(event, zones)
|
|
33
|
-
|
|
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
|
+
|
|
102
|
+
target = click(zones, column: event.x, row: event.y)
|
|
103
|
+
return target if event.bstate.anybits?(CLICKS)
|
|
104
|
+
|
|
105
|
+
Hover.new(target:) if event.bstate.anybits?(MOTION)
|
|
106
|
+
end
|
|
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)
|
|
34
122
|
end
|
|
35
123
|
|
|
36
124
|
# Where each of a Layout's segments with an event is on the screen, as [row, columns, event].
|
|
@@ -38,7 +126,7 @@ module Raindown
|
|
|
38
126
|
lines.each_with_index.flat_map do |segments, row|
|
|
39
127
|
column = 0
|
|
40
128
|
segments.filter_map do |segment|
|
|
41
|
-
width =
|
|
129
|
+
width = Text.display_width(segment.text)
|
|
42
130
|
zone = [row, column...(column + width), segment.event] if segment.event
|
|
43
131
|
column += width
|
|
44
132
|
zone
|
|
@@ -4,17 +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
|
-
|
|
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
|
|
18
23
|
|
|
19
24
|
def initialize(navigator)
|
|
20
25
|
@navigator = navigator
|
|
@@ -31,11 +36,9 @@ module Raindown
|
|
|
31
36
|
start
|
|
32
37
|
|
|
33
38
|
loop do
|
|
39
|
+
drawn_at = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
|
|
34
40
|
draw
|
|
35
|
-
|
|
36
|
-
break if effect == :quit
|
|
37
|
-
|
|
38
|
-
perform(effect)
|
|
41
|
+
break if take_events(drawn_at) == :quit
|
|
39
42
|
end
|
|
40
43
|
rescue Interrupt
|
|
41
44
|
nil
|
|
@@ -43,6 +46,23 @@ module Raindown
|
|
|
43
46
|
stop
|
|
44
47
|
end
|
|
45
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
|
+
|
|
46
66
|
# Runs a command, like the editor, then tells the Navigator, or shows a message while a slow task runs.
|
|
47
67
|
def perform(effect)
|
|
48
68
|
case effect
|
|
@@ -61,13 +81,11 @@ module Raindown
|
|
|
61
81
|
Curses.nonl
|
|
62
82
|
Curses.curs_set(0)
|
|
63
83
|
Curses.stdscr.keypad(true)
|
|
64
|
-
|
|
65
|
-
# The terminal sends the app its mouse events, so scrolling doesn't scroll the terminal either.
|
|
66
|
-
Curses.mousemask(Input::CLICKS)
|
|
67
|
-
Curses.mouseinterval(0)
|
|
84
|
+
Input.track_mouse
|
|
68
85
|
end
|
|
69
86
|
|
|
70
87
|
def stop
|
|
88
|
+
Input.untrack_mouse
|
|
71
89
|
Curses.close_screen
|
|
72
90
|
end
|
|
73
91
|
|
|
@@ -98,7 +116,7 @@ module Raindown
|
|
|
98
116
|
end
|
|
99
117
|
|
|
100
118
|
def paint(window, segment)
|
|
101
|
-
window.attron(@styles
|
|
119
|
+
window.attron(@styles[segment.style]) { window.addstr(segment.text) }
|
|
102
120
|
end
|
|
103
121
|
|
|
104
122
|
def last_line(segments)
|
|
@@ -106,22 +124,6 @@ module Raindown
|
|
|
106
124
|
[*rest, last.with(text: last.text[0...-1])]
|
|
107
125
|
end
|
|
108
126
|
|
|
109
|
-
# The next event, or :tick when no key is pressed in time, or nil for keys without one, like a resize.
|
|
110
|
-
def read
|
|
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)
|
|
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
|
|
@@ -131,13 +133,19 @@ module Raindown
|
|
|
131
133
|
window.refresh
|
|
132
134
|
end
|
|
133
135
|
|
|
134
|
-
# Leaves the screen to the command, like the editor, and comes back to it after.
|
|
136
|
+
# Leaves the screen to the command, like the editor, and comes back to it after. The command isn't sent the
|
|
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.
|
|
135
139
|
def suspend
|
|
140
|
+
interrupt = trap('INT') { nil }
|
|
136
141
|
Curses.def_prog_mode
|
|
142
|
+
Input.untrack_mouse
|
|
137
143
|
Curses.close_screen
|
|
138
144
|
yield
|
|
139
145
|
ensure
|
|
146
|
+
trap('INT', interrupt)
|
|
140
147
|
Curses.reset_prog_mode
|
|
148
|
+
Input.track_mouse
|
|
141
149
|
Curses.stdscr.clear
|
|
142
150
|
Curses.refresh
|
|
143
151
|
end
|
|
@@ -4,25 +4,68 @@ require 'curses'
|
|
|
4
4
|
|
|
5
5
|
module Raindown
|
|
6
6
|
module TUI
|
|
7
|
-
# The curses attributes of a Layout's styles.
|
|
8
|
-
#
|
|
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, while the title, headings, and the lines between the panes and beneath the title are cornflower
|
|
9
|
+
# blue. The selected
|
|
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
|
|
11
|
+
# background.
|
|
9
12
|
# With only 8 colours, there's no dark grey, so those rows are underlined, and without colours,
|
|
10
13
|
# the rest are in reverse.
|
|
11
14
|
module Styles
|
|
12
|
-
COLORS = {
|
|
13
|
-
|
|
15
|
+
COLORS = { button: Curses::COLOR_YELLOW, error: Curses::COLOR_RED }.freeze
|
|
16
|
+
# Directives, like charts' on the Dashboard, in purple, and the folders, projects and pages in the files, the title,
|
|
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
|
|
19
|
+
# have, or magenta and blue with 8.
|
|
20
|
+
CORNFLOWER = 111
|
|
21
|
+
ACCENTS = { directive: 141, folder: CORNFLOWER, title: CORNFLOWER, heading: CORNFLOWER, syrup: 130 }.freeze
|
|
22
|
+
BASIC_ACCENTS = { directive: Curses::COLOR_MAGENTA, folder: Curses::COLOR_BLUE, title: Curses::COLOR_BLUE,
|
|
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
|
+
|
|
14
28
|
# The colours of endpoints' HTTP methods, like GET in green, from the 256 colours most terminals have,
|
|
15
29
|
# which are the same in every theme, unlike the 8 they all do, where green can look yellow.
|
|
16
|
-
METHODS = { get:
|
|
30
|
+
METHODS = { get: GREEN, post: 33, delete: 196, put: 220, patch: 171, head: 44, options: 44 }.freeze
|
|
17
31
|
BASIC_METHODS = { get: Curses::COLOR_GREEN, post: Curses::COLOR_BLUE, delete: Curses::COLOR_RED,
|
|
18
32
|
put: Curses::COLOR_YELLOW, patch: Curses::COLOR_MAGENTA, head: Curses::COLOR_CYAN,
|
|
19
33
|
options: Curses::COLOR_CYAN }.freeze
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
34
|
+
|
|
35
|
+
# Highlighted code's colours, by the kind of token, from the 256 colours most terminals have, or the 8 they all do,
|
|
36
|
+
# where comments are dim instead of grey.
|
|
37
|
+
CODE = { code_keyword: 204, code_string: 114, code_number: 179, code_function: 75, code_constant: 117,
|
|
38
|
+
code_comment: 244 }.freeze
|
|
39
|
+
BASIC_CODE = { code_keyword: Curses::COLOR_MAGENTA, code_string: Curses::COLOR_GREEN, code_number: Curses::COLOR_YELLOW,
|
|
40
|
+
code_function: Curses::COLOR_BLUE, code_constant: Curses::COLOR_CYAN, code_comment: Curses::COLOR_WHITE }.freeze
|
|
41
|
+
|
|
42
|
+
# Code feelings' bright colours, for its rain and fireworks, from the 256 colours most terminals have, or
|
|
43
|
+
# the 8 they all do.
|
|
44
|
+
BRIGHT = { bright_red: 203, bright_orange: 215, bright_yellow: 227, bright_green: 120, bright_cyan: 87,
|
|
45
|
+
bright_blue: 75, bright_purple: 177, bright_pink: 213 }.freeze
|
|
46
|
+
BASIC_BRIGHT = { bright_red: Curses::COLOR_RED, bright_orange: Curses::COLOR_YELLOW, bright_yellow: Curses::COLOR_YELLOW,
|
|
47
|
+
bright_green: Curses::COLOR_GREEN, bright_cyan: Curses::COLOR_CYAN, bright_blue: Curses::COLOR_BLUE,
|
|
48
|
+
bright_purple: Curses::COLOR_MAGENTA, bright_pink: Curses::COLOR_MAGENTA }.freeze
|
|
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
|
+
|
|
56
|
+
# Text that has no colour of its own, from the 256 colours most terminals have, or the terminal's own with 8.
|
|
57
|
+
PERIWINKLE = 147
|
|
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
|
|
23
63
|
BRIGHT_WHITE = 15
|
|
24
64
|
DARK_GREY = 238
|
|
25
65
|
|
|
66
|
+
# Code feelings' red when the screen's too small for it, or the 8 colours' red.
|
|
67
|
+
DARK_RED = 88
|
|
68
|
+
|
|
26
69
|
module_function
|
|
27
70
|
|
|
28
71
|
# Sets up the colours, once the screen has started, and returns the attribute of each style.
|
|
@@ -33,33 +76,72 @@ module Raindown
|
|
|
33
76
|
colored
|
|
34
77
|
end
|
|
35
78
|
|
|
79
|
+
# Without colours, a style that isn't here is the terminal's own.
|
|
36
80
|
def plain
|
|
37
|
-
|
|
81
|
+
Hash.new(Curses::A_NORMAL).merge(
|
|
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,
|
|
38
85
|
cursor: Curses::A_UNDERLINE, cursor_button: Curses::A_UNDERLINE | Curses::A_BOLD, heading: Curses::A_BOLD,
|
|
39
|
-
body: Curses::A_DIM, error: Curses::A_BOLD, button: Curses::A_BOLD, folder: Curses::A_BOLD, project: Curses::A_BOLD,
|
|
40
|
-
key: Curses::A_REVERSE, border: Curses::A_DIM,
|
|
41
|
-
|
|
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,
|
|
87
|
+
key: Curses::A_REVERSE, border: Curses::A_DIM, divider: Curses::A_BOLD,
|
|
88
|
+
code_keyword: Curses::A_BOLD, code_comment: Curses::A_DIM
|
|
89
|
+
)
|
|
42
90
|
end
|
|
43
91
|
|
|
44
92
|
def colored
|
|
45
|
-
|
|
46
|
-
title: pair(:title) | Curses::A_BOLD,
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
selected_divider: pair(:selected_divider), run: pair(:run) | Curses::A_BOLD, **files, **unfocused
|
|
93
|
+
in_text_color.merge(
|
|
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
|
|
50
97
|
)
|
|
51
98
|
end
|
|
52
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
|
+
|
|
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
|
|
109
|
+
# also any style that isn't here.
|
|
110
|
+
def in_text_color
|
|
111
|
+
text = pair(:text)
|
|
112
|
+
Hash.new(text).merge(plain.transform_values { it | text })
|
|
113
|
+
end
|
|
114
|
+
|
|
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.
|
|
117
|
+
def emphasis
|
|
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) }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Highlighted code's tokens, like keywords and comments, with comments dim in 8 colours, where there's no grey.
|
|
123
|
+
def code_tokens
|
|
124
|
+
tokens = CODE.keys.to_h { [it, pair(it)] }
|
|
125
|
+
tokens[:code_comment] |= Curses::A_DIM if Curses.colors < 256
|
|
126
|
+
tokens
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# The bright colours, bold, so they're bright in 8 colours too.
|
|
130
|
+
def bright = BRIGHT.keys.to_h { [it, pair(it) | Curses::A_BOLD] }
|
|
131
|
+
|
|
132
|
+
# The lines between the panes and beneath the title.
|
|
133
|
+
def dividers = { divider: pair(:divider) }
|
|
134
|
+
|
|
53
135
|
def files
|
|
54
|
-
{ folder: pair(:folder) | Curses::A_BOLD, project: pair(:
|
|
55
|
-
|
|
136
|
+
{ folder: pair(:folder) | Curses::A_BOLD, project: pair(:folder) | Curses::A_BOLD,
|
|
137
|
+
page: pair(:folder) | Curses::A_BOLD, **METHODS.keys.to_h { [:"method_#{it}", pair(:"method_#{it}") | Curses::A_BOLD] } }
|
|
56
138
|
end
|
|
57
139
|
|
|
58
140
|
# The selected rows of the panes without the focus, on dark grey where there's one.
|
|
59
141
|
def unfocused
|
|
60
|
-
return { cursor_button: pair(:button) | Curses::A_BOLD
|
|
142
|
+
return { cursor_button: pair(:button) | Curses::A_BOLD } unless Curses.colors >= 256
|
|
61
143
|
|
|
62
|
-
{ cursor: pair(:cursor), cursor_button: pair(:cursor_button) | Curses::A_BOLD
|
|
144
|
+
{ cursor: pair(:cursor), cursor_button: pair(:cursor_button) | Curses::A_BOLD }
|
|
63
145
|
end
|
|
64
146
|
|
|
65
147
|
# Each colour pair as [name, foreground, background], where -1 is the terminal's own background.
|
|
@@ -67,14 +149,17 @@ module Raindown
|
|
|
67
149
|
many = Curses.colors >= 256
|
|
68
150
|
blues = many ? BLUES : BASIC_BLUES
|
|
69
151
|
white = many ? BRIGHT_WHITE : Curses::COLOR_WHITE
|
|
152
|
+
text = many ? PERIWINKLE : -1
|
|
70
153
|
|
|
71
154
|
[*COLORS.map { |name, color| [name, color, -1] },
|
|
72
155
|
*(many ? METHODS : BASIC_METHODS).map { |name, color| [:"method_#{name}", color, -1] },
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
[:
|
|
76
|
-
[:
|
|
77
|
-
[:
|
|
156
|
+
*(many ? CODE.merge(BRIGHT, FEELINGS, ACCENTS) : BASIC_CODE.merge(BASIC_BRIGHT, BASIC_FEELINGS, BASIC_ACCENTS))
|
|
157
|
+
.map { |name, color| [name, color, -1] },
|
|
158
|
+
[:text, text, -1],
|
|
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]]
|
|
78
163
|
end
|
|
79
164
|
|
|
80
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
|