raindown 0.7.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 (77) 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/cli.rb +39 -54
  7. data/lib/raindown/config.rb +2 -3
  8. data/lib/raindown/directives.rb +30 -13
  9. data/lib/raindown/document.rb +21 -15
  10. data/lib/raindown/endpoint/endpoint_runner.rb +13 -5
  11. data/lib/raindown/node_blocks.rb +22 -16
  12. data/lib/raindown/node_injector.rb +7 -10
  13. data/lib/raindown/pages.rb +1 -1
  14. data/lib/raindown/pre_renderer.rb +18 -0
  15. data/lib/raindown/tui/blueprints/blueprints_renderer.rb +93 -0
  16. data/lib/raindown/tui/blueprints/blueprints_view.rb +146 -0
  17. data/lib/raindown/tui/blueprints/canvas.rb +58 -0
  18. data/lib/raindown/tui/blueprints/code_comments.rb +71 -0
  19. data/lib/raindown/tui/blueprints/filter.rb +77 -0
  20. data/lib/raindown/tui/blueprints/traced_code.rb +56 -0
  21. data/lib/raindown/tui/blueprints/tracing.rb +129 -0
  22. data/lib/raindown/tui/{feelings/code_box.rb → code_box.rb} +44 -30
  23. data/lib/raindown/tui/endpoints.rb +2 -2
  24. data/lib/raindown/tui/events.rb +3 -1
  25. data/lib/raindown/tui/feelings/bins.rb +5 -5
  26. data/lib/raindown/tui/feelings/{celebration.rb → celebration/celebration.rb} +5 -2
  27. data/lib/raindown/tui/feelings/{scene.rb → celebration/scene.rb} +4 -1
  28. data/lib/raindown/tui/feelings/cell_moves.rb +34 -0
  29. data/lib/raindown/tui/feelings/feelings_code_box.rb +40 -0
  30. data/lib/raindown/tui/feelings/feelings_log.rb +22 -19
  31. data/lib/raindown/tui/feelings/feelings_renderer.rb +94 -95
  32. data/lib/raindown/tui/feelings/feelings_view.rb +28 -32
  33. data/lib/raindown/tui/feelings/magnifier.rb +68 -0
  34. data/lib/raindown/tui/feelings/motion.rb +93 -0
  35. data/lib/raindown/tui/feelings/waffle/baked_waffle.rb +88 -0
  36. data/lib/raindown/tui/feelings/waffle/balloons.rb +81 -0
  37. data/lib/raindown/tui/feelings/waffle/confetti.rb +55 -0
  38. data/lib/raindown/tui/feelings/waffle/sparkles.rb +61 -0
  39. data/lib/raindown/tui/feelings/waffle/waffle.rb +62 -0
  40. data/lib/raindown/tui/feelings/weekly_batch.rb +24 -5
  41. data/lib/raindown/tui/layout.rb +25 -18
  42. data/lib/raindown/tui/navigator/commands.rb +32 -1
  43. data/lib/raindown/tui/navigator/mouse.rb +43 -0
  44. data/lib/raindown/tui/navigator/navigator.rb +40 -44
  45. data/lib/raindown/tui/navigator/{search.rb → teleport.rb} +14 -12
  46. data/lib/raindown/tui/navigator/views.rb +2 -3
  47. data/lib/raindown/tui/{workers/project.rb → project.rb} +33 -22
  48. data/lib/raindown/tui/{menu_overlay.rb → project_box.rb} +1 -1
  49. data/lib/raindown/tui/renderers/renderer.rb +1 -0
  50. data/lib/raindown/tui/saves.rb +22 -0
  51. data/lib/raindown/tui/{search_overlay.rb → search_box.rb} +27 -14
  52. data/lib/raindown/tui/terminal/input.rb +58 -7
  53. data/lib/raindown/tui/terminal/screen.rb +31 -26
  54. data/lib/raindown/tui/terminal/styles.rb +55 -28
  55. data/lib/raindown/tui/terminal/terminal_title.rb +31 -0
  56. data/lib/raindown/tui/text.rb +35 -12
  57. data/lib/raindown/tui/views/dashboard_view.rb +2 -2
  58. data/lib/raindown/tui/views/endpoint_view.rb +3 -3
  59. data/lib/raindown/tui/views/list_view.rb +1 -1
  60. data/lib/raindown/tui/views/outline_view.rb +5 -4
  61. data/lib/raindown/tui.rb +17 -7
  62. data/lib/raindown/watcher/changes.rb +33 -0
  63. data/lib/raindown/watcher/file_watcher.rb +58 -0
  64. data/lib/raindown/watcher/project.rb +47 -0
  65. data/lib/raindown/watcher/supervisor.rb +111 -0
  66. data/lib/raindown/{tui/workers → workers}/worker.rb +37 -29
  67. data/lib/raindown/workers/workers.rb +63 -0
  68. data/lib/raindown.rb +4 -4
  69. data/lib/version.rb +1 -1
  70. metadata +37 -17
  71. data/lib/raindown/project_worker.rb +0 -48
  72. data/lib/raindown/supervisor.rb +0 -160
  73. data/lib/raindown/tui/feelings/waffle.rb +0 -43
  74. data/lib/raindown/tui/workers/workers.rb +0 -53
  75. data/lib/raindown/watcher.rb +0 -57
  76. /data/lib/raindown/tui/feelings/{fireworks.rb → celebration/fireworks.rb} +0 -0
  77. /data/lib/raindown/tui/feelings/{rain.rb → celebration/rain.rb} +0 -0
@@ -4,13 +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'
11
- require_relative 'search'
12
+ require_relative 'teleport'
12
13
  require_relative 'views'
13
- require_relative '../workers/workers'
14
+ require_relative '../project'
14
15
 
15
16
  module Raindown
16
17
  module TUI
@@ -21,26 +22,31 @@ module Raindown
21
22
  include Commands
22
23
  include Views
23
24
  include ProjectMenu
24
- include Search
25
+ include Teleport
25
26
  include Focus
27
+ include Mouse
26
28
 
27
29
  # Keys of the events that move the cursor or the focus, and those that act on what's selected.
28
30
  MOVES = { up: -1, down: 1 }.freeze
29
31
  PANES = { tab: nil, left: :nav, right: :main }.freeze
30
32
  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
+ # 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
33
36
 
34
37
  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
 
38
- 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)
39
42
  @workers = workers
43
+ @saves = saves
44
+ @runs_endpoints = runs_endpoints
40
45
  @files = FileTree.new(root:, workers:)
41
46
  @endpoints = Endpoints.new(workers:)
42
47
  @dashboard = DashboardView.new(root:, workers:)
43
48
  @feelings = FeelingsView.new(root:)
49
+ @blueprints = BlueprintsView.new(root:)
44
50
  @nav = ListView.new { nav_rows }
45
51
  first = Projects.files(root, nested: true).first
46
52
  # The dashboard is the first page, unless a file is asked for, and "d" goes from it to the first file.
@@ -55,6 +61,7 @@ module Raindown
55
61
  when Click then click(event)
56
62
  when Type then type(event)
57
63
  when Hover then hover(event)
64
+ when Drag then drag(event)
58
65
  else route(event)
59
66
  end
60
67
  end
@@ -73,57 +80,39 @@ module Raindown
73
80
  # The folder the files are shown from: the chosen project's, or the root's.
74
81
  def base = @files.base
75
82
 
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?
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?
78
85
 
79
- # While the view wants drawing faster than the ticks, like Code feelings' rain.
80
- def animating? = view.animating?
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)
81
88
 
82
89
  # What's in a pane: the nav's ListView on the left, or the view on the right, which keeps its own cursor.
83
90
  def content(pane) = pane == :nav ? @nav : view
84
91
 
85
92
  private
86
93
 
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.
94
+ # A typed character goes to Teleport, or to the view while it's typing.
93
95
  def type(event)
94
- return type_query(query + event.text) if searching?
96
+ return type_query(query + event.text) if teleporting?
95
97
 
96
98
  view.next_step(event)&.take(self) if view.typing?
97
99
  end
98
100
 
99
101
  def route(name)
100
102
  return :quit if name == :quit
101
- return search_event(name) if searching?
103
+ return teleport_event(name) if teleporting?
102
104
  return menu_event(name) if menu
103
- return send(OPENERS[name]) if OPENERS.key?(name)
105
+ return instance_exec(&OPENERS[name]) if opener?(name)
104
106
  return view.next_step(name)&.take(self) if @focus == :main && view.next_step?(name)
105
107
 
106
108
  browser_event(name)
107
109
  end
108
110
 
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
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)
124
114
 
125
- content(click.pane).cursor = click.index
126
- @focus = click.pane
115
+ OPENERS.key?(name)
127
116
  end
128
117
 
129
118
  def browser_event(name)
@@ -134,13 +123,20 @@ module Raindown
134
123
  send(ACTIONS[name]) if ACTIONS.key?(name)
135
124
  end
136
125
 
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.
126
+ # The Dashboard, Code feelings and Blueprints, then the files and the endpoints, with a blank line between each section that
127
+ # has rows.
139
128
  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)
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)
144
140
  end
145
141
 
146
142
  def selected
@@ -2,14 +2,14 @@
2
2
 
3
3
  require_relative '../events'
4
4
  require_relative '../views/endpoint_view'
5
- require_relative '../workers/worker'
5
+ require_relative '../../workers/worker'
6
6
 
7
7
  module Raindown
8
8
  module TUI
9
9
  # Teleport: "/" opens a search of the Dashboard, Code feelings, every file, heading and directive in the files, and every endpoint,
10
10
  # matched as it's typed by letters in order, so "stp" finds "## Setup". Up and down move between the matches,
11
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
12
+ module Teleport
13
13
  # The most matches it shows.
14
14
  LIMIT = 10
15
15
 
@@ -45,10 +45,13 @@ module Raindown
45
45
  end
46
46
  end
47
47
 
48
- # What's typed, while the search is open, and the selected match.
48
+ # What's typed, while Teleport is open, and the selected match.
49
49
  attr_reader :query, :match
50
50
 
51
- def searching? = !query.nil?
51
+ def teleporting? = !query.nil?
52
+
53
+ # The first match the box lists, as it lists them all.
54
+ def first_match = 0
52
55
 
53
56
  # The places that match what's typed, best first, then in the order they're in the files.
54
57
  def matches
@@ -58,18 +61,18 @@ module Raindown
58
61
 
59
62
  private
60
63
 
61
- def open_search
64
+ def open_teleport
62
65
  @places = places
63
66
  @match = 0
64
67
  @query = ''
65
68
  nil
66
69
  end
67
70
 
68
- def search_event(name)
71
+ def teleport_event(name)
69
72
  case name
70
73
  when :up, :down then move_match(name == :up ? -1 : 1)
71
74
  when :enter then teleport
72
- when :escape then close_search
75
+ when :escape then close_teleport
73
76
  when :backspace then type_query(query[0...-1])
74
77
  end
75
78
  end
@@ -87,18 +90,17 @@ module Raindown
87
90
 
88
91
  def teleport
89
92
  place = matches[@match]
90
- close_search
93
+ close_teleport
91
94
  place&.open&.take(self)
92
95
  end
93
96
 
94
- def close_search
97
+ def close_teleport
95
98
  @query = nil
96
99
  nil
97
100
  end
98
101
 
99
102
  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)),
103
+ [*pages.map { |text, view| Place.new(text:, where: '', open: OpenView.new(view:)) },
102
104
  *@files.files.flat_map { file_places(it) },
103
105
  *@endpoints.all.map { Place.new(text: it.text, where: "#{relative(it.path)}:#{it.line}", open: open_endpoint(it)) }]
104
106
  end
@@ -110,7 +112,7 @@ module Raindown
110
112
  items = (outline.headings + outline.directives).sort_by(&:line)
111
113
  [Place.new(text: where, where: '', open: Open.new(path:, line: nil)),
112
114
  *items.map { Place.new(text: item_text(it), where: "#{where}:#{it.line}", open: Open.new(path:, line: it.line)) }]
113
- rescue Worker::Error
115
+ rescue Workers::Worker::Error
114
116
  []
115
117
  end
116
118
 
@@ -1,6 +1,7 @@
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'
5
6
  require_relative '../feelings/feelings_view'
6
7
  require_relative '../views/outline_view'
@@ -12,7 +13,7 @@ module Raindown
12
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,
13
14
  # what's opened it in the files. It loads when it's opened, and again after a change.
14
15
  module Views
15
- attr_reader :view, :dashboard, :feelings
16
+ attr_reader :view, :dashboard, :feelings, :blueprints
16
17
 
17
18
  # The path of the file the view shows, if any.
18
19
  def path = view.path
@@ -39,8 +40,6 @@ module Raindown
39
40
 
40
41
  private
41
42
 
42
- def open_feelings = open_view(feelings)
43
-
44
43
  # Switches between the dashboard and the view before it.
45
44
  def toggle_dashboard
46
45
  return open_view(@previous) if view == dashboard && @previous
@@ -1,19 +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 '../../stat'
13
- require_relative '../card'
14
- require_relative '../endpoint'
15
- require_relative '../../code_blocks'
16
- 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'
17
18
 
18
19
  module Raindown
19
20
  module TUI
@@ -24,9 +25,17 @@ module Raindown
24
25
 
25
26
  attr_reader :root
26
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
+
27
34
  # The projects inside this one have workers of their own, so their files are left out.
28
- def initialize(root:)
35
+ def initialize(root:, bare: false, execute: false)
29
36
  @root = root
37
+ @bare = bare
38
+ @execute = execute
30
39
  @load_errors = []
31
40
  @file_errors = []
32
41
  Nodes.load(File.join(root, Nodes::FOLDER))
@@ -36,7 +45,7 @@ module Raindown
36
45
 
37
46
  # A file's lines, headings and directives.
38
47
  def outline(path)
39
- document = Document.read(path)
48
+ document = Document.read(path, bare: @bare)
40
49
  Outline.new(lines: File.readlines(path, chomp: true), headings: document.headings, directives: document.directives)
41
50
  end
42
51
 
@@ -52,23 +61,25 @@ module Raindown
52
61
  { cards: drawn, errors: @load_errors + @file_errors }
53
62
  end
54
63
 
55
- # 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.
56
66
  def endpoints
57
67
  Projects.files(root).flat_map do |path|
58
68
  lines = File.readlines(path, encoding: Encoding::UTF_8)
59
- 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) }
60
71
  rescue StandardError
61
72
  []
62
73
  end
63
74
  end
64
75
 
65
76
  def write_back(path)
66
- Actions.write_back(path)
77
+ Actions.write_back(path, bare: @bare, execute: @execute)
67
78
  end
68
79
 
69
80
  # Runs the endpoint on the line, replacing its response.
70
81
  def execute(path, line)
71
- 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? }
72
83
  raise Actions::NotAnEndpoint, "There's no endpoint on line #{line}" unless directive
73
84
 
74
85
  Actions.execute(path, directive)
@@ -84,7 +95,7 @@ module Raindown
84
95
 
85
96
  def charts_in(path)
86
97
  content = File.read(path, encoding: Encoding::UTF_8)
87
- bare = BareDirectives.enabled?(path:, content:)
98
+ bare = @bare || BareDirectives.enabled?(path:, content:)
88
99
  lines = content.lines
89
100
  headings = Document.parse(content, bare:).headings
90
101
 
@@ -106,7 +117,7 @@ module Raindown
106
117
 
107
118
  def chart(block, path:, lines:, headings:)
108
119
  line = block[:open] + 1
109
- directive = lines[block[:open]].strip.delete_prefix('<{').delete_suffix('}>').strip
120
+ directive = Directives.text(lines[block[:open]])
110
121
 
111
122
  { path:, line:, heading: headings.select { it.line < line }.last&.text, directive:, node: block[:node] }
112
123
  end
@@ -11,7 +11,7 @@ module Raindown
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:)
@@ -7,6 +7,7 @@ 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
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../watcher/changes'
4
+
5
+ module Raindown
6
+ module TUI
7
+ # The files saved, added or removed in the terminal UI's folder, like when it's watched, but kept until the Navigator
8
+ # takes them on its next tick, as Listen tells of them in a thread of its own.
9
+ class Saves
10
+ def initialize(root)
11
+ @queue = Thread::Queue.new
12
+ @listener = Watcher.listen(root) { |paths| paths.each { @queue << it } }
13
+ @listener.start
14
+ end
15
+
16
+ # The paths saved since they were last taken, once each.
17
+ def take = Array.new(@queue.size) { @queue.pop }.uniq
18
+
19
+ def stop = @listener.stop
20
+ end
21
+ end
22
+ end
@@ -5,30 +5,35 @@ require_relative 'text'
5
5
 
6
6
  module Raindown
7
7
  module TUI
8
- # The search, drawn over the screen's lines in a box below the title: what's typed, then the places that match it,
9
- # each with where it is, and the selected one highlighted:
10
- # ┌─ Teleport ───────────────────────────────┐
8
+ # A search in a box, like Teleport's, drawn over the screen's lines below the title: what's typed, then the places
9
+ # that match it, each with where it is, and the selected one highlighted:
10
+ # ┌──────────────── Teleport ────────────────┐
11
11
  # │ / stp▏ │
12
12
  # │ ## Setup guides/setup.md:3 │
13
13
  # │ ## Steps notes.md:12 │
14
14
  # └──────────────────────────────────────────┘
15
- # Each place is clicked to open it.
16
- class SearchOverlay
15
+ # Each place is clicked to open it. Another search, like Blueprints' filter, is drawn the same, under its own title,
16
+ # from its query, its matches, each with its text and where it is, from the first it lists, and its selected match,
17
+ # clicked in its pane.
18
+ class SearchBox
17
19
  include Text
18
20
 
19
21
  # The widest the box is, with its border.
20
22
  MAX_WIDTH = 72
21
23
 
22
- def initialize(navigator, width:)
23
- @navigator = navigator
24
+ def initialize(search, width:, title: 'Teleport', pane: :teleport, top: 1)
25
+ @search = search
26
+ @title = title
27
+ @pane = pane
28
+ @top = top
24
29
  @box_width = [width - 4, MAX_WIDTH].min
25
30
  @column = (width - @box_width) / 2
26
31
  end
27
32
 
28
- # The lines with the box drawn over them, from the line below the title.
33
+ # The lines with the box drawn over them, from its top line, below the title.
29
34
  def apply(lines)
30
35
  box.each_with_index do |segments, offset|
31
- row = offset + 1
36
+ row = offset + @top
32
37
  lines[row] = overlay_segments(lines[row], segments, column: @column) if lines[row]
33
38
  end
34
39
  lines
@@ -40,22 +45,23 @@ module Raindown
40
45
  def inner = @box_width - 4
41
46
 
42
47
  def box
43
- [edge('┌─ Teleport ', '┐'), boxed(segment("/ #{@navigator.query}▏", :menu, width: inner)), *results, edge('└', '┘')]
48
+ [title_edge, boxed(segment("/ #{@search.query}▏", :menu, width: inner)), *results, edge('└', '┘')]
44
49
  end
45
50
 
46
51
  def results
47
- matches = @navigator.matches
52
+ matches = @search.matches
48
53
  return [boxed(segment('No matches', :info, width: inner))] if matches.empty?
49
54
 
50
- matches.each_with_index.map { |place, index| place_line(place, index) }
55
+ first = @search.first_match
56
+ matches.each_with_index.map { |place, index| place_line(place, first + index) }
51
57
  end
52
58
 
53
59
  # The place's text, then where it is, on the right, both highlighted when it's selected.
54
60
  def place_line(place, index)
55
61
  text_style = :menu
56
62
  where_style = :body
57
- text_style = where_style = :selected if @navigator.match == index
58
- event = Click.new(pane: :search, index:, action: :enter)
63
+ text_style = where_style = :selected if @search.match == index
64
+ event = Click.new(pane: @pane, index:, action: :enter)
59
65
  where = " #{place.where}"
60
66
  where_width = [display_width(where), inner / 2].min
61
67
 
@@ -65,6 +71,13 @@ module Raindown
65
71
 
66
72
  def boxed(*segments) = [segment('│ ', :border, width: 2), *segments, segment(' │', :border, width: 2)]
67
73
 
74
+ # The box's top, with its title in the middle.
75
+ def title_edge
76
+ title = " #{@title} "
77
+ left = (@box_width - 2 - display_width(title)) / 2
78
+ edge("┌#{'─' * left}#{title}", '┐')
79
+ end
80
+
68
81
  def edge(left, right)
69
82
  [segment("#{left}#{'─' * (@box_width - display_width(left) - 1)}#{right}", :border, width: @box_width)]
70
83
  end
@@ -1,9 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'curses'
4
- require 'unicode/display_width'
5
4
 
6
5
  require_relative '../events'
6
+ require_relative '../text'
7
+ require_relative 'quiet'
7
8
 
8
9
  module Raindown
9
10
  module TUI
@@ -12,12 +13,14 @@ module Raindown
12
13
  KEYS = {
13
14
  Curses::KEY_UP => :up, 'k' => :up, Curses::KEY_DOWN => :down, 'j' => :down,
14
15
  Curses::KEY_LEFT => :left, 'h' => :left, Curses::KEY_RIGHT => :right, 'l' => :right,
16
+ Curses::KEY_SLEFT => :scroll_left, Curses::KEY_SRIGHT => :scroll_right, Curses::KEY_SR => :scroll_up,
17
+ Curses::KEY_SF => :scroll_down,
15
18
  "\t" => :tab, Curses::KEY_BTAB => :tab, "\r" => :enter, "\n" => :enter, Curses::KEY_ENTER => :enter,
16
19
  ' ' => :toggle, 'x' => :execute, 'e' => :edit, 'w' => :write_back, 'r' => :reload, 'd' => :dashboard,
17
- 'p' => :projects, 'f' => :feelings, "\x14" => :celebrate, '/' => :search,
18
- '1' => :bin1, '2' => :bin2, '3' => :bin3, '4' => :bin4, '5' => :bin5, '6' => :bin6, "\e" => :escape, 'q' => :quit
20
+ 'p' => :projects, 'f' => :feelings, 'b' => :blueprints, "\x14" => :celebrate, '/' => :search,
21
+ '1' => :bin1, '2' => :bin2, '3' => :bin3, '4' => :bin4, '5' => :bin5, "\e" => :escape, 'q' => :quit
19
22
  }.freeze
20
- # While typing, like in the search, printable characters are typed, and only these keys do anything else.
23
+ # While typing, like in Teleport, printable characters are typed, and only these keys do anything else.
21
24
  TYPING_KEYS = {
22
25
  Curses::KEY_UP => :up, Curses::KEY_DOWN => :down, "\r" => :enter, "\n" => :enter, Curses::KEY_ENTER => :enter,
23
26
  "\e" => :escape, Curses::KEY_BACKSPACE => :backspace, "\x7F" => :backspace, "\b" => :backspace
@@ -25,6 +28,24 @@ module Raindown
25
28
 
26
29
  module_function
27
30
 
31
+ # The next event, or :tick when no key is pressed within the timeout, in milliseconds, or nil for keys without one,
32
+ # like a resize, which clears the screen to draw it again.
33
+ def read(timeout, zones:, typing:)
34
+ Curses.stdscr.timeout = timeout
35
+ key = Curses.stdscr.getch
36
+ return :tick if key.nil?
37
+ return mouse(zones) if key == Curses::KEY_MOUSE
38
+
39
+ Curses.stdscr.clear if key == Curses::KEY_RESIZE
40
+ key_event(key, typing:)
41
+ end
42
+
43
+ # The curses gem warns the first time it makes a MouseEvent, that it's "undefining the allocator" of the class.
44
+ def mouse(zones)
45
+ event = Quiet.quietly { Curses.getmouse }
46
+ mouse_event(event, zones) if event
47
+ end
48
+
28
49
  # A key from getch, which is a string for a printable character and otherwise a number.
29
50
  # Letters are the same with Shift, as the buttons show them as capitals, unless they're being typed.
30
51
  def key_event(key, typing: false)
@@ -43,6 +64,10 @@ module Raindown
43
64
  # like macOS's, has no button for scrolling down, it can't be mistaken for a click.
44
65
  CLICKS = Curses::BUTTON1_PRESSED | Curses::BUTTON1_CLICKED
45
66
 
67
+ # Letting go of the button, which older ncurses, like macOS's, also reports for each move while it's held, as it
68
+ # doesn't know the moves terminals send then.
69
+ RELEASE = Curses::BUTTON1_RELEASED
70
+
46
71
  # Moving the mouse, which terminals report once they're asked to, with any motion tracking.
47
72
  MOTION = Curses::REPORT_MOUSE_POSITION
48
73
  ANY_MOTION = "\e[?1003h"
@@ -51,7 +76,7 @@ module Raindown
51
76
  # The terminal sends the app its mouse events, clicks and moves, so scrolling doesn't scroll the terminal either.
52
77
  # ncurses only asks some terminals to report every move, so it's asked directly too, until untrack_mouse.
53
78
  def track_mouse
54
- Curses.mousemask(CLICKS | MOTION)
79
+ Curses.mousemask(CLICKS | RELEASE | MOTION)
55
80
  Curses.mouseinterval(0)
56
81
  $stdout.write(ANY_MOTION)
57
82
  $stdout.flush
@@ -62,20 +87,46 @@ module Raindown
62
87
  $stdout.flush
63
88
  end
64
89
 
65
- # A click sends the event of what's under it, and moving the mouse a Hover of it.
90
+ # A click sends the event of what's under it, and moving the mouse a Hover of it, or a Drag by how far it's moved
91
+ # since the last event while the button's held. Terminals and ncurses versions report that in their own ways: a
92
+ # move the button's held for, a move after a press, until the release, or with older ncurses and some terminals,
93
+ # a release somewhere else than the last event, for each move. A release where the mouse already is, which ends a
94
+ # click or a drag, does nothing.
66
95
  def mouse_event(event, zones)
96
+ from = @last
97
+ @last = [event.x, event.y]
98
+ held = hold(event.bstate)
99
+ return drag(from, event) if from && dragged?(event, held:, moved: from != @last)
100
+ return if event.bstate.anybits?(RELEASE)
101
+
67
102
  target = click(zones, column: event.x, row: event.y)
68
103
  return target if event.bstate.anybits?(CLICKS)
69
104
 
70
105
  Hover.new(target:) if event.bstate.anybits?(MOTION)
71
106
  end
72
107
 
108
+ # Whether the button was held before this event, which holds it from a press until a release.
109
+ def hold(bstate)
110
+ held = @held
111
+ @held = bstate.anybits?(CLICKS) || (held && !bstate.anybits?(RELEASE))
112
+ held
113
+ end
114
+
115
+ def drag(from, event) = Drag.new(columns: event.x - from[0], rows: event.y - from[1])
116
+
117
+ def dragged?(event, held:, moved:)
118
+ bstate = event.bstate
119
+ return held || bstate.anybits?(Curses::BUTTON1_PRESSED) if bstate.anybits?(MOTION)
120
+
121
+ moved && bstate.anybits?(RELEASE)
122
+ end
123
+
73
124
  # Where each of a Layout's segments with an event is on the screen, as [row, columns, event].
74
125
  def zones(lines)
75
126
  lines.each_with_index.flat_map do |segments, row|
76
127
  column = 0
77
128
  segments.filter_map do |segment|
78
- width = Unicode::DisplayWidth.of(segment.text)
129
+ width = Text.display_width(segment.text)
79
130
  zone = [row, column...(column + width), segment.event] if segment.event
80
131
  column += width
81
132
  zone