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,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../events'
|
|
4
|
+
require_relative 'celebration'
|
|
5
|
+
require_relative 'code_methods'
|
|
6
|
+
require_relative 'feelings_log'
|
|
7
|
+
require_relative 'weekly_batch'
|
|
8
|
+
|
|
9
|
+
module Raindown
|
|
10
|
+
module TUI
|
|
11
|
+
# Code feelings, like Severance's macrodata refinement: the project's functions and methods, in any language,
|
|
12
|
+
# as clusters of digits in a grid, where long methods, and ones whose files keep changing, drift. The cursor moves
|
|
13
|
+
# between them, the selected one's code shows below the grid, its first lines or with Enter all that fits, and 1 to 6
|
|
14
|
+
# sort how it feels into a bin: frolic, hope, numb, woe, dread or malice, which notes it down in the FeelingsLog,
|
|
15
|
+
# staying on it. Woe, dread and malice ask why first, typed until Enter, or Esc to leave it out. E opens the method in the
|
|
16
|
+
# editor, and X asks Claude Code to fix it, and either way it's read again after, to be felt again.
|
|
17
|
+
class FeelingsView
|
|
18
|
+
# Best to worst, as CodeHealth scores them, so the keys rate the code.
|
|
19
|
+
BINS = { bin1: 'frolic', bin2: 'hope', bin3: 'numb', bin4: 'woe', bin5: 'dread', bin6: 'malice' }.freeze
|
|
20
|
+
# The tempers that ask why.
|
|
21
|
+
NOTED = %w[woe dread malice].freeze
|
|
22
|
+
# Its own keys, besides the bins: Enter and Space show all of the selected method's code, or its first lines again.
|
|
23
|
+
# Ctrl+T celebrates, to try the rain and the waffle, and isn't shown with the buttons.
|
|
24
|
+
KEYS = %i[up down left right edit execute tick enter toggle expand celebrate].freeze
|
|
25
|
+
|
|
26
|
+
# The temper being noted, and the note typed so far, while it's asking why, whether the selected method's code
|
|
27
|
+
# shows all that fits, until another's selected, and the rain then the waffle, once the batch is refined.
|
|
28
|
+
attr_reader :noting, :note, :cursor, :tick, :log, :expanded, :celebration
|
|
29
|
+
|
|
30
|
+
def initialize(root:)
|
|
31
|
+
@root = root
|
|
32
|
+
@log = FeelingsLog.new(root:)
|
|
33
|
+
@last_feelings = {}
|
|
34
|
+
@cursor = 0
|
|
35
|
+
@tick = 0
|
|
36
|
+
@week = WeeklyBatch.new(log: @log)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def renderer = FeelingsRenderer
|
|
40
|
+
def target = self
|
|
41
|
+
# It shows Ruby code, which isn't written back.
|
|
42
|
+
def path = nil
|
|
43
|
+
def changed? = false
|
|
44
|
+
def typing? = !noting.nil?
|
|
45
|
+
def animating? = celebration&.raining? == true
|
|
46
|
+
def status(_navigator) = [:info, "This week's code to feel, #{batch.week}"]
|
|
47
|
+
|
|
48
|
+
# Reads the code again, keeping the batch, like after a method's edited or fixed, and the selected method, by its
|
|
49
|
+
# file and name, even when the methods around it change, moving the feelings about code that's changed, or gone, to
|
|
50
|
+
# the memories.
|
|
51
|
+
def load
|
|
52
|
+
was = selected
|
|
53
|
+
every = CodeMethods.list(@root)
|
|
54
|
+
@log.move_stale_to_memories(every)
|
|
55
|
+
@week.read(every)
|
|
56
|
+
self.cursor = code_methods.index { [it.path, it.label] == [was&.path, was&.label] } || @cursor
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The Batch of files whose methods are in the grid.
|
|
60
|
+
def batch = @week.batch
|
|
61
|
+
def code_methods = batch.code_methods
|
|
62
|
+
|
|
63
|
+
def selected = code_methods[@cursor]
|
|
64
|
+
|
|
65
|
+
def cursor=(index)
|
|
66
|
+
index = index.clamp(0, [code_methods.length - 1, 0].max)
|
|
67
|
+
@expanded = false unless index == @cursor
|
|
68
|
+
@cursor = index
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Each cell's method, by its index, in reading order, in a grid as many cells across as the columns.
|
|
72
|
+
# They're kept, so up and down move a row.
|
|
73
|
+
# The first time the grid's drawn, with its rows, right after the view opens, the batch is picked to fill them.
|
|
74
|
+
def cells(columns, rows = nil)
|
|
75
|
+
@week.fill(columns * rows) if rows
|
|
76
|
+
@columns = columns
|
|
77
|
+
@cells = code_methods.each_with_index.flat_map { |method, index| [index] * method.size }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Every key while it's asking why or celebrating, and otherwise its own, but Tab to the files, or left from the
|
|
81
|
+
# first method.
|
|
82
|
+
def next_step?(key)
|
|
83
|
+
return true if typing? || celebration
|
|
84
|
+
|
|
85
|
+
(KEYS.include?(key) || BINS.key?(key)) && !(key == :left && @cursor.zero?)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def next_step(key)
|
|
89
|
+
return note_step(key) if typing?
|
|
90
|
+
return @celebration = nil if celebration&.over?(key)
|
|
91
|
+
return (@tick += 1).then { nil } if key == :tick
|
|
92
|
+
return (@celebration = Celebration.new).then { nil } if key == :celebrate
|
|
93
|
+
return unless selected
|
|
94
|
+
return feel(BINS[key]) if BINS.key?(key)
|
|
95
|
+
|
|
96
|
+
method_step(key)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
# Moves between the methods, or acts on the selected one.
|
|
102
|
+
def method_step(key)
|
|
103
|
+
case key
|
|
104
|
+
when :left then move(-1)
|
|
105
|
+
when :right then move(1)
|
|
106
|
+
when :up then move_row(-1)
|
|
107
|
+
when :down then move_row(1)
|
|
108
|
+
when :edit then Edit.new(path: selected.path, line: selected.line)
|
|
109
|
+
when :execute then fix
|
|
110
|
+
when :enter, :toggle, :expand then (@expanded = !@expanded).then { nil }
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Asks to fix the method, with how it last felt, by its file and name, which stay the same when its lines change.
|
|
115
|
+
def fix = Fix.new(feeling: @last_feelings[[selected.path, selected.label]] || Feeling.new(method: selected, temper: nil, note: nil))
|
|
116
|
+
|
|
117
|
+
def move(by)
|
|
118
|
+
self.cursor = @cursor + by
|
|
119
|
+
nil
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# To the method in the cell above or below the selected one's first cell.
|
|
123
|
+
def move_row(by)
|
|
124
|
+
cell = @cells[@cells.index(@cursor).to_i + (by * @columns)]
|
|
125
|
+
self.cursor = cell if cell
|
|
126
|
+
nil
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Frolic and numb are noted down straight away, while the rest ask why first.
|
|
130
|
+
def feel(temper)
|
|
131
|
+
return note_down(temper, nil) unless NOTED.include?(temper)
|
|
132
|
+
|
|
133
|
+
@noting = temper
|
|
134
|
+
@note = +''
|
|
135
|
+
nil
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def note_step(key)
|
|
139
|
+
case key
|
|
140
|
+
when Type then @note << key.text
|
|
141
|
+
when :backspace then @note.chop!
|
|
142
|
+
when :enter then return note_down(@noting, (@note unless @note.strip.empty?))
|
|
143
|
+
when :escape then return note_down(@noting, nil)
|
|
144
|
+
end
|
|
145
|
+
nil
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Notes the feeling down, staying on the method, so it can be fixed with what was just said about it. It rains
|
|
149
|
+
# when that refines the batch.
|
|
150
|
+
def note_down(temper, note)
|
|
151
|
+
feeling = Feeling.new(method: selected, temper:, note:)
|
|
152
|
+
@last_feelings[[selected.path, selected.label]] = feeling
|
|
153
|
+
@noting = nil
|
|
154
|
+
refined = batch.refined?
|
|
155
|
+
@log.add(feeling)
|
|
156
|
+
@celebration = Celebration.new if !refined && batch.refined?
|
|
157
|
+
Felt.new(feeling:)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'zlib'
|
|
4
|
+
|
|
5
|
+
require_relative 'rain'
|
|
6
|
+
|
|
7
|
+
module Raindown
|
|
8
|
+
module TUI
|
|
9
|
+
# Fireworks, like over the Rain when a batch of code feelings is refined: rockets rising from the bottom, a row a
|
|
10
|
+
# tick, that burst in a bright colour into sparks flying out eight ways, which fade, then another rises in their
|
|
11
|
+
# place. Sparks fly twice as far across as up or down, as the cells are about twice as tall as they're wide:
|
|
12
|
+
# · · ·
|
|
13
|
+
# + + +
|
|
14
|
+
# * * *
|
|
15
|
+
# · + * * * + ·
|
|
16
|
+
# * * *
|
|
17
|
+
# + + +
|
|
18
|
+
# · · ·
|
|
19
|
+
# ╵
|
|
20
|
+
# ·
|
|
21
|
+
class Fireworks
|
|
22
|
+
# The columns for each rocket in the air at once.
|
|
23
|
+
COLUMNS_A_ROCKET = 20
|
|
24
|
+
# The sparks, a frame each, as they fly out from one in the middle and fade.
|
|
25
|
+
SPARKS = %w[* * * + + · ·].freeze
|
|
26
|
+
# The eight ways sparks fly, as columns and rows a frame.
|
|
27
|
+
WAYS = [[-2, -1], [0, -1], [2, -1], [-2, 0], [2, 0], [-2, 1], [0, 1], [2, 1]].freeze
|
|
28
|
+
# Where a rocket rises, the row it bursts on, and its colour.
|
|
29
|
+
Rocket = Data.define(:column, :top, :color)
|
|
30
|
+
|
|
31
|
+
attr_reader :width, :height
|
|
32
|
+
|
|
33
|
+
def initialize(width:, height:)
|
|
34
|
+
@width = width
|
|
35
|
+
@height = height
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Draws the rockets and bursts at the tick into a Scene's cells, each rocket a tick apart from the others by a
|
|
39
|
+
# checksum of it.
|
|
40
|
+
def draw(cells, tick)
|
|
41
|
+
[width / COLUMNS_A_ROCKET, 1].max.times do |rocket|
|
|
42
|
+
launch, frame = (tick + Zlib.crc32("rocket #{rocket}")).divmod(height + SPARKS.length)
|
|
43
|
+
firework(cells, rocket(Zlib.crc32("rocket #{rocket} #{launch}")), frame)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# A rocket, from the seed, a checksum of it and how many it's launched, bursting in the top half or so.
|
|
50
|
+
def rocket(seed)
|
|
51
|
+
Rocket.new(column: seed % width, top: (height / 5) + ((seed >> 8) % [height / 3, 1].max),
|
|
52
|
+
color: Rain::COLORS[(seed >> 16) % Rain::COLORS.length])
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The rocket rising, with a spark trailing it, or once it's as high as it goes, bursting.
|
|
56
|
+
def firework(cells, rocket, frame)
|
|
57
|
+
rising = height - 1 - rocket.top
|
|
58
|
+
return burst(cells, rocket, frame - rising) if frame >= rising
|
|
59
|
+
|
|
60
|
+
put(cells, rocket.column, height - 1 - frame, '╵', rocket.color)
|
|
61
|
+
put(cells, rocket.column, height - frame, '·', rocket.color)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def burst(cells, rocket, frame)
|
|
65
|
+
return unless (spark = SPARKS[frame])
|
|
66
|
+
|
|
67
|
+
WAYS.each { |across, down| put(cells, rocket.column + (across * frame), rocket.top + (down * frame), spark, rocket.color) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def put(cells, column, row, char, color)
|
|
71
|
+
cells[row][column] = [char, color] if row.between?(0, height - 1) && column.between?(0, width - 1)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'zlib'
|
|
4
|
+
|
|
5
|
+
module Raindown
|
|
6
|
+
module TUI
|
|
7
|
+
# Rain, like when a batch of code feelings is refined: drops falling down some of the columns, each at its own speed
|
|
8
|
+
# and in a bright colour of its own, that splash and spread when they hit the bottom, then fall again from the top
|
|
9
|
+
# in another colour:
|
|
10
|
+
# │ ╎
|
|
11
|
+
# ╎ │ │
|
|
12
|
+
# │ ╎ │
|
|
13
|
+
# · · ◦ . .
|
|
14
|
+
class Rain
|
|
15
|
+
# One column in RAINY has a drop.
|
|
16
|
+
RAINY = 4
|
|
17
|
+
# The splash, a frame each after a drop hits the bottom, as its distance either side of the drop's column.
|
|
18
|
+
SPLASH = [[0, '◦'], [1, '·'], [2, '.']].freeze
|
|
19
|
+
COLORS = %i[bright_red bright_orange bright_yellow bright_green bright_cyan bright_blue bright_purple bright_pink].freeze
|
|
20
|
+
|
|
21
|
+
attr_reader :width, :height
|
|
22
|
+
|
|
23
|
+
def initialize(width:, height:)
|
|
24
|
+
@width = width
|
|
25
|
+
@height = height
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Draws the drops at the tick into a Scene's cells.
|
|
29
|
+
def draw(cells, tick)
|
|
30
|
+
width.times { drop(cells, it, Zlib.crc32("rain #{it}"), tick) }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# The column's drop, if it has one, from the seed, a checksum of the column, like how fast it falls and when it
|
|
36
|
+
# starts.
|
|
37
|
+
def drop(cells, column, seed, tick)
|
|
38
|
+
return unless (seed % RAINY).zero?
|
|
39
|
+
|
|
40
|
+
falling = (height + speed(seed) - 1) / speed(seed)
|
|
41
|
+
fallen, frame = (tick + (seed >> 8)).divmod(falling + SPLASH.length)
|
|
42
|
+
color = color(column, fallen)
|
|
43
|
+
return splash(cells, column, frame - falling, color) if frame >= falling
|
|
44
|
+
|
|
45
|
+
fall(cells, column, frame * speed(seed), color)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# A row or two a tick.
|
|
49
|
+
def speed(seed) = ((seed >> 4) % 2) + 1
|
|
50
|
+
|
|
51
|
+
# A colour each time the column's drop falls, from a checksum of how many times it has.
|
|
52
|
+
def color(column, fallen) = COLORS[Zlib.crc32("#{column} #{fallen}") % COLORS.length]
|
|
53
|
+
|
|
54
|
+
# The drop at the row, and its tail above it.
|
|
55
|
+
def fall(cells, column, row, color)
|
|
56
|
+
cells[row][column] = ['│', color]
|
|
57
|
+
cells[row - 1][column] = ['╎', color] if row.positive?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def splash(cells, column, frame, color)
|
|
61
|
+
spread, char = SPLASH[frame]
|
|
62
|
+
[column - spread, column + spread].uniq.each { cells[height - 1][it] = [char, color] if it.between?(0, width - 1) }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../text'
|
|
4
|
+
require_relative 'fireworks'
|
|
5
|
+
require_relative 'rain'
|
|
6
|
+
|
|
7
|
+
module Raindown
|
|
8
|
+
module TUI
|
|
9
|
+
# Animations drawn over each other, each into the same cells, a character and its style each, in turn, so the later
|
|
10
|
+
# ones are on top, like Fireworks over the Rain.
|
|
11
|
+
class Scene
|
|
12
|
+
include Text
|
|
13
|
+
|
|
14
|
+
attr_reader :width, :height
|
|
15
|
+
|
|
16
|
+
def initialize(width:, height:, animations: [Rain, Fireworks])
|
|
17
|
+
@width = width
|
|
18
|
+
@height = height
|
|
19
|
+
@animations = animations.map { it.new(width:, height:) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# The lines at the tick, as segments.
|
|
23
|
+
def lines(tick)
|
|
24
|
+
cells = Array.new(height) { Array.new(width) { [' ', :blank] } }
|
|
25
|
+
@animations.each { it.draw(cells, tick) }
|
|
26
|
+
cells.map { segments(it) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
# The row's runs of cells in the same style, a segment each.
|
|
32
|
+
def segments(row)
|
|
33
|
+
row.chunk_while { |cell, after| cell[1] == after[1] }.map { Segment.new(text: it.map(&:first).join, style: it[0][1], event: nil) }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Raindown
|
|
4
|
+
module TUI
|
|
5
|
+
# The waffle after the rain with a pat of butter, when a batch of code is refined, and the week's work is done:
|
|
6
|
+
# ╔═══╦═══╦═══╦═══╦═══╗
|
|
7
|
+
# ║ ║ ║ ║ ║ ║
|
|
8
|
+
# ╠═══╬═══╬═══╬═══╬═══╣
|
|
9
|
+
# ║ ║ ║███║ ║ ║
|
|
10
|
+
# ╠═══╬═══╬═══╬═══╬═══╣
|
|
11
|
+
# ║ ║ ║ ║ ║ ║
|
|
12
|
+
# ╚═══╩═══╩═══╩═══╩═══╝
|
|
13
|
+
module Waffle
|
|
14
|
+
EDGE = :code_number
|
|
15
|
+
BUTTER = :button
|
|
16
|
+
WAFFLE = [
|
|
17
|
+
[['╔═══╦═══╦═══╦═══╦═══╗', EDGE]],
|
|
18
|
+
[['║ ║ ║ ║ ║ ║', EDGE]],
|
|
19
|
+
[['╠═══╬═══╬═══╬═══╬═══╣', EDGE]],
|
|
20
|
+
[['║ ║ ║', EDGE], ['███', BUTTER], ['║ ║ ║', EDGE]],
|
|
21
|
+
[['╠═══╬═══╬═══╬═══╬═══╣', EDGE]],
|
|
22
|
+
[['║ ║ ║ ║ ║ ║', EDGE]],
|
|
23
|
+
[['╚═══╩═══╩═══╩═══╩═══╝', EDGE]],
|
|
24
|
+
[['100% refined', :feelings]],
|
|
25
|
+
[['The work is mysterious and important.', :feelings]]
|
|
26
|
+
].freeze
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def waffle
|
|
31
|
+
top = [(height - WAFFLE.length) / 2, 0].max
|
|
32
|
+
lines = [*Array.new(top, []), *WAFFLE, *Array.new(height, [])].first(height)
|
|
33
|
+
lines.map { centred(it) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The parts in the middle of the width.
|
|
37
|
+
def centred(parts)
|
|
38
|
+
left = [(width - parts.sum { display_width(it[0]) }) / 2, 0].max
|
|
39
|
+
padded([segment('', :blank, width: left), *parts.map { |text, style| segment(text, style, width: display_width(text)) }])
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'batch'
|
|
4
|
+
|
|
5
|
+
module Raindown
|
|
6
|
+
module TUI
|
|
7
|
+
# This week's Batch of a project's code to feel, picked to fill the grid when the view first opens and draws it,
|
|
8
|
+
# then kept through reloads, like after a method's edited or fixed.
|
|
9
|
+
class WeeklyBatch
|
|
10
|
+
attr_reader :batch
|
|
11
|
+
|
|
12
|
+
def initialize(log:)
|
|
13
|
+
@log = log
|
|
14
|
+
@batch = Batch.new(files: [], code_methods: [], log:, week: Batch.this_week)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# The methods read again, keeping the batch's files.
|
|
18
|
+
def read(every)
|
|
19
|
+
@every = every
|
|
20
|
+
@batch = batch.reading(every)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Picks the batch to fill the grid's cells, once they're known.
|
|
24
|
+
def fill(grid_cells)
|
|
25
|
+
return if @filled
|
|
26
|
+
|
|
27
|
+
@filled = true
|
|
28
|
+
@batch = Batch.pick(@every, log: @log, cells: grid_cells)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -1,54 +1,97 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative '../projects'
|
|
4
|
+
require_relative 'events'
|
|
5
|
+
require_relative 'views/outline_view'
|
|
4
6
|
require_relative 'row'
|
|
5
7
|
|
|
6
8
|
module Raindown
|
|
7
9
|
module TUI
|
|
8
|
-
# The
|
|
10
|
+
# The nav pane's files: a project's Markdown files as a tree, with folders before files,
|
|
11
|
+
# including the files of the projects inside it, whose folders are marked with Raindown's cloud.
|
|
12
|
+
# Folders start folded, and open with Enter, or when a file in them opens.
|
|
13
|
+
# A scope narrows it to one project's own files, from the project's folder, like when it's chosen in the header.
|
|
9
14
|
class FileTree
|
|
10
15
|
attr_reader :root
|
|
16
|
+
# The project the files are narrowed to, or nil for all of them.
|
|
17
|
+
attr_accessor :scope
|
|
11
18
|
|
|
12
|
-
def initialize(root:)
|
|
19
|
+
def initialize(root:, workers:)
|
|
13
20
|
@root = root
|
|
14
|
-
@
|
|
21
|
+
@workers = workers
|
|
22
|
+
@expanded = Set.new
|
|
15
23
|
load
|
|
16
24
|
end
|
|
17
25
|
|
|
26
|
+
# The rain cloud without its emoji variation selector, which every terminal draws one column wide, so it lines up
|
|
27
|
+
# the files with the panes beside them. With the selector, as in the title, some terminals draw it two.
|
|
28
|
+
PROJECT = '🌧'
|
|
29
|
+
|
|
18
30
|
def load
|
|
19
|
-
@files = Projects.files(root)
|
|
31
|
+
@files = Projects.files(root, nested: true)
|
|
32
|
+
@projects = Projects.roots(root).to_set
|
|
20
33
|
end
|
|
21
34
|
|
|
35
|
+
# The folder the tree starts from: the scope's, or the root's.
|
|
36
|
+
def base = scope || root
|
|
37
|
+
|
|
22
38
|
def rows
|
|
23
39
|
tree = {}
|
|
24
|
-
|
|
40
|
+
files.each { |path| relative(path).split('/').reduce(tree) { |node, part| node[part] ||= {} } }
|
|
25
41
|
tree_rows(tree, '', 0)
|
|
26
42
|
end
|
|
27
43
|
|
|
28
44
|
def toggle(folder)
|
|
29
|
-
@
|
|
45
|
+
@expanded.delete?(folder) || @expanded.add(folder)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Opens the folders the file is in, so its row is shown.
|
|
49
|
+
def reveal(path)
|
|
50
|
+
folder = File.dirname(path)
|
|
51
|
+
while folder.start_with?(File.join(root, ''))
|
|
52
|
+
@expanded.add(folder)
|
|
53
|
+
folder = File.dirname(folder)
|
|
54
|
+
end
|
|
30
55
|
end
|
|
31
56
|
|
|
32
57
|
def relative(path)
|
|
33
|
-
path.delete_prefix(File.join(
|
|
58
|
+
path.delete_prefix(File.join(base, ''))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# A scope's own files, without those of the projects inside it, which are projects of their own.
|
|
62
|
+
def files
|
|
63
|
+
return @files unless scope
|
|
64
|
+
|
|
65
|
+
@files.select { |path| owner(path) == scope }
|
|
34
66
|
end
|
|
35
67
|
|
|
36
68
|
private
|
|
37
69
|
|
|
70
|
+
def owner(path)
|
|
71
|
+
[root, *@projects].select { path.start_with?(File.join(it, '')) }.max_by(&:length)
|
|
72
|
+
end
|
|
73
|
+
|
|
38
74
|
def tree_rows(tree, prefix, depth)
|
|
39
75
|
folders, files = tree.partition { |_name, children| children.any? }
|
|
40
76
|
|
|
41
77
|
folders.sort.flat_map { |name, children| folder_rows(name, children, prefix, depth) } +
|
|
42
|
-
files.sort.map { |name, _|
|
|
78
|
+
files.sort.map { |name, _| file_row(name, File.join(base, prefix, name), depth) }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def file_row(name, path, depth)
|
|
82
|
+
Row.new(text: name, depth:, kind: :file, target: path, open: OpenView.new(view: OutlineView.new(path, workers: @workers)))
|
|
43
83
|
end
|
|
44
84
|
|
|
45
85
|
# A folder, and the tree inside it unless it's folded.
|
|
46
86
|
def folder_rows(name, children, prefix, depth)
|
|
47
|
-
folder = File.join(
|
|
48
|
-
|
|
49
|
-
|
|
87
|
+
folder = File.join(base, prefix, name)
|
|
88
|
+
expanded = @expanded.include?(folder)
|
|
89
|
+
project = @projects.include?(folder)
|
|
90
|
+
row = Row.new(text: [expanded ? '▾' : '▸', (PROJECT if project), "#{name}/"].compact.join(' '), depth:,
|
|
91
|
+
kind: project ? :project : :folder, target: folder, fold: -> { toggle(folder) })
|
|
92
|
+
return [row] unless expanded
|
|
50
93
|
|
|
51
|
-
[row, *
|
|
94
|
+
[row, *tree_rows(children, File.join(prefix, name), depth + 1)]
|
|
52
95
|
end
|
|
53
96
|
end
|
|
54
97
|
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Raindown
|
|
4
|
+
module TUI
|
|
5
|
+
# Code coloured with Rouge, which knows over 200 languages, as each line's [text, style] parts. Each part's style is
|
|
6
|
+
# the kind of token it is, like a keyword or a string, from the few code styles Styles has, so they work in any
|
|
7
|
+
# terminal. Code in a language Rouge doesn't know is a plain part a line.
|
|
8
|
+
module Highlighter
|
|
9
|
+
# Each kind of token's style, by the start of its name, like Literal.String.Double for a string, most specific first.
|
|
10
|
+
STYLES = [['Name.Function', :code_function], ['Name.Class', :code_constant], ['Name.Constant', :code_constant],
|
|
11
|
+
['Name.Builtin', :code_constant], ['Name.Label', :code_constant], ['Keyword', :code_keyword],
|
|
12
|
+
['Literal.String', :code_string], ['Literal.Number', :code_number], ['Comment', :code_comment]].freeze
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# The lines highlighted for a file's name, like shapes.rb, or a code fence's language, like json. Markdown
|
|
17
|
+
# highlights the code in its fences in their own languages, like an endpoint's request and response.
|
|
18
|
+
def lines(lines, filename: nil, language: nil)
|
|
19
|
+
lexer = lexer(filename:, language:)
|
|
20
|
+
return lines.map { [[it, :code]] } unless lexer
|
|
21
|
+
|
|
22
|
+
lexer.lex(lines.join("\n")).each_with_object([[]]) do |(token, text), highlighted|
|
|
23
|
+
text.split(/(\n)/).each do |piece|
|
|
24
|
+
next highlighted << [] if piece == "\n"
|
|
25
|
+
|
|
26
|
+
highlighted.last << [piece, style(token)] unless piece.empty?
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Rouge takes about a third of a second to load, so the TUI loads it when it starts, rather than with its code.
|
|
32
|
+
def lexer(filename:, language:)
|
|
33
|
+
require 'rouge'
|
|
34
|
+
lexer = Rouge::Lexer.find_fancy(language) unless language.to_s.empty?
|
|
35
|
+
lexer ||= Rouge::Lexer.guess_by_filename(filename).new if filename
|
|
36
|
+
lexer unless lexer.is_a?(Rouge::Lexers::PlainText)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def style(token)
|
|
40
|
+
name = token.qualname
|
|
41
|
+
STYLES.find { |prefix, _| name.start_with?(prefix) }&.last || :code
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|