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
@@ -1,25 +1,37 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'unicode/display_width'
3
+ require_relative 'events'
4
+ require_relative 'text'
5
+ require_relative 'title'
6
+ require_relative 'menu_overlay'
7
+ require_relative 'search_overlay'
4
8
 
5
9
  module Raindown
6
10
  module TUI
7
11
  # The screen as lines of styled segments, worked out from a Navigator with no terminal, so the Screen only paints them.
8
- # A title, the files pane beside the outline, a status line and a bar of buttons:
9
- # raindown docs guides/setup.md
10
- # ▾ guides/ │ # Setup
11
- # setup.md │ toc: [+3]
12
- # README.md │ ## Install
13
- # Opened guides/setup.md
14
- # Tab Pane Enter Open x Run e Edit w Write back r Reload q Quit
12
+ # A Layout and its Renderers are the display code, made again for each draw: they lay out and render what the
13
+ # Navigator and its views hold. The Layout draws the title, the status and the buttons around two panes, nav and main,
14
+ # and a Renderer draws what's in each pane: the nav's ListRenderer, and the open view's renderer, an OutlineRenderer,
15
+ # DashboardRenderer or EndpointRenderer, which gives the buttons and page too:
16
+ # ┌─ Layout ──────────────────────────────────────────────────┐
17
+ # │ Raindown ~/docs › guides/setup.md │
18
+ # │ ┌─ nav pane ──────┐ ┌─ main pane ───────────────────────┐ │
19
+ # │ │ Dashboard ▏ # Setup │ │
20
+ # │ │ ▾ guides/ ▏ toc: [+3] │ │
21
+ # │ │ setup.md ▏ ## Install │ │
22
+ # │ └─ ListRenderer ──┘ └─ view.renderer ───────────────────┘ │
23
+ # │ Opened guides/setup.md │
24
+ # │ Tab Pane Enter Open X Run E dit W rite back Q uit │
25
+ # └───────────────────────────────────────────────────────────┘
15
26
  # Each line is exactly the width, in display columns. A segment with an event is clicked to send it.
16
27
  class Layout
17
- Segment = Data.define(:text, :style, :event)
28
+ include Text
29
+ include Title
18
30
 
19
- BUTTONS = [['Tab', 'Pane', :tab], ['Enter', 'Open', :enter], ['x', 'Run', :execute], ['e', 'Edit', :edit],
20
- ['w', 'Write back', :write_back], ['r', 'Reload', :reload], ['q', 'Quit', :quit]].freeze
21
- FILES_WIDTH = (16..32)
22
- INDENT = ' '
31
+ NAV_WIDTH = (16..32)
32
+ # The line between the nav and main panes, at its column's left edge, unlike │ in the middle,
33
+ # so a highlighted file's row runs up to it.
34
+ DIVIDER = '▏'
23
35
  # The title, status and button lines.
24
36
  CHROME_HEIGHT = 3
25
37
 
@@ -34,57 +46,49 @@ module Raindown
34
46
  def lines
35
47
  return [status].first(height) if height < CHROME_HEIGHT + 1
36
48
 
37
- [title, *panes, status, button_bar]
49
+ overlaid([title, *panes, status, button_bar])
38
50
  end
39
51
 
40
- def files_width
41
- (width / 3).clamp(FILES_WIDTH.begin, [FILES_WIDTH.end, width / 2].min)
52
+ # A third of the width, within NAV_WIDTH, and never more than half of a narrow screen.
53
+ def nav_width
54
+ [(width / 3).clamp(NAV_WIDTH), width / 2].min
42
55
  end
43
56
 
44
57
  private
45
58
 
46
- def title
47
- path = navigator.path ? navigator.relative(navigator.path) : 'No Markdown files'
48
- [segment(" raindown #{File.basename(navigator.root)} #{path}", :title)]
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
49
65
  end
50
66
 
67
+ def body_height = height - CHROME_HEIGHT
68
+
69
+ # Below a blank line for spacing, the nav pane beside the main pane, each drawn by the renderer of what's in it.
51
70
  def panes
52
- pane_height = height - CHROME_HEIGHT
53
- files = navigator.visible(:files, pane_height)
54
- outline = navigator.visible(:outline, pane_height)
71
+ nav = renderer_in(:nav, nav_width).lines
72
+ main = view_renderer.lines
55
73
 
56
- Array.new(pane_height) do |index|
57
- [*cell(:files, *files, index, files_width), Segment.new(text: '│', style: :border, event: nil),
58
- *cell(:outline, *outline, index, width - files_width - 1)]
59
- end
74
+ [spacer, *Array.new(body_height - 1) { |index| [*nav[index], divider(main[index]), *main[index]] }]
60
75
  end
61
76
 
62
- # A pane's row as its text, then its buttons, or blank below its last row.
63
- def cell(pane, rows, offset, index, cell_width)
64
- row = rows[index]
65
- return [segment('', :blank, width: cell_width)] unless row
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]
66
79
 
67
- index += offset
68
- selected = navigator.cursor(pane) == index
69
- buttons = row_buttons(row, pane:, index:, selected:, cell_width:)
70
- text_width = cell_width - display_width(buttons.map(&:text).join)
71
- text = " #{INDENT * row.depth}#{row.text}"
72
-
73
- [segment(text, row_style(pane, row, selected), width: text_width, event: [:select, pane, index]), *buttons]
80
+ # The divider is on the background of the outline's row beside it, when that's highlighted, so they meet.
81
+ def divider(main_line)
82
+ style = { selected: :selected_divider, cursor: :cursor_divider }.fetch(main_line.first&.style, :divider)
83
+ segment(DIVIDER, style, width: 1)
74
84
  end
75
85
 
76
- # The row's buttons, right of its text, unless they'd take more than half the pane.
77
- def row_buttons(row, pane:, index:, selected:, cell_width:)
78
- buttons = row.buttons.map do |label, event|
79
- Segment.new(text: " #{label}", style: selected ? :selected_button : :button, event: [:press, pane, index, event])
80
- end
81
- display_width(buttons.map(&:text).join) > cell_width / 2 ? [] : buttons
86
+ def view_renderer
87
+ @view_renderer ||= renderer_in(:main, width - nav_width - 1)
82
88
  end
83
89
 
84
- def row_style(pane, row, selected)
85
- return row.kind unless selected
86
-
87
- navigator.focus == pane ? :selected : :cursor
90
+ def renderer_in(pane, width)
91
+ navigator.content(pane).renderer.new(navigator:, pane:, width:, height: body_height - 1)
88
92
  end
89
93
 
90
94
  def status
@@ -92,12 +96,20 @@ module Raindown
92
96
  [segment(" #{message}", kind)]
93
97
  end
94
98
 
99
+ # The first letter of a label is stylized as a keyboard key like "(E)dit".
100
+ def shown(key, label)
101
+ return [key, label] unless key.length == 1
102
+
103
+ [key.upcase, label.downcase.start_with?(key) ? label[1..] : label]
104
+ end
105
+
95
106
  # As many buttons as fit, each clicked by its key or its label.
96
107
  def button_bar
97
108
  segments = []
98
109
  used = 0
99
110
 
100
- BUTTONS.each do |key, label, event|
111
+ view_renderer.buttons.each do |key, label, event|
112
+ key, label = shown(key, label)
101
113
  pair = [Segment.new(text: " #{key} ", style: :key, event:), Segment.new(text: "#{label} ", style: :bar, event:)]
102
114
  pair_width = display_width(pair.map(&:text).join)
103
115
  break if used + pair_width > width
@@ -108,35 +120,12 @@ module Raindown
108
120
 
109
121
  [*segments, segment('', :bar, width: width - used)]
110
122
  end
111
-
112
- # Text cut or padded to the width, with a cut shown by an ellipsis.
113
- def segment(text, style, width: self.width, event: nil)
114
- Segment.new(text: fit(printable(text), width), style:, event:)
115
- end
116
-
117
- def fit(text, width)
118
- return '' if width <= 0
119
- return text + (' ' * (width - display_width(text))) if display_width(text) <= width
120
-
121
- cut = +''
122
- cut_width = 0
123
- text.each_char do |char|
124
- break if cut_width + display_width(char) > width - 1
125
-
126
- cut << char
127
- cut_width += display_width(char)
128
- end
129
- "#{cut}…#{' ' * (width - 1 - cut_width)}"
130
- end
131
-
132
- # Tabs and control characters, like in a directive's output, would move the terminal's cursor.
133
- def printable(text)
134
- text.gsub("\t", INDENT).gsub(/[[:cntrl:]]/, '')
135
- end
136
-
137
- def display_width(text)
138
- Unicode::DisplayWidth.of(text)
139
- end
140
123
  end
141
124
  end
142
125
  end
126
+
127
+ require_relative 'renderers/list_renderer'
128
+ require_relative 'renderers/outline_renderer'
129
+ require_relative 'renderers/dashboard_renderer'
130
+ require_relative 'renderers/endpoint_renderer'
131
+ require_relative 'feelings/feelings_renderer'
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'text'
4
+
5
+ module Raindown
6
+ module TUI
7
+ # The project menu, drawn over the screen's lines in a box below the header's path:
8
+ # 🌧 Raindown ~/dev ▾ › Dashboard
9
+ # ┌──────────────────────┐
10
+ # │ All projects ✓ │
11
+ # │ ~/dev/raindown │
12
+ # └──────────────────────┘
13
+ # Each item is clicked to choose it.
14
+ class MenuOverlay
15
+ include Text
16
+
17
+ def initialize(navigator, width:, column:)
18
+ @navigator = navigator
19
+ @width = width
20
+ @column = column
21
+ end
22
+
23
+ # The lines with the box drawn over them, from the line below the header.
24
+ def apply(lines)
25
+ box.each_with_index.map { |segments, offset| [offset + 1, segments] }.each do |row, segments|
26
+ lines[row] = overlay_segments(lines[row], segments, column: @column) if lines[row]
27
+ end
28
+ lines
29
+ end
30
+
31
+ private
32
+
33
+ def items
34
+ @navigator.menu_items.map { |project| project ? from_home(project) : 'All projects' }
35
+ end
36
+
37
+ def inner_width
38
+ [items.map { display_width(it) }.max + 4, @width - @column - 3].min
39
+ end
40
+
41
+ def box
42
+ top = segment("┌#{'─' * (inner_width + 2)}┐", :border)
43
+ bottom = segment("└#{'─' * (inner_width + 2)}┘", :border)
44
+ [[top], *items.each_with_index.map { |item, index| item_line(item, index) }, [bottom]]
45
+ end
46
+
47
+ def item_line(item, index)
48
+ chosen = @navigator.menu_items[index] == @navigator.project ? '✓' : ' '
49
+ style = @navigator.menu == index ? :selected : :menu
50
+ text = fit("#{fit(item, inner_width - 2)} #{chosen}", inner_width)
51
+ [segment('│ ', :border), segment(text, style, Click.new(pane: :menu, index:, action: :enter)), segment(' │', :border)]
52
+ end
53
+
54
+ def segment(text, style, event = nil)
55
+ Text::Segment.new(text:, style:, event:)
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../actions'
4
+
5
+ module Raindown
6
+ module TUI
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.
9
+ # Each sets the status, so the Screen shows how it went, and returns what the Screen should do next, if anything.
10
+ module Commands
11
+ def run(file, line, name) = Wait.new(message: "Running #{name}...", task: -> { run_endpoint(file, line) })
12
+
13
+ # Opens the editor at the file's line, and writes the file back after it closes.
14
+ def edit_at(file, line)
15
+ @edited = file
16
+ Run.new(command: Actions.edit_command(file, line:))
17
+ end
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
+
29
+ private
30
+
31
+ def claude? = ENV.fetch('PATH', '').split(File::PATH_SEPARATOR).any? { File.executable?(File.join(it, 'claude')) }
32
+
33
+ # Runs the endpoint selected in the outline, or listed below the files. One opened beside them runs itself.
34
+ def execute
35
+ target = selected&.target
36
+ return run(target.path, target.line, target.text) if target.is_a?(Endpoint)
37
+ return error('Select an endpoint to run') unless target.is_a?(Document::Directive) && target.endpoint?
38
+
39
+ run(path, target.line, "the endpoint on line #{target.line}")
40
+ end
41
+
42
+ def run_endpoint(file, line)
43
+ changed = @workers.for(file).call(:execute, file, line)
44
+ view.load
45
+ info(changed ? "Ran the endpoint on line #{line}" : 'The response is the same')
46
+ rescue StandardError => e
47
+ error(e.message)
48
+ end
49
+
50
+ # In the nav pane, the selected file opens at its top, and in the outline, the open file at the selected line.
51
+ def edit
52
+ row = selected
53
+ return error('Select a file to edit') if @focus == :nav && row&.kind != :file
54
+
55
+ return edit_at(row.target, 1) if @focus == :nav
56
+ return error('Open a file to edit') unless path
57
+
58
+ edit_at(path, row ? row.line : 1)
59
+ end
60
+
61
+ def write_back(file = path)
62
+ return error('Open a file to write back') unless file
63
+
64
+ changed = @workers.for(file).call(:write_back, file)
65
+ view.load
66
+ info(changed ? "Updated #{relative(file)}" : "#{relative(file)} is up to date")
67
+ rescue StandardError => e
68
+ error(e.message)
69
+ end
70
+
71
+ # The workers start again too, loading their projects' nodes again, like when one of them has changed.
72
+ def reload
73
+ @workers.restart
74
+ @files.load
75
+ @endpoints.load
76
+ view.load
77
+ info('Reloaded')
78
+ end
79
+
80
+ # Reads the file again when it's changed, like when it's saved in an editor or a watcher writes back to it.
81
+ def refresh
82
+ view.load if view.changed?
83
+ nil
84
+ end
85
+
86
+ def info(message)
87
+ @status = [:info, message]
88
+ nil
89
+ end
90
+
91
+ def error(message)
92
+ @status = [:error, message]
93
+ nil
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raindown
4
+ module TUI
5
+ # How a Navigator's focus and cursor move: Tab or left and right between the files and the view,
6
+ # and up and down in the pane with the focus.
7
+ module Focus
8
+ private
9
+
10
+ def other_pane
11
+ @focus == :nav ? :main : :nav
12
+ end
13
+
14
+ def move(by)
15
+ content(@focus).move(by)
16
+ nil
17
+ end
18
+
19
+ def switch(pane)
20
+ @focus = pane
21
+ nil
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../file_tree'
4
+ require_relative 'commands'
5
+ require_relative '../endpoints'
6
+ require_relative 'focus'
7
+ require_relative '../events'
8
+ require_relative '../views/outline_view'
9
+ require_relative '../views/list_view'
10
+ require_relative 'project_menu'
11
+ require_relative 'search'
12
+ require_relative 'views'
13
+ require_relative '../workers/workers'
14
+
15
+ module Raindown
16
+ module TUI
17
+ # What the terminal UI shows and does, in plain Ruby, with no terminal: a FileTree of the project's Markdown files,
18
+ # beside the open file's Outline, or the Dashboard of its charts. The Screen draws it and sends it events, keys like
19
+ # :down, or a Click, and next_step returns what the Screen does next: nil to draw again, :quit to stop, or a Run or Wait.
20
+ class Navigator
21
+ include Commands
22
+ include Views
23
+ include ProjectMenu
24
+ include Search
25
+ include Focus
26
+
27
+ # Keys of the events that move the cursor or the focus, and those that act on what's selected.
28
+ MOVES = { up: -1, down: 1 }.freeze
29
+ PANES = { tab: nil, left: :nav, right: :main }.freeze
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
33
+
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
37
+
38
+ def initialize(root:, path: nil, workers: Workers.new(root:))
39
+ @workers = workers
40
+ @files = FileTree.new(root:, workers:)
41
+ @endpoints = Endpoints.new(workers:)
42
+ @dashboard = DashboardView.new(root:, workers:)
43
+ @feelings = FeelingsView.new(root:)
44
+ @nav = ListView.new { nav_rows }
45
+ first = Projects.files(root, nested: true).first
46
+ # The dashboard is the first page, unless a file is asked for, and "d" goes from it to the first file.
47
+ @view = OutlineView.new(first, workers:) if first
48
+ path ? open_at(path, nil) : open_view(dashboard)
49
+ end
50
+
51
+ # An event nothing has, like a mouse button's release, does nothing.
52
+ def next_step(event)
53
+ case event
54
+ when nil then nil
55
+ when Click then click(event)
56
+ when Type then type(event)
57
+ when Hover then hover(event)
58
+ else route(event)
59
+ end
60
+ end
61
+
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.
64
+ def ran
65
+ write_back(@edited) if @files.files.include?(@edited)
66
+ view.load
67
+ @endpoints.load
68
+ end
69
+
70
+ def root = @files.root
71
+ def relative(path) = @files.relative(path)
72
+
73
+ # The folder the files are shown from: the chosen project's, or the root's.
74
+ def base = @files.base
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
+
82
+ # What's in a pane: the nav's ListView on the left, or the view on the right, which keeps its own cursor.
83
+ def content(pane) = pane == :nav ? @nav : view
84
+
85
+ private
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
+
99
+ def route(name)
100
+ return :quit if name == :quit
101
+ return search_event(name) if searching?
102
+ return menu_event(name) if menu
103
+ return send(OPENERS[name]) if OPENERS.key?(name)
104
+ return view.next_step(name)&.take(self) if @focus == :main && view.next_step?(name)
105
+
106
+ browser_event(name)
107
+ end
108
+
109
+ # A click moves the cursor where it's clicked, and does the click's action there, like a key, unless that would
110
+ # leave the terminal UI, like for the editor, so a stray click only moves the cursor.
111
+ # While the project menu or the search is open, only its items are clicked.
112
+ def click(click)
113
+ return if menu && click.pane != :menu
114
+ return if searching? && click.pane != :search
115
+
116
+ point(click)
117
+ effect = route(click.action)
118
+ effect unless effect.is_a?(Run)
119
+ end
120
+
121
+ def point(click)
122
+ return @menu = click.index if click.pane == :menu
123
+ return @match = click.index if click.pane == :search
124
+
125
+ content(click.pane).cursor = click.index
126
+ @focus = click.pane
127
+ end
128
+
129
+ def browser_event(name)
130
+ return move(MOVES[name]) if MOVES.key?(name)
131
+ return switch(PANES[name] || other_pane) if PANES.key?(name)
132
+ return activate(name) if %i[enter toggle].include?(name)
133
+
134
+ send(ACTIONS[name]) if ACTIONS.key?(name)
135
+ end
136
+
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.
139
+ def nav_rows
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)
144
+ end
145
+
146
+ def selected
147
+ content(@focus).selected
148
+ end
149
+
150
+ # Enter opens what's selected, or folds it, and Space only folds.
151
+ def activate(name)
152
+ row = selected
153
+ return row.open.take(self) if name == :enter && row&.open
154
+
155
+ row&.fold&.call
156
+ nil
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raindown
4
+ module TUI
5
+ # The list of projects that drops down from the header's path, with "p" or a click, when there's more than one.
6
+ # Choosing one narrows the files and the dashboard to its own, and "All projects" shows every project's again.
7
+ # It stays open until a project's chosen, with Enter or a click, while up and down move its cursor.
8
+ # Esc or "p" close it without choosing, and other keys and clicks do nothing while it's open.
9
+ module ProjectMenu
10
+ # The menu's cursor, while it's open.
11
+ attr_reader :menu
12
+
13
+ # The project the files and the dashboard are narrowed to, or nil for every project.
14
+ attr_reader :project
15
+
16
+ def projects = @workers.roots
17
+
18
+ # The menu's items: nil for all the projects, then each project's root.
19
+ def menu_items = [nil, *projects]
20
+
21
+ def projects? = projects.length > 1
22
+
23
+ private
24
+
25
+ def toggle_menu
26
+ return info("There's one project here") unless projects?
27
+
28
+ @menu = @menu ? nil : menu_items.index(project) || 0
29
+ nil
30
+ end
31
+
32
+ def menu_event(name)
33
+ case name
34
+ when :up, :down then move_menu(name == :up ? -1 : 1)
35
+ when :enter then choose(menu_items[@menu])
36
+ when :escape, :projects then close_menu
37
+ end
38
+ end
39
+
40
+ def move_menu(by)
41
+ @menu = (@menu + by).clamp(0, menu_items.length - 1)
42
+ nil
43
+ end
44
+
45
+ def close_menu
46
+ @menu = nil
47
+ nil
48
+ end
49
+
50
+ # Shows the project's dashboard, or every project's.
51
+ def choose(project)
52
+ @menu = nil
53
+ @project = project
54
+ @files.scope = project
55
+ @endpoints.project = project
56
+ dashboard.project = project
57
+ open_view(dashboard)
58
+ end
59
+ end
60
+ end
61
+ end