raindown 0.5.0 → 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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/lib/raindown/actions.rb +1 -1
  3. data/lib/raindown/bare_directives.rb +14 -3
  4. data/lib/raindown/cli.rb +2 -2
  5. data/lib/raindown/document.rb +18 -7
  6. data/lib/raindown/endpoint/curl.rb +15 -0
  7. data/lib/raindown/endpoint/endpoint_runner.rb +1 -1
  8. data/lib/raindown/node_injector.rb +1 -1
  9. data/lib/raindown/projects.rb +8 -4
  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 +35 -0
  14. data/lib/raindown/tui/file_tree.rb +53 -11
  15. data/lib/raindown/tui/layout.rb +60 -78
  16. data/lib/raindown/tui/menu_overlay.rb +59 -0
  17. data/lib/raindown/tui/navigator/commands.rb +84 -0
  18. data/lib/raindown/tui/navigator/focus.rb +25 -0
  19. data/lib/raindown/tui/navigator/navigator.rb +127 -0
  20. data/lib/raindown/tui/navigator/project_menu.rb +61 -0
  21. data/lib/raindown/tui/navigator/views.rb +49 -0
  22. data/lib/raindown/tui/renderers/dashboard_renderer.rb +102 -0
  23. data/lib/raindown/tui/renderers/endpoint_renderer.rb +77 -0
  24. data/lib/raindown/tui/renderers/list_renderer.rb +15 -0
  25. data/lib/raindown/tui/renderers/outline_renderer.rb +21 -0
  26. data/lib/raindown/tui/renderers/renderer.rb +15 -0
  27. data/lib/raindown/tui/row.rb +10 -3
  28. data/lib/raindown/tui/rows.rb +57 -0
  29. data/lib/raindown/tui/{input.rb → terminal/input.rb} +10 -11
  30. data/lib/raindown/tui/terminal/ncurses_shim.rb +46 -0
  31. data/lib/raindown/tui/terminal/quiet.rb +32 -0
  32. data/lib/raindown/tui/{screen.rb → terminal/screen.rb} +32 -17
  33. data/lib/raindown/tui/terminal/styles.rb +96 -0
  34. data/lib/raindown/tui/text.rb +83 -0
  35. data/lib/raindown/tui/title.rb +29 -0
  36. data/lib/raindown/tui/views/dashboard_view.rb +145 -0
  37. data/lib/raindown/tui/views/endpoint_view.rb +75 -0
  38. data/lib/raindown/tui/{pane.rb → views/list_view.rb} +9 -8
  39. data/lib/raindown/tui/{outline.rb → views/outline_view.rb} +39 -20
  40. data/lib/raindown/tui/workers/project.rb +122 -0
  41. data/lib/raindown/tui/workers/worker.rb +124 -0
  42. data/lib/raindown/tui/workers/workers.rb +53 -0
  43. data/lib/raindown/tui.rb +8 -5
  44. data/lib/raindown.rb +2 -0
  45. data/lib/version.rb +1 -1
  46. metadata +32 -9
  47. data/lib/raindown/tui/commands.rb +0 -70
  48. data/lib/raindown/tui/navigator.rb +0 -123
  49. data/lib/raindown/tui/styles.rb +0 -36
@@ -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
@@ -11,27 +11,26 @@ module Raindown
11
11
  Curses::KEY_UP => :up, 'k' => :up, Curses::KEY_DOWN => :down, 'j' => :down,
12
12
  Curses::KEY_LEFT => :left, 'h' => :left, Curses::KEY_RIGHT => :right, 'l' => :right,
13
13
  "\t" => :tab, Curses::KEY_BTAB => :tab, "\r" => :enter, "\n" => :enter, Curses::KEY_ENTER => :enter,
14
- ' ' => :toggle, 'x' => :execute, 'e' => :edit, 'w' => :write_back, 'r' => :reload, 'q' => :quit
14
+ ' ' => :toggle, 'x' => :execute, 'e' => :edit, 'w' => :write_back, 'r' => :reload, 'd' => :dashboard,
15
+ 'p' => :projects, "\e" => :escape, 'q' => :quit
15
16
  }.freeze
16
17
 
17
18
  module_function
18
19
 
19
20
  # A key from getch, which is a string for a printable character and otherwise a number.
21
+ # Letters are the same with Shift, as the buttons show them as capitals.
20
22
  def key_event(key)
21
23
  key = key.chr if key.is_a?(Integer) && key < 256
22
- KEYS[key]
24
+ KEYS[key.is_a?(String) ? key.downcase : key]
23
25
  end
24
26
 
25
- # A scroll of the wheel moves the cursor, and a click sends the event of what's under it.
26
- def mouse_event(event, zones)
27
- wheel.find { |button, _| event.bstate.anybits?(button) }&.last || click(zones, column: event.x, row: event.y)
28
- end
27
+ # The mouse buttons that click, which are the left button's. Scrolling does nothing, and since older ncurses,
28
+ # like macOS's, has no button for scrolling down, it can't be mistaken for a click.
29
+ CLICKS = Curses::BUTTON1_PRESSED | Curses::BUTTON1_CLICKED
29
30
 
30
- # Older ncurses has no fifth button, which is the wheel scrolling down.
31
- def wheel
32
- { Curses::BUTTON4_PRESSED => :up }.tap do |buttons|
33
- buttons[Curses::BUTTON5_PRESSED] = :down if Curses.const_defined?(:BUTTON5_PRESSED)
34
- end
31
+ # A click sends the event of what's under it.
32
+ def mouse_event(event, zones)
33
+ click(zones, column: event.x, row: event.y) if event.bstate.anybits?(CLICKS)
35
34
  end
36
35
 
37
36
  # Where each of a Layout's segments with an event is on the screen, as [row, columns, event].
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'curses'
4
+
5
+ module Raindown
6
+ module TUI
7
+ # ncurses before 6.1, like macOS's, repeats a character with the terminal's rep capability by writing one byte of it,
8
+ # so a run of █ in a bar chart draws as ?. Terminals with their own terminfo entries, like Ghostty's and kitty's,
9
+ # have rep, while the system's entries from those ncurses versions don't. So with old ncurses, a terminal whose
10
+ # entry isn't the system's starts curses as xterm-256color, which they draw the same, and TERM is put back after,
11
+ # for the commands run from the screen, like the editor.
12
+ module NcursesShim
13
+ FALLBACK = 'xterm-256color'
14
+ SYSTEM_TERMINFO = %w[/usr/share/terminfo /usr/lib/terminfo /lib/terminfo].freeze
15
+ FIXED_VERSION = [6, 1].freeze
16
+
17
+ module_function
18
+
19
+ def starting(version: Curses::VERSION)
20
+ term = ENV.fetch('TERM', nil)
21
+ ENV['TERM'] = FALLBACK if fallback?(term, version)
22
+ yield
23
+ ensure
24
+ ENV['TERM'] = term
25
+ end
26
+
27
+ def fallback?(term, version)
28
+ return false if term.nil? || term.empty? || term == FALLBACK
29
+
30
+ old?(version) && !system_entry?(term)
31
+ end
32
+
33
+ def old?(version)
34
+ numbers = version.to_s[/ncurses (\d+)\.(\d+)/, 0]&.scan(/\d+/)&.map(&:to_i)
35
+ numbers ? (numbers <=> FIXED_VERSION).negative? : false
36
+ end
37
+
38
+ # Entries are in a folder named by their first letter, or on macOS, its hex code, like 78/xterm-256color.
39
+ def system_entry?(term)
40
+ SYSTEM_TERMINFO.any? do |folder|
41
+ [term[0], term[0].ord.to_s(16)].any? { File.exist?(File.join(folder, it, term)) }
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'stringio'
4
+
5
+ module Raindown
6
+ module TUI
7
+ # Keeps text off the screen while it's up, since it would be written over it, and scroll it from the bottom line.
8
+ module Quiet
9
+ module_function
10
+
11
+ # What's written to stderr while the block runs, like a warning, is written once it's done.
12
+ def holding_stderr
13
+ stderr = $stderr
14
+ $stderr = StringIO.new
15
+ yield
16
+ ensure
17
+ held = $stderr.string
18
+ $stderr = stderr
19
+ $stderr.write(held) unless held.empty?
20
+ end
21
+
22
+ # Ruby's warnings are off while the block runs.
23
+ def quietly
24
+ verbose = $VERBOSE
25
+ $VERBOSE = nil
26
+ yield
27
+ ensure
28
+ $VERBOSE = verbose
29
+ end
30
+ end
31
+ end
32
+ end
@@ -3,7 +3,9 @@
3
3
  require 'curses'
4
4
 
5
5
  require_relative 'input'
6
- require_relative 'layout'
6
+ require_relative '../layout'
7
+ require_relative 'quiet'
8
+ require_relative 'ncurses_shim'
7
9
  require_relative 'styles'
8
10
 
9
11
  module Raindown
@@ -20,14 +22,20 @@ module Raindown
20
22
  end
21
23
 
22
24
  def run
25
+ Quiet.holding_stderr { loop_screen }
26
+ end
27
+
28
+ private
29
+
30
+ def loop_screen
23
31
  start
24
32
 
25
33
  loop do
26
34
  draw
27
- effect = @navigator.handle(read)
35
+ effect = @navigator.next_step(read)
28
36
  break if effect == :quit
29
37
 
30
- perform(*effect) if effect.is_a?(Array)
38
+ perform(effect)
31
39
  end
32
40
  rescue Interrupt
33
41
  nil
@@ -35,23 +43,18 @@ module Raindown
35
43
  stop
36
44
  end
37
45
 
38
- private
39
-
40
- def perform(name, *args)
41
- case name
42
- when :run
43
- suspend { system(*args.first) }
44
- @navigator.ran
45
- when :wait
46
- message, task = args
47
- show(message)
48
- task.call
46
+ # Runs a command, like the editor, then tells the Navigator, or shows a message while a slow task runs.
47
+ def perform(effect)
48
+ case effect
49
+ in Run(command:) then suspend { system(*command) }.then { @navigator.ran }
50
+ in Wait(message:, task:) then show(message).then { task.call }
51
+ else nil
49
52
  end
50
53
  end
51
54
 
52
55
  def start
53
56
  ENV['ESCDELAY'] ||= '25'
54
- Curses.init_screen
57
+ NcursesShim.starting { Curses.init_screen }
55
58
  @styles = Styles.start
56
59
  Curses.noecho
57
60
  Curses.cbreak
@@ -59,7 +62,8 @@ module Raindown
59
62
  Curses.curs_set(0)
60
63
  Curses.stdscr.keypad(true)
61
64
  Curses.stdscr.timeout = TICK
62
- Curses.mousemask(Curses::BUTTON1_PRESSED | Curses::BUTTON1_CLICKED | Input.wheel.keys.sum)
65
+ # The terminal sends the app its mouse events, so scrolling doesn't scroll the terminal either.
66
+ Curses.mousemask(Input::CLICKS)
63
67
  Curses.mouseinterval(0)
64
68
  end
65
69
 
@@ -80,9 +84,19 @@ module Raindown
80
84
  segments.each { |segment| paint(window, segment) }
81
85
  end
82
86
 
87
+ repaint_title(lines.first, window)
83
88
  window.refresh
84
89
  end
85
90
 
91
+ # The title's rain cloud is one column to ncurses, and two in terminals like iTerm and kitty, which would put
92
+ # the rest of the title a column out when ncurses changes part of it. So when it changes, the screen is
93
+ # repainted, and the title is written in one go.
94
+ def repaint_title(title, window)
95
+ text = title&.map(&:text)&.join
96
+ window.redraw if @title && text != @title
97
+ @title = text
98
+ end
99
+
86
100
  def paint(window, segment)
87
101
  window.attron(@styles.fetch(segment.style, Curses::A_NORMAL)) { window.addstr(segment.text) }
88
102
  end
@@ -102,8 +116,9 @@ module Raindown
102
116
  Input.key_event(key)
103
117
  end
104
118
 
119
+ # The curses gem warns the first time it makes a MouseEvent, that it's "undefining the allocator" of the class.
105
120
  def mouse
106
- event = Curses.getmouse
121
+ event = Quiet.quietly { Curses.getmouse }
107
122
  Input.mouse_event(event, @zones) if event
108
123
  end
109
124
 
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'curses'
4
+
5
+ module Raindown
6
+ module TUI
7
+ # The curses attributes of a Layout's styles. The title is white on blue, and the selected row white on a lighter
8
+ # blue, or on dark grey in the panes without the focus, while other colours are on the terminal's own background.
9
+ # With only 8 colours, there's no dark grey, so those rows are underlined, and without colours,
10
+ # the rest are in reverse.
11
+ module Styles
12
+ COLORS = { directive: Curses::COLOR_CYAN, button: Curses::COLOR_YELLOW, error: Curses::COLOR_RED,
13
+ folder: Curses::COLOR_BLUE }.freeze
14
+ # The colours of endpoints' HTTP methods, like GET in green, from the 256 colours most terminals have,
15
+ # which are the same in every theme, unlike the 8 they all do, where green can look yellow.
16
+ METHODS = { get: 40, post: 33, delete: 196, put: 220, patch: 171, head: 44, options: 44 }.freeze
17
+ BASIC_METHODS = { get: Curses::COLOR_GREEN, post: Curses::COLOR_BLUE, delete: Curses::COLOR_RED,
18
+ put: Curses::COLOR_YELLOW, patch: Curses::COLOR_MAGENTA, head: Curses::COLOR_CYAN,
19
+ options: Curses::COLOR_CYAN }.freeze
20
+ # The title's and the selected row's blues, from the 256 colours most terminals have, or the 8 they all do.
21
+ BLUES = { title: 25, selected: 33 }.freeze
22
+ BASIC_BLUES = { title: Curses::COLOR_BLUE, selected: Curses::COLOR_CYAN }.freeze
23
+ BRIGHT_WHITE = 15
24
+ DARK_GREY = 238
25
+
26
+ module_function
27
+
28
+ # Sets up the colours, once the screen has started, and returns the attribute of each style.
29
+ def start
30
+ return plain unless Curses.has_colors?
31
+
32
+ start_colors
33
+ colored
34
+ end
35
+
36
+ def plain
37
+ { title: Curses::A_REVERSE | Curses::A_BOLD, selected: Curses::A_REVERSE, selected_button: Curses::A_REVERSE,
38
+ cursor: Curses::A_UNDERLINE, cursor_button: Curses::A_UNDERLINE | Curses::A_BOLD, heading: Curses::A_BOLD,
39
+ body: Curses::A_DIM, error: Curses::A_BOLD, button: Curses::A_BOLD, folder: Curses::A_BOLD, project: Curses::A_BOLD, dashboard: Curses::A_BOLD,
40
+ key: Curses::A_REVERSE, border: Curses::A_DIM, run: Curses::A_REVERSE | Curses::A_BOLD, divider: Curses::A_BOLD,
41
+ selected_divider: Curses::A_REVERSE, cursor_divider: Curses::A_BOLD }
42
+ end
43
+
44
+ def colored
45
+ plain.merge(
46
+ title: pair(:title) | Curses::A_BOLD, selected: pair(:selected) | white_bold,
47
+ selected_button: pair(:selected_button) | Curses::A_BOLD, directive: pair(:directive),
48
+ error: pair(:error) | Curses::A_BOLD, button: pair(:button) | Curses::A_BOLD, divider: pair(:divider),
49
+ selected_divider: pair(:selected_divider), run: pair(:run) | Curses::A_BOLD, **files, **unfocused
50
+ )
51
+ end
52
+
53
+ def files
54
+ { folder: pair(:folder) | Curses::A_BOLD, project: pair(:directive) | Curses::A_BOLD,
55
+ dashboard: pair(:directive) | Curses::A_BOLD, **METHODS.keys.to_h { [:"method_#{it}", pair(:"method_#{it}") | Curses::A_BOLD] } }
56
+ end
57
+
58
+ # The selected rows of the panes without the focus, on dark grey where there's one.
59
+ def unfocused
60
+ return { cursor_button: pair(:button) | Curses::A_BOLD, cursor_divider: pair(:divider) } unless Curses.colors >= 256
61
+
62
+ { cursor: pair(:cursor), cursor_button: pair(:cursor_button) | Curses::A_BOLD, cursor_divider: pair(:cursor_divider) }
63
+ end
64
+
65
+ # Each colour pair as [name, foreground, background], where -1 is the terminal's own background.
66
+ def pairs
67
+ many = Curses.colors >= 256
68
+ blues = many ? BLUES : BASIC_BLUES
69
+ white = many ? BRIGHT_WHITE : Curses::COLOR_WHITE
70
+
71
+ [*COLORS.map { |name, color| [name, color, -1] },
72
+ *(many ? METHODS : BASIC_METHODS).map { |name, color| [:"method_#{name}", color, -1] },
73
+ [:title, white, blues[:title]],
74
+ [:selected, white, blues[:selected]], [:selected_button, Curses::COLOR_YELLOW, blues[:selected]],
75
+ [:cursor, white, DARK_GREY], [:cursor_button, Curses::COLOR_YELLOW, DARK_GREY], [:divider, blues[:title], -1],
76
+ [:selected_divider, blues[:title], blues[:selected]], [:cursor_divider, blues[:title], DARK_GREY],
77
+ [:run, white, Curses::COLOR_GREEN]]
78
+ end
79
+
80
+ def pair(name)
81
+ Curses.color_pair(pairs.index { it.first == name } + 1)
82
+ end
83
+
84
+ # The 8 colour white is grey, which bold brightens.
85
+ def white_bold
86
+ Curses.colors >= 256 ? Curses::A_NORMAL : Curses::A_BOLD
87
+ end
88
+
89
+ def start_colors
90
+ Curses.start_color
91
+ Curses.use_default_colors
92
+ pairs.each.with_index(1) { |(_name, foreground, background), index| Curses.init_pair(index, foreground, background) }
93
+ end
94
+ end
95
+ end
96
+ end