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.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/lib/raindown/chart/chart.rb +12 -1
  3. data/lib/raindown/cli.rb +2 -2
  4. data/lib/raindown/document.rb +16 -9
  5. data/lib/raindown/endpoint/curl.rb +19 -0
  6. data/lib/raindown/node_blocks.rb +9 -6
  7. data/lib/raindown/nodes.rb +1 -0
  8. data/lib/raindown/projects.rb +8 -4
  9. data/lib/raindown/stat.rb +53 -0
  10. data/lib/raindown/tui/card.rb +56 -0
  11. data/lib/raindown/tui/endpoint.rb +16 -0
  12. data/lib/raindown/tui/endpoints.rb +40 -0
  13. data/lib/raindown/tui/events.rb +50 -0
  14. data/lib/raindown/tui/feelings/batch.rb +47 -0
  15. data/lib/raindown/tui/feelings/bins.rb +67 -0
  16. data/lib/raindown/tui/feelings/celebration.rb +25 -0
  17. data/lib/raindown/tui/feelings/code_box.rb +129 -0
  18. data/lib/raindown/tui/feelings/code_methods.rb +134 -0
  19. data/lib/raindown/tui/feelings/feelings_log.rb +133 -0
  20. data/lib/raindown/tui/feelings/feelings_renderer.rb +184 -0
  21. data/lib/raindown/tui/feelings/feelings_view.rb +161 -0
  22. data/lib/raindown/tui/feelings/fireworks.rb +75 -0
  23. data/lib/raindown/tui/feelings/rain.rb +66 -0
  24. data/lib/raindown/tui/feelings/scene.rb +37 -0
  25. data/lib/raindown/tui/feelings/waffle.rb +43 -0
  26. data/lib/raindown/tui/feelings/weekly_batch.rb +32 -0
  27. data/lib/raindown/tui/file_tree.rb +55 -12
  28. data/lib/raindown/tui/highlighter.rb +45 -0
  29. data/lib/raindown/tui/layout.rb +66 -77
  30. data/lib/raindown/tui/menu_overlay.rb +59 -0
  31. data/lib/raindown/tui/navigator/commands.rb +97 -0
  32. data/lib/raindown/tui/navigator/focus.rb +25 -0
  33. data/lib/raindown/tui/navigator/navigator.rb +160 -0
  34. data/lib/raindown/tui/navigator/project_menu.rb +61 -0
  35. data/lib/raindown/tui/navigator/search.rb +126 -0
  36. data/lib/raindown/tui/navigator/views.rb +52 -0
  37. data/lib/raindown/tui/renderers/dashboard_renderer.rb +102 -0
  38. data/lib/raindown/tui/renderers/endpoint_renderer.rb +81 -0
  39. data/lib/raindown/tui/renderers/list_renderer.rb +15 -0
  40. data/lib/raindown/tui/renderers/outline_renderer.rb +21 -0
  41. data/lib/raindown/tui/renderers/renderer.rb +18 -0
  42. data/lib/raindown/tui/row.rb +12 -3
  43. data/lib/raindown/tui/rows.rb +58 -0
  44. data/lib/raindown/tui/search_overlay.rb +73 -0
  45. data/lib/raindown/tui/terminal/input.rb +91 -0
  46. data/lib/raindown/tui/terminal/ncurses_shim.rb +46 -0
  47. data/lib/raindown/tui/terminal/quiet.rb +32 -0
  48. data/lib/raindown/tui/{screen.rb → terminal/screen.rb} +42 -24
  49. data/lib/raindown/tui/terminal/styles.rb +154 -0
  50. data/lib/raindown/tui/text.rb +110 -0
  51. data/lib/raindown/tui/title.rb +29 -0
  52. data/lib/raindown/tui/views/dashboard_view.rb +147 -0
  53. data/lib/raindown/tui/views/endpoint_view.rb +77 -0
  54. data/lib/raindown/tui/{pane.rb → views/list_view.rb} +11 -8
  55. data/lib/raindown/tui/{outline.rb → views/outline_view.rb} +48 -20
  56. data/lib/raindown/tui/workers/project.rb +122 -0
  57. data/lib/raindown/tui/workers/worker.rb +124 -0
  58. data/lib/raindown/tui/workers/workers.rb +53 -0
  59. data/lib/raindown/tui.rb +10 -5
  60. data/lib/version.rb +1 -1
  61. metadata +63 -9
  62. data/lib/raindown/tui/commands.rb +0 -70
  63. data/lib/raindown/tui/input.rb +0 -55
  64. data/lib/raindown/tui/navigator.rb +0 -123
  65. 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 files pane: a project's Markdown files as a tree, with folders before files, where folders fold.
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
- @folded = Set.new
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
- @files.each { |path| relative(path).split('/').reduce(tree) { |node, part| node[part] ||= {} } }
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
- @folded.delete?(folder) || @folded.add(folder)
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(root, ''))
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, _| Row.new(text: name, depth:, kind: :file, target: File.join(root, prefix, 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(root, prefix, name)
48
- folded = @folded.include?(folder)
49
- row = Row.new(text: "#{folded ? '▸' : '▾'} #{name}/", depth:, kind: :folder, target: folder)
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, *(tree_rows(children, File.join(prefix, name), depth + 1) unless folded)]
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