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,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raindown
4
+ module TUI
5
+ # What a Navigator does with the mouse: a click where it's clicked, the mouse moving over something, which it keeps
6
+ # as what's hovered, and dragging with the button held, which the view takes, like a blueprint's canvas, if it can.
7
+ module Mouse
8
+ # What the mouse is over, which a click there would send, like a Click on a method's cells, or nil.
9
+ attr_reader :hovered
10
+
11
+ private
12
+
13
+ # A click moves the cursor where it's clicked, and does the click's action there, like a key, unless that would
14
+ # leave the terminal UI, like for the editor, so a stray click only moves the cursor.
15
+ # While the project menu or Teleport is open, only its items are clicked.
16
+ def click(click)
17
+ return if menu && click.pane != :menu
18
+ return if teleporting? && click.pane != :teleport
19
+
20
+ point(click)
21
+ effect = route(click.action)
22
+ effect unless effect.is_a?(Run)
23
+ end
24
+
25
+ def point(click)
26
+ return @menu = click.index if click.pane == :menu
27
+ return @match = click.index if click.pane == :teleport
28
+
29
+ content(click.pane).cursor = click.index
30
+ @focus = click.pane
31
+ end
32
+
33
+ def hover(hover)
34
+ @hovered = hover.target
35
+ nil
36
+ end
37
+
38
+ def drag(drag)
39
+ view.drag(drag) if view.respond_to?(:drag)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -4,12 +4,14 @@ require_relative '../file_tree'
4
4
  require_relative 'commands'
5
5
  require_relative '../endpoints'
6
6
  require_relative 'focus'
7
+ require_relative 'mouse'
7
8
  require_relative '../events'
8
9
  require_relative '../views/outline_view'
9
10
  require_relative '../views/list_view'
10
11
  require_relative 'project_menu'
12
+ require_relative 'teleport'
11
13
  require_relative 'views'
12
- require_relative '../workers/workers'
14
+ require_relative '../project'
13
15
 
14
16
  module Raindown
15
17
  module TUI
@@ -20,20 +22,31 @@ module Raindown
20
22
  include Commands
21
23
  include Views
22
24
  include ProjectMenu
25
+ include Teleport
23
26
  include Focus
27
+ include Mouse
24
28
 
25
29
  # Keys of the events that move the cursor or the focus, and those that act on what's selected.
26
30
  MOVES = { up: -1, down: 1 }.freeze
27
31
  PANES = { tab: nil, left: :nav, right: :main }.freeze
28
32
  ACTIONS = { execute: :execute, edit: :edit, write_back: :write_back, reload: :reload, tick: :refresh }.freeze
33
+ # Keys that open Teleport, the project menu, the Dashboard, Code feelings or Blueprints, from anywhere.
34
+ OPENERS = { search: -> { open_teleport }, projects: -> { toggle_menu }, dashboard: -> { toggle_dashboard },
35
+ feelings: -> { open_view(feelings) }, blueprints: -> { open_view(blueprints) } }.freeze
29
36
 
30
37
  attr_reader :focus, :status
31
38
 
32
- def initialize(root:, path: nil, workers: Workers.new(root:))
39
+ # Saves tells of the files saved in the folder, which are written back on the next tick, like when it's watched,
40
+ # running endpoints with no response as they are when it runs_endpoints, like with -x.
41
+ def initialize(root:, path: nil, workers: Project.workers(root:), saves: nil, runs_endpoints: false)
33
42
  @workers = workers
43
+ @saves = saves
44
+ @runs_endpoints = runs_endpoints
34
45
  @files = FileTree.new(root:, workers:)
35
46
  @endpoints = Endpoints.new(workers:)
36
47
  @dashboard = DashboardView.new(root:, workers:)
48
+ @feelings = FeelingsView.new(root:)
49
+ @blueprints = BlueprintsView.new(root:)
37
50
  @nav = ListView.new { nav_rows }
38
51
  first = Projects.files(root, nested: true).first
39
52
  # The dashboard is the first page, unless a file is asked for, and "d" goes from it to the first file.
@@ -46,13 +59,18 @@ module Raindown
46
59
  case event
47
60
  when nil then nil
48
61
  when Click then click(event)
62
+ when Type then type(event)
63
+ when Hover then hover(event)
64
+ when Drag then drag(event)
49
65
  else route(event)
50
66
  end
51
67
  end
52
68
 
53
- # After the editor closes, the file is written back, like a save while it's watched, and read again.
69
+ # After the editor closes, a Markdown file is written back, like a save while it's watched, and read again.
70
+ # Other files, like code that was felt, aren't written back, and the view reads them again.
54
71
  def ran
55
- write_back(@edited)
72
+ write_back(@edited) if @files.files.include?(@edited)
73
+ view.load
56
74
  @endpoints.load
57
75
  end
58
76
 
@@ -62,37 +80,39 @@ module Raindown
62
80
  # The folder the files are shown from: the chosen project's, or the root's.
63
81
  def base = @files.base
64
82
 
83
+ # While Teleport, or the view, like a feeling's note, takes what's typed, rather than keys' events.
84
+ def typing? = teleporting? || view.typing?
85
+
86
+ # How often the screen's drawn, which is up to the view, like faster for Code feelings' rain.
87
+ def pace = view.pace(hovered)
88
+
65
89
  # What's in a pane: the nav's ListView on the left, or the view on the right, which keeps its own cursor.
66
90
  def content(pane) = pane == :nav ? @nav : view
67
91
 
68
92
  private
69
93
 
94
+ # A typed character goes to Teleport, or to the view while it's typing.
95
+ def type(event)
96
+ return type_query(query + event.text) if teleporting?
97
+
98
+ view.next_step(event)&.take(self) if view.typing?
99
+ end
100
+
70
101
  def route(name)
71
102
  return :quit if name == :quit
103
+ return teleport_event(name) if teleporting?
72
104
  return menu_event(name) if menu
73
- return toggle_menu if name == :projects
74
- return toggle_dashboard if name == :dashboard
105
+ return instance_exec(&OPENERS[name]) if opener?(name)
75
106
  return view.next_step(name)&.take(self) if @focus == :main && view.next_step?(name)
76
107
 
77
108
  browser_event(name)
78
109
  end
79
110
 
80
- # A click moves the cursor where it's clicked, and does the click's action there, like a key, unless that would
81
- # leave the terminal UI, like for the editor, so a stray click only moves the cursor.
82
- # While the project menu's open, only its projects are clicked.
83
- def click(click)
84
- return if menu && click.pane != :menu
111
+ # Whether the key opens something from anywhere, unless the view's focused and keeps the key, like f in Blueprints.
112
+ def opener?(name)
113
+ return false if @focus == :main && view.respond_to?(:own_key?) && view.own_key?(name)
85
114
 
86
- point(click)
87
- effect = route(click.action)
88
- effect unless effect.is_a?(Run)
89
- end
90
-
91
- def point(click)
92
- return @menu = click.index if click.pane == :menu
93
-
94
- content(click.pane).cursor = click.index
95
- @focus = click.pane
115
+ OPENERS.key?(name)
96
116
  end
97
117
 
98
118
  def browser_event(name)
@@ -103,11 +123,20 @@ module Raindown
103
123
  send(ACTIONS[name]) if ACTIONS.key?(name)
104
124
  end
105
125
 
106
- # The Dashboard, which every terminal draws two columns wide, then the files and the endpoints,
107
- # with a blank line between each section that has rows.
126
+ # The Dashboard, Code feelings and Blueprints, then the files and the endpoints, with a blank line between each section that
127
+ # has rows.
108
128
  def nav_rows
109
- dashboard_row = Row.new(text: '📊 Dashboard', depth: 0, kind: :dashboard, target: dashboard, open: OpenView.new(view: dashboard))
110
- [[dashboard_row], @files.rows, @endpoints.rows].reject(&:empty?).flat_map { [Row::SEPARATOR, *it] }.drop(1)
129
+ rows = pages.map { |text, view| Row.new(text:, depth: 0, kind: :page, target: view, open: OpenView.new(view:)) }
130
+ [rows, @files.rows, @endpoints.rows].reject(&:empty?).flat_map { [Row::SEPARATOR, *it] }.drop(1)
131
+ end
132
+
133
+ # The pages above the files, by how they're shown, in the files and Teleport, with emoji every terminal draws two
134
+ # columns wide. Blueprints is listed once the project has some, from Claude Code or its code's comments.
135
+ def pages
136
+ pages = { '📊 Dashboard' => dashboard, '🎭 Feelings' => feelings }
137
+ return pages unless blueprints.any?
138
+
139
+ pages.merge('🌀 Blueprints' => blueprints)
111
140
  end
112
141
 
113
142
  def selected
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../events'
4
+ require_relative '../views/endpoint_view'
5
+ require_relative '../../workers/worker'
6
+
7
+ module Raindown
8
+ module TUI
9
+ # Teleport: "/" opens a search of the Dashboard, Code feelings, every file, heading and directive in the files, and every endpoint,
10
+ # matched as it's typed by letters in order, so "stp" finds "## Setup". Up and down move between the matches,
11
+ # Enter or a click opens the one selected, and Esc closes it. Other keys and clicks do nothing while it's open.
12
+ module Teleport
13
+ # The most matches it shows.
14
+ LIMIT = 10
15
+
16
+ # A place to teleport to: its text, where it is, like "guides/setup.md:12", and the step that opens it.
17
+ Place = Data.define(:text, :where, :open) do
18
+ # How well the query matches, or nil when it doesn't: in the text alone, which ranks above a match that needs
19
+ # where it is too, like "setup notes" for "## Setup" in notes.md.
20
+ def score(query)
21
+ in_text = letters_score(text, query)
22
+ return in_text + query.length if in_text
23
+
24
+ letters_score("#{text} #{where}", query)
25
+ end
26
+
27
+ private
28
+
29
+ # How well the query's letters appear in order in the text, ignoring case: higher for runs of them and for
30
+ # the starts of words, or nil when they don't all appear.
31
+ def letters_score(text, query)
32
+ haystack = text.downcase
33
+ last = -1
34
+ score = 0
35
+ query.downcase.each_char do |char|
36
+ index = haystack.index(char, last + 1)
37
+ return nil unless index
38
+
39
+ score += 1
40
+ score += 2 if index == last + 1
41
+ score += 2 if index.zero? || haystack[index - 1].match?(%r{[\s/#:._-]})
42
+ last = index
43
+ end
44
+ score
45
+ end
46
+ end
47
+
48
+ # What's typed, while Teleport is open, and the selected match.
49
+ attr_reader :query, :match
50
+
51
+ def teleporting? = !query.nil?
52
+
53
+ # The first match the box lists, as it lists them all.
54
+ def first_match = 0
55
+
56
+ # The places that match what's typed, best first, then in the order they're in the files.
57
+ def matches
58
+ @places.each_with_index.filter_map { |place, index| (score = place.score(query)) && [-score, index, place] }
59
+ .min(LIMIT).map(&:last)
60
+ end
61
+
62
+ private
63
+
64
+ def open_teleport
65
+ @places = places
66
+ @match = 0
67
+ @query = ''
68
+ nil
69
+ end
70
+
71
+ def teleport_event(name)
72
+ case name
73
+ when :up, :down then move_match(name == :up ? -1 : 1)
74
+ when :enter then teleport
75
+ when :escape then close_teleport
76
+ when :backspace then type_query(query[0...-1])
77
+ end
78
+ end
79
+
80
+ def type_query(query)
81
+ @query = query
82
+ @match = 0
83
+ nil
84
+ end
85
+
86
+ def move_match(by)
87
+ @match = (@match + by).clamp(0, [matches.length - 1, 0].max)
88
+ nil
89
+ end
90
+
91
+ def teleport
92
+ place = matches[@match]
93
+ close_teleport
94
+ place&.open&.take(self)
95
+ end
96
+
97
+ def close_teleport
98
+ @query = nil
99
+ nil
100
+ end
101
+
102
+ def places
103
+ [*pages.map { |text, view| Place.new(text:, where: '', open: OpenView.new(view:)) },
104
+ *@files.files.flat_map { file_places(it) },
105
+ *@endpoints.all.map { Place.new(text: it.text, where: "#{relative(it.path)}:#{it.line}", open: open_endpoint(it)) }]
106
+ end
107
+
108
+ # The file, then its headings and directives, in the order they're in it.
109
+ def file_places(path)
110
+ outline = @workers.for(path).call(:outline, path)
111
+ where = relative(path)
112
+ items = (outline.headings + outline.directives).sort_by(&:line)
113
+ [Place.new(text: where, where: '', open: Open.new(path:, line: nil)),
114
+ *items.map { Place.new(text: item_text(it), where: "#{where}:#{it.line}", open: Open.new(path:, line: it.line)) }]
115
+ rescue Workers::Worker::Error
116
+ []
117
+ end
118
+
119
+ def item_text(item)
120
+ return "#{'#' * item.level} #{item.text}" if item.is_a?(Document::Heading)
121
+
122
+ "#{item.name}: #{item.args}".strip
123
+ end
124
+
125
+ def open_endpoint(endpoint) = OpenView.new(view: EndpointView.new(endpoint, workers: @workers))
126
+ end
127
+ end
128
+ end
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative '../views/dashboard_view'
4
+ require_relative '../blueprints/blueprints_view'
4
5
  require_relative '../views/endpoint_view'
6
+ require_relative '../feelings/feelings_view'
5
7
  require_relative '../views/outline_view'
6
8
 
7
9
  module Raindown
@@ -11,7 +13,7 @@ module Raindown
11
13
  # A view has the pane that draws it, a next step for the keys it takes while it has the focus, and a target,
12
14
  # what's opened it in the files. It loads when it's opened, and again after a change.
13
15
  module Views
14
- attr_reader :view, :dashboard
16
+ attr_reader :view, :dashboard, :feelings, :blueprints
15
17
 
16
18
  # The path of the file the view shows, if any.
17
19
  def path = view.path
@@ -27,12 +29,12 @@ module Raindown
27
29
  nil
28
30
  end
29
31
 
30
- # Opens the file in the outline, on the directive at the line.
32
+ # Opens the file in the outline, on the heading or directive at the line.
31
33
  def open_at(path, line)
32
34
  path = File.expand_path(path)
33
35
  @files.reveal(path)
34
36
  open_view(OutlineView.new(path, workers: @workers))
35
- view.cursor = view.rows.index { it.kind == :directive && it.target.line == line } || 0
37
+ view.cursor = view.rows.index { %i[heading directive].include?(it.kind) && it.target.line == line } || 0
36
38
  nil
37
39
  end
38
40
 
@@ -1,18 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative '../../actions'
4
- require_relative '../../bare_directives'
5
- require_relative '../../chart/chart'
6
- require_relative '../../document'
7
- require_relative '../../front_matter'
8
- require_relative '../../node_blocks'
9
- require_relative '../../nodes'
10
- require_relative '../../pages'
11
- require_relative '../../projects'
12
- require_relative '../card'
13
- require_relative '../endpoint'
14
- require_relative '../../code_blocks'
15
- require_relative '../../endpoint/curl'
3
+ require_relative '../actions'
4
+ require_relative '../bare_directives'
5
+ require_relative '../chart/chart'
6
+ require_relative '../document'
7
+ require_relative '../front_matter'
8
+ require_relative '../node_blocks'
9
+ require_relative '../nodes'
10
+ require_relative '../pages'
11
+ require_relative '../projects'
12
+ require_relative '../workers/workers'
13
+ require_relative '../stat'
14
+ require_relative 'card'
15
+ require_relative 'endpoint'
16
+ require_relative '../code_blocks'
17
+ require_relative '../endpoint/curl'
16
18
 
17
19
  module Raindown
18
20
  module TUI
@@ -23,9 +25,17 @@ module Raindown
23
25
 
24
26
  attr_reader :root
25
27
 
28
+ # Workers for the projects in the folder, each answering as a Project, which allows bare directives in any file,
29
+ # and runs endpoints with no response as it writes back, when asked, like with --bare and -x.
30
+ def self.workers(root:, bare: false, execute: false, **)
31
+ Workers.new(root:, **) { new(root: it, bare:, execute:) }
32
+ end
33
+
26
34
  # The projects inside this one have workers of their own, so their files are left out.
27
- def initialize(root:)
35
+ def initialize(root:, bare: false, execute: false)
28
36
  @root = root
37
+ @bare = bare
38
+ @execute = execute
29
39
  @load_errors = []
30
40
  @file_errors = []
31
41
  Nodes.load(File.join(root, Nodes::FOLDER))
@@ -35,11 +45,12 @@ module Raindown
35
45
 
36
46
  # A file's lines, headings and directives.
37
47
  def outline(path)
38
- document = Document.read(path)
48
+ document = Document.read(path, bare: @bare)
39
49
  Outline.new(lines: File.readlines(path, chomp: true), headings: document.headings, directives: document.directives)
40
50
  end
41
51
 
42
- # Every chart in the project's files, drawn from its data, and the files that couldn't be read.
52
+ # Every chart and stat in the project's files, or any node that draws a card, drawn from its data, and the files
53
+ # that couldn't be read.
43
54
  # The charts are found again when asked, and otherwise drawn again, so they follow changes to their data.
44
55
  def charts(find: false)
45
56
  if find || @charts.nil?
@@ -50,23 +61,25 @@ module Raindown
50
61
  { cards: drawn, errors: @load_errors + @file_errors }
51
62
  end
52
63
 
53
- # Every endpoint in the project's files, by its request, leaving out the ones whose snippet can't be read.
64
+ # Every endpoint in the project's files that writes its response back, so it can run, by its request, leaving out
65
+ # the ones whose snippet can't be read.
54
66
  def endpoints
55
67
  Projects.files(root).flat_map do |path|
56
68
  lines = File.readlines(path, encoding: Encoding::UTF_8)
57
- Document.read(path).directives.select(&:endpoint?).filter_map { |directive| endpoint(path, lines, directive) }
69
+ runnable = Document.read(path, bare: @bare).directives.select { it.endpoint? && it.writes }
70
+ runnable.filter_map { |directive| endpoint(path, lines, directive) }
58
71
  rescue StandardError
59
72
  []
60
73
  end
61
74
  end
62
75
 
63
76
  def write_back(path)
64
- Actions.write_back(path)
77
+ Actions.write_back(path, bare: @bare, execute: @execute)
65
78
  end
66
79
 
67
80
  # Runs the endpoint on the line, replacing its response.
68
81
  def execute(path, line)
69
- directive = Document.read(path).directives.find { it.line == line && it.endpoint? }
82
+ directive = Document.read(path, bare: @bare).directives.find { it.line == line && it.endpoint? }
70
83
  raise Actions::NotAnEndpoint, "There's no endpoint on line #{line}" unless directive
71
84
 
72
85
  Actions.execute(path, directive)
@@ -82,12 +95,12 @@ module Raindown
82
95
 
83
96
  def charts_in(path)
84
97
  content = File.read(path, encoding: Encoding::UTF_8)
85
- bare = BareDirectives.enabled?(path:, content:)
98
+ bare = @bare || BareDirectives.enabled?(path:, content:)
86
99
  lines = content.lines
87
100
  headings = Document.parse(content, bare:).headings
88
101
 
89
102
  NodeBlocks.find(lines, front_matter: FrontMatter.parse(content).first, bare:)
90
- .select { |block| block[:node].is_a?(Raindown::Chart) }
103
+ .select { it[:node].respond_to?(:card_lines) }
91
104
  .map { |block| chart(block, path:, lines:, headings:) }
92
105
  rescue StandardError => e
93
106
  @file_errors << "#{path.delete_prefix(File.join(root, ''))}: #{e.message}"
@@ -104,16 +117,14 @@ module Raindown
104
117
 
105
118
  def chart(block, path:, lines:, headings:)
106
119
  line = block[:open] + 1
107
- directive = lines[block[:open]].strip.delete_prefix('<{').delete_suffix('}>').strip
120
+ directive = Directives.text(lines[block[:open]])
108
121
 
109
122
  { path:, line:, heading: headings.select { it.line < line }.last&.text, directive:, node: block[:node] }
110
123
  end
111
124
 
112
- # Draws the chart as its Markdown would be written back, with the files its project lists.
125
+ # Draws the chart or stat as its Markdown would be written back, with the files its project lists.
113
126
  def lines(chart, metadata)
114
- node = chart[:node]
115
- points = node.points(metadata:)
116
- points.empty? ? ['No data'] : node.draw(points).map(&:rstrip)
127
+ chart[:node].card_lines(metadata:)
117
128
  rescue StandardError => e
118
129
  ["Can't chart: #{e.message}"]
119
130
  end
@@ -5,13 +5,13 @@ require_relative 'text'
5
5
  module Raindown
6
6
  module TUI
7
7
  # The project menu, drawn over the screen's lines in a box below the header's path:
8
- # 🌧 Raindown ~/dev ▾ › Dashboard
8
+ # 🌧 Raindown ~/dev ▾ › Dashboard
9
9
  # ┌──────────────────────┐
10
10
  # │ All projects ✓ │
11
11
  # │ ~/dev/raindown │
12
12
  # └──────────────────────┘
13
13
  # Each item is clicked to choose it.
14
- class MenuOverlay
14
+ class ProjectBox
15
15
  include Text
16
16
 
17
17
  def initialize(navigator, width:, column:)
@@ -15,14 +15,14 @@ module Raindown
15
15
  # ╰─────────────────────────────╯ ╰────────────────────────────────╯
16
16
  class DashboardRenderer < Renderer
17
17
  BUTTONS = [['Tab', 'Pane', :tab], ['Enter', 'Open', :enter], ['e', 'Edit', :edit], ['r', 'Reload', :reload],
18
- ['q', 'Quit', :quit]].freeze
18
+ ['/', 'Search', :search], ['q', 'Quit', :quit]].freeze
19
19
  PROJECTS_BUTTON = ['p', 'Projects', :projects].freeze
20
20
 
21
21
  # Room for the project menu's button, when there's more than one project.
22
22
  def buttons
23
- if navigator.projects? then BUTTONS.dup.insert(3, PROJECTS_BUTTON)
24
- else BUTTONS
25
- end
23
+ return BUTTONS unless navigator.projects?
24
+
25
+ BUTTONS.dup.insert(3, PROJECTS_BUTTON)
26
26
  end
27
27
 
28
28
  def page = 'Dashboard'
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../highlighter'
3
4
  require_relative 'renderer'
4
5
  require_relative 'outline_renderer'
5
6
 
@@ -8,7 +9,7 @@ module Raindown
8
9
  # An endpoint opened from the files, right of them: its URL in a bar with rounded corners, and a button to run it,
9
10
  # then its response:
10
11
  # ╭──────────────────────────────────────────╮
11
- # │ GET https://httpbin.org/uuid [ Run ] │
12
+ # │ GET https://httpbin.org/uuid [Run] │
12
13
  # ╰──────────────────────────────────────────╯
13
14
  #
14
15
  # Response · json · fetched 2026-10-26
@@ -16,7 +17,7 @@ module Raindown
16
17
  # "uuid": "48024974-f1b1-431a-a0cf-5cac0099a1a5"
17
18
  # }
18
19
  class EndpointRenderer < Renderer
19
- RUN = '[ Run ]'
20
+ RUN = '[Run]'
20
21
  RUN_WIDTH = RUN.length
21
22
  BUTTONS = OutlineRenderer::BUTTONS.reject { it.last == :write_back }.freeze
22
23
 
@@ -35,9 +36,12 @@ module Raindown
35
36
  # A click on the response moves the focus to it, to scroll it.
36
37
  def focus = Click.new(pane:, index: 0, action: :focus)
37
38
 
39
+ # The response's lines that fit, highlighted in the language of its code block, like json.
38
40
  def response(view, lines)
39
- texts = view.visible([lines, 0].max)
40
- Array.new([lines, 0].max) { [segment(texts[it] ? " #{texts[it]}" : '', :response, width:, event: focus)] }
41
+ code = Highlighter.lines(view.visible([lines, 0].max), language: view.language)
42
+ Array.new([lines, 0].max) do |line|
43
+ [segment(' ', :response, width: 1, event: focus), *code_segments(code[line] || [], width - 1, event: focus)]
44
+ end
41
45
  end
42
46
 
43
47
  # The bar is a column in from each side of the pane, with the URL and the button inside its border.
@@ -57,7 +61,7 @@ module Raindown
57
61
  run_button, segment(' │', :border, width: 2)]
58
62
  end
59
63
 
60
- def run_button = segment(RUN, :run, width: RUN_WIDTH, event: Click.new(pane:, index: 0, action: :execute))
64
+ def run_button = segment(RUN, :button, width: RUN_WIDTH, event: Click.new(pane:, index: 0, action: :execute))
61
65
 
62
66
  def edge(left, right, inner)
63
67
  padded([margin, segment("#{left}#{'─' * (inner + 2)}#{right}", :border, width: inner + 4)])
@@ -66,11 +70,11 @@ module Raindown
66
70
  def margin = segment('', :blank, width: 1)
67
71
 
68
72
  def about(view)
69
- text = if view.error then view.error
70
- elsif view.response.empty? then 'No response yet. X runs the request.'
71
- else ['Response', view.language, ("fetched #{view.fetched}" if view.fetched)].reject { it.to_s.empty? }.join(' · ')
72
- end
73
- [segment(" #{text}", view.error ? :error : :heading, width:)]
73
+ return [segment(" #{view.error}", :error, width:)] if view.error
74
+ return [segment(' No response yet. X runs the request.', :heading, width:)] if view.response.empty?
75
+
76
+ details = ['Response', view.language, ("fetched #{view.fetched}" if view.fetched)].reject { it.to_s.empty? }
77
+ [segment(" #{details.join(' · ')}", :heading, width:)]
74
78
  end
75
79
  end
76
80
  end
@@ -7,14 +7,14 @@ module Raindown
7
7
  # The open file's outline, right of the files: a row on each line, with its buttons.
8
8
  class OutlineRenderer < ListRenderer
9
9
  BUTTONS = [['Tab', 'Pane', :tab], ['Enter', 'Open', :enter], ['x', 'Run', :execute], ['e', 'Edit', :edit],
10
- ['w', 'Write back', :write_back], ['r', 'Reload', :reload], ['q', 'Quit', :quit]].freeze
10
+ ['w', 'Write back', :write_back], ['r', 'Reload', :reload], ['/', 'Search', :search], ['q', 'Quit', :quit]].freeze
11
11
 
12
12
  def buttons = BUTTONS
13
13
 
14
14
  def page
15
- if navigator.path then navigator.relative(navigator.path)
16
- else 'No Markdown files'
17
- end
15
+ return 'No Markdown files' unless navigator.path
16
+
17
+ navigator.relative(navigator.path)
18
18
  end
19
19
  end
20
20
  end
@@ -7,9 +7,13 @@ require_relative '../text'
7
7
  module Raindown
8
8
  module TUI
9
9
  # Draws what's in a pane, :nav or :main, as lines of the pane's width and height, which the Layout puts in it.
10
+ # Like the Layout, it's made again for each draw, so it draws what the Navigator and its view hold then.
10
11
  Renderer = Struct.new(:navigator, :pane, :width, :height) do
11
12
  include Rows
12
13
  include Text
14
+
15
+ # Its side of the line above the panes, which is blank unless it has something to say there, like a progress.
16
+ def spacer = [segment('', :blank)]
13
17
  end
14
18
  end
15
19
  end
@@ -5,8 +5,10 @@ module Raindown
5
5
  # A line of a pane. Target is what it's about: a folder or file path, a Heading or Directive, or a line number.
6
6
  # Buttons are [label, event] pairs, like ['[Run]', :execute], and a tag is an Endpoint, whose method is before its text.
7
7
  # Open is the Navigator's next step on Enter, like an Open of a file, and fold, a block that folds or expands the row.
8
- Row = Data.define(:text, :depth, :kind, :target, :buttons, :tag, :open, :fold) do
9
- def initialize(buttons: [], tag: nil, open: nil, fold: nil, **) = super
8
+ # Code is the text highlighted, as [text, style] parts, for a line of code, like a directive's body.
9
+ # Each optional field has a default, which is why initialize takes so many.
10
+ Row = Data.define(:text, :depth, :kind, :target, :buttons, :tag, :open, :fold, :code) do
11
+ def initialize(buttons: [], tag: nil, open: nil, fold: nil, code: nil, **) = super # rubocop:disable Metrics/ParameterLists
10
12
 
11
13
  # A separator is space between a pane's sections, which the cursor skips and clicks don't reach.
12
14
  def selectable? = kind != :separator
@@ -29,9 +29,10 @@ module Raindown
29
29
  parts.zip(spread(parts.map(&:first), width)).map { |(text, style), part| segment(text, style, width: part, event:) }
30
30
  end
31
31
 
32
- # The row's text, after its tag in the tag's style, unless the row's selected.
32
+ # The row's text, after its tag in the tag's style, or highlighted when it's code, unless the row's selected.
33
33
  def row_parts(row, style, selected)
34
34
  lead = " #{Text::INDENT * row.depth}"
35
+ return [[lead, style], *row.code] if row.code && !selected
35
36
  return [["#{lead}#{row.text}", style]] unless row.tag
36
37
 
37
38
  [["#{lead}#{row.tag.method} ", selected ? style : row.tag.style], [row.text, style]]