raindown 0.6.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 (90) 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/chart/chart.rb +12 -1
  7. data/lib/raindown/cli.rb +39 -54
  8. data/lib/raindown/config.rb +2 -3
  9. data/lib/raindown/directives.rb +30 -13
  10. data/lib/raindown/document.rb +22 -20
  11. data/lib/raindown/endpoint/curl.rb +11 -7
  12. data/lib/raindown/endpoint/endpoint_runner.rb +13 -5
  13. data/lib/raindown/node_blocks.rb +31 -22
  14. data/lib/raindown/node_injector.rb +7 -10
  15. data/lib/raindown/nodes.rb +1 -0
  16. data/lib/raindown/pages.rb +1 -1
  17. data/lib/raindown/pre_renderer.rb +18 -0
  18. data/lib/raindown/stat.rb +53 -0
  19. data/lib/raindown/tui/blueprints/blueprints_renderer.rb +93 -0
  20. data/lib/raindown/tui/blueprints/blueprints_view.rb +146 -0
  21. data/lib/raindown/tui/blueprints/canvas.rb +58 -0
  22. data/lib/raindown/tui/blueprints/code_comments.rb +71 -0
  23. data/lib/raindown/tui/blueprints/filter.rb +77 -0
  24. data/lib/raindown/tui/blueprints/traced_code.rb +56 -0
  25. data/lib/raindown/tui/blueprints/tracing.rb +129 -0
  26. data/lib/raindown/tui/code_box.rb +143 -0
  27. data/lib/raindown/tui/endpoints.rb +2 -2
  28. data/lib/raindown/tui/events.rb +18 -1
  29. data/lib/raindown/tui/feelings/batch.rb +47 -0
  30. data/lib/raindown/tui/feelings/bins.rb +67 -0
  31. data/lib/raindown/tui/feelings/celebration/celebration.rb +28 -0
  32. data/lib/raindown/tui/feelings/celebration/fireworks.rb +75 -0
  33. data/lib/raindown/tui/feelings/celebration/rain.rb +66 -0
  34. data/lib/raindown/tui/feelings/celebration/scene.rb +40 -0
  35. data/lib/raindown/tui/feelings/cell_moves.rb +34 -0
  36. data/lib/raindown/tui/feelings/code_methods.rb +134 -0
  37. data/lib/raindown/tui/feelings/feelings_code_box.rb +40 -0
  38. data/lib/raindown/tui/feelings/feelings_log.rb +136 -0
  39. data/lib/raindown/tui/feelings/feelings_renderer.rb +183 -0
  40. data/lib/raindown/tui/feelings/feelings_view.rb +157 -0
  41. data/lib/raindown/tui/feelings/magnifier.rb +68 -0
  42. data/lib/raindown/tui/feelings/motion.rb +93 -0
  43. data/lib/raindown/tui/feelings/waffle/baked_waffle.rb +88 -0
  44. data/lib/raindown/tui/feelings/waffle/balloons.rb +81 -0
  45. data/lib/raindown/tui/feelings/waffle/confetti.rb +55 -0
  46. data/lib/raindown/tui/feelings/waffle/sparkles.rb +61 -0
  47. data/lib/raindown/tui/feelings/waffle/waffle.rb +62 -0
  48. data/lib/raindown/tui/feelings/weekly_batch.rb +51 -0
  49. data/lib/raindown/tui/file_tree.rb +6 -5
  50. data/lib/raindown/tui/highlighter.rb +45 -0
  51. data/lib/raindown/tui/layout.rb +32 -18
  52. data/lib/raindown/tui/navigator/commands.rb +46 -2
  53. data/lib/raindown/tui/navigator/mouse.rb +43 -0
  54. data/lib/raindown/tui/navigator/navigator.rb +54 -25
  55. data/lib/raindown/tui/navigator/teleport.rb +128 -0
  56. data/lib/raindown/tui/navigator/views.rb +5 -3
  57. data/lib/raindown/tui/{workers/project.rb → project.rb} +38 -27
  58. data/lib/raindown/tui/{menu_overlay.rb → project_box.rb} +2 -2
  59. data/lib/raindown/tui/renderers/dashboard_renderer.rb +4 -4
  60. data/lib/raindown/tui/renderers/endpoint_renderer.rb +14 -10
  61. data/lib/raindown/tui/renderers/outline_renderer.rb +4 -4
  62. data/lib/raindown/tui/renderers/renderer.rb +4 -0
  63. data/lib/raindown/tui/row.rb +4 -2
  64. data/lib/raindown/tui/rows.rb +2 -1
  65. data/lib/raindown/tui/saves.rb +22 -0
  66. data/lib/raindown/tui/search_box.rb +86 -0
  67. data/lib/raindown/tui/terminal/input.rb +95 -7
  68. data/lib/raindown/tui/terminal/screen.rb +37 -29
  69. data/lib/raindown/tui/terminal/styles.rb +111 -26
  70. data/lib/raindown/tui/terminal/terminal_title.rb +31 -0
  71. data/lib/raindown/tui/text.rb +62 -12
  72. data/lib/raindown/tui/title.rb +1 -1
  73. data/lib/raindown/tui/views/dashboard_view.rb +7 -5
  74. data/lib/raindown/tui/views/endpoint_view.rb +4 -2
  75. data/lib/raindown/tui/views/list_view.rb +2 -0
  76. data/lib/raindown/tui/views/outline_view.rb +16 -6
  77. data/lib/raindown/tui.rb +19 -7
  78. data/lib/raindown/watcher/changes.rb +33 -0
  79. data/lib/raindown/watcher/file_watcher.rb +58 -0
  80. data/lib/raindown/watcher/project.rb +47 -0
  81. data/lib/raindown/watcher/supervisor.rb +111 -0
  82. data/lib/raindown/{tui/workers → workers}/worker.rb +37 -29
  83. data/lib/raindown/workers/workers.rb +63 -0
  84. data/lib/raindown.rb +4 -4
  85. data/lib/version.rb +1 -1
  86. metadata +60 -9
  87. data/lib/raindown/project_worker.rb +0 -48
  88. data/lib/raindown/supervisor.rb +0 -160
  89. data/lib/raindown/tui/workers/workers.rb +0 -53
  90. data/lib/raindown/watcher.rb +0 -57
@@ -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
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'events'
4
+ require_relative 'text'
5
+
6
+ module Raindown
7
+ module TUI
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
+ # │ / stp▏ │
12
+ # │ ## Setup guides/setup.md:3 │
13
+ # │ ## Steps notes.md:12 │
14
+ # └──────────────────────────────────────────┘
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
19
+ include Text
20
+
21
+ # The widest the box is, with its border.
22
+ MAX_WIDTH = 72
23
+
24
+ def initialize(search, width:, title: 'Teleport', pane: :teleport, top: 1)
25
+ @search = search
26
+ @title = title
27
+ @pane = pane
28
+ @top = top
29
+ @box_width = [width - 4, MAX_WIDTH].min
30
+ @column = (width - @box_width) / 2
31
+ end
32
+
33
+ # The lines with the box drawn over them, from its top line, below the title.
34
+ def apply(lines)
35
+ box.each_with_index do |segments, offset|
36
+ row = offset + @top
37
+ lines[row] = overlay_segments(lines[row], segments, column: @column) if lines[row]
38
+ end
39
+ lines
40
+ end
41
+
42
+ private
43
+
44
+ # The columns inside the border, "│ " and " │".
45
+ def inner = @box_width - 4
46
+
47
+ def box
48
+ [title_edge, boxed(segment("/ #{@search.query}▏", :menu, width: inner)), *results, edge('└', '┘')]
49
+ end
50
+
51
+ def results
52
+ matches = @search.matches
53
+ return [boxed(segment('No matches', :info, width: inner))] if matches.empty?
54
+
55
+ first = @search.first_match
56
+ matches.each_with_index.map { |place, index| place_line(place, first + index) }
57
+ end
58
+
59
+ # The place's text, then where it is, on the right, both highlighted when it's selected.
60
+ def place_line(place, index)
61
+ text_style = :menu
62
+ where_style = :body
63
+ text_style = where_style = :selected if @search.match == index
64
+ event = Click.new(pane: @pane, index:, action: :enter)
65
+ where = " #{place.where}"
66
+ where_width = [display_width(where), inner / 2].min
67
+
68
+ boxed(segment(place.text, text_style, width: inner - where_width, event:),
69
+ segment(where, where_style, width: where_width, event:))
70
+ end
71
+
72
+ def boxed(*segments) = [segment('│ ', :border, width: 2), *segments, segment(' │', :border, width: 2)]
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
+
81
+ def edge(left, right)
82
+ [segment("#{left}#{'─' * (@box_width - display_width(left) - 1)}#{right}", :border, width: @box_width)]
83
+ end
84
+ end
85
+ end
86
+ end
@@ -1,7 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'curses'
4
- require 'unicode/display_width'
4
+
5
+ require_relative '../events'
6
+ require_relative '../text'
7
+ require_relative 'quiet'
5
8
 
6
9
  module Raindown
7
10
  module TUI
@@ -10,27 +13,112 @@ module Raindown
10
13
  KEYS = {
11
14
  Curses::KEY_UP => :up, 'k' => :up, Curses::KEY_DOWN => :down, 'j' => :down,
12
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,
13
18
  "\t" => :tab, Curses::KEY_BTAB => :tab, "\r" => :enter, "\n" => :enter, Curses::KEY_ENTER => :enter,
14
19
  ' ' => :toggle, 'x' => :execute, 'e' => :edit, 'w' => :write_back, 'r' => :reload, 'd' => :dashboard,
15
- 'p' => :projects, "\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
22
+ }.freeze
23
+ # While typing, like in Teleport, printable characters are typed, and only these keys do anything else.
24
+ TYPING_KEYS = {
25
+ Curses::KEY_UP => :up, Curses::KEY_DOWN => :down, "\r" => :enter, "\n" => :enter, Curses::KEY_ENTER => :enter,
26
+ "\e" => :escape, Curses::KEY_BACKSPACE => :backspace, "\x7F" => :backspace, "\b" => :backspace
16
27
  }.freeze
17
28
 
18
29
  module_function
19
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
+
20
49
  # 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.
22
- def key_event(key)
50
+ # Letters are the same with Shift, as the buttons show them as capitals, unless they're being typed.
51
+ def key_event(key, typing: false)
52
+ return typing_event(key) if typing
53
+
23
54
  key = key.chr if key.is_a?(Integer) && key < 256
24
55
  KEYS[key.is_a?(String) ? key.downcase : key]
25
56
  end
26
57
 
58
+ def typing_event(key)
59
+ key = key.chr if key.is_a?(Integer) && key < 256
60
+ TYPING_KEYS.fetch(key) { Type.new(text: key) if key.is_a?(String) && key.match?(/\A[[:print:]]\z/) }
61
+ end
62
+
27
63
  # The mouse buttons that click, which are the left button's. Scrolling does nothing, and since older ncurses,
28
64
  # like macOS's, has no button for scrolling down, it can't be mistaken for a click.
29
65
  CLICKS = Curses::BUTTON1_PRESSED | Curses::BUTTON1_CLICKED
30
66
 
31
- # A click sends the event of what's under it.
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
+
71
+ # Moving the mouse, which terminals report once they're asked to, with any motion tracking.
72
+ MOTION = Curses::REPORT_MOUSE_POSITION
73
+ ANY_MOTION = "\e[?1003h"
74
+ NO_MOTION = "\e[?1003l"
75
+
76
+ # The terminal sends the app its mouse events, clicks and moves, so scrolling doesn't scroll the terminal either.
77
+ # ncurses only asks some terminals to report every move, so it's asked directly too, until untrack_mouse.
78
+ def track_mouse
79
+ Curses.mousemask(CLICKS | RELEASE | MOTION)
80
+ Curses.mouseinterval(0)
81
+ $stdout.write(ANY_MOTION)
82
+ $stdout.flush
83
+ end
84
+
85
+ def untrack_mouse
86
+ $stdout.write(NO_MOTION)
87
+ $stdout.flush
88
+ end
89
+
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.
32
95
  def mouse_event(event, zones)
33
- click(zones, column: event.x, row: event.y) if event.bstate.anybits?(CLICKS)
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
+
102
+ target = click(zones, column: event.x, row: event.y)
103
+ return target if event.bstate.anybits?(CLICKS)
104
+
105
+ Hover.new(target:) if event.bstate.anybits?(MOTION)
106
+ end
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)
34
122
  end
35
123
 
36
124
  # Where each of a Layout's segments with an event is on the screen, as [row, columns, event].
@@ -38,7 +126,7 @@ module Raindown
38
126
  lines.each_with_index.flat_map do |segments, row|
39
127
  column = 0
40
128
  segments.filter_map do |segment|
41
- width = Unicode::DisplayWidth.of(segment.text)
129
+ width = Text.display_width(segment.text)
42
130
  zone = [row, column...(column + width), segment.event] if segment.event
43
131
  column += width
44
132
  zone
@@ -4,17 +4,22 @@ require 'curses'
4
4
 
5
5
  require_relative 'input'
6
6
  require_relative '../layout'
7
- require_relative 'quiet'
8
7
  require_relative 'ncurses_shim'
8
+ require_relative 'quiet'
9
9
  require_relative 'styles'
10
+ require_relative 'terminal_title'
10
11
 
11
12
  module Raindown
12
13
  module TUI
13
14
  # Paints a Navigator's Layout with curses, and sends the Navigator the events of keys and clicks, read with Input.
14
15
  # Only this class draws on the terminal, so another library could draw the same Layout.
15
16
  class Screen
16
- # How often, in milliseconds, the open file is checked for changes while no key is pressed.
17
- TICK = 500
17
+ prepend TerminalTitle
18
+
19
+ # How often, in milliseconds, the screen's drawn, and the open file checked for changes, while no key is pressed, by
20
+ # the view's pace, from when the last frame started drawing: every half second while it's active, at 12 frames a
21
+ # second while it's reactive, just faster than Code feelings' digits tremble, and at 20 during an animation.
22
+ TICKS = { active: 500, reactive: 83, animation: 50 }.freeze
18
23
 
19
24
  def initialize(navigator)
20
25
  @navigator = navigator
@@ -31,11 +36,9 @@ module Raindown
31
36
  start
32
37
 
33
38
  loop do
39
+ drawn_at = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
34
40
  draw
35
- effect = @navigator.next_step(read)
36
- break if effect == :quit
37
-
38
- perform(effect)
41
+ break if take_events(drawn_at) == :quit
39
42
  end
40
43
  rescue Interrupt
41
44
  nil
@@ -43,6 +46,23 @@ module Raindown
43
46
  stop
44
47
  end
45
48
 
49
+ # The Navigator takes the next event, then any others already waiting, like the mouse's many moves, before the
50
+ # screen's drawn again, so drawing doesn't fall further and further behind them.
51
+ def take_events(drawn_at)
52
+ event = Input.read(wait(drawn_at), zones: @zones, typing: @navigator.typing?)
53
+ loop do
54
+ effect = @navigator.next_step(event)
55
+ return effect if effect == :quit
56
+
57
+ perform(effect)
58
+ event = Input.read(0, zones: @zones, typing: @navigator.typing?)
59
+ return if event == :tick
60
+ end
61
+ end
62
+
63
+ # The milliseconds left of the pace's tick since the frame started drawing, so drawing doesn't slow the pace.
64
+ def wait(drawn_at) = [TICKS.fetch(@navigator.pace) + drawn_at - Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond), 0].max
65
+
46
66
  # Runs a command, like the editor, then tells the Navigator, or shows a message while a slow task runs.
47
67
  def perform(effect)
48
68
  case effect
@@ -61,13 +81,11 @@ module Raindown
61
81
  Curses.nonl
62
82
  Curses.curs_set(0)
63
83
  Curses.stdscr.keypad(true)
64
- Curses.stdscr.timeout = TICK
65
- # The terminal sends the app its mouse events, so scrolling doesn't scroll the terminal either.
66
- Curses.mousemask(Input::CLICKS)
67
- Curses.mouseinterval(0)
84
+ Input.track_mouse
68
85
  end
69
86
 
70
87
  def stop
88
+ Input.untrack_mouse
71
89
  Curses.close_screen
72
90
  end
73
91
 
@@ -98,7 +116,7 @@ module Raindown
98
116
  end
99
117
 
100
118
  def paint(window, segment)
101
- window.attron(@styles.fetch(segment.style, Curses::A_NORMAL)) { window.addstr(segment.text) }
119
+ window.attron(@styles[segment.style]) { window.addstr(segment.text) }
102
120
  end
103
121
 
104
122
  def last_line(segments)
@@ -106,22 +124,6 @@ module Raindown
106
124
  [*rest, last.with(text: last.text[0...-1])]
107
125
  end
108
126
 
109
- # The next event, or :tick when no key is pressed in time, or nil for keys without one, like a resize.
110
- def read
111
- key = Curses.stdscr.getch
112
- return :tick if key.nil?
113
- return mouse if key == Curses::KEY_MOUSE
114
-
115
- Curses.stdscr.clear if key == Curses::KEY_RESIZE
116
- Input.key_event(key)
117
- end
118
-
119
- # The curses gem warns the first time it makes a MouseEvent, that it's "undefining the allocator" of the class.
120
- def mouse
121
- event = Quiet.quietly { Curses.getmouse }
122
- Input.mouse_event(event, @zones) if event
123
- end
124
-
125
127
  # Shows a message in the status line while something slow runs, like an endpoint's request.
126
128
  def show(message)
127
129
  window = Curses.stdscr
@@ -131,13 +133,19 @@ module Raindown
131
133
  window.refresh
132
134
  end
133
135
 
134
- # Leaves the screen to the command, like the editor, and comes back to it after.
136
+ # Leaves the screen to the command, like the editor, and comes back to it after. The command isn't sent the
137
+ # mouse's moves meanwhile. Ctrl+C, like to leave Claude Code, goes to the TUI too, which waits for the command
138
+ # rather than quitting, while the command gets Ctrl+C as usual.
135
139
  def suspend
140
+ interrupt = trap('INT') { nil }
136
141
  Curses.def_prog_mode
142
+ Input.untrack_mouse
137
143
  Curses.close_screen
138
144
  yield
139
145
  ensure
146
+ trap('INT', interrupt)
140
147
  Curses.reset_prog_mode
148
+ Input.track_mouse
141
149
  Curses.stdscr.clear
142
150
  Curses.refresh
143
151
  end
@@ -4,25 +4,68 @@ require 'curses'
4
4
 
5
5
  module Raindown
6
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.
7
+ # The curses attributes of a Layout's styles. Text is periwinkle where there are 256 colours, and the terminal's own
8
+ # colour otherwise, while the title, headings, and the lines between the panes and beneath the title are cornflower
9
+ # blue. The selected
10
+ # row is white on blue, or on dark grey in the panes without the focus, while other colours are on the terminal's own
11
+ # background.
9
12
  # With only 8 colours, there's no dark grey, so those rows are underlined, and without colours,
10
13
  # the rest are in reverse.
11
14
  module Styles
12
- COLORS = { directive: Curses::COLOR_CYAN, button: Curses::COLOR_YELLOW, error: Curses::COLOR_RED,
13
- folder: Curses::COLOR_BLUE }.freeze
15
+ COLORS = { button: Curses::COLOR_YELLOW, error: Curses::COLOR_RED }.freeze
16
+ # Directives, like charts' on the Dashboard, in purple, and the folders, projects and pages in the files, the title,
17
+ # and headings, like the outline's and the Dashboard cards', in cornflower blue, and the waffle's syrup in brown,
18
+ # from the 256 colours most terminals
19
+ # have, or magenta and blue with 8.
20
+ CORNFLOWER = 111
21
+ ACCENTS = { directive: 141, folder: CORNFLOWER, title: CORNFLOWER, heading: CORNFLOWER, syrup: 130 }.freeze
22
+ BASIC_ACCENTS = { directive: Curses::COLOR_MAGENTA, folder: Curses::COLOR_BLUE, title: Curses::COLOR_BLUE,
23
+ heading: Curses::COLOR_BLUE, syrup: Curses::COLOR_YELLOW }.freeze
24
+
25
+ # The green of GET, and of a selected method in Code feelings that's already been felt.
26
+ GREEN = 34
27
+
14
28
  # The colours of endpoints' HTTP methods, like GET in green, from the 256 colours most terminals have,
15
29
  # 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
30
+ METHODS = { get: GREEN, post: 33, delete: 196, put: 220, patch: 171, head: 44, options: 44 }.freeze
17
31
  BASIC_METHODS = { get: Curses::COLOR_GREEN, post: Curses::COLOR_BLUE, delete: Curses::COLOR_RED,
18
32
  put: Curses::COLOR_YELLOW, patch: Curses::COLOR_MAGENTA, head: Curses::COLOR_CYAN,
19
33
  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
34
+
35
+ # Highlighted code's colours, by the kind of token, from the 256 colours most terminals have, or the 8 they all do,
36
+ # where comments are dim instead of grey.
37
+ CODE = { code_keyword: 204, code_string: 114, code_number: 179, code_function: 75, code_constant: 117,
38
+ code_comment: 244 }.freeze
39
+ BASIC_CODE = { code_keyword: Curses::COLOR_MAGENTA, code_string: Curses::COLOR_GREEN, code_number: Curses::COLOR_YELLOW,
40
+ code_function: Curses::COLOR_BLUE, code_constant: Curses::COLOR_CYAN, code_comment: Curses::COLOR_WHITE }.freeze
41
+
42
+ # Code feelings' bright colours, for its rain and fireworks, from the 256 colours most terminals have, or
43
+ # the 8 they all do.
44
+ BRIGHT = { bright_red: 203, bright_orange: 215, bright_yellow: 227, bright_green: 120, bright_cyan: 87,
45
+ bright_blue: 75, bright_purple: 177, bright_pink: 213 }.freeze
46
+ BASIC_BRIGHT = { bright_red: Curses::COLOR_RED, bright_orange: Curses::COLOR_YELLOW, bright_yellow: Curses::COLOR_YELLOW,
47
+ bright_green: Curses::COLOR_GREEN, bright_cyan: Curses::COLOR_CYAN, bright_blue: Curses::COLOR_BLUE,
48
+ bright_purple: Curses::COLOR_MAGENTA, bright_pink: Curses::COLOR_MAGENTA }.freeze
49
+
50
+ # The selected row's blue, the green of a selected method in Code feelings that's already been felt, and the
51
+ # cornflower blue of the lines between the panes and beneath the title, from the 256 colours most terminals have,
52
+ # or the 8 they all do.
53
+ BLUES = { selected: 33, felt: GREEN, line: CORNFLOWER }.freeze
54
+ BASIC_BLUES = { selected: Curses::COLOR_CYAN, felt: Curses::COLOR_GREEN, line: Curses::COLOR_BLUE }.freeze
55
+
56
+ # Text that has no colour of its own, from the 256 colours most terminals have, or the terminal's own with 8.
57
+ PERIWINKLE = 147
58
+
59
+ # Code feelings' text and lines, in lavender, or magenta in 8 colours, and the purple its magnified digits take turns
60
+ # with, or blue.
61
+ FEELINGS = { feelings: 183, feelings_purple: 135 }.freeze
62
+ BASIC_FEELINGS = { feelings: Curses::COLOR_MAGENTA, feelings_purple: Curses::COLOR_BLUE }.freeze
23
63
  BRIGHT_WHITE = 15
24
64
  DARK_GREY = 238
25
65
 
66
+ # Code feelings' red when the screen's too small for it, or the 8 colours' red.
67
+ DARK_RED = 88
68
+
26
69
  module_function
27
70
 
28
71
  # Sets up the colours, once the screen has started, and returns the attribute of each style.
@@ -33,33 +76,72 @@ module Raindown
33
76
  colored
34
77
  end
35
78
 
79
+ # Without colours, a style that isn't here is the terminal's own.
36
80
  def plain
37
- { title: Curses::A_REVERSE | Curses::A_BOLD, selected: Curses::A_REVERSE, selected_button: Curses::A_REVERSE,
81
+ Hash.new(Curses::A_NORMAL).merge(
82
+ title: Curses::A_REVERSE | Curses::A_BOLD, selected: Curses::A_REVERSE, too_small: Curses::A_REVERSE,
83
+ feelings_selected: Curses::A_REVERSE | Curses::A_BOLD, felt_selected: Curses::A_REVERSE | Curses::A_BOLD,
84
+ selected_button: Curses::A_REVERSE,
38
85
  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 }
86
+ body: Curses::A_DIM, error: Curses::A_BOLD, button: Curses::A_BOLD, folder: Curses::A_BOLD, project: Curses::A_BOLD, page: Curses::A_BOLD,
87
+ key: Curses::A_REVERSE, border: Curses::A_DIM, divider: Curses::A_BOLD,
88
+ code_keyword: Curses::A_BOLD, code_comment: Curses::A_DIM
89
+ )
42
90
  end
43
91
 
44
92
  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
93
+ in_text_color.merge(
94
+ title: pair(:title) | Curses::A_BOLD, directive: pair(:directive), error: pair(:error) | Curses::A_BOLD,
95
+ button: pair(:button) | Curses::A_BOLD, **selections, **emphasis, **dividers, **code_tokens, **bright, **files,
96
+ **unfocused
50
97
  )
51
98
  end
52
99
 
100
+ # The selected row, its buttons, Code feelings' selected method, bold, on green when it's already been felt, and its
101
+ # red when the screen's too small for it.
102
+ def selections
103
+ { selected: pair(:selected) | white_bold, feelings_selected: pair(:selected) | Curses::A_BOLD,
104
+ felt_selected: pair(:felt_selected) | Curses::A_BOLD,
105
+ too_small: pair(:too_small) | white_bold, selected_button: pair(:selected_button) | Curses::A_BOLD }
106
+ end
107
+
108
+ # The styles without a colour of their own, or that are only bold, dim or the like, in the text's colour, which is
109
+ # also any style that isn't here.
110
+ def in_text_color
111
+ text = pair(:text)
112
+ Hash.new(text).merge(plain.transform_values { it | text })
113
+ end
114
+
115
+ # Headings, the waffle's butter's shine and its syrup, and Code feelings' text and lines, bold around the method the
116
+ # mouse is over, and its purple.
117
+ def emphasis
118
+ { heading: pair(:heading) | Curses::A_BOLD, feelings: pair(:feelings), nearby: pair(:feelings) | Curses::A_BOLD,
119
+ feelings_purple: pair(:feelings_purple), shine: pair(:shine) | Curses::A_BOLD, syrup: pair(:syrup) }
120
+ end
121
+
122
+ # Highlighted code's tokens, like keywords and comments, with comments dim in 8 colours, where there's no grey.
123
+ def code_tokens
124
+ tokens = CODE.keys.to_h { [it, pair(it)] }
125
+ tokens[:code_comment] |= Curses::A_DIM if Curses.colors < 256
126
+ tokens
127
+ end
128
+
129
+ # The bright colours, bold, so they're bright in 8 colours too.
130
+ def bright = BRIGHT.keys.to_h { [it, pair(it) | Curses::A_BOLD] }
131
+
132
+ # The lines between the panes and beneath the title.
133
+ def dividers = { divider: pair(:divider) }
134
+
53
135
  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] } }
136
+ { folder: pair(:folder) | Curses::A_BOLD, project: pair(:folder) | Curses::A_BOLD,
137
+ page: pair(:folder) | Curses::A_BOLD, **METHODS.keys.to_h { [:"method_#{it}", pair(:"method_#{it}") | Curses::A_BOLD] } }
56
138
  end
57
139
 
58
140
  # The selected rows of the panes without the focus, on dark grey where there's one.
59
141
  def unfocused
60
- return { cursor_button: pair(:button) | Curses::A_BOLD, cursor_divider: pair(:divider) } unless Curses.colors >= 256
142
+ return { cursor_button: pair(:button) | Curses::A_BOLD } unless Curses.colors >= 256
61
143
 
62
- { cursor: pair(:cursor), cursor_button: pair(:cursor_button) | Curses::A_BOLD, cursor_divider: pair(:cursor_divider) }
144
+ { cursor: pair(:cursor), cursor_button: pair(:cursor_button) | Curses::A_BOLD }
63
145
  end
64
146
 
65
147
  # Each colour pair as [name, foreground, background], where -1 is the terminal's own background.
@@ -67,14 +149,17 @@ module Raindown
67
149
  many = Curses.colors >= 256
68
150
  blues = many ? BLUES : BASIC_BLUES
69
151
  white = many ? BRIGHT_WHITE : Curses::COLOR_WHITE
152
+ text = many ? PERIWINKLE : -1
70
153
 
71
154
  [*COLORS.map { |name, color| [name, color, -1] },
72
155
  *(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]]
156
+ *(many ? CODE.merge(BRIGHT, FEELINGS, ACCENTS) : BASIC_CODE.merge(BASIC_BRIGHT, BASIC_FEELINGS, BASIC_ACCENTS))
157
+ .map { |name, color| [name, color, -1] },
158
+ [:text, text, -1],
159
+ [:selected, white, blues[:selected]], [:felt_selected, white, blues[:felt]], [:too_small, white, many ? DARK_RED : Curses::COLOR_RED],
160
+ [:selected_button, Curses::COLOR_YELLOW, blues[:selected]],
161
+ [:cursor, white, DARK_GREY], [:cursor_button, Curses::COLOR_YELLOW, DARK_GREY], [:divider, blues[:line], -1],
162
+ [:shine, white, -1]]
78
163
  end
79
164
 
80
165
  def pair(name)
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raindown
4
+ module TUI
5
+ # The terminal's title while the Screen's open, which it's prepended to: blank, rather than the command's name,
6
+ # saving the title it had, then putting it back, which terminals that can't ignore. It's a space, as terminals like
7
+ # Ghostty take an empty title to mean their own.
8
+ module TerminalTitle
9
+ BLANK_TITLE = "\e]2; \a"
10
+ SAVE_TITLE = "\e[22;2t"
11
+ RESTORE_TITLE = "\e[23;2t"
12
+
13
+ private
14
+
15
+ def start
16
+ super
17
+ terminal("#{SAVE_TITLE}#{BLANK_TITLE}")
18
+ end
19
+
20
+ def stop
21
+ terminal(RESTORE_TITLE)
22
+ super
23
+ end
24
+
25
+ def terminal(sequence)
26
+ $stdout.write(sequence)
27
+ $stdout.flush
28
+ end
29
+ end
30
+ end
31
+ end