raindown 0.5.1 → 0.6.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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/lib/raindown/cli.rb +2 -2
  3. data/lib/raindown/document.rb +15 -4
  4. data/lib/raindown/endpoint/curl.rb +15 -0
  5. data/lib/raindown/projects.rb +8 -4
  6. data/lib/raindown/tui/card.rb +56 -0
  7. data/lib/raindown/tui/endpoint.rb +16 -0
  8. data/lib/raindown/tui/endpoints.rb +40 -0
  9. data/lib/raindown/tui/events.rb +35 -0
  10. data/lib/raindown/tui/file_tree.rb +53 -11
  11. data/lib/raindown/tui/layout.rb +60 -78
  12. data/lib/raindown/tui/menu_overlay.rb +59 -0
  13. data/lib/raindown/tui/navigator/commands.rb +84 -0
  14. data/lib/raindown/tui/navigator/focus.rb +25 -0
  15. data/lib/raindown/tui/navigator/navigator.rb +127 -0
  16. data/lib/raindown/tui/navigator/project_menu.rb +61 -0
  17. data/lib/raindown/tui/navigator/views.rb +49 -0
  18. data/lib/raindown/tui/renderers/dashboard_renderer.rb +102 -0
  19. data/lib/raindown/tui/renderers/endpoint_renderer.rb +77 -0
  20. data/lib/raindown/tui/renderers/list_renderer.rb +15 -0
  21. data/lib/raindown/tui/renderers/outline_renderer.rb +21 -0
  22. data/lib/raindown/tui/renderers/renderer.rb +15 -0
  23. data/lib/raindown/tui/row.rb +10 -3
  24. data/lib/raindown/tui/rows.rb +57 -0
  25. data/lib/raindown/tui/{input.rb → terminal/input.rb} +10 -11
  26. data/lib/raindown/tui/terminal/ncurses_shim.rb +46 -0
  27. data/lib/raindown/tui/terminal/quiet.rb +32 -0
  28. data/lib/raindown/tui/{screen.rb → terminal/screen.rb} +32 -17
  29. data/lib/raindown/tui/terminal/styles.rb +96 -0
  30. data/lib/raindown/tui/text.rb +83 -0
  31. data/lib/raindown/tui/title.rb +29 -0
  32. data/lib/raindown/tui/views/dashboard_view.rb +145 -0
  33. data/lib/raindown/tui/views/endpoint_view.rb +75 -0
  34. data/lib/raindown/tui/{pane.rb → views/list_view.rb} +9 -8
  35. data/lib/raindown/tui/{outline.rb → views/outline_view.rb} +39 -20
  36. data/lib/raindown/tui/workers/project.rb +122 -0
  37. data/lib/raindown/tui/workers/worker.rb +124 -0
  38. data/lib/raindown/tui/workers/workers.rb +53 -0
  39. data/lib/raindown/tui.rb +8 -5
  40. data/lib/version.rb +1 -1
  41. metadata +32 -9
  42. data/lib/raindown/tui/commands.rb +0 -70
  43. data/lib/raindown/tui/navigator.rb +0 -123
  44. data/lib/raindown/tui/styles.rb +0 -36
@@ -0,0 +1,84 @@
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
+ # Each sets the status, so the Screen shows how it went, and returns what the Screen should do next, if anything.
9
+ module Commands
10
+ def run(file, line, name) = Wait.new(message: "Running #{name}...", task: -> { run_endpoint(file, line) })
11
+
12
+ # Opens the editor at the file's line, and writes the file back after it closes.
13
+ def edit_at(file, line)
14
+ @edited = file
15
+ Run.new(command: Actions.edit_command(file, line:))
16
+ end
17
+
18
+ private
19
+
20
+ # Runs the endpoint selected in the outline, or listed below the files. One opened beside them runs itself.
21
+ def execute
22
+ target = selected&.target
23
+ return run(target.path, target.line, target.text) if target.is_a?(Endpoint)
24
+ return error('Select an endpoint to run') unless target.is_a?(Document::Directive) && target.endpoint?
25
+
26
+ run(path, target.line, "the endpoint on line #{target.line}")
27
+ end
28
+
29
+ def run_endpoint(file, line)
30
+ changed = @workers.for(file).call(:execute, file, line)
31
+ view.load
32
+ info(changed ? "Ran the endpoint on line #{line}" : 'The response is the same')
33
+ rescue StandardError => e
34
+ error(e.message)
35
+ end
36
+
37
+ # In the nav pane, the selected file opens at its top, and in the outline, the open file at the selected line.
38
+ def edit
39
+ row = selected
40
+ return error('Select a file to edit') if @focus == :nav && row&.kind != :file
41
+
42
+ return edit_at(row.target, 1) if @focus == :nav
43
+ return error('Open a file to edit') unless path
44
+
45
+ edit_at(path, row ? row.line : 1)
46
+ end
47
+
48
+ def write_back(file = path)
49
+ return error('Open a file to write back') unless file
50
+
51
+ changed = @workers.for(file).call(:write_back, file)
52
+ view.load
53
+ info(changed ? "Updated #{relative(file)}" : "#{relative(file)} is up to date")
54
+ rescue StandardError => e
55
+ error(e.message)
56
+ end
57
+
58
+ # The workers start again too, loading their projects' nodes again, like when one of them has changed.
59
+ def reload
60
+ @workers.restart
61
+ @files.load
62
+ @endpoints.load
63
+ view.load
64
+ info('Reloaded')
65
+ end
66
+
67
+ # Reads the file again when it's changed, like when it's saved in an editor or a watcher writes back to it.
68
+ def refresh
69
+ view.load if view.changed?
70
+ nil
71
+ end
72
+
73
+ def info(message)
74
+ @status = [:info, message]
75
+ nil
76
+ end
77
+
78
+ def error(message)
79
+ @status = [:error, message]
80
+ nil
81
+ end
82
+ end
83
+ end
84
+ 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,127 @@
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 'views'
12
+ require_relative '../workers/workers'
13
+
14
+ module Raindown
15
+ module TUI
16
+ # What the terminal UI shows and does, in plain Ruby, with no terminal: a FileTree of the project's Markdown files,
17
+ # beside the open file's Outline, or the Dashboard of its charts. The Screen draws it and sends it events, keys like
18
+ # :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.
19
+ class Navigator
20
+ include Commands
21
+ include Views
22
+ include ProjectMenu
23
+ include Focus
24
+
25
+ # Keys of the events that move the cursor or the focus, and those that act on what's selected.
26
+ MOVES = { up: -1, down: 1 }.freeze
27
+ PANES = { tab: nil, left: :nav, right: :main }.freeze
28
+ ACTIONS = { execute: :execute, edit: :edit, write_back: :write_back, reload: :reload, tick: :refresh }.freeze
29
+
30
+ attr_reader :focus, :status
31
+
32
+ def initialize(root:, path: nil, workers: Workers.new(root:))
33
+ @workers = workers
34
+ @files = FileTree.new(root:, workers:)
35
+ @endpoints = Endpoints.new(workers:)
36
+ @dashboard = DashboardView.new(root:, workers:)
37
+ @nav = ListView.new { nav_rows }
38
+ first = Projects.files(root, nested: true).first
39
+ # The dashboard is the first page, unless a file is asked for, and "d" goes from it to the first file.
40
+ @view = OutlineView.new(first, workers:) if first
41
+ path ? open_at(path, nil) : open_view(dashboard)
42
+ end
43
+
44
+ # An event nothing has, like a mouse button's release, does nothing.
45
+ def next_step(event)
46
+ case event
47
+ when nil then nil
48
+ when Click then click(event)
49
+ else route(event)
50
+ end
51
+ end
52
+
53
+ # After the editor closes, the file is written back, like a save while it's watched, and read again.
54
+ def ran
55
+ write_back(@edited)
56
+ @endpoints.load
57
+ end
58
+
59
+ def root = @files.root
60
+ def relative(path) = @files.relative(path)
61
+
62
+ # The folder the files are shown from: the chosen project's, or the root's.
63
+ def base = @files.base
64
+
65
+ # What's in a pane: the nav's ListView on the left, or the view on the right, which keeps its own cursor.
66
+ def content(pane) = pane == :nav ? @nav : view
67
+
68
+ private
69
+
70
+ def route(name)
71
+ return :quit if name == :quit
72
+ return menu_event(name) if menu
73
+ return toggle_menu if name == :projects
74
+ return toggle_dashboard if name == :dashboard
75
+ return view.next_step(name)&.take(self) if @focus == :main && view.next_step?(name)
76
+
77
+ browser_event(name)
78
+ end
79
+
80
+ # A click moves the cursor where it's clicked, and does the click's action there, like a key, unless that would
81
+ # leave the terminal UI, like for the editor, so a stray click only moves the cursor.
82
+ # While the project menu's open, only its projects are clicked.
83
+ def click(click)
84
+ return if menu && click.pane != :menu
85
+
86
+ point(click)
87
+ effect = route(click.action)
88
+ effect unless effect.is_a?(Run)
89
+ end
90
+
91
+ def point(click)
92
+ return @menu = click.index if click.pane == :menu
93
+
94
+ content(click.pane).cursor = click.index
95
+ @focus = click.pane
96
+ end
97
+
98
+ def browser_event(name)
99
+ return move(MOVES[name]) if MOVES.key?(name)
100
+ return switch(PANES[name] || other_pane) if PANES.key?(name)
101
+ return activate(name) if %i[enter toggle].include?(name)
102
+
103
+ send(ACTIONS[name]) if ACTIONS.key?(name)
104
+ end
105
+
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.
108
+ 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)
111
+ end
112
+
113
+ def selected
114
+ content(@focus).selected
115
+ end
116
+
117
+ # Enter opens what's selected, or folds it, and Space only folds.
118
+ def activate(name)
119
+ row = selected
120
+ return row.open.take(self) if name == :enter && row&.open
121
+
122
+ row&.fold&.call
123
+ nil
124
+ end
125
+ end
126
+ end
127
+ 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
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../views/dashboard_view'
4
+ require_relative '../views/endpoint_view'
5
+ require_relative '../views/outline_view'
6
+
7
+ module Raindown
8
+ module TUI
9
+ # Beside the files, a Navigator shows a view: the open file's Outline, the Dashboard of the project's charts,
10
+ # which is the first item in the files and "d" switches to, or an EndpointView of an endpoint listed below them.
11
+ # 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
+ # what's opened it in the files. It loads when it's opened, and again after a change.
13
+ module Views
14
+ attr_reader :view, :dashboard
15
+
16
+ # The path of the file the view shows, if any.
17
+ def path = view.path
18
+
19
+ # Shows the view right of the files, with the focus, and the files' cursor on the row that opened it.
20
+ def open_view(view)
21
+ @previous = @view unless view == @view
22
+ @view = view
23
+ @focus = :main
24
+ @nav.cursor = @nav.rows.index { it.target == view.target } || 0
25
+ view.load
26
+ @status = view.status(self)
27
+ nil
28
+ end
29
+
30
+ # Opens the file in the outline, on the directive at the line.
31
+ def open_at(path, line)
32
+ path = File.expand_path(path)
33
+ @files.reveal(path)
34
+ open_view(OutlineView.new(path, workers: @workers))
35
+ view.cursor = view.rows.index { it.kind == :directive && it.target.line == line } || 0
36
+ nil
37
+ end
38
+
39
+ private
40
+
41
+ # Switches between the dashboard and the view before it.
42
+ def toggle_dashboard
43
+ return open_view(@previous) if view == dashboard && @previous
44
+
45
+ open_view(dashboard)
46
+ end
47
+ end
48
+ end
49
+ 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
+ ['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
+ if navigator.projects? then BUTTONS.dup.insert(3, PROJECTS_BUTTON)
24
+ else BUTTONS
25
+ end
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,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'renderer'
4
+ require_relative 'outline_renderer'
5
+
6
+ module Raindown
7
+ module TUI
8
+ # An endpoint opened from the files, right of them: its URL in a bar with rounded corners, and a button to run it,
9
+ # then its response:
10
+ # ╭──────────────────────────────────────────╮
11
+ # │ GET https://httpbin.org/uuid [ Run ] │
12
+ # ╰──────────────────────────────────────────╯
13
+ #
14
+ # Response · json · fetched 2026-10-26
15
+ # {
16
+ # "uuid": "48024974-f1b1-431a-a0cf-5cac0099a1a5"
17
+ # }
18
+ class EndpointRenderer < Renderer
19
+ RUN = '[ Run ]'
20
+ RUN_WIDTH = RUN.length
21
+ BUTTONS = OutlineRenderer::BUTTONS.reject { it.last == :write_back }.freeze
22
+
23
+ def buttons = BUTTONS
24
+ def page = navigator.view.endpoint.text
25
+
26
+ def lines
27
+ view = navigator.view
28
+ top = [*url_bar(view.endpoint), [segment('', :blank, width:)], about(view)]
29
+
30
+ (top + response(view, height - top.length)).first(height)
31
+ end
32
+
33
+ private
34
+
35
+ # A click on the response moves the focus to it, to scroll it.
36
+ def focus = Click.new(pane:, index: 0, action: :focus)
37
+
38
+ 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
+ end
42
+
43
+ # The bar is a column in from each side of the pane, with the URL and the button inside its border.
44
+ def url_bar(endpoint)
45
+ inner = [width - 6, 0].max
46
+ [edge('╭', '╮', inner), padded([margin, segment('│ ', :border, width: 2), *request(endpoint, inner)]),
47
+ edge('╰', '╯', inner)]
48
+ end
49
+
50
+ # The method in its colour, then the URL, cut to what the method and button leave, then the button,
51
+ # and the border's side.
52
+ def request(endpoint, inner)
53
+ method_width = display_width(endpoint.method)
54
+ url_width = [inner - method_width - RUN_WIDTH - 1, 0].max
55
+
56
+ [segment(endpoint.method, endpoint.style, width: method_width), segment(" #{endpoint.url} ", :url, width: url_width + 1),
57
+ run_button, segment(' │', :border, width: 2)]
58
+ end
59
+
60
+ def run_button = segment(RUN, :run, width: RUN_WIDTH, event: Click.new(pane:, index: 0, action: :execute))
61
+
62
+ def edge(left, right, inner)
63
+ padded([margin, segment("#{left}#{'─' * (inner + 2)}#{right}", :border, width: inner + 4)])
64
+ end
65
+
66
+ def margin = segment('', :blank, width: 1)
67
+
68
+ 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:)]
74
+ end
75
+ end
76
+ end
77
+ 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], ['q', 'Quit', :quit]].freeze
11
+
12
+ def buttons = BUTTONS
13
+
14
+ def page
15
+ if navigator.path then navigator.relative(navigator.path)
16
+ else 'No Markdown files'
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,15 @@
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
+ end
14
+ end
15
+ end
@@ -3,14 +3,21 @@
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
+ Row = Data.define(:text, :depth, :kind, :target, :buttons, :tag, :open, :fold) do
9
+ def initialize(buttons: [], tag: nil, open: nil, fold: nil, **) = super
10
+
11
+ # A separator is space between a pane's sections, which the cursor skips and clicks don't reach.
12
+ def selectable? = kind != :separator
9
13
 
10
14
  # The line the editor opens at for the row.
11
15
  def line
12
16
  target.is_a?(Integer) ? target : target.line
13
17
  end
14
18
  end
19
+
20
+ # Space between a pane's sections, like the Dashboard and the files.
21
+ Row::SEPARATOR = Row.new(text: '', depth: 0, kind: :separator, target: nil)
15
22
  end
16
23
  end
@@ -0,0 +1,57 @@
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, unless the row's selected.
33
+ def row_parts(row, style, selected)
34
+ lead = " #{Text::INDENT * row.depth}"
35
+ return [["#{lead}#{row.text}", style]] unless row.tag
36
+
37
+ [["#{lead}#{row.tag.method} ", selected ? style : row.tag.style], [row.text, style]]
38
+ end
39
+
40
+ # The row's buttons, right of its text, unless they'd take more than half the pane.
41
+ def row_buttons(row, pane:, index:, selected:, cell_width:)
42
+ buttons = row.buttons.map do |label, event|
43
+ style = selected ? :"#{row_style(pane, row, true)}_button" : :button
44
+ segment(" #{label}", style, width: display_width(" #{label}"), event: Click.new(pane:, index:, action: event))
45
+ end
46
+ display_width(buttons.map(&:text).join) > cell_width / 2 ? [] : buttons
47
+ end
48
+
49
+ # The cursor's row is highlighted in the pane with the focus, and dimmer in the other, like the file a chart opened.
50
+ def row_style(pane, row, selected)
51
+ return row.kind unless selected
52
+
53
+ navigator.focus == pane ? :selected : :cursor
54
+ end
55
+ end
56
+ end
57
+ end