raindown 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/lib/raindown/chart/chart.rb +12 -1
  3. data/lib/raindown/cli.rb +2 -2
  4. data/lib/raindown/document.rb +16 -9
  5. data/lib/raindown/endpoint/curl.rb +19 -0
  6. data/lib/raindown/node_blocks.rb +9 -6
  7. data/lib/raindown/nodes.rb +1 -0
  8. data/lib/raindown/projects.rb +8 -4
  9. data/lib/raindown/stat.rb +53 -0
  10. data/lib/raindown/tui/card.rb +56 -0
  11. data/lib/raindown/tui/endpoint.rb +16 -0
  12. data/lib/raindown/tui/endpoints.rb +40 -0
  13. data/lib/raindown/tui/events.rb +50 -0
  14. data/lib/raindown/tui/feelings/batch.rb +47 -0
  15. data/lib/raindown/tui/feelings/bins.rb +67 -0
  16. data/lib/raindown/tui/feelings/celebration.rb +25 -0
  17. data/lib/raindown/tui/feelings/code_box.rb +129 -0
  18. data/lib/raindown/tui/feelings/code_methods.rb +134 -0
  19. data/lib/raindown/tui/feelings/feelings_log.rb +133 -0
  20. data/lib/raindown/tui/feelings/feelings_renderer.rb +184 -0
  21. data/lib/raindown/tui/feelings/feelings_view.rb +161 -0
  22. data/lib/raindown/tui/feelings/fireworks.rb +75 -0
  23. data/lib/raindown/tui/feelings/rain.rb +66 -0
  24. data/lib/raindown/tui/feelings/scene.rb +37 -0
  25. data/lib/raindown/tui/feelings/waffle.rb +43 -0
  26. data/lib/raindown/tui/feelings/weekly_batch.rb +32 -0
  27. data/lib/raindown/tui/file_tree.rb +55 -12
  28. data/lib/raindown/tui/highlighter.rb +45 -0
  29. data/lib/raindown/tui/layout.rb +66 -77
  30. data/lib/raindown/tui/menu_overlay.rb +59 -0
  31. data/lib/raindown/tui/navigator/commands.rb +97 -0
  32. data/lib/raindown/tui/navigator/focus.rb +25 -0
  33. data/lib/raindown/tui/navigator/navigator.rb +160 -0
  34. data/lib/raindown/tui/navigator/project_menu.rb +61 -0
  35. data/lib/raindown/tui/navigator/search.rb +126 -0
  36. data/lib/raindown/tui/navigator/views.rb +52 -0
  37. data/lib/raindown/tui/renderers/dashboard_renderer.rb +102 -0
  38. data/lib/raindown/tui/renderers/endpoint_renderer.rb +81 -0
  39. data/lib/raindown/tui/renderers/list_renderer.rb +15 -0
  40. data/lib/raindown/tui/renderers/outline_renderer.rb +21 -0
  41. data/lib/raindown/tui/renderers/renderer.rb +18 -0
  42. data/lib/raindown/tui/row.rb +12 -3
  43. data/lib/raindown/tui/rows.rb +58 -0
  44. data/lib/raindown/tui/search_overlay.rb +73 -0
  45. data/lib/raindown/tui/terminal/input.rb +91 -0
  46. data/lib/raindown/tui/terminal/ncurses_shim.rb +46 -0
  47. data/lib/raindown/tui/terminal/quiet.rb +32 -0
  48. data/lib/raindown/tui/{screen.rb → terminal/screen.rb} +42 -24
  49. data/lib/raindown/tui/terminal/styles.rb +154 -0
  50. data/lib/raindown/tui/text.rb +110 -0
  51. data/lib/raindown/tui/title.rb +29 -0
  52. data/lib/raindown/tui/views/dashboard_view.rb +147 -0
  53. data/lib/raindown/tui/views/endpoint_view.rb +77 -0
  54. data/lib/raindown/tui/{pane.rb → views/list_view.rb} +11 -8
  55. data/lib/raindown/tui/{outline.rb → views/outline_view.rb} +48 -20
  56. data/lib/raindown/tui/workers/project.rb +122 -0
  57. data/lib/raindown/tui/workers/worker.rb +124 -0
  58. data/lib/raindown/tui/workers/workers.rb +53 -0
  59. data/lib/raindown/tui.rb +10 -5
  60. data/lib/version.rb +1 -1
  61. metadata +63 -9
  62. data/lib/raindown/tui/commands.rb +0 -70
  63. data/lib/raindown/tui/input.rb +0 -55
  64. data/lib/raindown/tui/navigator.rb +0 -123
  65. data/lib/raindown/tui/styles.rb +0 -36
@@ -0,0 +1,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
@@ -11,8 +13,9 @@ module Raindown
11
13
  # Paints a Navigator's Layout with curses, and sends the Navigator the events of keys and clicks, read with Input.
12
14
  # Only this class draws on the terminal, so another library could draw the same Layout.
13
15
  class Screen
14
- # How often, in milliseconds, the open file is checked for changes while no key is pressed.
15
- TICK = 500
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
16
19
 
17
20
  def initialize(navigator)
18
21
  @navigator = navigator
@@ -20,14 +23,20 @@ module Raindown
20
23
  end
21
24
 
22
25
  def run
26
+ Quiet.holding_stderr { loop_screen }
27
+ end
28
+
29
+ private
30
+
31
+ def loop_screen
23
32
  start
24
33
 
25
34
  loop do
26
35
  draw
27
- effect = @navigator.handle(read)
36
+ effect = @navigator.next_step(read)
28
37
  break if effect == :quit
29
38
 
30
- perform(*effect) if effect.is_a?(Array)
39
+ perform(effect)
31
40
  end
32
41
  rescue Interrupt
33
42
  nil
@@ -35,35 +44,29 @@ module Raindown
35
44
  stop
36
45
  end
37
46
 
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
47
+ # Runs a command, like the editor, then tells the Navigator, or shows a message while a slow task runs.
48
+ def perform(effect)
49
+ case effect
50
+ in Run(command:) then suspend { system(*command) }.then { @navigator.ran }
51
+ in Wait(message:, task:) then show(message).then { task.call }
52
+ else nil
49
53
  end
50
54
  end
51
55
 
52
56
  def start
53
57
  ENV['ESCDELAY'] ||= '25'
54
- Curses.init_screen
58
+ NcursesShim.starting { Curses.init_screen }
55
59
  @styles = Styles.start
56
60
  Curses.noecho
57
61
  Curses.cbreak
58
62
  Curses.nonl
59
63
  Curses.curs_set(0)
60
64
  Curses.stdscr.keypad(true)
61
- Curses.stdscr.timeout = TICK
62
- Curses.mousemask(Curses::BUTTON1_PRESSED | Curses::BUTTON1_CLICKED | Input.wheel.keys.sum)
63
- Curses.mouseinterval(0)
65
+ Input.track_mouse
64
66
  end
65
67
 
66
68
  def stop
69
+ Input.untrack_mouse
67
70
  Curses.close_screen
68
71
  end
69
72
 
@@ -80,11 +83,21 @@ module Raindown
80
83
  segments.each { |segment| paint(window, segment) }
81
84
  end
82
85
 
86
+ repaint_title(lines.first, window)
83
87
  window.refresh
84
88
  end
85
89
 
90
+ # The title's rain cloud is one column to ncurses, and two in terminals like iTerm and kitty, which would put
91
+ # the rest of the title a column out when ncurses changes part of it. So when it changes, the screen is
92
+ # repainted, and the title is written in one go.
93
+ def repaint_title(title, window)
94
+ text = title&.map(&:text)&.join
95
+ window.redraw if @title && text != @title
96
+ @title = text
97
+ end
98
+
86
99
  def paint(window, segment)
87
- window.attron(@styles.fetch(segment.style, Curses::A_NORMAL)) { window.addstr(segment.text) }
100
+ window.attron(@styles[segment.style]) { window.addstr(segment.text) }
88
101
  end
89
102
 
90
103
  def last_line(segments)
@@ -94,16 +107,18 @@ module Raindown
94
107
 
95
108
  # The next event, or :tick when no key is pressed in time, or nil for keys without one, like a resize.
96
109
  def read
110
+ Curses.stdscr.timeout = TICKS.fetch(@navigator.animating?)
97
111
  key = Curses.stdscr.getch
98
112
  return :tick if key.nil?
99
113
  return mouse if key == Curses::KEY_MOUSE
100
114
 
101
115
  Curses.stdscr.clear if key == Curses::KEY_RESIZE
102
- Input.key_event(key)
116
+ Input.key_event(key, typing: @navigator.typing?)
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
 
@@ -116,13 +131,16 @@ module Raindown
116
131
  window.refresh
117
132
  end
118
133
 
119
- # Leaves the screen to the command, like the editor, and comes back to it after.
134
+ # 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.
120
136
  def suspend
121
137
  Curses.def_prog_mode
138
+ Input.untrack_mouse
122
139
  Curses.close_screen
123
140
  yield
124
141
  ensure
125
142
  Curses.reset_prog_mode
143
+ Input.track_mouse
126
144
  Curses.stdscr.clear
127
145
  Curses.refresh
128
146
  end
@@ -0,0 +1,154 @@
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. 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
9
+ # 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
+ # background.
11
+ # With only 8 colours, there's no dark grey, so those rows are underlined, and without colours,
12
+ # the rest are in reverse.
13
+ module Styles
14
+ COLORS = { button: Curses::COLOR_YELLOW, error: Curses::COLOR_RED }.freeze
15
+ # 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
+ # have, or magenta and blue with 8.
18
+ ACCENTS = { directive: 141, folder: 111, title: 111, heading: 111 }.freeze
19
+ BASIC_ACCENTS = { directive: Curses::COLOR_MAGENTA, folder: Curses::COLOR_BLUE, title: Curses::COLOR_BLUE,
20
+ heading: Curses::COLOR_BLUE }.freeze
21
+ # The colours of endpoints' HTTP methods, like GET in green, from the 256 colours most terminals have,
22
+ # 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
24
+ BASIC_METHODS = { get: Curses::COLOR_GREEN, post: Curses::COLOR_BLUE, delete: Curses::COLOR_RED,
25
+ put: Curses::COLOR_YELLOW, patch: Curses::COLOR_MAGENTA, head: Curses::COLOR_CYAN,
26
+ options: Curses::COLOR_CYAN }.freeze
27
+ # Highlighted code's colours, by the kind of token, from the 256 colours most terminals have, or the 8 they all do,
28
+ # where comments are dim instead of grey.
29
+ CODE = { code_keyword: 204, code_string: 114, code_number: 179, code_function: 75, code_constant: 117,
30
+ code_comment: 244 }.freeze
31
+ BASIC_CODE = { code_keyword: Curses::COLOR_MAGENTA, code_string: Curses::COLOR_GREEN, code_number: Curses::COLOR_YELLOW,
32
+ code_function: Curses::COLOR_BLUE, code_constant: Curses::COLOR_CYAN, code_comment: Curses::COLOR_WHITE }.freeze
33
+ # Code feelings' bright colours, for its rain and fireworks, from the 256 colours most terminals have, or
34
+ # the 8 they all do.
35
+ BRIGHT = { bright_red: 203, bright_orange: 215, bright_yellow: 227, bright_green: 120, bright_cyan: 87,
36
+ bright_blue: 75, bright_purple: 177, bright_pink: 213 }.freeze
37
+ BASIC_BRIGHT = { bright_red: Curses::COLOR_RED, bright_orange: Curses::COLOR_YELLOW, bright_yellow: Curses::COLOR_YELLOW,
38
+ bright_green: Curses::COLOR_GREEN, bright_cyan: Curses::COLOR_CYAN, bright_blue: Curses::COLOR_BLUE,
39
+ 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
43
+ # Text that has no colour of its own, from the 256 colours most terminals have, or the terminal's own with 8.
44
+ 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
48
+ BRIGHT_WHITE = 15
49
+ DARK_GREY = 238
50
+
51
+ module_function
52
+
53
+ # Sets up the colours, once the screen has started, and returns the attribute of each style.
54
+ def start
55
+ return plain unless Curses.has_colors?
56
+
57
+ start_colors
58
+ colored
59
+ end
60
+
61
+ # Without colours, a style that isn't here is the terminal's own.
62
+ def plain
63
+ Hash.new(Curses::A_NORMAL).merge(
64
+ title: Curses::A_REVERSE | Curses::A_BOLD, selected: Curses::A_REVERSE, selected_button: Curses::A_REVERSE,
65
+ cursor: Curses::A_UNDERLINE, cursor_button: Curses::A_UNDERLINE | Curses::A_BOLD, heading: Curses::A_BOLD,
66
+ 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
+ key: Curses::A_REVERSE, border: Curses::A_DIM, divider: Curses::A_BOLD,
68
+ selected_divider: Curses::A_REVERSE, cursor_divider: Curses::A_BOLD,
69
+ code_keyword: Curses::A_BOLD, code_comment: Curses::A_DIM
70
+ )
71
+ end
72
+
73
+ def colored
74
+ 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
79
+ )
80
+ end
81
+
82
+ # 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
+ # also any style that isn't here.
84
+ def in_text_color
85
+ text = pair(:text)
86
+ Hash.new(text).merge(plain.transform_values { it | text })
87
+ end
88
+
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.
91
+ def emphasis
92
+ lavender = pair(:feelings)
93
+ { heading: pair(:heading) | Curses::A_BOLD, feelings: lavender, chart: lavender, code: lavender }
94
+ end
95
+
96
+ # Highlighted code's tokens, like keywords and comments, with comments dim in 8 colours, where there's no grey.
97
+ def code_tokens
98
+ tokens = CODE.keys.to_h { [it, pair(it)] }
99
+ tokens[:code_comment] |= Curses::A_DIM if Curses.colors < 256
100
+ tokens
101
+ end
102
+
103
+ # The bright colours, bold, so they're bright in 8 colours too.
104
+ def bright = BRIGHT.keys.to_h { [it, pair(it) | Curses::A_BOLD] }
105
+
106
+ # The line between the panes, and where it meets a selected row.
107
+ def dividers = { divider: pair(:divider), selected_divider: pair(:selected_divider) }
108
+
109
+ def files
110
+ { folder: pair(:folder) | Curses::A_BOLD, project: pair(:folder) | Curses::A_BOLD,
111
+ page: pair(:folder) | Curses::A_BOLD, **METHODS.keys.to_h { [:"method_#{it}", pair(:"method_#{it}") | Curses::A_BOLD] } }
112
+ end
113
+
114
+ # The selected rows of the panes without the focus, on dark grey where there's one.
115
+ def unfocused
116
+ return { cursor_button: pair(:button) | Curses::A_BOLD, cursor_divider: pair(:divider) } unless Curses.colors >= 256
117
+
118
+ { cursor: pair(:cursor), cursor_button: pair(:cursor_button) | Curses::A_BOLD, cursor_divider: pair(:cursor_divider) }
119
+ end
120
+
121
+ # Each colour pair as [name, foreground, background], where -1 is the terminal's own background.
122
+ def pairs
123
+ many = Curses.colors >= 256
124
+ blues = many ? BLUES : BASIC_BLUES
125
+ white = many ? BRIGHT_WHITE : Curses::COLOR_WHITE
126
+ text = many ? PERIWINKLE : -1
127
+
128
+ [*COLORS.map { |name, color| [name, color, -1] },
129
+ *(many ? METHODS : BASIC_METHODS).map { |name, color| [:"method_#{name}", color, -1] },
130
+ *(many ? CODE.merge(BRIGHT, FEELINGS, ACCENTS) : BASIC_CODE.merge(BASIC_BRIGHT, BASIC_FEELINGS, BASIC_ACCENTS))
131
+ .map { |name, color| [name, color, -1] },
132
+ [: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]]
136
+ end
137
+
138
+ def pair(name)
139
+ Curses.color_pair(pairs.index { it.first == name } + 1)
140
+ end
141
+
142
+ # The 8 colour white is grey, which bold brightens.
143
+ def white_bold
144
+ Curses.colors >= 256 ? Curses::A_NORMAL : Curses::A_BOLD
145
+ end
146
+
147
+ def start_colors
148
+ Curses.start_color
149
+ Curses.use_default_colors
150
+ pairs.each.with_index(1) { |(_name, foreground, background), index| Curses.init_pair(index, foreground, background) }
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'unicode/display_width'
4
+
5
+ module Raindown
6
+ module TUI
7
+ # Text measured in a terminal's columns, where a wide character like 界 takes two, cut to fit them in styled Segments.
8
+ module Text
9
+ Segment = Data.define(:text, :style, :event)
10
+ INDENT = ' '
11
+
12
+ private
13
+
14
+ # A path from the home folder when it's in it, like ~/dev/raindown.
15
+ def from_home(path)
16
+ home = Dir.home
17
+ path == home || path.start_with?(File.join(home, '')) ? path.sub(home, '~') : path
18
+ rescue ArgumentError
19
+ path
20
+ end
21
+
22
+ # The text fit to the width, as a Segment in the style.
23
+ def segment(text, style, width: self.width, event: nil)
24
+ Segment.new(text: fit(printable(text), width), style:, event:)
25
+ end
26
+
27
+ # Text cut or padded to the width, with a cut shown by an ellipsis.
28
+ def fit(text, width)
29
+ return '' if width <= 0
30
+ return text + (' ' * (width - display_width(text))) if display_width(text) <= width
31
+
32
+ cut = +''
33
+ cut_width = 0
34
+ text.each_char do |char|
35
+ break if cut_width + display_width(char) > width - 1
36
+
37
+ cut << char
38
+ cut_width += display_width(char)
39
+ end
40
+ "#{cut}…#{' ' * (width - 1 - cut_width)}"
41
+ end
42
+
43
+ # The widths of texts side by side in a width: each as wide as it is, and the last as wide as what's left,
44
+ # with none past the width.
45
+ def spread(texts, width)
46
+ texts.each_with_index.map do |text, index|
47
+ part = index == texts.length - 1 ? width : [display_width(text), width].min
48
+ width -= part
49
+ part
50
+ end
51
+ end
52
+
53
+ # A line of highlighted code's [text, style] parts as segments, cut or padded to the width.
54
+ def code_segments(parts, width, event: nil)
55
+ segments = segments_between(parts.map { |text, style| Segment.new(text: printable(text), style:, event:) }, 0, width)
56
+ [*segments, segment('', :code, width: width - segments.sum { display_width(it.text) }, event:)]
57
+ end
58
+
59
+ # The text in lines that fit the width, broken at spaces where it can, and inside a word too long for a line.
60
+ def wrap(text, width)
61
+ return [text] unless width.positive?
62
+
63
+ text.scan(/\S+|\s+/).each_with_object([+'']) do |part, lines|
64
+ next lines.last << part if display_width(lines.last + part) <= width
65
+ next lines << +'' if part.strip.empty?
66
+
67
+ lines << +'' unless lines.last.empty?
68
+ break_word(part, width, lines)
69
+ end
70
+ end
71
+
72
+ # Adds a word too long for a line to the lines, a character at a time, starting a new line when one's full.
73
+ def break_word(word, width, lines)
74
+ word.each_char do |char|
75
+ lines << +'' if display_width(lines.last + char) > width
76
+ lines.last << char
77
+ end
78
+ end
79
+
80
+ # The line's segments, with others drawn over its columns from the column on, like a box over the screen.
81
+ def overlay_segments(line, segments, column:)
82
+ width = segments.sum { display_width(it.text) }
83
+ [*segments_between(line, 0, column), *segments, *segments_between(line, column + width, Float::INFINITY)]
84
+ end
85
+
86
+ # The parts of the line's segments between two columns, where a wide character cut in two leaves spaces.
87
+ def segments_between(line, from, to)
88
+ column = 0
89
+ 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?
97
+ end
98
+ end
99
+
100
+ # Tabs and control characters, like in a directive's output, would move the terminal's cursor.
101
+ def printable(text)
102
+ text.gsub("\t", INDENT).gsub(/[[:cntrl:]]/, '')
103
+ end
104
+
105
+ def display_width(text)
106
+ Unicode::DisplayWidth.of(text)
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raindown
4
+ module TUI
5
+ # A Layout's title line: Raindown's name, the folder the files are shown from, and the page.
6
+ module Title
7
+ # The rain cloud without the emoji variation selector, like the nav's projects, which terminals draw one column wide,
8
+ # as it's measured, so the title line reaches the edge.
9
+ NAME = '🌧 Raindown'
10
+
11
+ private
12
+
13
+ # The name, the folder, which opens the project menu when there's more than one project, and the page.
14
+ def title
15
+ parts = [[name, nil], ["#{location}#{' ▾' if navigator.projects?}", (:projects if navigator.projects?)],
16
+ [" › #{view_renderer.page}", nil]]
17
+
18
+ parts.zip(spread(parts.map(&:first), width)).map do |(text, event), part_width|
19
+ segment(text, :title, width: part_width, event:)
20
+ end
21
+ end
22
+
23
+ def name = " #{NAME} "
24
+
25
+ # The folder the files are shown from.
26
+ def location = from_home(navigator.base)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../events'
4
+
5
+ module Raindown
6
+ module TUI
7
+ # Every chart in the projects' Markdown files, drawn from its data by its project's Worker, as cards in a grid
8
+ # with a cursor. A chart is a chart: directive, or a project's own node that's a Raindown::Chart, like
9
+ # "Sales < Raindown::Chart", or a Raindown::Stat, one big number, like "CodeDebt < Raindown::Stat". Charts are drawn
10
+ # again every few seconds, so they follow changes to their data, like a CSV file, without a save.
11
+ # A key's next step, for the Navigator, is nil, or an Open or Edit of a chart's directive.
12
+ class DashboardView
13
+ # Seconds between drawing the charts again.
14
+ REDRAW = 2
15
+ MOVES = %i[up down left right].freeze
16
+ # Columns between the cards, and the narrowest a card is, with its border.
17
+ GAP = 2
18
+ MIN_CARD_WIDTH = 28
19
+
20
+ attr_reader :root, :errors, :cursor, :project
21
+
22
+ def initialize(root:, workers:)
23
+ @root = root
24
+ @workers = workers
25
+ @cursor = 0
26
+ @offset = 0
27
+ @rows = []
28
+ end
29
+
30
+ def renderer = DashboardRenderer
31
+ def target = self
32
+ def path = nil
33
+ def changed? = false
34
+ def typing? = false
35
+ def animating? = false
36
+
37
+ def status(_navigator)
38
+ found = "#{charts.length} #{charts.length == 1 ? 'chart' : 'charts'} in #{File.basename(project || root)}"
39
+ errors.empty? ? [:info, found] : [:error, "#{found}, but couldn't read #{errors.join(', ')}"]
40
+ end
41
+
42
+ # Finds the charts again, like when it's opened, or a file is saved or added.
43
+ def load
44
+ draw(find: true)
45
+ @cursor = @cursor.clamp(0, [charts.length - 1, 0].max)
46
+ end
47
+
48
+ # The charts, as each project's Worker drew them, of the project they're narrowed to, if any.
49
+ def charts
50
+ project ? @cards.filter { it.project == project } : @cards
51
+ end
52
+
53
+ # Narrows the charts to a project's, or with nil, shows every project's.
54
+ def project=(project)
55
+ @project = project
56
+ @cursor = 0
57
+ @offset = 0
58
+ end
59
+
60
+ def cards
61
+ draw if Process.clock_gettime(Process::CLOCK_MONOTONIC) - @drawn_at >= REDRAW
62
+ charts
63
+ end
64
+
65
+ # The charts' indexes in rows, as many of a project's as fit across the width after a column of margin, with each
66
+ # project's charts starting a row. They're kept, so up and down move a row.
67
+ def rows(width)
68
+ @rows = charts.each_index.chunk_while { |a, b| charts[a].project == charts[b].project }.flat_map { group_rows(it, width) }
69
+ end
70
+
71
+ # The widths a card can be: no narrower than MIN_CARD_WIDTH, when there's room, and no wider than the width.
72
+ def card_widths(width) = [MIN_CARD_WIDTH, width - 1].min..(width - 1)
73
+
74
+ def selected = charts[@cursor]
75
+
76
+ def first_in_row?
77
+ @rows.empty? || @rows.any? { it.first == @cursor }
78
+ end
79
+
80
+ # Every key but Tab to the files, or left from the first card in a row.
81
+ def next_step?(key) = !%i[tab tick].include?(key) && !(key == :left && first_in_row?)
82
+
83
+ def next_step(key)
84
+ chart = selected
85
+ case key
86
+ when *MOVES then move(key).then { nil }
87
+ when :enter then chart && Open.new(path: chart.path, line: chart.line)
88
+ when :edit then chart && Edit.new(path: chart.path, line: chart.line)
89
+ when :reload then load.then { nil }
90
+ end
91
+ end
92
+
93
+ def cursor=(index)
94
+ @cursor = index.clamp(0, [charts.length - 1, 0].max)
95
+ end
96
+
97
+ # The first row of cards to show, given each row's height, scrolled so the cursor's row is shown.
98
+ def scroll(row_heights, height)
99
+ row = row_of(@cursor)
100
+ @offset = row if row < @offset
101
+ @offset += 1 while @offset < row && row_heights[@offset..row].sum > height
102
+ @offset = @offset.clamp(0, [row_heights.length - 1, 0].max)
103
+ end
104
+
105
+ private
106
+
107
+ def draw(find: false)
108
+ @cards = []
109
+ @errors = []
110
+ @workers.each do |worker|
111
+ answer = worker.call(:charts, find:)
112
+ @cards.concat(answer[:cards])
113
+ @errors.concat(answer[:errors])
114
+ rescue Worker::Error => e
115
+ @errors << "#{File.basename(worker.root)}: #{e.message}"
116
+ end
117
+ @cards.sort_by! { [it.project, it.path, it.line] }
118
+ @drawn_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
119
+ end
120
+
121
+ def group_rows(indexes, width)
122
+ indexes.each_with_object([[]]) do |index, rows|
123
+ rows << [] if rows.last.any? && row_width(rows.last + [index], width) > width
124
+ rows.last << index
125
+ end
126
+ end
127
+
128
+ # A row of cards' width, after a column of margin, with a gap between each.
129
+ def row_width(row, width)
130
+ 1 + row.sum { charts[it].box_width(within: card_widths(width)) } + (GAP * (row.length - 1))
131
+ end
132
+
133
+ # Left and right move along the cards, and up and down to the card in the same place in the row above or below.
134
+ def move(name)
135
+ return self.cursor = @cursor + (name == :left ? -1 : 1) if %i[left right].include?(name)
136
+
137
+ row = row_of(@cursor)
138
+ target = @rows[(row + (name == :up ? -1 : 1)).clamp(0, [@rows.length - 1, 0].max)]
139
+ self.cursor = target ? target[[@rows[row].index(@cursor), target.length - 1].min] : @cursor
140
+ end
141
+
142
+ def row_of(index)
143
+ @rows.index { it.include?(index) } || 0
144
+ end
145
+ end
146
+ end
147
+ end