raindown 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/lib/nodes/endpoint_node.rb +3 -2
  3. data/lib/raindown/actions.rb +5 -3
  4. data/lib/raindown/bare_directives.rb +14 -27
  5. data/lib/raindown/blueprints.rb +255 -0
  6. data/lib/raindown/cli.rb +39 -54
  7. data/lib/raindown/config.rb +2 -3
  8. data/lib/raindown/directives.rb +30 -13
  9. data/lib/raindown/document.rb +21 -15
  10. data/lib/raindown/endpoint/endpoint_runner.rb +13 -5
  11. data/lib/raindown/node_blocks.rb +22 -16
  12. data/lib/raindown/node_injector.rb +7 -10
  13. data/lib/raindown/pages.rb +1 -1
  14. data/lib/raindown/pre_renderer.rb +18 -0
  15. data/lib/raindown/tui/blueprints/blueprints_renderer.rb +93 -0
  16. data/lib/raindown/tui/blueprints/blueprints_view.rb +146 -0
  17. data/lib/raindown/tui/blueprints/canvas.rb +58 -0
  18. data/lib/raindown/tui/blueprints/code_comments.rb +71 -0
  19. data/lib/raindown/tui/blueprints/filter.rb +77 -0
  20. data/lib/raindown/tui/blueprints/traced_code.rb +56 -0
  21. data/lib/raindown/tui/blueprints/tracing.rb +129 -0
  22. data/lib/raindown/tui/{feelings/code_box.rb → code_box.rb} +44 -30
  23. data/lib/raindown/tui/endpoints.rb +2 -2
  24. data/lib/raindown/tui/events.rb +3 -1
  25. data/lib/raindown/tui/feelings/bins.rb +5 -5
  26. data/lib/raindown/tui/feelings/{celebration.rb → celebration/celebration.rb} +5 -2
  27. data/lib/raindown/tui/feelings/{scene.rb → celebration/scene.rb} +4 -1
  28. data/lib/raindown/tui/feelings/cell_moves.rb +34 -0
  29. data/lib/raindown/tui/feelings/feelings_code_box.rb +40 -0
  30. data/lib/raindown/tui/feelings/feelings_log.rb +22 -19
  31. data/lib/raindown/tui/feelings/feelings_renderer.rb +94 -95
  32. data/lib/raindown/tui/feelings/feelings_view.rb +28 -32
  33. data/lib/raindown/tui/feelings/magnifier.rb +68 -0
  34. data/lib/raindown/tui/feelings/motion.rb +93 -0
  35. data/lib/raindown/tui/feelings/waffle/baked_waffle.rb +88 -0
  36. data/lib/raindown/tui/feelings/waffle/balloons.rb +81 -0
  37. data/lib/raindown/tui/feelings/waffle/confetti.rb +55 -0
  38. data/lib/raindown/tui/feelings/waffle/sparkles.rb +61 -0
  39. data/lib/raindown/tui/feelings/waffle/waffle.rb +62 -0
  40. data/lib/raindown/tui/feelings/weekly_batch.rb +24 -5
  41. data/lib/raindown/tui/layout.rb +25 -18
  42. data/lib/raindown/tui/navigator/commands.rb +32 -1
  43. data/lib/raindown/tui/navigator/mouse.rb +43 -0
  44. data/lib/raindown/tui/navigator/navigator.rb +40 -44
  45. data/lib/raindown/tui/navigator/{search.rb → teleport.rb} +14 -12
  46. data/lib/raindown/tui/navigator/views.rb +2 -3
  47. data/lib/raindown/tui/{workers/project.rb → project.rb} +33 -22
  48. data/lib/raindown/tui/{menu_overlay.rb → project_box.rb} +1 -1
  49. data/lib/raindown/tui/renderers/renderer.rb +1 -0
  50. data/lib/raindown/tui/saves.rb +22 -0
  51. data/lib/raindown/tui/{search_overlay.rb → search_box.rb} +27 -14
  52. data/lib/raindown/tui/terminal/input.rb +58 -7
  53. data/lib/raindown/tui/terminal/screen.rb +31 -26
  54. data/lib/raindown/tui/terminal/styles.rb +55 -28
  55. data/lib/raindown/tui/terminal/terminal_title.rb +31 -0
  56. data/lib/raindown/tui/text.rb +35 -12
  57. data/lib/raindown/tui/views/dashboard_view.rb +2 -2
  58. data/lib/raindown/tui/views/endpoint_view.rb +3 -3
  59. data/lib/raindown/tui/views/list_view.rb +1 -1
  60. data/lib/raindown/tui/views/outline_view.rb +5 -4
  61. data/lib/raindown/tui.rb +17 -7
  62. data/lib/raindown/watcher/changes.rb +33 -0
  63. data/lib/raindown/watcher/file_watcher.rb +58 -0
  64. data/lib/raindown/watcher/project.rb +47 -0
  65. data/lib/raindown/watcher/supervisor.rb +111 -0
  66. data/lib/raindown/{tui/workers → workers}/worker.rb +37 -29
  67. data/lib/raindown/workers/workers.rb +63 -0
  68. data/lib/raindown.rb +4 -4
  69. data/lib/version.rb +1 -1
  70. metadata +37 -17
  71. data/lib/raindown/project_worker.rb +0 -48
  72. data/lib/raindown/supervisor.rb +0 -160
  73. data/lib/raindown/tui/feelings/waffle.rb +0 -43
  74. data/lib/raindown/tui/workers/workers.rb +0 -53
  75. data/lib/raindown/watcher.rb +0 -57
  76. /data/lib/raindown/tui/feelings/{fireworks.rb → celebration/fireworks.rb} +0 -0
  77. /data/lib/raindown/tui/feelings/{rain.rb → celebration/rain.rb} +0 -0
@@ -4,18 +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, or, while the view
17
- # animates, like Code feelings' rain, it's drawn.
18
- TICKS = { false => 500, true => 60 }.freeze
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
19
23
 
20
24
  def initialize(navigator)
21
25
  @navigator = navigator
@@ -32,11 +36,9 @@ module Raindown
32
36
  start
33
37
 
34
38
  loop do
39
+ drawn_at = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
35
40
  draw
36
- effect = @navigator.next_step(read)
37
- break if effect == :quit
38
-
39
- perform(effect)
41
+ break if take_events(drawn_at) == :quit
40
42
  end
41
43
  rescue Interrupt
42
44
  nil
@@ -44,6 +46,23 @@ module Raindown
44
46
  stop
45
47
  end
46
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
+
47
66
  # Runs a command, like the editor, then tells the Navigator, or shows a message while a slow task runs.
48
67
  def perform(effect)
49
68
  case effect
@@ -105,23 +124,6 @@ module Raindown
105
124
  [*rest, last.with(text: last.text[0...-1])]
106
125
  end
107
126
 
108
- # The next event, or :tick when no key is pressed in time, or nil for keys without one, like a resize.
109
- def read
110
- Curses.stdscr.timeout = TICKS.fetch(@navigator.animating?)
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, typing: @navigator.typing?)
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
@@ -132,13 +134,16 @@ module Raindown
132
134
  end
133
135
 
134
136
  # Leaves the screen to the command, like the editor, and comes back to it after. The command isn't sent the
135
- # mouse's moves meanwhile.
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.
136
139
  def suspend
140
+ interrupt = trap('INT') { nil }
137
141
  Curses.def_prog_mode
138
142
  Input.untrack_mouse
139
143
  Curses.close_screen
140
144
  yield
141
145
  ensure
146
+ trap('INT', interrupt)
142
147
  Curses.reset_prog_mode
143
148
  Input.track_mouse
144
149
  Curses.stdscr.clear
@@ -5,7 +5,8 @@ require 'curses'
5
5
  module Raindown
6
6
  module TUI
7
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, like the line between the panes, while the title and headings are cornflower blue. The selected
8
+ # colour otherwise, while the title, headings, and the lines between the panes and beneath the title are cornflower
9
+ # blue. The selected
9
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
10
11
  # background.
11
12
  # With only 8 colours, there's no dark grey, so those rows are underlined, and without colours,
@@ -13,23 +14,31 @@ module Raindown
13
14
  module Styles
14
15
  COLORS = { button: Curses::COLOR_YELLOW, error: Curses::COLOR_RED }.freeze
15
16
  # Directives, like charts' on the Dashboard, in purple, and the folders, projects and pages in the files, the title,
16
- # and headings, like the outline's and the Dashboard cards', in cornflower blue, from the 256 colours most terminals
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
17
19
  # have, or magenta and blue with 8.
18
- ACCENTS = { directive: 141, folder: 111, title: 111, heading: 111 }.freeze
20
+ CORNFLOWER = 111
21
+ ACCENTS = { directive: 141, folder: CORNFLOWER, title: CORNFLOWER, heading: CORNFLOWER, syrup: 130 }.freeze
19
22
  BASIC_ACCENTS = { directive: Curses::COLOR_MAGENTA, folder: Curses::COLOR_BLUE, title: Curses::COLOR_BLUE,
20
- heading: Curses::COLOR_BLUE }.freeze
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
+
21
28
  # The colours of endpoints' HTTP methods, like GET in green, from the 256 colours most terminals have,
22
29
  # which are the same in every theme, unlike the 8 they all do, where green can look yellow.
23
- 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
24
31
  BASIC_METHODS = { get: Curses::COLOR_GREEN, post: Curses::COLOR_BLUE, delete: Curses::COLOR_RED,
25
32
  put: Curses::COLOR_YELLOW, patch: Curses::COLOR_MAGENTA, head: Curses::COLOR_CYAN,
26
33
  options: Curses::COLOR_CYAN }.freeze
34
+
27
35
  # Highlighted code's colours, by the kind of token, from the 256 colours most terminals have, or the 8 they all do,
28
36
  # where comments are dim instead of grey.
29
37
  CODE = { code_keyword: 204, code_string: 114, code_number: 179, code_function: 75, code_constant: 117,
30
38
  code_comment: 244 }.freeze
31
39
  BASIC_CODE = { code_keyword: Curses::COLOR_MAGENTA, code_string: Curses::COLOR_GREEN, code_number: Curses::COLOR_YELLOW,
32
40
  code_function: Curses::COLOR_BLUE, code_constant: Curses::COLOR_CYAN, code_comment: Curses::COLOR_WHITE }.freeze
41
+
33
42
  # Code feelings' bright colours, for its rain and fireworks, from the 256 colours most terminals have, or
34
43
  # the 8 they all do.
35
44
  BRIGHT = { bright_red: 203, bright_orange: 215, bright_yellow: 227, bright_green: 120, bright_cyan: 87,
@@ -37,17 +46,26 @@ module Raindown
37
46
  BASIC_BRIGHT = { bright_red: Curses::COLOR_RED, bright_orange: Curses::COLOR_YELLOW, bright_yellow: Curses::COLOR_YELLOW,
38
47
  bright_green: Curses::COLOR_GREEN, bright_cyan: Curses::COLOR_CYAN, bright_blue: Curses::COLOR_BLUE,
39
48
  bright_purple: Curses::COLOR_MAGENTA, bright_pink: Curses::COLOR_MAGENTA }.freeze
40
- # The selected row's blue, from the 256 colours most terminals have, or the 8 they all do.
41
- BLUES = { selected: 33 }.freeze
42
- BASIC_BLUES = { selected: Curses::COLOR_CYAN }.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
+
43
56
  # Text that has no colour of its own, from the 256 colours most terminals have, or the terminal's own with 8.
44
57
  PERIWINKLE = 147
45
- # Code feelings' text and lines, the Dashboard cards' charts, and code blocks, in lavender, or magenta in 8 colours.
46
- FEELINGS = { feelings: 183 }.freeze
47
- BASIC_FEELINGS = { feelings: Curses::COLOR_MAGENTA }.freeze
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
48
63
  BRIGHT_WHITE = 15
49
64
  DARK_GREY = 238
50
65
 
66
+ # Code feelings' red when the screen's too small for it, or the 8 colours' red.
67
+ DARK_RED = 88
68
+
51
69
  module_function
52
70
 
53
71
  # Sets up the colours, once the screen has started, and returns the attribute of each style.
@@ -61,24 +79,32 @@ module Raindown
61
79
  # Without colours, a style that isn't here is the terminal's own.
62
80
  def plain
63
81
  Hash.new(Curses::A_NORMAL).merge(
64
- title: Curses::A_REVERSE | Curses::A_BOLD, selected: Curses::A_REVERSE, selected_button: Curses::A_REVERSE,
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,
65
85
  cursor: Curses::A_UNDERLINE, cursor_button: Curses::A_UNDERLINE | Curses::A_BOLD, heading: Curses::A_BOLD,
66
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,
67
87
  key: Curses::A_REVERSE, border: Curses::A_DIM, divider: Curses::A_BOLD,
68
- selected_divider: Curses::A_REVERSE, cursor_divider: Curses::A_BOLD,
69
88
  code_keyword: Curses::A_BOLD, code_comment: Curses::A_DIM
70
89
  )
71
90
  end
72
91
 
73
92
  def colored
74
93
  in_text_color.merge(
75
- title: pair(:title) | Curses::A_BOLD, selected: pair(:selected) | white_bold,
76
- selected_button: pair(:selected_button) | Curses::A_BOLD, directive: pair(:directive),
77
- error: pair(:error) | Curses::A_BOLD, button: pair(:button) | Curses::A_BOLD, **emphasis,
78
- **dividers, **code_tokens, **bright, **files, **unfocused
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
79
97
  )
80
98
  end
81
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
+
82
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
83
109
  # also any style that isn't here.
84
110
  def in_text_color
@@ -86,11 +112,11 @@ module Raindown
86
112
  Hash.new(text).merge(plain.transform_values { it | text })
87
113
  end
88
114
 
89
- # Headings, and Code feelings' text and lines, whose lavender the Dashboard cards' charts and code
90
- # blocks are too, apart from highlighted code's keywords, strings and the like.
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.
91
117
  def emphasis
92
- lavender = pair(:feelings)
93
- { heading: pair(:heading) | Curses::A_BOLD, feelings: lavender, chart: lavender, code: lavender }
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) }
94
120
  end
95
121
 
96
122
  # Highlighted code's tokens, like keywords and comments, with comments dim in 8 colours, where there's no grey.
@@ -103,8 +129,8 @@ module Raindown
103
129
  # The bright colours, bold, so they're bright in 8 colours too.
104
130
  def bright = BRIGHT.keys.to_h { [it, pair(it) | Curses::A_BOLD] }
105
131
 
106
- # The line between the panes, and where it meets a selected row.
107
- def dividers = { divider: pair(:divider), selected_divider: pair(:selected_divider) }
132
+ # The lines between the panes and beneath the title.
133
+ def dividers = { divider: pair(:divider) }
108
134
 
109
135
  def files
110
136
  { folder: pair(:folder) | Curses::A_BOLD, project: pair(:folder) | Curses::A_BOLD,
@@ -113,9 +139,9 @@ module Raindown
113
139
 
114
140
  # The selected rows of the panes without the focus, on dark grey where there's one.
115
141
  def unfocused
116
- 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
117
143
 
118
- { 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 }
119
145
  end
120
146
 
121
147
  # Each colour pair as [name, foreground, background], where -1 is the terminal's own background.
@@ -130,9 +156,10 @@ module Raindown
130
156
  *(many ? CODE.merge(BRIGHT, FEELINGS, ACCENTS) : BASIC_CODE.merge(BASIC_BRIGHT, BASIC_FEELINGS, BASIC_ACCENTS))
131
157
  .map { |name, color| [name, color, -1] },
132
158
  [:text, text, -1],
133
- [:selected, white, blues[:selected]], [:selected_button, Curses::COLOR_YELLOW, blues[:selected]],
134
- [:cursor, white, DARK_GREY], [:cursor_button, Curses::COLOR_YELLOW, DARK_GREY], [:divider, text, -1],
135
- [:selected_divider, text, blues[:selected]], [:cursor_divider, text, DARK_GREY]]
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]]
136
163
  end
137
164
 
138
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
@@ -8,6 +8,16 @@ module Raindown
8
8
  module Text
9
9
  Segment = Data.define(:text, :style, :event)
10
10
  INDENT = ' '
11
+ WIDTHS = 10_000
12
+
13
+ # Printable ASCII, like most code and labels, is a column a character, without asking Unicode, and other text,
14
+ # like boxes' borders and magnified digits, is the same every frame, so its width's remembered, up to WIDTHS texts.
15
+ def self.display_width(text)
16
+ return text.length if text.match?(/\A[ -~]*\z/)
17
+
18
+ @widths = {} if @widths.nil? || @widths.size >= WIDTHS
19
+ @widths[text] ||= Unicode::DisplayWidth.of(text)
20
+ end
11
21
 
12
22
  private
13
23
 
@@ -27,7 +37,9 @@ module Raindown
27
37
  # Text cut or padded to the width, with a cut shown by an ellipsis.
28
38
  def fit(text, width)
29
39
  return '' if width <= 0
30
- return text + (' ' * (width - display_width(text))) if display_width(text) <= width
40
+
41
+ text_width = display_width(text)
42
+ return text + (' ' * (width - text_width)) if text_width <= width
31
43
 
32
44
  cut = +''
33
45
  cut_width = 0
@@ -83,28 +95,39 @@ module Raindown
83
95
  [*segments_between(line, 0, column), *segments, *segments_between(line, column + width, Float::INFINITY)]
84
96
  end
85
97
 
86
- # The parts of the line's segments between two columns, where a wide character cut in two leaves spaces.
98
+ # The parts of the line's segments between two columns, where a wide character cut in two leaves spaces. A segment
99
+ # wholly between them, or wholly outside, is kept or left out whole, without going through its characters.
87
100
  def segments_between(line, from, to)
88
101
  column = 0
89
102
  line.filter_map do |segment|
90
- text = segment.text.each_char.map do |char|
91
- start = column
92
- column += display_width(char)
93
- overlap = [column, to].min - [start, from].max
94
- overlap == display_width(char) ? char : ' ' * [overlap, 0].max
95
- end.join
96
- segment.with(text:) unless text.empty?
103
+ start = column
104
+ column += display_width(segment.text)
105
+ next if segment.text.empty? || column <= from || start >= to
106
+ next segment if start >= from && column <= to
107
+
108
+ segment.with(text: chars_between(segment.text, start, from, to))
97
109
  end
98
110
  end
99
111
 
112
+ # The text's characters between two columns, from the column it starts at, where a wide character cut in two
113
+ # leaves spaces.
114
+ def chars_between(text, column, from, to)
115
+ text.each_char.map do |char|
116
+ start = column
117
+ column += display_width(char)
118
+ overlap = [column, to].min - [start, from].max
119
+ overlap == display_width(char) ? char : ' ' * [overlap, 0].max
120
+ end.join
121
+ end
122
+
100
123
  # Tabs and control characters, like in a directive's output, would move the terminal's cursor.
101
124
  def printable(text)
125
+ return text unless text.match?(/[[:cntrl:]]/)
126
+
102
127
  text.gsub("\t", INDENT).gsub(/[[:cntrl:]]/, '')
103
128
  end
104
129
 
105
- def display_width(text)
106
- Unicode::DisplayWidth.of(text)
107
- end
130
+ def display_width(text) = Text.display_width(text)
108
131
  end
109
132
  end
110
133
  end
@@ -32,7 +32,7 @@ module Raindown
32
32
  def path = nil
33
33
  def changed? = false
34
34
  def typing? = false
35
- def animating? = false
35
+ def pace(_hovered) = :active
36
36
 
37
37
  def status(_navigator)
38
38
  found = "#{charts.length} #{charts.length == 1 ? 'chart' : 'charts'} in #{File.basename(project || root)}"
@@ -111,7 +111,7 @@ module Raindown
111
111
  answer = worker.call(:charts, find:)
112
112
  @cards.concat(answer[:cards])
113
113
  @errors.concat(answer[:errors])
114
- rescue Worker::Error => e
114
+ rescue Workers::Worker::Error => e
115
115
  @errors << "#{File.basename(worker.root)}: #{e.message}"
116
116
  end
117
117
  @cards.sort_by! { [it.project, it.path, it.line] }
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative '../events'
4
- require_relative '../workers/worker'
4
+ require_relative '../../workers/worker'
5
5
 
6
6
  module Raindown
7
7
  module TUI
@@ -27,7 +27,7 @@ module Raindown
27
27
  def status(_navigator) = [:info, endpoint.text]
28
28
  def changed? = false
29
29
  def typing? = false
30
- def animating? = false
30
+ def pace(_hovered) = :active
31
31
  def next_step?(key) = %i[up down enter edit execute].include?(key)
32
32
 
33
33
  # Clicks on the response only move the focus to it.
@@ -40,7 +40,7 @@ module Raindown
40
40
  @language = lines.first.to_s[FENCE, 1]
41
41
  @response = @language ? lines[1...-1] : lines
42
42
  @error = nil
43
- rescue Worker::Error => e
43
+ rescue Workers::Worker::Error => e
44
44
  @response = []
45
45
  @error = e.message
46
46
  end
@@ -14,7 +14,7 @@ module Raindown
14
14
 
15
15
  def renderer = ListRenderer
16
16
  def typing? = false
17
- def animating? = false
17
+ def pace(_hovered) = :active
18
18
 
19
19
  def rows = @rows.call
20
20
  def selected = rows[cursor]
@@ -5,7 +5,7 @@ require_relative '../events'
5
5
  require_relative 'list_view'
6
6
  require_relative '../highlighter'
7
7
  require_relative '../row'
8
- require_relative '../workers/worker'
8
+ require_relative '../../workers/worker'
9
9
 
10
10
  module Raindown
11
11
  module TUI
@@ -37,7 +37,7 @@ module Raindown
37
37
  @directives = outline.directives
38
38
  @highlighted = {}
39
39
  @error = nil
40
- rescue Worker::Error, SystemCallError => e
40
+ rescue Workers::Worker::Error, SystemCallError => e
41
41
  @error = e.message
42
42
  end
43
43
 
@@ -128,12 +128,13 @@ module Raindown
128
128
  def buttons(directive, expanded:)
129
129
  buttons = []
130
130
  buttons << [expanded ? '[-]' : "[+#{directive.body.size}]", :toggle] if directive.body
131
- buttons << ['[Run]', :execute] if directive.endpoint?
131
+ buttons << ['[Run]', :execute] if directive.endpoint? && directive.writes
132
132
  buttons
133
133
  end
134
134
 
135
+ # A self-closing directive, like <{ :toc }>, by its name after a colon, and the rest by theirs before one.
135
136
  def directive_text(directive)
136
- return ":#{directive.name}" unless directive.writes
137
+ return ":#{directive.name}" if !directive.writes && directive.line == directive.end_line
137
138
 
138
139
  [directive.name, ':', (" #{directive.args}" unless directive.args.empty?)].join
139
140
  end
data/lib/raindown/tui.rb CHANGED
@@ -4,28 +4,38 @@ require_relative 'projects'
4
4
  require_relative 'tui/navigator/navigator'
5
5
 
6
6
  module Raindown
7
- # The terminal UI, started with "raindown tui": a project's Markdown files beside the open file's outline,
7
+ # The terminal UI, started with "raindown": a project's Markdown files beside the open file's outline,
8
8
  # where directives fold, endpoints run and the editor opens at a line. Curses only loads here,
9
9
  # so watching and rendering start without it.
10
10
  module TUI
11
11
  module_function
12
12
 
13
- # PATH is a file to open, or a directory. Its project is the one it's in, or else the directory itself.
14
- def start(path)
13
+ # PATH is a file to open, or a directory. Its project is the one it's in, or else the directory itself. It returns
14
+ # true once it's quit. Without a terminal, like in a script or a service, it says so, once curses has loaded, and returns false, to be
15
+ # watched instead, as curses' own error would be "Error opening terminal: unknown". Bare and execute, like --bare and
16
+ # -x, allow bare directives in any file and run endpoints with no response as it writes back.
17
+ def start(path, bare: false, execute: false)
15
18
  require_relative 'tui/terminal/screen'
16
- # Without a terminal, curses' own error is "Error opening terminal: unknown".
17
- abort('raindown tui needs a terminal') unless $stdin.tty? && $stdout.tty?
19
+ unless $stdin.tty? && $stdout.tty?
20
+ warn("raindown needs a terminal, so it's watching instead")
21
+ return false
22
+ end
18
23
 
19
24
  path = File.expand_path(path)
20
25
  root = Projects.root(path) || (File.directory?(path) ? path : File.dirname(path))
21
26
  # The projects inside it are shown too. Each loads its own nodes in its worker's process, like when watching,
22
27
  # so two projects' nodes of the same name stay apart.
23
- workers = Workers.new(root:)
28
+ workers = Project.workers(root:, bare:, execute:)
24
29
 
25
30
  # Rouge loads here, rather than with Raindown, which doesn't highlight code outside the TUI.
26
31
  require 'rouge'
27
- Screen.new(Navigator.new(root:, path: (path unless File.directory?(path)), workers:)).run
32
+ # Saved files are written back, like when the folder's watched.
33
+ require_relative 'tui/saves'
34
+ saves = Saves.new(root)
35
+ Screen.new(Navigator.new(root:, path: (path unless File.directory?(path)), workers:, saves:, runs_endpoints: execute)).run
36
+ true
28
37
  ensure
38
+ saves&.stop
29
39
  workers&.stop
30
40
  end
31
41
  end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../projects'
4
+
5
+ module Raindown
6
+ module Watcher
7
+ # Listening skips these folders as they hold dependencies and builds, which Projects ignores too.
8
+ IGNORE = %r{(?:\A|/)(?:#{Projects::IGNORED.join('|')})/}
9
+
10
+ # A change to a project's nodes, or to one of its Markdown files, which is written back when it's been saved.
11
+ Change = Data.define(:root, :path, :nodes) do
12
+ def nodes? = nodes
13
+ def saved? = !nodes && File.exist?(path)
14
+ end
15
+
16
+ # A listener, still to start, that gives the block the paths saved, added or removed in the directory.
17
+ def self.listen(directory, &)
18
+ require 'listen'
19
+ Listen.to(directory, ignore: IGNORE) { |modified, added, removed| yield(modified + added + removed) }
20
+ end
21
+
22
+ # The changes among the paths, by the project each is in, the nearest one above it, or else the root, when there is
23
+ # one, leaving out files that are neither nodes nor Markdown, or are in ignored folders.
24
+ def self.changes(paths, root: nil)
25
+ paths.filter_map do |path|
26
+ project = Projects.root(path) || root || next
27
+ next Change.new(root: project, path:, nodes: true) if Projects.node?(path, root: project)
28
+
29
+ Change.new(root: project, path:, nodes: false) if Projects.markdown?(path, root: project)
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../raindown'
4
+ require_relative '../rewriter'
5
+ require_relative 'changes'
6
+
7
+ module Raindown
8
+ module Watcher
9
+ # Watches one file and writes back to it as it's saved, like its TOC, tables and endpoint responses.
10
+ class FileWatcher
11
+ attr_reader :path, :bare, :execute
12
+
13
+ def initialize(path:, bare: false, execute: false)
14
+ @path = path
15
+ @bare = bare
16
+ @execute = execute
17
+ end
18
+
19
+ def start
20
+ $stdout.sync = true
21
+
22
+ safely { rewrite }
23
+ puts "Watching #{path} for changes..."
24
+
25
+ listener.start
26
+ sleep
27
+ end
28
+
29
+ private
30
+
31
+ def listener
32
+ directory = File.dirname(File.expand_path(path))
33
+ filename = File.basename(path)
34
+
35
+ Watcher.listen(directory) do |paths|
36
+ next unless paths.any? { File.basename(it) == filename && File.exist?(it) }
37
+
38
+ # Writing back saves the file again, and that save changes nothing, so it ends there.
39
+ safely { puts "Updated #{path}" if rewrite }
40
+ end
41
+ end
42
+
43
+ # An error in the file is shown rather than stopping the watcher, so it can be fixed and saved again.
44
+ def safely
45
+ yield
46
+ rescue StandardError => e
47
+ raise if ENV['RAINDOWN_DEBUG']
48
+
49
+ warn "raindown: #{path}: #{e.message}"
50
+ end
51
+
52
+ # The files' metadata is loaded again each time, so new and renamed files are listed.
53
+ def rewrite
54
+ Rewriter.update(path:, bare:, execute:, metadata: Pages.for(path))
55
+ end
56
+ end
57
+ end
58
+ end