raindown 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/lib/raindown/chart/chart.rb +12 -1
  3. data/lib/raindown/cli.rb +2 -2
  4. data/lib/raindown/document.rb +16 -9
  5. data/lib/raindown/endpoint/curl.rb +19 -0
  6. data/lib/raindown/node_blocks.rb +9 -6
  7. data/lib/raindown/nodes.rb +1 -0
  8. data/lib/raindown/projects.rb +8 -4
  9. data/lib/raindown/stat.rb +53 -0
  10. data/lib/raindown/tui/card.rb +56 -0
  11. data/lib/raindown/tui/endpoint.rb +16 -0
  12. data/lib/raindown/tui/endpoints.rb +40 -0
  13. data/lib/raindown/tui/events.rb +50 -0
  14. data/lib/raindown/tui/feelings/batch.rb +47 -0
  15. data/lib/raindown/tui/feelings/bins.rb +67 -0
  16. data/lib/raindown/tui/feelings/celebration.rb +25 -0
  17. data/lib/raindown/tui/feelings/code_box.rb +129 -0
  18. data/lib/raindown/tui/feelings/code_methods.rb +134 -0
  19. data/lib/raindown/tui/feelings/feelings_log.rb +133 -0
  20. data/lib/raindown/tui/feelings/feelings_renderer.rb +184 -0
  21. data/lib/raindown/tui/feelings/feelings_view.rb +161 -0
  22. data/lib/raindown/tui/feelings/fireworks.rb +75 -0
  23. data/lib/raindown/tui/feelings/rain.rb +66 -0
  24. data/lib/raindown/tui/feelings/scene.rb +37 -0
  25. data/lib/raindown/tui/feelings/waffle.rb +43 -0
  26. data/lib/raindown/tui/feelings/weekly_batch.rb +32 -0
  27. data/lib/raindown/tui/file_tree.rb +55 -12
  28. data/lib/raindown/tui/highlighter.rb +45 -0
  29. data/lib/raindown/tui/layout.rb +66 -77
  30. data/lib/raindown/tui/menu_overlay.rb +59 -0
  31. data/lib/raindown/tui/navigator/commands.rb +97 -0
  32. data/lib/raindown/tui/navigator/focus.rb +25 -0
  33. data/lib/raindown/tui/navigator/navigator.rb +160 -0
  34. data/lib/raindown/tui/navigator/project_menu.rb +61 -0
  35. data/lib/raindown/tui/navigator/search.rb +126 -0
  36. data/lib/raindown/tui/navigator/views.rb +52 -0
  37. data/lib/raindown/tui/renderers/dashboard_renderer.rb +102 -0
  38. data/lib/raindown/tui/renderers/endpoint_renderer.rb +81 -0
  39. data/lib/raindown/tui/renderers/list_renderer.rb +15 -0
  40. data/lib/raindown/tui/renderers/outline_renderer.rb +21 -0
  41. data/lib/raindown/tui/renderers/renderer.rb +18 -0
  42. data/lib/raindown/tui/row.rb +12 -3
  43. data/lib/raindown/tui/rows.rb +58 -0
  44. data/lib/raindown/tui/search_overlay.rb +73 -0
  45. data/lib/raindown/tui/terminal/input.rb +91 -0
  46. data/lib/raindown/tui/terminal/ncurses_shim.rb +46 -0
  47. data/lib/raindown/tui/terminal/quiet.rb +32 -0
  48. data/lib/raindown/tui/{screen.rb → terminal/screen.rb} +42 -24
  49. data/lib/raindown/tui/terminal/styles.rb +154 -0
  50. data/lib/raindown/tui/text.rb +110 -0
  51. data/lib/raindown/tui/title.rb +29 -0
  52. data/lib/raindown/tui/views/dashboard_view.rb +147 -0
  53. data/lib/raindown/tui/views/endpoint_view.rb +77 -0
  54. data/lib/raindown/tui/{pane.rb → views/list_view.rb} +11 -8
  55. data/lib/raindown/tui/{outline.rb → views/outline_view.rb} +48 -20
  56. data/lib/raindown/tui/workers/project.rb +122 -0
  57. data/lib/raindown/tui/workers/worker.rb +124 -0
  58. data/lib/raindown/tui/workers/workers.rb +53 -0
  59. data/lib/raindown/tui.rb +10 -5
  60. data/lib/version.rb +1 -1
  61. metadata +63 -9
  62. data/lib/raindown/tui/commands.rb +0 -70
  63. data/lib/raindown/tui/input.rb +0 -55
  64. data/lib/raindown/tui/navigator.rb +0 -123
  65. data/lib/raindown/tui/styles.rb +0 -36
@@ -0,0 +1,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
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../views/dashboard_view'
4
+ require_relative '../views/endpoint_view'
5
+ require_relative '../feelings/feelings_view'
6
+ require_relative '../views/outline_view'
7
+
8
+ module Raindown
9
+ module TUI
10
+ # Beside the files, a Navigator shows a view: the open file's Outline, the Dashboard of the project's charts,
11
+ # which is the first item in the files and "d" switches to, or an EndpointView of an endpoint listed below them.
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,
13
+ # what's opened it in the files. It loads when it's opened, and again after a change.
14
+ module Views
15
+ attr_reader :view, :dashboard, :feelings
16
+
17
+ # The path of the file the view shows, if any.
18
+ def path = view.path
19
+
20
+ # Shows the view right of the files, with the focus, and the files' cursor on the row that opened it.
21
+ def open_view(view)
22
+ @previous = @view unless view == @view
23
+ @view = view
24
+ @focus = :main
25
+ @nav.cursor = @nav.rows.index { it.target == view.target } || 0
26
+ view.load
27
+ @status = view.status(self)
28
+ nil
29
+ end
30
+
31
+ # Opens the file in the outline, on the heading or directive at the line.
32
+ def open_at(path, line)
33
+ path = File.expand_path(path)
34
+ @files.reveal(path)
35
+ open_view(OutlineView.new(path, workers: @workers))
36
+ view.cursor = view.rows.index { %i[heading directive].include?(it.kind) && it.target.line == line } || 0
37
+ nil
38
+ end
39
+
40
+ private
41
+
42
+ def open_feelings = open_view(feelings)
43
+
44
+ # Switches between the dashboard and the view before it.
45
+ def toggle_dashboard
46
+ return open_view(@previous) if view == dashboard && @previous
47
+
48
+ open_view(dashboard)
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'renderer'
4
+
5
+ module Raindown
6
+ module TUI
7
+ # The dashboard, right of the files: a card for each of the projects' charts, as wide as its chart,
8
+ # with as many in a row as fit. With more than one project, each project's charts are under its path.
9
+ # A card has its file and heading, and its directive, both cut to the chart's width, then the chart,
10
+ # in a border with rounded corners:
11
+ # ╭─────────────────────────────╮ ╭────────────────────────────────╮
12
+ # │ raindown.rd › Charts │ │ raindown.rd › Commits │
13
+ # │ chart: 'data/downloads.csv' │ │ chart: 'data/commits.csv' min… │
14
+ # │ Jun ██████▊ 640 │ │ c2f98fc -18 █│███▊ +96 │
15
+ # ╰─────────────────────────────╯ ╰────────────────────────────────╯
16
+ class DashboardRenderer < Renderer
17
+ BUTTONS = [['Tab', 'Pane', :tab], ['Enter', 'Open', :enter], ['e', 'Edit', :edit], ['r', 'Reload', :reload],
18
+ ['/', 'Search', :search], ['q', 'Quit', :quit]].freeze
19
+ PROJECTS_BUTTON = ['p', 'Projects', :projects].freeze
20
+
21
+ # Room for the project menu's button, when there's more than one project.
22
+ def buttons
23
+ return BUTTONS unless navigator.projects?
24
+
25
+ BUTTONS.dup.insert(3, PROJECTS_BUTTON)
26
+ end
27
+
28
+ def page = 'Dashboard'
29
+
30
+ def lines
31
+ cards = dashboard.cards
32
+ return blank(height, " No charts in #{File.basename(navigator.root)} yet, like <{ chart: '3 5 2 8' }>") if cards.empty?
33
+
34
+ rows = dashboard.rows(width)
35
+ @headings = headings(rows, cards)
36
+ grid(rows, cards)
37
+ end
38
+
39
+ private
40
+
41
+ def dashboard = navigator.dashboard
42
+
43
+ # Each project's path above its first row of cards, by the row's index, when there's more than one project.
44
+ def headings(rows, cards)
45
+ firsts = rows.each_index.chunk_while { |a, b| cards[rows[a].first].project == cards[rows[b].first].project }.map(&:first)
46
+ return {} if firsts.one?
47
+
48
+ firsts.to_h { [it, cards[rows[it].first].project] }
49
+ end
50
+
51
+ # The rows of cards from the first that's scrolled to, each card's lines side by side, with a line between rows.
52
+ def grid(rows, cards)
53
+ heights = rows.each_with_index.map { |row, index| row_height(row, cards) + (@headings.key?(index) ? 1 : 0) }
54
+ first = dashboard.scroll(heights, height)
55
+ lines = (first...rows.length).flat_map { |index| row_lines(rows[index], cards, heading: @headings[index]) }
56
+
57
+ (lines + blank(height)).first(height)
58
+ end
59
+
60
+ # The tallest card's lines, in its border, and one line below it.
61
+ def row_height(row, cards)
62
+ row.map { cards[it].box_height }.max + 1
63
+ end
64
+
65
+ def row_line(row, cards, line)
66
+ segments = [segment('', :blank, width: 1)]
67
+
68
+ row.each_with_index do |index, column|
69
+ segments << segment('', :blank, width: DashboardView::GAP) unless column.zero?
70
+ segments.concat(card_line(cards[index], index, line))
71
+ end
72
+
73
+ padded(segments)
74
+ end
75
+
76
+ # A card opens chart source when it's clicked, like Enter, but not the space below a card shorter than its row.
77
+ # The selected card's title is highlighted, like a selected row.
78
+ def card_line(card, index, line)
79
+ title_style = dashboard.cursor == index ? row_style(pane, nil, true) : :heading
80
+ event = Click.new(pane:, index:, action: :enter) if line < card.box_height
81
+ box_width = card.box_width(within: dashboard.card_widths(width))
82
+ card.format_line(line, box_width:, title_style:).map do |text, style, part_width|
83
+ segment(text, style, width: part_width, event:)
84
+ end
85
+ end
86
+
87
+ # A row of cards' lines, under its project's heading when it's the project's first row.
88
+ def row_lines(row, cards, heading:)
89
+ [*([heading_line(heading)] if heading), *Array.new(row_height(row, cards)) { |line| row_line(row, cards, line) }]
90
+ end
91
+
92
+ # A project's path from the home folder, above its charts.
93
+ def heading_line(project)
94
+ [segment(" #{from_home(project)}", :project)]
95
+ end
96
+
97
+ def blank(count, message = '')
98
+ [[segment(message, :info)], *Array.new([count - 1, 0].max) { [segment('', :blank)] }].first(count)
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../highlighter'
4
+ require_relative 'renderer'
5
+ require_relative 'outline_renderer'
6
+
7
+ module Raindown
8
+ module TUI
9
+ # An endpoint opened from the files, right of them: its URL in a bar with rounded corners, and a button to run it,
10
+ # then its response:
11
+ # ╭──────────────────────────────────────────╮
12
+ # │ GET https://httpbin.org/uuid [Run] │
13
+ # ╰──────────────────────────────────────────╯
14
+ #
15
+ # Response · json · fetched 2026-10-26
16
+ # {
17
+ # "uuid": "48024974-f1b1-431a-a0cf-5cac0099a1a5"
18
+ # }
19
+ class EndpointRenderer < Renderer
20
+ RUN = '[Run]'
21
+ RUN_WIDTH = RUN.length
22
+ BUTTONS = OutlineRenderer::BUTTONS.reject { it.last == :write_back }.freeze
23
+
24
+ def buttons = BUTTONS
25
+ def page = navigator.view.endpoint.text
26
+
27
+ def lines
28
+ view = navigator.view
29
+ top = [*url_bar(view.endpoint), [segment('', :blank, width:)], about(view)]
30
+
31
+ (top + response(view, height - top.length)).first(height)
32
+ end
33
+
34
+ private
35
+
36
+ # A click on the response moves the focus to it, to scroll it.
37
+ def focus = Click.new(pane:, index: 0, action: :focus)
38
+
39
+ # The response's lines that fit, highlighted in the language of its code block, like json.
40
+ def response(view, lines)
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
45
+ end
46
+
47
+ # The bar is a column in from each side of the pane, with the URL and the button inside its border.
48
+ def url_bar(endpoint)
49
+ inner = [width - 6, 0].max
50
+ [edge('╭', '╮', inner), padded([margin, segment('│ ', :border, width: 2), *request(endpoint, inner)]),
51
+ edge('╰', '╯', inner)]
52
+ end
53
+
54
+ # The method in its colour, then the URL, cut to what the method and button leave, then the button,
55
+ # and the border's side.
56
+ def request(endpoint, inner)
57
+ method_width = display_width(endpoint.method)
58
+ url_width = [inner - method_width - RUN_WIDTH - 1, 0].max
59
+
60
+ [segment(endpoint.method, endpoint.style, width: method_width), segment(" #{endpoint.url} ", :url, width: url_width + 1),
61
+ run_button, segment(' │', :border, width: 2)]
62
+ end
63
+
64
+ def run_button = segment(RUN, :button, width: RUN_WIDTH, event: Click.new(pane:, index: 0, action: :execute))
65
+
66
+ def edge(left, right, inner)
67
+ padded([margin, segment("#{left}#{'─' * (inner + 2)}#{right}", :border, width: inner + 4)])
68
+ end
69
+
70
+ def margin = segment('', :blank, width: 1)
71
+
72
+ def about(view)
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:)]
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'renderer'
4
+
5
+ module Raindown
6
+ module TUI
7
+ # A ListView's rows in its pane, like the files in the nav or a file's outline, each with its buttons.
8
+ class ListRenderer < Renderer
9
+ def lines
10
+ list = navigator.content(pane).visible(height)
11
+ Array.new(height) { |index| cell(pane, *list, index, width) }
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'list_renderer'
4
+
5
+ module Raindown
6
+ module TUI
7
+ # The open file's outline, right of the files: a row on each line, with its buttons.
8
+ class OutlineRenderer < ListRenderer
9
+ BUTTONS = [['Tab', 'Pane', :tab], ['Enter', 'Open', :enter], ['x', 'Run', :execute], ['e', 'Edit', :edit],
10
+ ['w', 'Write back', :write_back], ['r', 'Reload', :reload], ['/', 'Search', :search], ['q', 'Quit', :quit]].freeze
11
+
12
+ def buttons = BUTTONS
13
+
14
+ def page
15
+ return 'No Markdown files' unless navigator.path
16
+
17
+ navigator.relative(navigator.path)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../events'
4
+ require_relative '../rows'
5
+ require_relative '../text'
6
+
7
+ module Raindown
8
+ module TUI
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
+ Renderer = Struct.new(:navigator, :pane, :width, :height) do
11
+ include Rows
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)]
16
+ end
17
+ end
18
+ end
@@ -3,14 +3,23 @@
3
3
  module Raindown
4
4
  module TUI
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
- # Buttons are [label, event] pairs, like ['[Run]', :execute].
7
- Row = Data.define(:text, :depth, :kind, :target, :buttons) do
8
- def initialize(buttons: [], **) = super
6
+ # Buttons are [label, event] pairs, like ['[Run]', :execute], and a tag is an Endpoint, whose method is before its text.
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
+ # 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
12
+
13
+ # A separator is space between a pane's sections, which the cursor skips and clicks don't reach.
14
+ def selectable? = kind != :separator
9
15
 
10
16
  # The line the editor opens at for the row.
11
17
  def line
12
18
  target.is_a?(Integer) ? target : target.line
13
19
  end
14
20
  end
21
+
22
+ # Space between a pane's sections, like the Dashboard and the files.
23
+ Row::SEPARATOR = Row.new(text: '', depth: 0, kind: :separator, target: nil)
15
24
  end
16
25
  end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raindown
4
+ module TUI
5
+ # A ListRenderer's rows, like the files and the outline, each a line of the pane's width: a row's text, after its tag,
6
+ # like an endpoint's method, then its buttons.
7
+ module Rows
8
+ private
9
+
10
+ # A row as its text, then its buttons, or blank below its last row.
11
+ def cell(pane, rows, offset, index, cell_width)
12
+ row = rows[index]
13
+ return [segment('', :blank, width: cell_width)] unless row&.selectable?
14
+
15
+ index += offset
16
+ selected = navigator.content(pane).cursor == index
17
+ buttons = row_buttons(row, pane:, index:, selected:, cell_width:)
18
+ text_width = cell_width - display_width(buttons.map(&:text).join)
19
+ parts = row_parts(row, row_style(pane, row, selected), selected)
20
+
21
+ [*spread_segments(parts, text_width, event: Click.new(pane:, index:, action: :enter)), *buttons]
22
+ end
23
+
24
+ # A line's segments, then blank to the edge.
25
+ def padded(segments) = [*segments, segment('', :blank, width: [width - segments.sum { display_width(it.text) }, 0].max)]
26
+
27
+ # [text, style] parts side by side in the width, each a segment.
28
+ def spread_segments(parts, width, event: nil)
29
+ parts.zip(spread(parts.map(&:first), width)).map { |(text, style), part| segment(text, style, width: part, event:) }
30
+ end
31
+
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
+ def row_parts(row, style, selected)
34
+ lead = " #{Text::INDENT * row.depth}"
35
+ return [[lead, style], *row.code] if row.code && !selected
36
+ return [["#{lead}#{row.text}", style]] unless row.tag
37
+
38
+ [["#{lead}#{row.tag.method} ", selected ? style : row.tag.style], [row.text, style]]
39
+ end
40
+
41
+ # The row's buttons, right of its text, unless they'd take more than half the pane.
42
+ def row_buttons(row, pane:, index:, selected:, cell_width:)
43
+ buttons = row.buttons.map do |label, event|
44
+ style = selected ? :"#{row_style(pane, row, true)}_button" : :button
45
+ segment(" #{label}", style, width: display_width(" #{label}"), event: Click.new(pane:, index:, action: event))
46
+ end
47
+ display_width(buttons.map(&:text).join) > cell_width / 2 ? [] : buttons
48
+ end
49
+
50
+ # The cursor's row is highlighted in the pane with the focus, and dimmer in the other, like the file a chart opened.
51
+ def row_style(pane, row, selected)
52
+ return row.kind unless selected
53
+
54
+ navigator.focus == pane ? :selected : :cursor
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'events'
4
+ require_relative 'text'
5
+
6
+ module Raindown
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 ───────────────────────────────┐
11
+ # │ / stp▏ │
12
+ # │ ## Setup guides/setup.md:3 │
13
+ # │ ## Steps notes.md:12 │
14
+ # └──────────────────────────────────────────┘
15
+ # Each place is clicked to open it.
16
+ class SearchOverlay
17
+ include Text
18
+
19
+ # The widest the box is, with its border.
20
+ MAX_WIDTH = 72
21
+
22
+ def initialize(navigator, width:)
23
+ @navigator = navigator
24
+ @box_width = [width - 4, MAX_WIDTH].min
25
+ @column = (width - @box_width) / 2
26
+ end
27
+
28
+ # The lines with the box drawn over them, from the line below the title.
29
+ def apply(lines)
30
+ box.each_with_index do |segments, offset|
31
+ row = offset + 1
32
+ lines[row] = overlay_segments(lines[row], segments, column: @column) if lines[row]
33
+ end
34
+ lines
35
+ end
36
+
37
+ private
38
+
39
+ # The columns inside the border, "│ " and " │".
40
+ def inner = @box_width - 4
41
+
42
+ def box
43
+ [edge('┌─ Teleport ', '┐'), boxed(segment("/ #{@navigator.query}▏", :menu, width: inner)), *results, edge('└', '┘')]
44
+ end
45
+
46
+ def results
47
+ matches = @navigator.matches
48
+ return [boxed(segment('No matches', :info, width: inner))] if matches.empty?
49
+
50
+ matches.each_with_index.map { |place, index| place_line(place, index) }
51
+ end
52
+
53
+ # The place's text, then where it is, on the right, both highlighted when it's selected.
54
+ def place_line(place, index)
55
+ text_style = :menu
56
+ where_style = :body
57
+ text_style = where_style = :selected if @navigator.match == index
58
+ event = Click.new(pane: :search, index:, action: :enter)
59
+ where = " #{place.where}"
60
+ where_width = [display_width(where), inner / 2].min
61
+
62
+ boxed(segment(place.text, text_style, width: inner - where_width, event:),
63
+ segment(where, where_style, width: where_width, event:))
64
+ end
65
+
66
+ def boxed(*segments) = [segment('│ ', :border, width: 2), *segments, segment(' │', :border, width: 2)]
67
+
68
+ def edge(left, right)
69
+ [segment("#{left}#{'─' * (@box_width - display_width(left) - 1)}#{right}", :border, width: @box_width)]
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'curses'
4
+ require 'unicode/display_width'
5
+
6
+ require_relative '../events'
7
+
8
+ module Raindown
9
+ module TUI
10
+ # Turns curses' keys and clicks into a Navigator's events.
11
+ module Input
12
+ KEYS = {
13
+ Curses::KEY_UP => :up, 'k' => :up, Curses::KEY_DOWN => :down, 'j' => :down,
14
+ Curses::KEY_LEFT => :left, 'h' => :left, Curses::KEY_RIGHT => :right, 'l' => :right,
15
+ "\t" => :tab, Curses::KEY_BTAB => :tab, "\r" => :enter, "\n" => :enter, Curses::KEY_ENTER => :enter,
16
+ ' ' => :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
19
+ }.freeze
20
+ # While typing, like in the search, printable characters are typed, and only these keys do anything else.
21
+ TYPING_KEYS = {
22
+ Curses::KEY_UP => :up, Curses::KEY_DOWN => :down, "\r" => :enter, "\n" => :enter, Curses::KEY_ENTER => :enter,
23
+ "\e" => :escape, Curses::KEY_BACKSPACE => :backspace, "\x7F" => :backspace, "\b" => :backspace
24
+ }.freeze
25
+
26
+ module_function
27
+
28
+ # A key from getch, which is a string for a printable character and otherwise a number.
29
+ # Letters are the same with Shift, as the buttons show them as capitals, unless they're being typed.
30
+ def key_event(key, typing: false)
31
+ return typing_event(key) if typing
32
+
33
+ key = key.chr if key.is_a?(Integer) && key < 256
34
+ KEYS[key.is_a?(String) ? key.downcase : key]
35
+ end
36
+
37
+ def typing_event(key)
38
+ key = key.chr if key.is_a?(Integer) && key < 256
39
+ TYPING_KEYS.fetch(key) { Type.new(text: key) if key.is_a?(String) && key.match?(/\A[[:print:]]\z/) }
40
+ end
41
+
42
+ # The mouse buttons that click, which are the left button's. Scrolling does nothing, and since older ncurses,
43
+ # like macOS's, has no button for scrolling down, it can't be mistaken for a click.
44
+ CLICKS = Curses::BUTTON1_PRESSED | Curses::BUTTON1_CLICKED
45
+
46
+ # Moving the mouse, which terminals report once they're asked to, with any motion tracking.
47
+ MOTION = Curses::REPORT_MOUSE_POSITION
48
+ ANY_MOTION = "\e[?1003h"
49
+ NO_MOTION = "\e[?1003l"
50
+
51
+ # The terminal sends the app its mouse events, clicks and moves, so scrolling doesn't scroll the terminal either.
52
+ # ncurses only asks some terminals to report every move, so it's asked directly too, until untrack_mouse.
53
+ def track_mouse
54
+ Curses.mousemask(CLICKS | MOTION)
55
+ Curses.mouseinterval(0)
56
+ $stdout.write(ANY_MOTION)
57
+ $stdout.flush
58
+ end
59
+
60
+ def untrack_mouse
61
+ $stdout.write(NO_MOTION)
62
+ $stdout.flush
63
+ end
64
+
65
+ # A click sends the event of what's under it, and moving the mouse a Hover of it.
66
+ def mouse_event(event, zones)
67
+ target = click(zones, column: event.x, row: event.y)
68
+ return target if event.bstate.anybits?(CLICKS)
69
+
70
+ Hover.new(target:) if event.bstate.anybits?(MOTION)
71
+ end
72
+
73
+ # Where each of a Layout's segments with an event is on the screen, as [row, columns, event].
74
+ def zones(lines)
75
+ lines.each_with_index.flat_map do |segments, row|
76
+ column = 0
77
+ segments.filter_map do |segment|
78
+ width = Unicode::DisplayWidth.of(segment.text)
79
+ zone = [row, column...(column + width), segment.event] if segment.event
80
+ column += width
81
+ zone
82
+ end
83
+ end
84
+ end
85
+
86
+ def click(zones, column:, row:)
87
+ zones.find { |zone_row, columns, _event| zone_row == row && columns.cover?(column) }&.last
88
+ end
89
+ end
90
+ end
91
+ end