raindown 0.6.0 → 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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/lib/raindown/chart/chart.rb +12 -1
  3. data/lib/raindown/document.rb +1 -5
  4. data/lib/raindown/endpoint/curl.rb +11 -7
  5. data/lib/raindown/node_blocks.rb +9 -6
  6. data/lib/raindown/nodes.rb +1 -0
  7. data/lib/raindown/stat.rb +53 -0
  8. data/lib/raindown/tui/events.rb +16 -1
  9. data/lib/raindown/tui/feelings/batch.rb +47 -0
  10. data/lib/raindown/tui/feelings/bins.rb +67 -0
  11. data/lib/raindown/tui/feelings/celebration.rb +25 -0
  12. data/lib/raindown/tui/feelings/code_box.rb +129 -0
  13. data/lib/raindown/tui/feelings/code_methods.rb +134 -0
  14. data/lib/raindown/tui/feelings/feelings_log.rb +133 -0
  15. data/lib/raindown/tui/feelings/feelings_renderer.rb +184 -0
  16. data/lib/raindown/tui/feelings/feelings_view.rb +161 -0
  17. data/lib/raindown/tui/feelings/fireworks.rb +75 -0
  18. data/lib/raindown/tui/feelings/rain.rb +66 -0
  19. data/lib/raindown/tui/feelings/scene.rb +37 -0
  20. data/lib/raindown/tui/feelings/waffle.rb +43 -0
  21. data/lib/raindown/tui/feelings/weekly_batch.rb +32 -0
  22. data/lib/raindown/tui/file_tree.rb +6 -5
  23. data/lib/raindown/tui/highlighter.rb +45 -0
  24. data/lib/raindown/tui/layout.rb +14 -7
  25. data/lib/raindown/tui/menu_overlay.rb +1 -1
  26. data/lib/raindown/tui/navigator/commands.rb +14 -1
  27. data/lib/raindown/tui/navigator/navigator.rb +42 -9
  28. data/lib/raindown/tui/navigator/search.rb +126 -0
  29. data/lib/raindown/tui/navigator/views.rb +6 -3
  30. data/lib/raindown/tui/renderers/dashboard_renderer.rb +4 -4
  31. data/lib/raindown/tui/renderers/endpoint_renderer.rb +14 -10
  32. data/lib/raindown/tui/renderers/outline_renderer.rb +4 -4
  33. data/lib/raindown/tui/renderers/renderer.rb +3 -0
  34. data/lib/raindown/tui/row.rb +4 -2
  35. data/lib/raindown/tui/rows.rb +2 -1
  36. data/lib/raindown/tui/search_overlay.rb +73 -0
  37. data/lib/raindown/tui/terminal/input.rb +42 -5
  38. data/lib/raindown/tui/terminal/screen.rb +12 -9
  39. data/lib/raindown/tui/terminal/styles.rb +78 -20
  40. data/lib/raindown/tui/text.rb +27 -0
  41. data/lib/raindown/tui/title.rb +1 -1
  42. data/lib/raindown/tui/views/dashboard_view.rb +6 -4
  43. data/lib/raindown/tui/views/endpoint_view.rb +2 -0
  44. data/lib/raindown/tui/views/list_view.rb +2 -0
  45. data/lib/raindown/tui/views/outline_view.rb +11 -2
  46. data/lib/raindown/tui/workers/project.rb +6 -6
  47. data/lib/raindown/tui.rb +2 -0
  48. data/lib/version.rb +1 -1
  49. metadata +33 -2
@@ -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
@@ -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 'menu_overlay'
7
+ require_relative 'search_overlay'
6
8
 
7
9
  module Raindown
8
10
  module TUI
@@ -44,11 +46,7 @@ module Raindown
44
46
  def lines
45
47
  return [status].first(height) if height < CHROME_HEIGHT + 1
46
48
 
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)
49
+ overlaid([title, *panes, status, button_bar])
52
50
  end
53
51
 
54
52
  # A third of the width, within NAV_WIDTH, and never more than half of a narrow screen.
@@ -58,6 +56,14 @@ module Raindown
58
56
 
59
57
  private
60
58
 
59
+ # The project menu or the search, drawn over the lines while it's open.
60
+ def overlaid(lines)
61
+ return MenuOverlay.new(navigator, width:, column: display_width(name)).apply(lines) if navigator.menu
62
+ return SearchOverlay.new(navigator, width:).apply(lines) if navigator.searching?
63
+
64
+ lines
65
+ end
66
+
61
67
  def body_height = height - CHROME_HEIGHT
62
68
 
63
69
  # Below a blank line for spacing, the nav pane beside the main pane, each drawn by the renderer of what's in it.
@@ -68,8 +74,8 @@ module Raindown
68
74
  [spacer, *Array.new(body_height - 1) { |index| [*nav[index], divider(main[index]), *main[index]] }]
69
75
  end
70
76
 
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)]
77
+ # A line above the panes, for spacing, blank but for what the main pane's renderer puts in its side of it.
78
+ def spacer = [segment('', :blank, width: nav_width), divider([]), *view_renderer.spacer]
73
79
 
74
80
  # The divider is on the background of the outline's row beside it, when that's highlighted, so they meet.
75
81
  def divider(main_line)
@@ -122,3 +128,4 @@ require_relative 'renderers/list_renderer'
122
128
  require_relative 'renderers/outline_renderer'
123
129
  require_relative 'renderers/dashboard_renderer'
124
130
  require_relative 'renderers/endpoint_renderer'
131
+ require_relative 'feelings/feelings_renderer'
@@ -5,7 +5,7 @@ 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 │
@@ -4,7 +4,8 @@ require_relative '../../actions'
4
4
 
5
5
  module Raindown
6
6
  module TUI
7
- # What a Navigator does to the open file: run an endpoint, open the editor, write back and read it again.
7
+ # What a Navigator does to the open file: run an endpoint, open the editor, write back and read it again,
8
+ # and to a feeling about some code: draw the page it was noted down in, or ask Claude Code to fix the code.
8
9
  # Each sets the status, so the Screen shows how it went, and returns what the Screen should do next, if anything.
9
10
  module Commands
10
11
  def run(file, line, name) = Wait.new(message: "Running #{name}...", task: -> { run_endpoint(file, line) })
@@ -15,8 +16,20 @@ module Raindown
15
16
  Run.new(command: Actions.edit_command(file, line:))
16
17
  end
17
18
 
19
+ def felt(feeling) = info("Felt #{feeling.temper} about #{feeling.method.label}")
20
+
21
+ # Leaves the screen for Claude Code, asked to fix the method, and the view reads the code again after.
22
+ def fix_with_agent(feeling)
23
+ return error('Install Claude Code, the claude command, to fix code with X') unless claude?
24
+
25
+ @edited = feeling.method.path
26
+ Run.new(command: ['claude', feeling.prompt])
27
+ end
28
+
18
29
  private
19
30
 
31
+ def claude? = ENV.fetch('PATH', '').split(File::PATH_SEPARATOR).any? { File.executable?(File.join(it, 'claude')) }
32
+
20
33
  # Runs the endpoint selected in the outline, or listed below the files. One opened beside them runs itself.
21
34
  def execute
22
35
  target = selected&.target
@@ -8,6 +8,7 @@ require_relative '../events'
8
8
  require_relative '../views/outline_view'
9
9
  require_relative '../views/list_view'
10
10
  require_relative 'project_menu'
11
+ require_relative 'search'
11
12
  require_relative 'views'
12
13
  require_relative '../workers/workers'
13
14
 
@@ -20,20 +21,26 @@ module Raindown
20
21
  include Commands
21
22
  include Views
22
23
  include ProjectMenu
24
+ include Search
23
25
  include Focus
24
26
 
25
27
  # Keys of the events that move the cursor or the focus, and those that act on what's selected.
26
28
  MOVES = { up: -1, down: 1 }.freeze
27
29
  PANES = { tab: nil, left: :nav, right: :main }.freeze
28
30
  ACTIONS = { execute: :execute, edit: :edit, write_back: :write_back, reload: :reload, tick: :refresh }.freeze
31
+ # Keys that open the search, the project menu, the Dashboard or Code feelings, from anywhere.
32
+ OPENERS = { search: :open_search, projects: :toggle_menu, dashboard: :toggle_dashboard, feelings: :open_feelings }.freeze
29
33
 
30
34
  attr_reader :focus, :status
35
+ # What the mouse is over, which a click there would send, like a Click on a method's cells, or nil.
36
+ attr_reader :hovered
31
37
 
32
38
  def initialize(root:, path: nil, workers: Workers.new(root:))
33
39
  @workers = workers
34
40
  @files = FileTree.new(root:, workers:)
35
41
  @endpoints = Endpoints.new(workers:)
36
42
  @dashboard = DashboardView.new(root:, workers:)
43
+ @feelings = FeelingsView.new(root:)
37
44
  @nav = ListView.new { nav_rows }
38
45
  first = Projects.files(root, nested: true).first
39
46
  # The dashboard is the first page, unless a file is asked for, and "d" goes from it to the first file.
@@ -46,13 +53,17 @@ module Raindown
46
53
  case event
47
54
  when nil then nil
48
55
  when Click then click(event)
56
+ when Type then type(event)
57
+ when Hover then hover(event)
49
58
  else route(event)
50
59
  end
51
60
  end
52
61
 
53
- # After the editor closes, the file is written back, like a save while it's watched, and read again.
62
+ # After the editor closes, a Markdown file is written back, like a save while it's watched, and read again.
63
+ # Other files, like code that was felt, aren't written back, and the view reads them again.
54
64
  def ran
55
- write_back(@edited)
65
+ write_back(@edited) if @files.files.include?(@edited)
66
+ view.load
56
67
  @endpoints.load
57
68
  end
58
69
 
@@ -62,16 +73,34 @@ module Raindown
62
73
  # The folder the files are shown from: the chosen project's, or the root's.
63
74
  def base = @files.base
64
75
 
76
+ # While the search, or the view, like a feeling's note, takes what's typed, rather than keys' events.
77
+ def typing? = searching? || view.typing?
78
+
79
+ # While the view wants drawing faster than the ticks, like Code feelings' rain.
80
+ def animating? = view.animating?
81
+
65
82
  # What's in a pane: the nav's ListView on the left, or the view on the right, which keeps its own cursor.
66
83
  def content(pane) = pane == :nav ? @nav : view
67
84
 
68
85
  private
69
86
 
87
+ def hover(hover)
88
+ @hovered = hover.target
89
+ nil
90
+ end
91
+
92
+ # A typed character goes to the search, or to the view while it's typing.
93
+ def type(event)
94
+ return type_query(query + event.text) if searching?
95
+
96
+ view.next_step(event)&.take(self) if view.typing?
97
+ end
98
+
70
99
  def route(name)
71
100
  return :quit if name == :quit
101
+ return search_event(name) if searching?
72
102
  return menu_event(name) if menu
73
- return toggle_menu if name == :projects
74
- return toggle_dashboard if name == :dashboard
103
+ return send(OPENERS[name]) if OPENERS.key?(name)
75
104
  return view.next_step(name)&.take(self) if @focus == :main && view.next_step?(name)
76
105
 
77
106
  browser_event(name)
@@ -79,9 +108,10 @@ module Raindown
79
108
 
80
109
  # A click moves the cursor where it's clicked, and does the click's action there, like a key, unless that would
81
110
  # 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.
111
+ # While the project menu or the search is open, only its items are clicked.
83
112
  def click(click)
84
113
  return if menu && click.pane != :menu
114
+ return if searching? && click.pane != :search
85
115
 
86
116
  point(click)
87
117
  effect = route(click.action)
@@ -90,6 +120,7 @@ module Raindown
90
120
 
91
121
  def point(click)
92
122
  return @menu = click.index if click.pane == :menu
123
+ return @match = click.index if click.pane == :search
93
124
 
94
125
  content(click.pane).cursor = click.index
95
126
  @focus = click.pane
@@ -103,11 +134,13 @@ module Raindown
103
134
  send(ACTIONS[name]) if ACTIONS.key?(name)
104
135
  end
105
136
 
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.
137
+ # The Dashboard and Code feelings, whose emoji every terminal draws two columns wide, then the files and the
138
+ # endpoints, with a blank line between each section that has rows.
108
139
  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)
140
+ pages = { '📊 Dashboard' => dashboard, '🎭 Feelings' => feelings }.map do |text, view|
141
+ Row.new(text:, depth: 0, kind: :page, target: view, open: OpenView.new(view:))
142
+ end
143
+ [pages, @files.rows, @endpoints.rows].reject(&:empty?).flat_map { [Row::SEPARATOR, *it] }.drop(1)
111
144
  end
112
145
 
113
146
  def selected
@@ -0,0 +1,126 @@
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 Search
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 the search is open, and the selected match.
49
+ attr_reader :query, :match
50
+
51
+ def searching? = !query.nil?
52
+
53
+ # The places that match what's typed, best first, then in the order they're in the files.
54
+ def matches
55
+ @places.each_with_index.filter_map { |place, index| (score = place.score(query)) && [-score, index, place] }
56
+ .min(LIMIT).map(&:last)
57
+ end
58
+
59
+ private
60
+
61
+ def open_search
62
+ @places = places
63
+ @match = 0
64
+ @query = ''
65
+ nil
66
+ end
67
+
68
+ def search_event(name)
69
+ case name
70
+ when :up, :down then move_match(name == :up ? -1 : 1)
71
+ when :enter then teleport
72
+ when :escape then close_search
73
+ when :backspace then type_query(query[0...-1])
74
+ end
75
+ end
76
+
77
+ def type_query(query)
78
+ @query = query
79
+ @match = 0
80
+ nil
81
+ end
82
+
83
+ def move_match(by)
84
+ @match = (@match + by).clamp(0, [matches.length - 1, 0].max)
85
+ nil
86
+ end
87
+
88
+ def teleport
89
+ place = matches[@match]
90
+ close_search
91
+ place&.open&.take(self)
92
+ end
93
+
94
+ def close_search
95
+ @query = nil
96
+ nil
97
+ end
98
+
99
+ def places
100
+ [Place.new(text: 'Dashboard', where: '', open: OpenView.new(view: dashboard)),
101
+ Place.new(text: 'Code feelings', where: '', open: OpenView.new(view: feelings)),
102
+ *@files.files.flat_map { file_places(it) },
103
+ *@endpoints.all.map { Place.new(text: it.text, where: "#{relative(it.path)}:#{it.line}", open: open_endpoint(it)) }]
104
+ end
105
+
106
+ # The file, then its headings and directives, in the order they're in it.
107
+ def file_places(path)
108
+ outline = @workers.for(path).call(:outline, path)
109
+ where = relative(path)
110
+ items = (outline.headings + outline.directives).sort_by(&:line)
111
+ [Place.new(text: where, where: '', open: Open.new(path:, line: nil)),
112
+ *items.map { Place.new(text: item_text(it), where: "#{where}:#{it.line}", open: Open.new(path:, line: it.line)) }]
113
+ rescue Worker::Error
114
+ []
115
+ end
116
+
117
+ def item_text(item)
118
+ return "#{'#' * item.level} #{item.text}" if item.is_a?(Document::Heading)
119
+
120
+ "#{item.name}: #{item.args}".strip
121
+ end
122
+
123
+ def open_endpoint(endpoint) = OpenView.new(view: EndpointView.new(endpoint, workers: @workers))
124
+ end
125
+ end
126
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative '../views/dashboard_view'
4
4
  require_relative '../views/endpoint_view'
5
+ require_relative '../feelings/feelings_view'
5
6
  require_relative '../views/outline_view'
6
7
 
7
8
  module Raindown
@@ -11,7 +12,7 @@ module Raindown
11
12
  # 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
13
  # what's opened it in the files. It loads when it's opened, and again after a change.
13
14
  module Views
14
- attr_reader :view, :dashboard
15
+ attr_reader :view, :dashboard, :feelings
15
16
 
16
17
  # The path of the file the view shows, if any.
17
18
  def path = view.path
@@ -27,17 +28,19 @@ module Raindown
27
28
  nil
28
29
  end
29
30
 
30
- # Opens the file in the outline, on the directive at the line.
31
+ # Opens the file in the outline, on the heading or directive at the line.
31
32
  def open_at(path, line)
32
33
  path = File.expand_path(path)
33
34
  @files.reveal(path)
34
35
  open_view(OutlineView.new(path, workers: @workers))
35
- view.cursor = view.rows.index { it.kind == :directive && it.target.line == line } || 0
36
+ view.cursor = view.rows.index { %i[heading directive].include?(it.kind) && it.target.line == line } || 0
36
37
  nil
37
38
  end
38
39
 
39
40
  private
40
41
 
42
+ def open_feelings = open_view(feelings)
43
+
41
44
  # Switches between the dashboard and the view before it.
42
45
  def toggle_dashboard
43
46
  return open_view(@previous) if view == dashboard && @previous
@@ -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
@@ -10,6 +10,9 @@ module Raindown
10
10
  Renderer = Struct.new(:navigator, :pane, :width, :height) do
11
11
  include Rows
12
12
  include Text
13
+
14
+ # Its side of the line above the panes, which is blank unless it has something to say there, like a progress.
15
+ def spacer = [segment('', :blank)]
13
16
  end
14
17
  end
15
18
  end