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.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/lib/nodes/endpoint_node.rb +3 -2
  3. data/lib/raindown/actions.rb +5 -3
  4. data/lib/raindown/bare_directives.rb +14 -27
  5. data/lib/raindown/blueprints.rb +255 -0
  6. data/lib/raindown/chart/chart.rb +12 -1
  7. data/lib/raindown/cli.rb +39 -54
  8. data/lib/raindown/config.rb +2 -3
  9. data/lib/raindown/directives.rb +30 -13
  10. data/lib/raindown/document.rb +22 -20
  11. data/lib/raindown/endpoint/curl.rb +11 -7
  12. data/lib/raindown/endpoint/endpoint_runner.rb +13 -5
  13. data/lib/raindown/node_blocks.rb +31 -22
  14. data/lib/raindown/node_injector.rb +7 -10
  15. data/lib/raindown/nodes.rb +1 -0
  16. data/lib/raindown/pages.rb +1 -1
  17. data/lib/raindown/pre_renderer.rb +18 -0
  18. data/lib/raindown/stat.rb +53 -0
  19. data/lib/raindown/tui/blueprints/blueprints_renderer.rb +93 -0
  20. data/lib/raindown/tui/blueprints/blueprints_view.rb +146 -0
  21. data/lib/raindown/tui/blueprints/canvas.rb +58 -0
  22. data/lib/raindown/tui/blueprints/code_comments.rb +71 -0
  23. data/lib/raindown/tui/blueprints/filter.rb +77 -0
  24. data/lib/raindown/tui/blueprints/traced_code.rb +56 -0
  25. data/lib/raindown/tui/blueprints/tracing.rb +129 -0
  26. data/lib/raindown/tui/code_box.rb +143 -0
  27. data/lib/raindown/tui/endpoints.rb +2 -2
  28. data/lib/raindown/tui/events.rb +18 -1
  29. data/lib/raindown/tui/feelings/batch.rb +47 -0
  30. data/lib/raindown/tui/feelings/bins.rb +67 -0
  31. data/lib/raindown/tui/feelings/celebration/celebration.rb +28 -0
  32. data/lib/raindown/tui/feelings/celebration/fireworks.rb +75 -0
  33. data/lib/raindown/tui/feelings/celebration/rain.rb +66 -0
  34. data/lib/raindown/tui/feelings/celebration/scene.rb +40 -0
  35. data/lib/raindown/tui/feelings/cell_moves.rb +34 -0
  36. data/lib/raindown/tui/feelings/code_methods.rb +134 -0
  37. data/lib/raindown/tui/feelings/feelings_code_box.rb +40 -0
  38. data/lib/raindown/tui/feelings/feelings_log.rb +136 -0
  39. data/lib/raindown/tui/feelings/feelings_renderer.rb +183 -0
  40. data/lib/raindown/tui/feelings/feelings_view.rb +157 -0
  41. data/lib/raindown/tui/feelings/magnifier.rb +68 -0
  42. data/lib/raindown/tui/feelings/motion.rb +93 -0
  43. data/lib/raindown/tui/feelings/waffle/baked_waffle.rb +88 -0
  44. data/lib/raindown/tui/feelings/waffle/balloons.rb +81 -0
  45. data/lib/raindown/tui/feelings/waffle/confetti.rb +55 -0
  46. data/lib/raindown/tui/feelings/waffle/sparkles.rb +61 -0
  47. data/lib/raindown/tui/feelings/waffle/waffle.rb +62 -0
  48. data/lib/raindown/tui/feelings/weekly_batch.rb +51 -0
  49. data/lib/raindown/tui/file_tree.rb +6 -5
  50. data/lib/raindown/tui/highlighter.rb +45 -0
  51. data/lib/raindown/tui/layout.rb +32 -18
  52. data/lib/raindown/tui/navigator/commands.rb +46 -2
  53. data/lib/raindown/tui/navigator/mouse.rb +43 -0
  54. data/lib/raindown/tui/navigator/navigator.rb +54 -25
  55. data/lib/raindown/tui/navigator/teleport.rb +128 -0
  56. data/lib/raindown/tui/navigator/views.rb +5 -3
  57. data/lib/raindown/tui/{workers/project.rb → project.rb} +38 -27
  58. data/lib/raindown/tui/{menu_overlay.rb → project_box.rb} +2 -2
  59. data/lib/raindown/tui/renderers/dashboard_renderer.rb +4 -4
  60. data/lib/raindown/tui/renderers/endpoint_renderer.rb +14 -10
  61. data/lib/raindown/tui/renderers/outline_renderer.rb +4 -4
  62. data/lib/raindown/tui/renderers/renderer.rb +4 -0
  63. data/lib/raindown/tui/row.rb +4 -2
  64. data/lib/raindown/tui/rows.rb +2 -1
  65. data/lib/raindown/tui/saves.rb +22 -0
  66. data/lib/raindown/tui/search_box.rb +86 -0
  67. data/lib/raindown/tui/terminal/input.rb +95 -7
  68. data/lib/raindown/tui/terminal/screen.rb +37 -29
  69. data/lib/raindown/tui/terminal/styles.rb +111 -26
  70. data/lib/raindown/tui/terminal/terminal_title.rb +31 -0
  71. data/lib/raindown/tui/text.rb +62 -12
  72. data/lib/raindown/tui/title.rb +1 -1
  73. data/lib/raindown/tui/views/dashboard_view.rb +7 -5
  74. data/lib/raindown/tui/views/endpoint_view.rb +4 -2
  75. data/lib/raindown/tui/views/list_view.rb +2 -0
  76. data/lib/raindown/tui/views/outline_view.rb +16 -6
  77. data/lib/raindown/tui.rb +19 -7
  78. data/lib/raindown/watcher/changes.rb +33 -0
  79. data/lib/raindown/watcher/file_watcher.rb +58 -0
  80. data/lib/raindown/watcher/project.rb +47 -0
  81. data/lib/raindown/watcher/supervisor.rb +111 -0
  82. data/lib/raindown/{tui/workers → workers}/worker.rb +37 -29
  83. data/lib/raindown/workers/workers.rb +63 -0
  84. data/lib/raindown.rb +4 -4
  85. data/lib/version.rb +1 -1
  86. metadata +60 -9
  87. data/lib/raindown/project_worker.rb +0 -48
  88. data/lib/raindown/supervisor.rb +0 -160
  89. data/lib/raindown/tui/workers/workers.rb +0 -53
  90. data/lib/raindown/watcher.rb +0 -57
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raindown
4
+ module TUI
5
+ # A waffle's rows, as [text, style] parts: a grid of pockets between golden ridges, shaded darker at their tops,
6
+ # under the ridges, so they look deep, with rounded corners, and a pat of butter melting where the middle ridges cross,
7
+ # with a white shine at its top left, and as many of its pockets as have been filled with syrup, nearest the butter
8
+ # first:
9
+ # ▄███████████████████████▄
10
+ # █▓▓▓▓▓█▓▓▓▓▓█▓▓▓▓▓█▓▓▓▓▓█
11
+ # █░░░░░█░░░░░█░░░░░█░░░░░█
12
+ # █████████████████████████
13
+ # █▓▓▓▓▓█▓▓▓▓▓█▓▓▓▓▓█▓▓▓▓▓█
14
+ # █░░░░░█░░░▄▄▄▄▄░░░█░░░░░█
15
+ # ██████████████████████████
16
+ module BakedWaffle
17
+ RIDGE = :code_number
18
+ POCKET = :bright_orange
19
+ BUTTER = :button
20
+ SHINE = :shine
21
+ SYRUP = :syrup
22
+ # The pockets across and down, the columns each takes, and the lines a pocket and a ridge take.
23
+ POCKETS = 4
24
+ POCKET_WIDTH = 5
25
+ ROW_LINES = 3
26
+ # The pat of butter's lines, from the line above the middle ridge, a drip below it, and the shine at its top left, as
27
+ # its lines and columns.
28
+ PAT = %w[▄▄▄▄▄ █████ ▀▀█▀▀].freeze
29
+ DRIP = '▀'
30
+ SHINE_AT = [[0, 0], [0, 1], [1, 0]].freeze
31
+
32
+ module_function
33
+
34
+ def rows(syrup: 0)
35
+ cells = Array.new(down) { |row| Array.new(across) { |column| cell(row, column, across, down) } }
36
+ buttered(sweetened(cells, syrup), down / 2, across / 2).map { parts(it) }
37
+ end
38
+
39
+ def across = 1 + (POCKETS * (POCKET_WIDTH + 1))
40
+ def down = 1 + (POCKETS * ROW_LINES)
41
+
42
+ # The cells with as many pockets filled with syrup, nearest the middle first, keeping their shading.
43
+ def sweetened(cells, filled)
44
+ pockets.first(filled).each do |pocket_row, pocket_column|
45
+ (1...ROW_LINES).to_a.product((1..POCKET_WIDTH).to_a).each do |line, offset|
46
+ cells[(pocket_row * ROW_LINES) + line][(pocket_column * (POCKET_WIDTH + 1)) + offset][1] = SYRUP
47
+ end
48
+ end
49
+ cells
50
+ end
51
+
52
+ # The pockets, by their row and column, nearest the middle first.
53
+ def pockets
54
+ middle = (POCKETS - 1) / 2.0
55
+ (0...POCKETS).to_a.product((0...POCKETS).to_a).sort_by { |row, column| [(row - middle).abs + (column - middle).abs, row, column] }
56
+ end
57
+
58
+ # A ridge, rounded at its corners, or a pocket, darker at its top, under the ridge above it, than below.
59
+ def cell(row, column, across, down)
60
+ edge = [0, down - 1].include?(row)
61
+ return [' ', :blank] if edge && [0, across - 1].include?(column)
62
+ return ['▄', RIDGE] if row.zero? && [1, across - 2].include?(column)
63
+ return ['▀', RIDGE] if edge && [1, across - 2].include?(column)
64
+ return ['█', RIDGE] if (row % ROW_LINES).zero? || (column % (POCKET_WIDTH + 1)).zero?
65
+
66
+ [%w[▓ ░][(row % ROW_LINES) - 1], POCKET]
67
+ end
68
+
69
+ # The cells with the pat of butter on the middle ridges' cross, its drip, and its shine.
70
+ def buttered(cells, row, column)
71
+ PAT.each_with_index do |line, at|
72
+ line.each_char.with_index { |char, offset| cells[row - 1 + at][column - 2 + offset] = [char, BUTTER] }
73
+ end
74
+ cells[row + 2][column] = [DRIP, BUTTER]
75
+ shine(cells, row, column)
76
+ end
77
+
78
+ # The cells with the butter's top left shining.
79
+ def shine(cells, row, column)
80
+ SHINE_AT.each { |line, offset| cells[row - 1 + line][column - 2 + offset][1] = SHINE }
81
+ cells
82
+ end
83
+
84
+ # The row's cells in runs of the same style, as [text, style] parts.
85
+ def parts(row) = row.chunk_while { |cell, after| cell[1] == after[1] }.map { [it.map(&:first).join, it[0][1]] }
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zlib'
4
+
5
+ require_relative '../celebration/rain'
6
+
7
+ module Raindown
8
+ module TUI
9
+ # Balloons floating up either side of the waffle, when a batch of code feelings is refined: one for every so many
10
+ # columns, left and right in turn, clear of the waffle in the middle, each in a bright colour, shaded where the light
11
+ # catches it, with a knot and a string that kinks, rising from below the bottom, swaying a column either way, then
12
+ # round again. Their spaces are see-through:
13
+ # ▄███▄
14
+ # █░▒████
15
+ # █▒█████
16
+ # ▀███▀
17
+ # ▼
18
+ # │
19
+ # ╰╮
20
+ # │
21
+ class Balloons
22
+ BALLOON = [' ▄███▄', '█░▒████', '█▒█████', ' ▀███▀', ' ▼'].freeze
23
+ STRING = [' │', ' ╰╮', ' │'].freeze
24
+ # The columns for each balloon, and the frames each row it rises takes.
25
+ COLUMNS_A_BALLOON = 20
26
+ # The columns kept clear in the middle, for the waffle and its text.
27
+ MIDDLE = 39
28
+ RISE_FRAMES = 2.1
29
+ # Where a balloon is, by its left column and its top row, and its colour.
30
+ Balloon = Data.define(:left, :top, :color)
31
+
32
+ attr_reader :width, :height
33
+
34
+ def initialize(width:, height:)
35
+ @width = width
36
+ @height = height
37
+ end
38
+
39
+ # Draws the balloons where they've floated to by the tick into a Scene's cells.
40
+ def draw(cells, tick)
41
+ [width / COLUMNS_A_BALLOON, 2].max.times { balloon(cells, floated(it, Zlib.crc32("balloon #{it}"), tick)) }
42
+ end
43
+
44
+ private
45
+
46
+ # Where a balloon's floated to by the tick, from its seed, a checksum of it: where it rises in its side, when it starts,
47
+ # how it sways, never past an edge, and its colour.
48
+ def floated(number, seed, tick)
49
+ sway = (side(number, seed) + Math.sin((tick + seed) / 6.4).round).clamp(0, [width - BALLOON.map(&:length).max, 0].max)
50
+ Balloon.new(left: sway, top: risen(seed, tick),
51
+ color: Rain::COLORS[(seed >> 16) % Rain::COLORS.length])
52
+ end
53
+
54
+ # A column in the left side for the even balloons, and the right for the odd, either side of the middle.
55
+ def side(number, seed)
56
+ room = [((width - MIDDLE) / 2) - BALLOON.map(&:length).max, 1].max
57
+ column = seed % room
58
+ return column if number.even?
59
+
60
+ width - room + column - BALLOON.map(&:length).max
61
+ end
62
+
63
+ # Its top row, a row higher every RISE_FRAMES, from below the bottom to above the top, then round again.
64
+ def risen(seed, tick) = height - 1 - (((tick / RISE_FRAMES).floor + (seed >> 8)) % (height + BALLOON.length + STRING.length))
65
+
66
+ def balloon(cells, balloon)
67
+ BALLOON.each_with_index { |text, row| put(cells, balloon.top + row, balloon.left, text, balloon.color) }
68
+ STRING.each_with_index { |text, row| put(cells, balloon.top + BALLOON.length + row, balloon.left, text, :border) }
69
+ end
70
+
71
+ # The text's characters from the left column, leaving the cells under its spaces as they are.
72
+ def put(cells, row, left, text, style)
73
+ return unless row.between?(0, height - 1)
74
+
75
+ text.each_char.with_index do |char, at|
76
+ cells[row][left + at] = [char, style] if char != ' ' && (left + at).between?(0, width - 1)
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zlib'
4
+
5
+ require_relative '../celebration/rain'
6
+ require_relative 'balloons'
7
+
8
+ module Raindown
9
+ module TUI
10
+ # Confetti falling either side of the waffle, behind the balloons, when a batch of code feelings is refined: flecks in
11
+ # bright colours, a row or two lower each frame, as fast as the Rain, wavering a column either way, then round again
12
+ # from the top.
13
+ # ▪ ◆
14
+ # · ▴
15
+ class Confetti
16
+ FLECKS = %w[▪ ◆ ▴ ·].freeze
17
+ # The columns for each fleck, and the frames each column it wavers takes.
18
+ COLUMNS_A_FLECK = 3
19
+ WAVER_FRAMES = 11
20
+
21
+ attr_reader :width, :height
22
+
23
+ def initialize(width:, height:)
24
+ @width = width
25
+ @height = height
26
+ end
27
+
28
+ # Draws the confetti where it's drifted to by the tick into a Scene's cells, clear of the waffle in the middle.
29
+ def draw(cells, tick)
30
+ (width / COLUMNS_A_FLECK).times { fleck(cells, Zlib.crc32("confetti #{it}"), tick) }
31
+ end
32
+
33
+ private
34
+
35
+ def fleck(cells, seed, tick)
36
+ column = drifted(seed, tick)
37
+ return if clear?(column)
38
+
39
+ cells[((tick * speed(seed)) + (seed >> 8)) % height][column] = look(seed)
40
+ end
41
+
42
+ # A row or two a frame, like the Rain's drops.
43
+ def speed(seed) = ((seed >> 12) % 2) + 1
44
+
45
+ # Its column, wavering a column either way as it falls.
46
+ def drifted(seed, tick) = (seed % width) + (((tick / WAVER_FRAMES) + seed) % 3) - 1
47
+
48
+ # Whether the column's in the middle, kept clear for the waffle, or off the screen.
49
+ def clear?(column) = (column - (width / 2)).abs <= Balloons::MIDDLE / 2 || !column.between?(0, width - 1)
50
+
51
+ # Its fleck and colour.
52
+ def look(seed) = [FLECKS[(seed >> 4) % FLECKS.length], Rain::COLORS[(seed >> 16) % Rain::COLORS.length]]
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zlib'
4
+
5
+ require_relative 'baked_waffle'
6
+
7
+ module Raindown
8
+ module TUI
9
+ # Sparkles twinkling around the waffle, when a batch of code feelings is refined: white, on a ring just clear of it,
10
+ # each growing from a dot to a star and back, then gone for a while, at its own time.
11
+ # · ✦
12
+ # ▄█████████████▄ ✧
13
+ class Sparkles
14
+ TWINKLE = %w[· ✧ ✦ ✧ ·].freeze
15
+ COUNT = 10
16
+ # The frames each of a twinkle's steps takes, and the steps between a sparkle's twinkles.
17
+ TWINKLE_FRAMES = 3
18
+ TWINKLE_EVERY = 16
19
+ # The columns and lines the ring's clear of the waffle by.
20
+ MARGIN = 3
21
+
22
+ attr_reader :width, :height
23
+
24
+ def initialize(width:, height:)
25
+ @width = width
26
+ @height = height
27
+ end
28
+
29
+ # Draws the sparkles twinkling by the tick into a Scene's cells.
30
+ def draw(cells, tick)
31
+ COUNT.times { sparkle(cells, Zlib.crc32("sparkle #{it}"), tick) }
32
+ end
33
+
34
+ private
35
+
36
+ def sparkle(cells, seed, tick)
37
+ char = TWINKLE[((tick / TWINKLE_FRAMES) + seed) % TWINKLE_EVERY]
38
+ row, column = spot(seed)
39
+ cells[row][column] = [char, :shine] if char && row.between?(0, height - 1) && column.between?(0, width - 1)
40
+ end
41
+
42
+ def spot(seed) = ring[seed % ring.length]
43
+
44
+ # The spots on a ring just clear of the waffle, in the middle of the screen, by their row and column: along its top
45
+ # and bottom, and down its sides.
46
+ def ring
47
+ @ring ||= begin
48
+ top, left, bottom, right = corners
49
+ [*(left..right).flat_map { [[top, it], [bottom, it]] }, *((top + 1)...bottom).flat_map { [[it, left], [it, right]] }]
50
+ end
51
+ end
52
+
53
+ # The ring's top row, left column, bottom row and right column, MARGIN clear of the waffle.
54
+ def corners
55
+ left = (width - BakedWaffle.across) / 2 - MARGIN
56
+ top = ((height - BakedWaffle.down) / 2) - MARGIN + 1
57
+ [top, left, top + BakedWaffle.down + MARGIN, left + BakedWaffle.across + (MARGIN * 2) - 1]
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'baked_waffle'
4
+
5
+ module Raindown
6
+ module TUI
7
+ # The waffle after the rain, between balloons floating up, when a batch of code is refined, and the week's work is
8
+ # done: a BakedWaffle, then a blank line, then what's been done. Once it's popped, syrup pours onto its butter, then
9
+ # fills its pockets.
10
+ module Waffle
11
+ TEXT = [[], [['100% refined', :feelings]], [['The work is mysterious and important.', :feelings]]].freeze
12
+ # The row the waffle pops out from, its butter's on the middle ridge, and the frames each row more above and below
13
+ # it takes, half a second at the Screen's 20 frames a second while it celebrates.
14
+ BUTTER_ROW = BakedWaffle::POCKETS * BakedWaffle::ROW_LINES / 2
15
+ POP_FRAMES = 10
16
+ # The frames until it's all popped, and then the frames each pocket takes to fill with syrup, once it's poured.
17
+ POPPED_FRAMES = POP_FRAMES * (BakedWaffle.down + TEXT.length - 1 - BUTTER_ROW)
18
+ FILL_FRAMES = 10
19
+
20
+ private
21
+
22
+ # The waffle over the lines, as it pops, by the frames since the rain stopped: the butter's row first, then a row
23
+ # more above and below it every POP_FRAMES, then its lines of text, and then the syrup.
24
+ def waffle(lines, frame)
25
+ top = waffle_top
26
+ [*BakedWaffle.rows(syrup: filled(frame, top)), *TEXT].each_with_index do |parts, row|
27
+ next unless popped?(parts, row, frame) && lines[top + row]
28
+
29
+ lines[top + row] = over(lines[top + row], parts)
30
+ end
31
+ pour(lines, frame, top)
32
+ end
33
+
34
+ def waffle_top = [(height - BakedWaffle.down - TEXT.length) / 2, 0].max
35
+
36
+ def popped?(parts, row, frame) = parts.any? && (row - BUTTER_ROW).abs <= frame / POP_FRAMES
37
+
38
+ # The syrup's stream, falling a line a frame from the top onto the butter, once it's all popped, until every
39
+ # pocket's filled.
40
+ def pour(lines, frame, top)
41
+ poured = frame - POPPED_FRAMES
42
+ return lines if poured.negative? || filled(frame, top) >= BakedWaffle::POCKETS**2
43
+
44
+ [poured, stream(top)].min.times { lines[it] = overlay_segments(lines[it], [segment('│', :syrup, width: 1)], column: stream_column) }
45
+ lines
46
+ end
47
+
48
+ # The lines from the top down to the butter, and the column of the waffle's middle, which the syrup falls down.
49
+ def stream(top) = top + BUTTER_ROW - 1
50
+ def stream_column = ((width - BakedWaffle.across - 2) / 2) + 1 + (BakedWaffle.across / 2)
51
+
52
+ # The pockets filled with syrup by the frame, one every FILL_FRAMES once the stream's reached the butter.
53
+ def filled(frame, top) = [(frame - POPPED_FRAMES - stream(top)) / FILL_FRAMES, 0].max
54
+
55
+ # The parts over the middle of the line, with a space either side, so they're clear of the balloons behind them.
56
+ def over(line, parts)
57
+ segments = [[' ', :blank], *parts, [' ', :blank]].map { |text, style| segment(text, style, width: display_width(text)) }
58
+ overlay_segments(line, segments, column: [(width - segments.sum { display_width(it.text) }) / 2, 0].max)
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,51 @@
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, until the screen's resized.
9
+ class WeeklyBatch
10
+ # How long a resized grid keeps its new number of cells before the batch is picked again to fill it, so it isn't
11
+ # picked for every size the screen passes through while it's dragged.
12
+ RESIZE_SECONDS = 2
13
+
14
+ attr_reader :batch
15
+
16
+ def initialize(log:)
17
+ @log = log
18
+ @batch = Batch.new(files: [], code_methods: [], log:, week: Batch.this_week)
19
+ end
20
+
21
+ # The methods read again, keeping the batch's files.
22
+ def read(every)
23
+ @every = every
24
+ @batch = batch.reading(every)
25
+ end
26
+
27
+ # Picks the batch to fill the grid's cells, once they're known, and again once a resized grid's kept its new number
28
+ # of them for RESIZE_SECONDS, and returns whether it did.
29
+ def fill(grid_cells, now: Process.clock_gettime(Process::CLOCK_MONOTONIC))
30
+ return refill(grid_cells) unless @filled_for
31
+
32
+ @resizing = nil unless @resizing&.first == grid_cells
33
+ return false if grid_cells == @filled_for
34
+
35
+ @resizing ||= [grid_cells, now]
36
+ return false if now - @resizing.last < RESIZE_SECONDS
37
+
38
+ refill(grid_cells)
39
+ end
40
+
41
+ private
42
+
43
+ def refill(grid_cells)
44
+ @filled_for = grid_cells
45
+ @resizing = nil
46
+ @batch = Batch.pick(@every, log: @log, cells: grid_cells)
47
+ true
48
+ end
49
+ end
50
+ end
51
+ end
@@ -58,8 +58,6 @@ module Raindown
58
58
  path.delete_prefix(File.join(base, ''))
59
59
  end
60
60
 
61
- private
62
-
63
61
  # A scope's own files, without those of the projects inside it, which are projects of their own.
64
62
  def files
65
63
  return @files unless scope
@@ -67,6 +65,8 @@ module Raindown
67
65
  @files.select { |path| owner(path) == scope }
68
66
  end
69
67
 
68
+ private
69
+
70
70
  def owner(path)
71
71
  [root, *@projects].select { path.start_with?(File.join(it, '')) }.max_by(&:length)
72
72
  end
@@ -85,12 +85,13 @@ module Raindown
85
85
  # A folder, and the tree inside it unless it's folded.
86
86
  def folder_rows(name, children, prefix, depth)
87
87
  folder = File.join(base, prefix, name)
88
- folded = !@expanded.include?(folder)
88
+ expanded = @expanded.include?(folder)
89
89
  project = @projects.include?(folder)
90
- row = Row.new(text: "#{folded ? '▸' : '▾'} #{"#{PROJECT} " if project}#{name}/", depth:,
90
+ row = Row.new(text: [expanded ? '▾' : '▸', (PROJECT if project), "#{name}/"].compact.join(' '), depth:,
91
91
  kind: project ? :project : :folder, target: folder, fold: -> { toggle(folder) })
92
+ return [row] unless expanded
92
93
 
93
- [row, *(tree_rows(children, File.join(prefix, name), depth + 1) unless folded)]
94
+ [row, *tree_rows(children, File.join(prefix, name), depth + 1)]
94
95
  end
95
96
  end
96
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
@@ -3,6 +3,8 @@
3
3
  require_relative 'events'
4
4
  require_relative 'text'
5
5
  require_relative 'title'
6
+ require_relative 'project_box'
7
+ require_relative 'search_box'
6
8
 
7
9
  module Raindown
8
10
  module TUI
@@ -14,9 +16,9 @@ module Raindown
14
16
  # ┌─ Layout ──────────────────────────────────────────────────┐
15
17
  # │ Raindown ~/docs › guides/setup.md │
16
18
  # │ ┌─ nav pane ──────┐ ┌─ main pane ───────────────────────┐ │
17
- # │ │ Dashboard ▏ # Setup │ │
18
- # │ │ ▾ guides/ ▏ toc: [+3] │ │
19
- # │ │ setup.md ▏ ## Install │ │
19
+ # │ │ Dashboard │ # Setup │ │
20
+ # │ │ ▾ guides/ │ toc: [+3] │ │
21
+ # │ │ setup.md │ ## Install │ │
20
22
  # │ └─ ListRenderer ──┘ └─ view.renderer ───────────────────┘ │
21
23
  # │ Opened guides/setup.md │
22
24
  # │ Tab Pane Enter Open X Run E dit W rite back Q uit │
@@ -27,9 +29,12 @@ module Raindown
27
29
  include Title
28
30
 
29
31
  NAV_WIDTH = (16..32)
30
- # The line between the nav and main panes, at its column's left edge, unlike │ in the middle,
31
- # so a highlighted file's row runs up to it.
32
- DIVIDER = '▏'
32
+ # The line between the nav and main panes, and the line beneath the title, along the line above the panes, in the
33
+ # middle of their rows and columns, joined where they meet, like a box's lines. The divider is never highlighted,
34
+ # so a highlighted row stops at the edge of its column.
35
+ DIVIDER = '│'
36
+ BORDER = '─'
37
+ JOIN = '┬'
33
38
  # The title, status and button lines.
34
39
  CHROME_HEIGHT = 3
35
40
 
@@ -44,11 +49,7 @@ module Raindown
44
49
  def lines
45
50
  return [status].first(height) if height < CHROME_HEIGHT + 1
46
51
 
47
- lines = [title, *panes, status, button_bar]
48
- return lines unless navigator.menu
49
-
50
- require_relative 'menu_overlay'
51
- MenuOverlay.new(navigator, width:, column: display_width(name)).apply(lines)
52
+ overlaid([title, *panes, status, button_bar])
52
53
  end
53
54
 
54
55
  # A third of the width, within NAV_WIDTH, and never more than half of a narrow screen.
@@ -58,6 +59,14 @@ module Raindown
58
59
 
59
60
  private
60
61
 
62
+ # The project menu or Teleport, drawn over the lines while it's open.
63
+ def overlaid(lines)
64
+ return ProjectBox.new(navigator, width:, column: display_width(name)).apply(lines) if navigator.menu
65
+ return SearchBox.new(navigator, width:).apply(lines) if navigator.teleporting?
66
+
67
+ lines
68
+ end
69
+
61
70
  def body_height = height - CHROME_HEIGHT
62
71
 
63
72
  # Below a blank line for spacing, the nav pane beside the main pane, each drawn by the renderer of what's in it.
@@ -65,16 +74,19 @@ module Raindown
65
74
  nav = renderer_in(:nav, nav_width).lines
66
75
  main = view_renderer.lines
67
76
 
68
- [spacer, *Array.new(body_height - 1) { |index| [*nav[index], divider(main[index]), *main[index]] }]
77
+ [spacer, *Array.new(body_height - 1) { |index| [*nav[index], segment(DIVIDER, :divider, width: 1), *main[index]] }]
78
+ end
79
+
80
+ # A line above the panes, the border beneath the title, but for what the main pane's renderer puts in its side of it.
81
+ def spacer
82
+ [border(segment('', :blank, width: nav_width)), segment(JOIN, :divider, width: 1), *view_renderer.spacer.map { border(it) }]
69
83
  end
70
84
 
71
- # A blank line above the panes, for spacing.
72
- def spacer = [segment('', :blank, width: nav_width), divider([]), segment('', :blank, width: width - nav_width - 1)]
85
+ # A blank segment of the line above the panes as the border, in the divider's colour.
86
+ def border(segment)
87
+ return segment unless segment.style == :blank
73
88
 
74
- # The divider is on the background of the outline's row beside it, when that's highlighted, so they meet.
75
- def divider(main_line)
76
- style = { selected: :selected_divider, cursor: :cursor_divider }.fetch(main_line.first&.style, :divider)
77
- segment(DIVIDER, style, width: 1)
89
+ segment.with(text: segment.text.tr(' ', BORDER), style: :divider)
78
90
  end
79
91
 
80
92
  def view_renderer
@@ -121,4 +133,6 @@ end
121
133
  require_relative 'renderers/list_renderer'
122
134
  require_relative 'renderers/outline_renderer'
123
135
  require_relative 'renderers/dashboard_renderer'
136
+ require_relative 'blueprints/blueprints_renderer'
124
137
  require_relative 'renderers/endpoint_renderer'
138
+ require_relative 'feelings/feelings_renderer'
@@ -1,10 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative '../../actions'
4
+ require_relative '../../watcher/changes'
4
5
 
5
6
  module Raindown
6
7
  module TUI
7
- # What a Navigator does to the open file: run an endpoint, open the editor, write back and read it again.
8
+ # What a Navigator does to the open file: run an endpoint, open the editor, write back and read it again,
9
+ # and to a feeling about some code: draw the page it was noted down in, or ask Claude Code to fix the code.
8
10
  # Each sets the status, so the Screen shows how it went, and returns what the Screen should do next, if anything.
9
11
  module Commands
10
12
  def run(file, line, name) = Wait.new(message: "Running #{name}...", task: -> { run_endpoint(file, line) })
@@ -15,8 +17,20 @@ module Raindown
15
17
  Run.new(command: Actions.edit_command(file, line:))
16
18
  end
17
19
 
20
+ def felt(feeling) = info("Felt #{feeling.temper} about #{feeling.method.label}")
21
+
22
+ # Leaves the screen for Claude Code, asked to fix the method, and the view reads the code again after.
23
+ def fix_with_agent(feeling)
24
+ return error('Install Claude Code, the claude command, to fix code with X') unless claude?
25
+
26
+ @edited = feeling.method.path
27
+ Run.new(command: ['claude', feeling.prompt])
28
+ end
29
+
18
30
  private
19
31
 
32
+ def claude? = ENV.fetch('PATH', '').split(File::PATH_SEPARATOR).any? { File.executable?(File.join(it, 'claude')) }
33
+
20
34
  # Runs the endpoint selected in the outline, or listed below the files. One opened beside them runs itself.
21
35
  def execute
22
36
  target = selected&.target
@@ -64,12 +78,42 @@ module Raindown
64
78
  info('Reloaded')
65
79
  end
66
80
 
67
- # Reads the file again when it's changed, like when it's saved in an editor or a watcher writes back to it.
81
+ # Writes back the files saved since the last tick, like when they're watched, then reads the open file again when
82
+ # it's changed, like when it's saved in an editor or written back to.
68
83
  def refresh
84
+ effect = watched(@saves.take) if @saves
69
85
  view.load if view.changed?
86
+ effect
87
+ end
88
+
89
+ # A change to a project's nodes loads them again, and a saved Markdown file's written back, like when it's watched,
90
+ # and the files and endpoints are read again for the files added, removed or changed. When endpoints run as files
91
+ # are written back, like with -x, the screen says so while they do, as they can take a while.
92
+ def watched(paths)
93
+ changes = Watcher.changes(paths, root:)
94
+ return reload if changes.any?(&:nodes?)
95
+ return if changes.empty?
96
+
97
+ saved = changes.select(&:saved?).map(&:path)
98
+ return write_back_all(saved) unless @runs_endpoints && saved.any?
99
+
100
+ Wait.new(message: "Writing back #{relative(saved.first)}...", task: -> { write_back_all(saved) })
101
+ end
102
+
103
+ def write_back_all(files)
104
+ files.each { write_back_saved(it) }
105
+ @files.load
106
+ @endpoints.load
70
107
  nil
71
108
  end
72
109
 
110
+ # A saved file's written back, saying so only when it's changed, as when it's watched.
111
+ def write_back_saved(file)
112
+ info("Updated #{relative(file)}") if @workers.for(file).call(:write_back, file)
113
+ rescue StandardError => e
114
+ error("#{relative(file)}: #{e.message}")
115
+ end
116
+
73
117
  def info(message)
74
118
  @status = [:info, message]
75
119
  nil