raindown 0.5.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/raindown/chart/chart.rb +12 -1
- data/lib/raindown/cli.rb +2 -2
- data/lib/raindown/document.rb +16 -9
- data/lib/raindown/endpoint/curl.rb +19 -0
- data/lib/raindown/node_blocks.rb +9 -6
- data/lib/raindown/nodes.rb +1 -0
- data/lib/raindown/projects.rb +8 -4
- data/lib/raindown/stat.rb +53 -0
- data/lib/raindown/tui/card.rb +56 -0
- data/lib/raindown/tui/endpoint.rb +16 -0
- data/lib/raindown/tui/endpoints.rb +40 -0
- data/lib/raindown/tui/events.rb +50 -0
- data/lib/raindown/tui/feelings/batch.rb +47 -0
- data/lib/raindown/tui/feelings/bins.rb +67 -0
- data/lib/raindown/tui/feelings/celebration.rb +25 -0
- data/lib/raindown/tui/feelings/code_box.rb +129 -0
- data/lib/raindown/tui/feelings/code_methods.rb +134 -0
- data/lib/raindown/tui/feelings/feelings_log.rb +133 -0
- data/lib/raindown/tui/feelings/feelings_renderer.rb +184 -0
- data/lib/raindown/tui/feelings/feelings_view.rb +161 -0
- data/lib/raindown/tui/feelings/fireworks.rb +75 -0
- data/lib/raindown/tui/feelings/rain.rb +66 -0
- data/lib/raindown/tui/feelings/scene.rb +37 -0
- data/lib/raindown/tui/feelings/waffle.rb +43 -0
- data/lib/raindown/tui/feelings/weekly_batch.rb +32 -0
- data/lib/raindown/tui/file_tree.rb +55 -12
- data/lib/raindown/tui/highlighter.rb +45 -0
- data/lib/raindown/tui/layout.rb +66 -77
- data/lib/raindown/tui/menu_overlay.rb +59 -0
- data/lib/raindown/tui/navigator/commands.rb +97 -0
- data/lib/raindown/tui/navigator/focus.rb +25 -0
- data/lib/raindown/tui/navigator/navigator.rb +160 -0
- data/lib/raindown/tui/navigator/project_menu.rb +61 -0
- data/lib/raindown/tui/navigator/search.rb +126 -0
- data/lib/raindown/tui/navigator/views.rb +52 -0
- data/lib/raindown/tui/renderers/dashboard_renderer.rb +102 -0
- data/lib/raindown/tui/renderers/endpoint_renderer.rb +81 -0
- data/lib/raindown/tui/renderers/list_renderer.rb +15 -0
- data/lib/raindown/tui/renderers/outline_renderer.rb +21 -0
- data/lib/raindown/tui/renderers/renderer.rb +18 -0
- data/lib/raindown/tui/row.rb +12 -3
- data/lib/raindown/tui/rows.rb +58 -0
- data/lib/raindown/tui/search_overlay.rb +73 -0
- data/lib/raindown/tui/terminal/input.rb +91 -0
- data/lib/raindown/tui/terminal/ncurses_shim.rb +46 -0
- data/lib/raindown/tui/terminal/quiet.rb +32 -0
- data/lib/raindown/tui/{screen.rb → terminal/screen.rb} +42 -24
- data/lib/raindown/tui/terminal/styles.rb +154 -0
- data/lib/raindown/tui/text.rb +110 -0
- data/lib/raindown/tui/title.rb +29 -0
- data/lib/raindown/tui/views/dashboard_view.rb +147 -0
- data/lib/raindown/tui/views/endpoint_view.rb +77 -0
- data/lib/raindown/tui/{pane.rb → views/list_view.rb} +11 -8
- data/lib/raindown/tui/{outline.rb → views/outline_view.rb} +48 -20
- data/lib/raindown/tui/workers/project.rb +122 -0
- data/lib/raindown/tui/workers/worker.rb +124 -0
- data/lib/raindown/tui/workers/workers.rb +53 -0
- data/lib/raindown/tui.rb +10 -5
- data/lib/version.rb +1 -1
- metadata +63 -9
- data/lib/raindown/tui/commands.rb +0 -70
- data/lib/raindown/tui/input.rb +0 -55
- data/lib/raindown/tui/navigator.rb +0 -123
- data/lib/raindown/tui/styles.rb +0 -36
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'curses'
|
|
4
|
+
|
|
5
|
+
module Raindown
|
|
6
|
+
module TUI
|
|
7
|
+
# ncurses before 6.1, like macOS's, repeats a character with the terminal's rep capability by writing one byte of it,
|
|
8
|
+
# so a run of █ in a bar chart draws as ?. Terminals with their own terminfo entries, like Ghostty's and kitty's,
|
|
9
|
+
# have rep, while the system's entries from those ncurses versions don't. So with old ncurses, a terminal whose
|
|
10
|
+
# entry isn't the system's starts curses as xterm-256color, which they draw the same, and TERM is put back after,
|
|
11
|
+
# for the commands run from the screen, like the editor.
|
|
12
|
+
module NcursesShim
|
|
13
|
+
FALLBACK = 'xterm-256color'
|
|
14
|
+
SYSTEM_TERMINFO = %w[/usr/share/terminfo /usr/lib/terminfo /lib/terminfo].freeze
|
|
15
|
+
FIXED_VERSION = [6, 1].freeze
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
def starting(version: Curses::VERSION)
|
|
20
|
+
term = ENV.fetch('TERM', nil)
|
|
21
|
+
ENV['TERM'] = FALLBACK if fallback?(term, version)
|
|
22
|
+
yield
|
|
23
|
+
ensure
|
|
24
|
+
ENV['TERM'] = term
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def fallback?(term, version)
|
|
28
|
+
return false if term.nil? || term.empty? || term == FALLBACK
|
|
29
|
+
|
|
30
|
+
old?(version) && !system_entry?(term)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def old?(version)
|
|
34
|
+
numbers = version.to_s[/ncurses (\d+)\.(\d+)/, 0]&.scan(/\d+/)&.map(&:to_i)
|
|
35
|
+
numbers ? (numbers <=> FIXED_VERSION).negative? : false
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Entries are in a folder named by their first letter, or on macOS, its hex code, like 78/xterm-256color.
|
|
39
|
+
def system_entry?(term)
|
|
40
|
+
SYSTEM_TERMINFO.any? do |folder|
|
|
41
|
+
[term[0], term[0].ord.to_s(16)].any? { File.exist?(File.join(folder, it, term)) }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'stringio'
|
|
4
|
+
|
|
5
|
+
module Raindown
|
|
6
|
+
module TUI
|
|
7
|
+
# Keeps text off the screen while it's up, since it would be written over it, and scroll it from the bottom line.
|
|
8
|
+
module Quiet
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# What's written to stderr while the block runs, like a warning, is written once it's done.
|
|
12
|
+
def holding_stderr
|
|
13
|
+
stderr = $stderr
|
|
14
|
+
$stderr = StringIO.new
|
|
15
|
+
yield
|
|
16
|
+
ensure
|
|
17
|
+
held = $stderr.string
|
|
18
|
+
$stderr = stderr
|
|
19
|
+
$stderr.write(held) unless held.empty?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Ruby's warnings are off while the block runs.
|
|
23
|
+
def quietly
|
|
24
|
+
verbose = $VERBOSE
|
|
25
|
+
$VERBOSE = nil
|
|
26
|
+
yield
|
|
27
|
+
ensure
|
|
28
|
+
$VERBOSE = verbose
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
require 'curses'
|
|
4
4
|
|
|
5
5
|
require_relative 'input'
|
|
6
|
-
require_relative 'layout'
|
|
6
|
+
require_relative '../layout'
|
|
7
|
+
require_relative 'quiet'
|
|
8
|
+
require_relative 'ncurses_shim'
|
|
7
9
|
require_relative 'styles'
|
|
8
10
|
|
|
9
11
|
module Raindown
|
|
@@ -11,8 +13,9 @@ module Raindown
|
|
|
11
13
|
# Paints a Navigator's Layout with curses, and sends the Navigator the events of keys and clicks, read with Input.
|
|
12
14
|
# Only this class draws on the terminal, so another library could draw the same Layout.
|
|
13
15
|
class Screen
|
|
14
|
-
# How often, in milliseconds, the open file is checked for changes while no key is pressed
|
|
15
|
-
|
|
16
|
+
# How often, in milliseconds, the open file is checked for changes while no key is pressed, or, while the view
|
|
17
|
+
# animates, like Code feelings' rain, it's drawn.
|
|
18
|
+
TICKS = { false => 500, true => 60 }.freeze
|
|
16
19
|
|
|
17
20
|
def initialize(navigator)
|
|
18
21
|
@navigator = navigator
|
|
@@ -20,14 +23,20 @@ module Raindown
|
|
|
20
23
|
end
|
|
21
24
|
|
|
22
25
|
def run
|
|
26
|
+
Quiet.holding_stderr { loop_screen }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def loop_screen
|
|
23
32
|
start
|
|
24
33
|
|
|
25
34
|
loop do
|
|
26
35
|
draw
|
|
27
|
-
effect = @navigator.
|
|
36
|
+
effect = @navigator.next_step(read)
|
|
28
37
|
break if effect == :quit
|
|
29
38
|
|
|
30
|
-
perform(
|
|
39
|
+
perform(effect)
|
|
31
40
|
end
|
|
32
41
|
rescue Interrupt
|
|
33
42
|
nil
|
|
@@ -35,35 +44,29 @@ module Raindown
|
|
|
35
44
|
stop
|
|
36
45
|
end
|
|
37
46
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
@navigator.ran
|
|
45
|
-
when :wait
|
|
46
|
-
message, task = args
|
|
47
|
-
show(message)
|
|
48
|
-
task.call
|
|
47
|
+
# Runs a command, like the editor, then tells the Navigator, or shows a message while a slow task runs.
|
|
48
|
+
def perform(effect)
|
|
49
|
+
case effect
|
|
50
|
+
in Run(command:) then suspend { system(*command) }.then { @navigator.ran }
|
|
51
|
+
in Wait(message:, task:) then show(message).then { task.call }
|
|
52
|
+
else nil
|
|
49
53
|
end
|
|
50
54
|
end
|
|
51
55
|
|
|
52
56
|
def start
|
|
53
57
|
ENV['ESCDELAY'] ||= '25'
|
|
54
|
-
Curses.init_screen
|
|
58
|
+
NcursesShim.starting { Curses.init_screen }
|
|
55
59
|
@styles = Styles.start
|
|
56
60
|
Curses.noecho
|
|
57
61
|
Curses.cbreak
|
|
58
62
|
Curses.nonl
|
|
59
63
|
Curses.curs_set(0)
|
|
60
64
|
Curses.stdscr.keypad(true)
|
|
61
|
-
|
|
62
|
-
Curses.mousemask(Curses::BUTTON1_PRESSED | Curses::BUTTON1_CLICKED | Input.wheel.keys.sum)
|
|
63
|
-
Curses.mouseinterval(0)
|
|
65
|
+
Input.track_mouse
|
|
64
66
|
end
|
|
65
67
|
|
|
66
68
|
def stop
|
|
69
|
+
Input.untrack_mouse
|
|
67
70
|
Curses.close_screen
|
|
68
71
|
end
|
|
69
72
|
|
|
@@ -80,11 +83,21 @@ module Raindown
|
|
|
80
83
|
segments.each { |segment| paint(window, segment) }
|
|
81
84
|
end
|
|
82
85
|
|
|
86
|
+
repaint_title(lines.first, window)
|
|
83
87
|
window.refresh
|
|
84
88
|
end
|
|
85
89
|
|
|
90
|
+
# The title's rain cloud is one column to ncurses, and two in terminals like iTerm and kitty, which would put
|
|
91
|
+
# the rest of the title a column out when ncurses changes part of it. So when it changes, the screen is
|
|
92
|
+
# repainted, and the title is written in one go.
|
|
93
|
+
def repaint_title(title, window)
|
|
94
|
+
text = title&.map(&:text)&.join
|
|
95
|
+
window.redraw if @title && text != @title
|
|
96
|
+
@title = text
|
|
97
|
+
end
|
|
98
|
+
|
|
86
99
|
def paint(window, segment)
|
|
87
|
-
window.attron(@styles
|
|
100
|
+
window.attron(@styles[segment.style]) { window.addstr(segment.text) }
|
|
88
101
|
end
|
|
89
102
|
|
|
90
103
|
def last_line(segments)
|
|
@@ -94,16 +107,18 @@ module Raindown
|
|
|
94
107
|
|
|
95
108
|
# The next event, or :tick when no key is pressed in time, or nil for keys without one, like a resize.
|
|
96
109
|
def read
|
|
110
|
+
Curses.stdscr.timeout = TICKS.fetch(@navigator.animating?)
|
|
97
111
|
key = Curses.stdscr.getch
|
|
98
112
|
return :tick if key.nil?
|
|
99
113
|
return mouse if key == Curses::KEY_MOUSE
|
|
100
114
|
|
|
101
115
|
Curses.stdscr.clear if key == Curses::KEY_RESIZE
|
|
102
|
-
Input.key_event(key)
|
|
116
|
+
Input.key_event(key, typing: @navigator.typing?)
|
|
103
117
|
end
|
|
104
118
|
|
|
119
|
+
# The curses gem warns the first time it makes a MouseEvent, that it's "undefining the allocator" of the class.
|
|
105
120
|
def mouse
|
|
106
|
-
event = Curses.getmouse
|
|
121
|
+
event = Quiet.quietly { Curses.getmouse }
|
|
107
122
|
Input.mouse_event(event, @zones) if event
|
|
108
123
|
end
|
|
109
124
|
|
|
@@ -116,13 +131,16 @@ module Raindown
|
|
|
116
131
|
window.refresh
|
|
117
132
|
end
|
|
118
133
|
|
|
119
|
-
# Leaves the screen to the command, like the editor, and comes back to it after.
|
|
134
|
+
# 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.
|
|
120
136
|
def suspend
|
|
121
137
|
Curses.def_prog_mode
|
|
138
|
+
Input.untrack_mouse
|
|
122
139
|
Curses.close_screen
|
|
123
140
|
yield
|
|
124
141
|
ensure
|
|
125
142
|
Curses.reset_prog_mode
|
|
143
|
+
Input.track_mouse
|
|
126
144
|
Curses.stdscr.clear
|
|
127
145
|
Curses.refresh
|
|
128
146
|
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'curses'
|
|
4
|
+
|
|
5
|
+
module Raindown
|
|
6
|
+
module TUI
|
|
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, like the line between the panes, while the title and headings are cornflower blue. The selected
|
|
9
|
+
# 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
|
+
# background.
|
|
11
|
+
# With only 8 colours, there's no dark grey, so those rows are underlined, and without colours,
|
|
12
|
+
# the rest are in reverse.
|
|
13
|
+
module Styles
|
|
14
|
+
COLORS = { button: Curses::COLOR_YELLOW, error: Curses::COLOR_RED }.freeze
|
|
15
|
+
# 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, from the 256 colours most terminals
|
|
17
|
+
# have, or magenta and blue with 8.
|
|
18
|
+
ACCENTS = { directive: 141, folder: 111, title: 111, heading: 111 }.freeze
|
|
19
|
+
BASIC_ACCENTS = { directive: Curses::COLOR_MAGENTA, folder: Curses::COLOR_BLUE, title: Curses::COLOR_BLUE,
|
|
20
|
+
heading: Curses::COLOR_BLUE }.freeze
|
|
21
|
+
# The colours of endpoints' HTTP methods, like GET in green, from the 256 colours most terminals have,
|
|
22
|
+
# which are the same in every theme, unlike the 8 they all do, where green can look yellow.
|
|
23
|
+
METHODS = { get: 40, post: 33, delete: 196, put: 220, patch: 171, head: 44, options: 44 }.freeze
|
|
24
|
+
BASIC_METHODS = { get: Curses::COLOR_GREEN, post: Curses::COLOR_BLUE, delete: Curses::COLOR_RED,
|
|
25
|
+
put: Curses::COLOR_YELLOW, patch: Curses::COLOR_MAGENTA, head: Curses::COLOR_CYAN,
|
|
26
|
+
options: Curses::COLOR_CYAN }.freeze
|
|
27
|
+
# Highlighted code's colours, by the kind of token, from the 256 colours most terminals have, or the 8 they all do,
|
|
28
|
+
# where comments are dim instead of grey.
|
|
29
|
+
CODE = { code_keyword: 204, code_string: 114, code_number: 179, code_function: 75, code_constant: 117,
|
|
30
|
+
code_comment: 244 }.freeze
|
|
31
|
+
BASIC_CODE = { code_keyword: Curses::COLOR_MAGENTA, code_string: Curses::COLOR_GREEN, code_number: Curses::COLOR_YELLOW,
|
|
32
|
+
code_function: Curses::COLOR_BLUE, code_constant: Curses::COLOR_CYAN, code_comment: Curses::COLOR_WHITE }.freeze
|
|
33
|
+
# Code feelings' bright colours, for its rain and fireworks, from the 256 colours most terminals have, or
|
|
34
|
+
# the 8 they all do.
|
|
35
|
+
BRIGHT = { bright_red: 203, bright_orange: 215, bright_yellow: 227, bright_green: 120, bright_cyan: 87,
|
|
36
|
+
bright_blue: 75, bright_purple: 177, bright_pink: 213 }.freeze
|
|
37
|
+
BASIC_BRIGHT = { bright_red: Curses::COLOR_RED, bright_orange: Curses::COLOR_YELLOW, bright_yellow: Curses::COLOR_YELLOW,
|
|
38
|
+
bright_green: Curses::COLOR_GREEN, bright_cyan: Curses::COLOR_CYAN, bright_blue: Curses::COLOR_BLUE,
|
|
39
|
+
bright_purple: Curses::COLOR_MAGENTA, bright_pink: Curses::COLOR_MAGENTA }.freeze
|
|
40
|
+
# The selected row's blue, from the 256 colours most terminals have, or the 8 they all do.
|
|
41
|
+
BLUES = { selected: 33 }.freeze
|
|
42
|
+
BASIC_BLUES = { selected: Curses::COLOR_CYAN }.freeze
|
|
43
|
+
# Text that has no colour of its own, from the 256 colours most terminals have, or the terminal's own with 8.
|
|
44
|
+
PERIWINKLE = 147
|
|
45
|
+
# Code feelings' text and lines, the Dashboard cards' charts, and code blocks, in lavender, or magenta in 8 colours.
|
|
46
|
+
FEELINGS = { feelings: 183 }.freeze
|
|
47
|
+
BASIC_FEELINGS = { feelings: Curses::COLOR_MAGENTA }.freeze
|
|
48
|
+
BRIGHT_WHITE = 15
|
|
49
|
+
DARK_GREY = 238
|
|
50
|
+
|
|
51
|
+
module_function
|
|
52
|
+
|
|
53
|
+
# Sets up the colours, once the screen has started, and returns the attribute of each style.
|
|
54
|
+
def start
|
|
55
|
+
return plain unless Curses.has_colors?
|
|
56
|
+
|
|
57
|
+
start_colors
|
|
58
|
+
colored
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Without colours, a style that isn't here is the terminal's own.
|
|
62
|
+
def plain
|
|
63
|
+
Hash.new(Curses::A_NORMAL).merge(
|
|
64
|
+
title: Curses::A_REVERSE | Curses::A_BOLD, selected: Curses::A_REVERSE, selected_button: Curses::A_REVERSE,
|
|
65
|
+
cursor: Curses::A_UNDERLINE, cursor_button: Curses::A_UNDERLINE | Curses::A_BOLD, heading: Curses::A_BOLD,
|
|
66
|
+
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
|
+
key: Curses::A_REVERSE, border: Curses::A_DIM, divider: Curses::A_BOLD,
|
|
68
|
+
selected_divider: Curses::A_REVERSE, cursor_divider: Curses::A_BOLD,
|
|
69
|
+
code_keyword: Curses::A_BOLD, code_comment: Curses::A_DIM
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def colored
|
|
74
|
+
in_text_color.merge(
|
|
75
|
+
title: pair(:title) | Curses::A_BOLD, selected: pair(:selected) | white_bold,
|
|
76
|
+
selected_button: pair(:selected_button) | Curses::A_BOLD, directive: pair(:directive),
|
|
77
|
+
error: pair(:error) | Curses::A_BOLD, button: pair(:button) | Curses::A_BOLD, **emphasis,
|
|
78
|
+
**dividers, **code_tokens, **bright, **files, **unfocused
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# 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
|
+
# also any style that isn't here.
|
|
84
|
+
def in_text_color
|
|
85
|
+
text = pair(:text)
|
|
86
|
+
Hash.new(text).merge(plain.transform_values { it | text })
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Headings, and Code feelings' text and lines, whose lavender the Dashboard cards' charts and code
|
|
90
|
+
# blocks are too, apart from highlighted code's keywords, strings and the like.
|
|
91
|
+
def emphasis
|
|
92
|
+
lavender = pair(:feelings)
|
|
93
|
+
{ heading: pair(:heading) | Curses::A_BOLD, feelings: lavender, chart: lavender, code: lavender }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Highlighted code's tokens, like keywords and comments, with comments dim in 8 colours, where there's no grey.
|
|
97
|
+
def code_tokens
|
|
98
|
+
tokens = CODE.keys.to_h { [it, pair(it)] }
|
|
99
|
+
tokens[:code_comment] |= Curses::A_DIM if Curses.colors < 256
|
|
100
|
+
tokens
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# The bright colours, bold, so they're bright in 8 colours too.
|
|
104
|
+
def bright = BRIGHT.keys.to_h { [it, pair(it) | Curses::A_BOLD] }
|
|
105
|
+
|
|
106
|
+
# The line between the panes, and where it meets a selected row.
|
|
107
|
+
def dividers = { divider: pair(:divider), selected_divider: pair(:selected_divider) }
|
|
108
|
+
|
|
109
|
+
def files
|
|
110
|
+
{ folder: pair(:folder) | Curses::A_BOLD, project: pair(:folder) | Curses::A_BOLD,
|
|
111
|
+
page: pair(:folder) | Curses::A_BOLD, **METHODS.keys.to_h { [:"method_#{it}", pair(:"method_#{it}") | Curses::A_BOLD] } }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# The selected rows of the panes without the focus, on dark grey where there's one.
|
|
115
|
+
def unfocused
|
|
116
|
+
return { cursor_button: pair(:button) | Curses::A_BOLD, cursor_divider: pair(:divider) } unless Curses.colors >= 256
|
|
117
|
+
|
|
118
|
+
{ cursor: pair(:cursor), cursor_button: pair(:cursor_button) | Curses::A_BOLD, cursor_divider: pair(:cursor_divider) }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Each colour pair as [name, foreground, background], where -1 is the terminal's own background.
|
|
122
|
+
def pairs
|
|
123
|
+
many = Curses.colors >= 256
|
|
124
|
+
blues = many ? BLUES : BASIC_BLUES
|
|
125
|
+
white = many ? BRIGHT_WHITE : Curses::COLOR_WHITE
|
|
126
|
+
text = many ? PERIWINKLE : -1
|
|
127
|
+
|
|
128
|
+
[*COLORS.map { |name, color| [name, color, -1] },
|
|
129
|
+
*(many ? METHODS : BASIC_METHODS).map { |name, color| [:"method_#{name}", color, -1] },
|
|
130
|
+
*(many ? CODE.merge(BRIGHT, FEELINGS, ACCENTS) : BASIC_CODE.merge(BASIC_BRIGHT, BASIC_FEELINGS, BASIC_ACCENTS))
|
|
131
|
+
.map { |name, color| [name, color, -1] },
|
|
132
|
+
[:text, text, -1],
|
|
133
|
+
[:selected, white, blues[:selected]], [:selected_button, Curses::COLOR_YELLOW, blues[:selected]],
|
|
134
|
+
[:cursor, white, DARK_GREY], [:cursor_button, Curses::COLOR_YELLOW, DARK_GREY], [:divider, text, -1],
|
|
135
|
+
[:selected_divider, text, blues[:selected]], [:cursor_divider, text, DARK_GREY]]
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def pair(name)
|
|
139
|
+
Curses.color_pair(pairs.index { it.first == name } + 1)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# The 8 colour white is grey, which bold brightens.
|
|
143
|
+
def white_bold
|
|
144
|
+
Curses.colors >= 256 ? Curses::A_NORMAL : Curses::A_BOLD
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def start_colors
|
|
148
|
+
Curses.start_color
|
|
149
|
+
Curses.use_default_colors
|
|
150
|
+
pairs.each.with_index(1) { |(_name, foreground, background), index| Curses.init_pair(index, foreground, background) }
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'unicode/display_width'
|
|
4
|
+
|
|
5
|
+
module Raindown
|
|
6
|
+
module TUI
|
|
7
|
+
# Text measured in a terminal's columns, where a wide character like 界 takes two, cut to fit them in styled Segments.
|
|
8
|
+
module Text
|
|
9
|
+
Segment = Data.define(:text, :style, :event)
|
|
10
|
+
INDENT = ' '
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
# A path from the home folder when it's in it, like ~/dev/raindown.
|
|
15
|
+
def from_home(path)
|
|
16
|
+
home = Dir.home
|
|
17
|
+
path == home || path.start_with?(File.join(home, '')) ? path.sub(home, '~') : path
|
|
18
|
+
rescue ArgumentError
|
|
19
|
+
path
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# The text fit to the width, as a Segment in the style.
|
|
23
|
+
def segment(text, style, width: self.width, event: nil)
|
|
24
|
+
Segment.new(text: fit(printable(text), width), style:, event:)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Text cut or padded to the width, with a cut shown by an ellipsis.
|
|
28
|
+
def fit(text, width)
|
|
29
|
+
return '' if width <= 0
|
|
30
|
+
return text + (' ' * (width - display_width(text))) if display_width(text) <= width
|
|
31
|
+
|
|
32
|
+
cut = +''
|
|
33
|
+
cut_width = 0
|
|
34
|
+
text.each_char do |char|
|
|
35
|
+
break if cut_width + display_width(char) > width - 1
|
|
36
|
+
|
|
37
|
+
cut << char
|
|
38
|
+
cut_width += display_width(char)
|
|
39
|
+
end
|
|
40
|
+
"#{cut}…#{' ' * (width - 1 - cut_width)}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The widths of texts side by side in a width: each as wide as it is, and the last as wide as what's left,
|
|
44
|
+
# with none past the width.
|
|
45
|
+
def spread(texts, width)
|
|
46
|
+
texts.each_with_index.map do |text, index|
|
|
47
|
+
part = index == texts.length - 1 ? width : [display_width(text), width].min
|
|
48
|
+
width -= part
|
|
49
|
+
part
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# A line of highlighted code's [text, style] parts as segments, cut or padded to the width.
|
|
54
|
+
def code_segments(parts, width, event: nil)
|
|
55
|
+
segments = segments_between(parts.map { |text, style| Segment.new(text: printable(text), style:, event:) }, 0, width)
|
|
56
|
+
[*segments, segment('', :code, width: width - segments.sum { display_width(it.text) }, event:)]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The text in lines that fit the width, broken at spaces where it can, and inside a word too long for a line.
|
|
60
|
+
def wrap(text, width)
|
|
61
|
+
return [text] unless width.positive?
|
|
62
|
+
|
|
63
|
+
text.scan(/\S+|\s+/).each_with_object([+'']) do |part, lines|
|
|
64
|
+
next lines.last << part if display_width(lines.last + part) <= width
|
|
65
|
+
next lines << +'' if part.strip.empty?
|
|
66
|
+
|
|
67
|
+
lines << +'' unless lines.last.empty?
|
|
68
|
+
break_word(part, width, lines)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Adds a word too long for a line to the lines, a character at a time, starting a new line when one's full.
|
|
73
|
+
def break_word(word, width, lines)
|
|
74
|
+
word.each_char do |char|
|
|
75
|
+
lines << +'' if display_width(lines.last + char) > width
|
|
76
|
+
lines.last << char
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The line's segments, with others drawn over its columns from the column on, like a box over the screen.
|
|
81
|
+
def overlay_segments(line, segments, column:)
|
|
82
|
+
width = segments.sum { display_width(it.text) }
|
|
83
|
+
[*segments_between(line, 0, column), *segments, *segments_between(line, column + width, Float::INFINITY)]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The parts of the line's segments between two columns, where a wide character cut in two leaves spaces.
|
|
87
|
+
def segments_between(line, from, to)
|
|
88
|
+
column = 0
|
|
89
|
+
line.filter_map do |segment|
|
|
90
|
+
text = segment.text.each_char.map do |char|
|
|
91
|
+
start = column
|
|
92
|
+
column += display_width(char)
|
|
93
|
+
overlap = [column, to].min - [start, from].max
|
|
94
|
+
overlap == display_width(char) ? char : ' ' * [overlap, 0].max
|
|
95
|
+
end.join
|
|
96
|
+
segment.with(text:) unless text.empty?
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Tabs and control characters, like in a directive's output, would move the terminal's cursor.
|
|
101
|
+
def printable(text)
|
|
102
|
+
text.gsub("\t", INDENT).gsub(/[[:cntrl:]]/, '')
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def display_width(text)
|
|
106
|
+
Unicode::DisplayWidth.of(text)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Raindown
|
|
4
|
+
module TUI
|
|
5
|
+
# A Layout's title line: Raindown's name, the folder the files are shown from, and the page.
|
|
6
|
+
module Title
|
|
7
|
+
# The rain cloud without the emoji variation selector, like the nav's projects, which terminals draw one column wide,
|
|
8
|
+
# as it's measured, so the title line reaches the edge.
|
|
9
|
+
NAME = '🌧 Raindown'
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
# The name, the folder, which opens the project menu when there's more than one project, and the page.
|
|
14
|
+
def title
|
|
15
|
+
parts = [[name, nil], ["#{location}#{' ▾' if navigator.projects?}", (:projects if navigator.projects?)],
|
|
16
|
+
[" › #{view_renderer.page}", nil]]
|
|
17
|
+
|
|
18
|
+
parts.zip(spread(parts.map(&:first), width)).map do |(text, event), part_width|
|
|
19
|
+
segment(text, :title, width: part_width, event:)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def name = " #{NAME} "
|
|
24
|
+
|
|
25
|
+
# The folder the files are shown from.
|
|
26
|
+
def location = from_home(navigator.base)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../events'
|
|
4
|
+
|
|
5
|
+
module Raindown
|
|
6
|
+
module TUI
|
|
7
|
+
# Every chart in the projects' Markdown files, drawn from its data by its project's Worker, as cards in a grid
|
|
8
|
+
# with a cursor. A chart is a chart: directive, or a project's own node that's a Raindown::Chart, like
|
|
9
|
+
# "Sales < Raindown::Chart", or a Raindown::Stat, one big number, like "CodeDebt < Raindown::Stat". Charts are drawn
|
|
10
|
+
# again every few seconds, so they follow changes to their data, like a CSV file, without a save.
|
|
11
|
+
# A key's next step, for the Navigator, is nil, or an Open or Edit of a chart's directive.
|
|
12
|
+
class DashboardView
|
|
13
|
+
# Seconds between drawing the charts again.
|
|
14
|
+
REDRAW = 2
|
|
15
|
+
MOVES = %i[up down left right].freeze
|
|
16
|
+
# Columns between the cards, and the narrowest a card is, with its border.
|
|
17
|
+
GAP = 2
|
|
18
|
+
MIN_CARD_WIDTH = 28
|
|
19
|
+
|
|
20
|
+
attr_reader :root, :errors, :cursor, :project
|
|
21
|
+
|
|
22
|
+
def initialize(root:, workers:)
|
|
23
|
+
@root = root
|
|
24
|
+
@workers = workers
|
|
25
|
+
@cursor = 0
|
|
26
|
+
@offset = 0
|
|
27
|
+
@rows = []
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def renderer = DashboardRenderer
|
|
31
|
+
def target = self
|
|
32
|
+
def path = nil
|
|
33
|
+
def changed? = false
|
|
34
|
+
def typing? = false
|
|
35
|
+
def animating? = false
|
|
36
|
+
|
|
37
|
+
def status(_navigator)
|
|
38
|
+
found = "#{charts.length} #{charts.length == 1 ? 'chart' : 'charts'} in #{File.basename(project || root)}"
|
|
39
|
+
errors.empty? ? [:info, found] : [:error, "#{found}, but couldn't read #{errors.join(', ')}"]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Finds the charts again, like when it's opened, or a file is saved or added.
|
|
43
|
+
def load
|
|
44
|
+
draw(find: true)
|
|
45
|
+
@cursor = @cursor.clamp(0, [charts.length - 1, 0].max)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The charts, as each project's Worker drew them, of the project they're narrowed to, if any.
|
|
49
|
+
def charts
|
|
50
|
+
project ? @cards.filter { it.project == project } : @cards
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Narrows the charts to a project's, or with nil, shows every project's.
|
|
54
|
+
def project=(project)
|
|
55
|
+
@project = project
|
|
56
|
+
@cursor = 0
|
|
57
|
+
@offset = 0
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def cards
|
|
61
|
+
draw if Process.clock_gettime(Process::CLOCK_MONOTONIC) - @drawn_at >= REDRAW
|
|
62
|
+
charts
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The charts' indexes in rows, as many of a project's as fit across the width after a column of margin, with each
|
|
66
|
+
# project's charts starting a row. They're kept, so up and down move a row.
|
|
67
|
+
def rows(width)
|
|
68
|
+
@rows = charts.each_index.chunk_while { |a, b| charts[a].project == charts[b].project }.flat_map { group_rows(it, width) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The widths a card can be: no narrower than MIN_CARD_WIDTH, when there's room, and no wider than the width.
|
|
72
|
+
def card_widths(width) = [MIN_CARD_WIDTH, width - 1].min..(width - 1)
|
|
73
|
+
|
|
74
|
+
def selected = charts[@cursor]
|
|
75
|
+
|
|
76
|
+
def first_in_row?
|
|
77
|
+
@rows.empty? || @rows.any? { it.first == @cursor }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Every key but Tab to the files, or left from the first card in a row.
|
|
81
|
+
def next_step?(key) = !%i[tab tick].include?(key) && !(key == :left && first_in_row?)
|
|
82
|
+
|
|
83
|
+
def next_step(key)
|
|
84
|
+
chart = selected
|
|
85
|
+
case key
|
|
86
|
+
when *MOVES then move(key).then { nil }
|
|
87
|
+
when :enter then chart && Open.new(path: chart.path, line: chart.line)
|
|
88
|
+
when :edit then chart && Edit.new(path: chart.path, line: chart.line)
|
|
89
|
+
when :reload then load.then { nil }
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def cursor=(index)
|
|
94
|
+
@cursor = index.clamp(0, [charts.length - 1, 0].max)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# The first row of cards to show, given each row's height, scrolled so the cursor's row is shown.
|
|
98
|
+
def scroll(row_heights, height)
|
|
99
|
+
row = row_of(@cursor)
|
|
100
|
+
@offset = row if row < @offset
|
|
101
|
+
@offset += 1 while @offset < row && row_heights[@offset..row].sum > height
|
|
102
|
+
@offset = @offset.clamp(0, [row_heights.length - 1, 0].max)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def draw(find: false)
|
|
108
|
+
@cards = []
|
|
109
|
+
@errors = []
|
|
110
|
+
@workers.each do |worker|
|
|
111
|
+
answer = worker.call(:charts, find:)
|
|
112
|
+
@cards.concat(answer[:cards])
|
|
113
|
+
@errors.concat(answer[:errors])
|
|
114
|
+
rescue Worker::Error => e
|
|
115
|
+
@errors << "#{File.basename(worker.root)}: #{e.message}"
|
|
116
|
+
end
|
|
117
|
+
@cards.sort_by! { [it.project, it.path, it.line] }
|
|
118
|
+
@drawn_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def group_rows(indexes, width)
|
|
122
|
+
indexes.each_with_object([[]]) do |index, rows|
|
|
123
|
+
rows << [] if rows.last.any? && row_width(rows.last + [index], width) > width
|
|
124
|
+
rows.last << index
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# A row of cards' width, after a column of margin, with a gap between each.
|
|
129
|
+
def row_width(row, width)
|
|
130
|
+
1 + row.sum { charts[it].box_width(within: card_widths(width)) } + (GAP * (row.length - 1))
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Left and right move along the cards, and up and down to the card in the same place in the row above or below.
|
|
134
|
+
def move(name)
|
|
135
|
+
return self.cursor = @cursor + (name == :left ? -1 : 1) if %i[left right].include?(name)
|
|
136
|
+
|
|
137
|
+
row = row_of(@cursor)
|
|
138
|
+
target = @rows[(row + (name == :up ? -1 : 1)).clamp(0, [@rows.length - 1, 0].max)]
|
|
139
|
+
self.cursor = target ? target[[@rows[row].index(@cursor), target.length - 1].min] : @cursor
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def row_of(index)
|
|
143
|
+
@rows.index { it.include?(index) } || 0
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|