raindown 0.6.0 → 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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/lib/raindown/chart/chart.rb +12 -1
  3. data/lib/raindown/document.rb +1 -5
  4. data/lib/raindown/endpoint/curl.rb +11 -7
  5. data/lib/raindown/node_blocks.rb +9 -6
  6. data/lib/raindown/nodes.rb +1 -0
  7. data/lib/raindown/stat.rb +53 -0
  8. data/lib/raindown/tui/events.rb +16 -1
  9. data/lib/raindown/tui/feelings/batch.rb +47 -0
  10. data/lib/raindown/tui/feelings/bins.rb +67 -0
  11. data/lib/raindown/tui/feelings/celebration.rb +25 -0
  12. data/lib/raindown/tui/feelings/code_box.rb +129 -0
  13. data/lib/raindown/tui/feelings/code_methods.rb +134 -0
  14. data/lib/raindown/tui/feelings/feelings_log.rb +133 -0
  15. data/lib/raindown/tui/feelings/feelings_renderer.rb +184 -0
  16. data/lib/raindown/tui/feelings/feelings_view.rb +161 -0
  17. data/lib/raindown/tui/feelings/fireworks.rb +75 -0
  18. data/lib/raindown/tui/feelings/rain.rb +66 -0
  19. data/lib/raindown/tui/feelings/scene.rb +37 -0
  20. data/lib/raindown/tui/feelings/waffle.rb +43 -0
  21. data/lib/raindown/tui/feelings/weekly_batch.rb +32 -0
  22. data/lib/raindown/tui/file_tree.rb +6 -5
  23. data/lib/raindown/tui/highlighter.rb +45 -0
  24. data/lib/raindown/tui/layout.rb +14 -7
  25. data/lib/raindown/tui/menu_overlay.rb +1 -1
  26. data/lib/raindown/tui/navigator/commands.rb +14 -1
  27. data/lib/raindown/tui/navigator/navigator.rb +42 -9
  28. data/lib/raindown/tui/navigator/search.rb +126 -0
  29. data/lib/raindown/tui/navigator/views.rb +6 -3
  30. data/lib/raindown/tui/renderers/dashboard_renderer.rb +4 -4
  31. data/lib/raindown/tui/renderers/endpoint_renderer.rb +14 -10
  32. data/lib/raindown/tui/renderers/outline_renderer.rb +4 -4
  33. data/lib/raindown/tui/renderers/renderer.rb +3 -0
  34. data/lib/raindown/tui/row.rb +4 -2
  35. data/lib/raindown/tui/rows.rb +2 -1
  36. data/lib/raindown/tui/search_overlay.rb +73 -0
  37. data/lib/raindown/tui/terminal/input.rb +42 -5
  38. data/lib/raindown/tui/terminal/screen.rb +12 -9
  39. data/lib/raindown/tui/terminal/styles.rb +78 -20
  40. data/lib/raindown/tui/text.rb +27 -0
  41. data/lib/raindown/tui/title.rb +1 -1
  42. data/lib/raindown/tui/views/dashboard_view.rb +6 -4
  43. data/lib/raindown/tui/views/endpoint_view.rb +2 -0
  44. data/lib/raindown/tui/views/list_view.rb +2 -0
  45. data/lib/raindown/tui/views/outline_view.rb +11 -2
  46. data/lib/raindown/tui/workers/project.rb +6 -6
  47. data/lib/raindown/tui.rb +2 -0
  48. data/lib/version.rb +1 -1
  49. metadata +33 -2
@@ -5,8 +5,10 @@ module Raindown
5
5
  # A line of a pane. Target is what it's about: a folder or file path, a Heading or Directive, or a line number.
6
6
  # Buttons are [label, event] pairs, like ['[Run]', :execute], and a tag is an Endpoint, whose method is before its text.
7
7
  # Open is the Navigator's next step on Enter, like an Open of a file, and fold, a block that folds or expands the row.
8
- Row = Data.define(:text, :depth, :kind, :target, :buttons, :tag, :open, :fold) do
9
- def initialize(buttons: [], tag: nil, open: nil, fold: nil, **) = super
8
+ # Code is the text highlighted, as [text, style] parts, for a line of code, like a directive's body.
9
+ # Each optional field has a default, which is why initialize takes so many.
10
+ Row = Data.define(:text, :depth, :kind, :target, :buttons, :tag, :open, :fold, :code) do
11
+ def initialize(buttons: [], tag: nil, open: nil, fold: nil, code: nil, **) = super # rubocop:disable Metrics/ParameterLists
10
12
 
11
13
  # A separator is space between a pane's sections, which the cursor skips and clicks don't reach.
12
14
  def selectable? = kind != :separator
@@ -29,9 +29,10 @@ module Raindown
29
29
  parts.zip(spread(parts.map(&:first), width)).map { |(text, style), part| segment(text, style, width: part, event:) }
30
30
  end
31
31
 
32
- # The row's text, after its tag in the tag's style, unless the row's selected.
32
+ # The row's text, after its tag in the tag's style, or highlighted when it's code, unless the row's selected.
33
33
  def row_parts(row, style, selected)
34
34
  lead = " #{Text::INDENT * row.depth}"
35
+ return [[lead, style], *row.code] if row.code && !selected
35
36
  return [["#{lead}#{row.text}", style]] unless row.tag
36
37
 
37
38
  [["#{lead}#{row.tag.method} ", selected ? style : row.tag.style], [row.text, style]]
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'events'
4
+ require_relative 'text'
5
+
6
+ module Raindown
7
+ module TUI
8
+ # The search, drawn over the screen's lines in a box below the title: what's typed, then the places that match it,
9
+ # 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.
16
+ class SearchOverlay
17
+ include Text
18
+
19
+ # The widest the box is, with its border.
20
+ MAX_WIDTH = 72
21
+
22
+ def initialize(navigator, width:)
23
+ @navigator = navigator
24
+ @box_width = [width - 4, MAX_WIDTH].min
25
+ @column = (width - @box_width) / 2
26
+ end
27
+
28
+ # The lines with the box drawn over them, from the line below the title.
29
+ def apply(lines)
30
+ box.each_with_index do |segments, offset|
31
+ row = offset + 1
32
+ lines[row] = overlay_segments(lines[row], segments, column: @column) if lines[row]
33
+ end
34
+ lines
35
+ end
36
+
37
+ private
38
+
39
+ # The columns inside the border, "│ " and " │".
40
+ def inner = @box_width - 4
41
+
42
+ def box
43
+ [edge('┌─ Teleport ', '┐'), boxed(segment("/ #{@navigator.query}▏", :menu, width: inner)), *results, edge('└', '┘')]
44
+ end
45
+
46
+ def results
47
+ matches = @navigator.matches
48
+ return [boxed(segment('No matches', :info, width: inner))] if matches.empty?
49
+
50
+ matches.each_with_index.map { |place, index| place_line(place, index) }
51
+ end
52
+
53
+ # The place's text, then where it is, on the right, both highlighted when it's selected.
54
+ def place_line(place, index)
55
+ text_style = :menu
56
+ where_style = :body
57
+ text_style = where_style = :selected if @navigator.match == index
58
+ event = Click.new(pane: :search, index:, action: :enter)
59
+ where = " #{place.where}"
60
+ where_width = [display_width(where), inner / 2].min
61
+
62
+ boxed(segment(place.text, text_style, width: inner - where_width, event:),
63
+ segment(where, where_style, width: where_width, event:))
64
+ end
65
+
66
+ def boxed(*segments) = [segment('│ ', :border, width: 2), *segments, segment(' │', :border, width: 2)]
67
+
68
+ def edge(left, right)
69
+ [segment("#{left}#{'─' * (@box_width - display_width(left) - 1)}#{right}", :border, width: @box_width)]
70
+ end
71
+ end
72
+ end
73
+ end
@@ -3,6 +3,8 @@
3
3
  require 'curses'
4
4
  require 'unicode/display_width'
5
5
 
6
+ require_relative '../events'
7
+
6
8
  module Raindown
7
9
  module TUI
8
10
  # Turns curses' keys and clicks into a Navigator's events.
@@ -12,25 +14,60 @@ module Raindown
12
14
  Curses::KEY_LEFT => :left, 'h' => :left, Curses::KEY_RIGHT => :right, 'l' => :right,
13
15
  "\t" => :tab, Curses::KEY_BTAB => :tab, "\r" => :enter, "\n" => :enter, Curses::KEY_ENTER => :enter,
14
16
  ' ' => :toggle, 'x' => :execute, 'e' => :edit, 'w' => :write_back, 'r' => :reload, 'd' => :dashboard,
15
- 'p' => :projects, "\e" => :escape, 'q' => :quit
17
+ 'p' => :projects, 'f' => :feelings, "\x14" => :celebrate, '/' => :search,
18
+ '1' => :bin1, '2' => :bin2, '3' => :bin3, '4' => :bin4, '5' => :bin5, '6' => :bin6, "\e" => :escape, 'q' => :quit
19
+ }.freeze
20
+ # While typing, like in the search, printable characters are typed, and only these keys do anything else.
21
+ TYPING_KEYS = {
22
+ Curses::KEY_UP => :up, Curses::KEY_DOWN => :down, "\r" => :enter, "\n" => :enter, Curses::KEY_ENTER => :enter,
23
+ "\e" => :escape, Curses::KEY_BACKSPACE => :backspace, "\x7F" => :backspace, "\b" => :backspace
16
24
  }.freeze
17
25
 
18
26
  module_function
19
27
 
20
28
  # 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)
29
+ # Letters are the same with Shift, as the buttons show them as capitals, unless they're being typed.
30
+ def key_event(key, typing: false)
31
+ return typing_event(key) if typing
32
+
23
33
  key = key.chr if key.is_a?(Integer) && key < 256
24
34
  KEYS[key.is_a?(String) ? key.downcase : key]
25
35
  end
26
36
 
37
+ def typing_event(key)
38
+ key = key.chr if key.is_a?(Integer) && key < 256
39
+ TYPING_KEYS.fetch(key) { Type.new(text: key) if key.is_a?(String) && key.match?(/\A[[:print:]]\z/) }
40
+ end
41
+
27
42
  # The mouse buttons that click, which are the left button's. Scrolling does nothing, and since older ncurses,
28
43
  # like macOS's, has no button for scrolling down, it can't be mistaken for a click.
29
44
  CLICKS = Curses::BUTTON1_PRESSED | Curses::BUTTON1_CLICKED
30
45
 
31
- # A click sends the event of what's under it.
46
+ # Moving the mouse, which terminals report once they're asked to, with any motion tracking.
47
+ MOTION = Curses::REPORT_MOUSE_POSITION
48
+ ANY_MOTION = "\e[?1003h"
49
+ NO_MOTION = "\e[?1003l"
50
+
51
+ # The terminal sends the app its mouse events, clicks and moves, so scrolling doesn't scroll the terminal either.
52
+ # ncurses only asks some terminals to report every move, so it's asked directly too, until untrack_mouse.
53
+ def track_mouse
54
+ Curses.mousemask(CLICKS | MOTION)
55
+ Curses.mouseinterval(0)
56
+ $stdout.write(ANY_MOTION)
57
+ $stdout.flush
58
+ end
59
+
60
+ def untrack_mouse
61
+ $stdout.write(NO_MOTION)
62
+ $stdout.flush
63
+ end
64
+
65
+ # A click sends the event of what's under it, and moving the mouse a Hover of it.
32
66
  def mouse_event(event, zones)
33
- click(zones, column: event.x, row: event.y) if event.bstate.anybits?(CLICKS)
67
+ target = click(zones, column: event.x, row: event.y)
68
+ return target if event.bstate.anybits?(CLICKS)
69
+
70
+ Hover.new(target:) if event.bstate.anybits?(MOTION)
34
71
  end
35
72
 
36
73
  # Where each of a Layout's segments with an event is on the screen, as [row, columns, event].
@@ -13,8 +13,9 @@ module Raindown
13
13
  # Paints a Navigator's Layout with curses, and sends the Navigator the events of keys and clicks, read with Input.
14
14
  # Only this class draws on the terminal, so another library could draw the same Layout.
15
15
  class Screen
16
- # How often, in milliseconds, the open file is checked for changes while no key is pressed.
17
- 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
18
19
 
19
20
  def initialize(navigator)
20
21
  @navigator = navigator
@@ -61,13 +62,11 @@ module Raindown
61
62
  Curses.nonl
62
63
  Curses.curs_set(0)
63
64
  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)
65
+ Input.track_mouse
68
66
  end
69
67
 
70
68
  def stop
69
+ Input.untrack_mouse
71
70
  Curses.close_screen
72
71
  end
73
72
 
@@ -98,7 +97,7 @@ module Raindown
98
97
  end
99
98
 
100
99
  def paint(window, segment)
101
- window.attron(@styles.fetch(segment.style, Curses::A_NORMAL)) { window.addstr(segment.text) }
100
+ window.attron(@styles[segment.style]) { window.addstr(segment.text) }
102
101
  end
103
102
 
104
103
  def last_line(segments)
@@ -108,12 +107,13 @@ module Raindown
108
107
 
109
108
  # The next event, or :tick when no key is pressed in time, or nil for keys without one, like a resize.
110
109
  def read
110
+ Curses.stdscr.timeout = TICKS.fetch(@navigator.animating?)
111
111
  key = Curses.stdscr.getch
112
112
  return :tick if key.nil?
113
113
  return mouse if key == Curses::KEY_MOUSE
114
114
 
115
115
  Curses.stdscr.clear if key == Curses::KEY_RESIZE
116
- Input.key_event(key)
116
+ Input.key_event(key, typing: @navigator.typing?)
117
117
  end
118
118
 
119
119
  # The curses gem warns the first time it makes a MouseEvent, that it's "undefining the allocator" of the class.
@@ -131,13 +131,16 @@ module Raindown
131
131
  window.refresh
132
132
  end
133
133
 
134
- # 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.
135
136
  def suspend
136
137
  Curses.def_prog_mode
138
+ Input.untrack_mouse
137
139
  Curses.close_screen
138
140
  yield
139
141
  ensure
140
142
  Curses.reset_prog_mode
143
+ Input.track_mouse
141
144
  Curses.stdscr.clear
142
145
  Curses.refresh
143
146
  end
@@ -4,22 +4,47 @@ 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, 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.
9
11
  # With only 8 colours, there's no dark grey, so those rows are underlined, and without colours,
10
12
  # the rest are in reverse.
11
13
  module Styles
12
- COLORS = { directive: Curses::COLOR_CYAN, button: Curses::COLOR_YELLOW, error: Curses::COLOR_RED,
13
- folder: Curses::COLOR_BLUE }.freeze
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
14
21
  # The colours of endpoints' HTTP methods, like GET in green, from the 256 colours most terminals have,
15
22
  # which are the same in every theme, unlike the 8 they all do, where green can look yellow.
16
23
  METHODS = { get: 40, post: 33, delete: 196, put: 220, patch: 171, head: 44, options: 44 }.freeze
17
24
  BASIC_METHODS = { get: Curses::COLOR_GREEN, post: Curses::COLOR_BLUE, delete: Curses::COLOR_RED,
18
25
  put: Curses::COLOR_YELLOW, patch: Curses::COLOR_MAGENTA, head: Curses::COLOR_CYAN,
19
26
  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
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
23
48
  BRIGHT_WHITE = 15
24
49
  DARK_GREY = 238
25
50
 
@@ -33,26 +58,57 @@ module Raindown
33
58
  colored
34
59
  end
35
60
 
61
+ # Without colours, a style that isn't here is the terminal's own.
36
62
  def plain
37
- { title: Curses::A_REVERSE | Curses::A_BOLD, selected: Curses::A_REVERSE, selected_button: Curses::A_REVERSE,
63
+ Hash.new(Curses::A_NORMAL).merge(
64
+ title: Curses::A_REVERSE | Curses::A_BOLD, selected: Curses::A_REVERSE, selected_button: Curses::A_REVERSE,
38
65
  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 }
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
+ )
42
71
  end
43
72
 
44
73
  def colored
45
- plain.merge(
74
+ in_text_color.merge(
46
75
  title: pair(:title) | Curses::A_BOLD, selected: pair(:selected) | white_bold,
47
76
  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
77
+ error: pair(:error) | Curses::A_BOLD, button: pair(:button) | Curses::A_BOLD, **emphasis,
78
+ **dividers, **code_tokens, **bright, **files, **unfocused
50
79
  )
51
80
  end
52
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
+
53
109
  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] } }
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] } }
56
112
  end
57
113
 
58
114
  # The selected rows of the panes without the focus, on dark grey where there's one.
@@ -67,14 +123,16 @@ module Raindown
67
123
  many = Curses.colors >= 256
68
124
  blues = many ? BLUES : BASIC_BLUES
69
125
  white = many ? BRIGHT_WHITE : Curses::COLOR_WHITE
126
+ text = many ? PERIWINKLE : -1
70
127
 
71
128
  [*COLORS.map { |name, color| [name, color, -1] },
72
129
  *(many ? METHODS : BASIC_METHODS).map { |name, color| [:"method_#{name}", color, -1] },
73
- [:title, white, blues[:title]],
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],
74
133
  [: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]]
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]]
78
136
  end
79
137
 
80
138
  def pair(name)
@@ -50,6 +50,33 @@ module Raindown
50
50
  end
51
51
  end
52
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
+
53
80
  # The line's segments, with others drawn over its columns from the column on, like a box over the screen.
54
81
  def overlay_segments(line, segments, column:)
55
82
  width = segments.sum { display_width(it.text) }
@@ -6,7 +6,7 @@ module Raindown
6
6
  module Title
7
7
  # The rain cloud without the emoji variation selector, like the nav's projects, which terminals draw one column wide,
8
8
  # as it's measured, so the title line reaches the edge.
9
- NAME = '🌧 Raindown'
9
+ NAME = '🌧 Raindown'
10
10
 
11
11
  private
12
12
 
@@ -5,9 +5,9 @@ require_relative '../events'
5
5
  module Raindown
6
6
  module TUI
7
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,
9
- # like "Sales < Raindown::Chart". Charts are drawn again every few seconds, so they follow changes to their data,
10
- # like a CSV file, without a save.
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
11
  # A key's next step, for the Navigator, is nil, or an Open or Edit of a chart's directive.
12
12
  class DashboardView
13
13
  # Seconds between drawing the charts again.
@@ -31,6 +31,8 @@ module Raindown
31
31
  def target = self
32
32
  def path = nil
33
33
  def changed? = false
34
+ def typing? = false
35
+ def animating? = false
34
36
 
35
37
  def status(_navigator)
36
38
  found = "#{charts.length} #{charts.length == 1 ? 'chart' : 'charts'} in #{File.basename(project || root)}"
@@ -45,7 +47,7 @@ module Raindown
45
47
 
46
48
  # The charts, as each project's Worker drew them, of the project they're narrowed to, if any.
47
49
  def charts
48
- project ? @cards.select { it.project == project } : @cards
50
+ project ? @cards.filter { it.project == project } : @cards
49
51
  end
50
52
 
51
53
  # Narrows the charts to a project's, or with nil, shows every project's.
@@ -26,6 +26,8 @@ module Raindown
26
26
  def path = endpoint.path
27
27
  def status(_navigator) = [:info, endpoint.text]
28
28
  def changed? = false
29
+ def typing? = false
30
+ def animating? = false
29
31
  def next_step?(key) = %i[up down enter edit execute].include?(key)
30
32
 
31
33
  # Clicks on the response only move the focus to it.
@@ -13,6 +13,8 @@ module Raindown
13
13
  end
14
14
 
15
15
  def renderer = ListRenderer
16
+ def typing? = false
17
+ def animating? = false
16
18
 
17
19
  def rows = @rows.call
18
20
  def selected = rows[cursor]
@@ -3,6 +3,7 @@
3
3
  require_relative '../../document'
4
4
  require_relative '../events'
5
5
  require_relative 'list_view'
6
+ require_relative '../highlighter'
6
7
  require_relative '../row'
7
8
  require_relative '../workers/worker'
8
9
 
@@ -34,6 +35,7 @@ module Raindown
34
35
  @lines = outline.lines
35
36
  @headings = outline.headings
36
37
  @directives = outline.directives
38
+ @highlighted = {}
37
39
  @error = nil
38
40
  rescue Worker::Error, SystemCallError => e
39
41
  @error = e.message
@@ -104,11 +106,18 @@ module Raindown
104
106
  buttons: buttons(directive, expanded:), **directive_action(directive))
105
107
  return [row] unless expanded
106
108
 
107
- [row, *directive.body.map do |number|
108
- Row.new(text: @lines[number - 1].to_s, depth: depth + 1, kind: :body, target: number, open: Edit.new(path:, line: number))
109
+ code = highlighted(directive)
110
+ [row, *directive.body.each_with_index.map do |number, index|
111
+ Row.new(text: @lines[number - 1].to_s, depth: depth + 1, kind: :body, target: number, open: Edit.new(path:, line: number),
112
+ code: code[index])
109
113
  end]
110
114
  end
111
115
 
116
+ # The directive's body highlighted as Markdown, which highlights its code blocks in their languages, once a read.
117
+ def highlighted(directive)
118
+ @highlighted[directive.line] ||= Highlighter.lines(directive.body.map { @lines[it - 1].to_s }, language: 'markdown')
119
+ end
120
+
112
121
  # A directive with a body expands it, and one without opens in the editor, like a line of its body.
113
122
  def directive_action(directive)
114
123
  if directive.body then { fold: -> { expand(directive) } }
@@ -9,6 +9,7 @@ require_relative '../../node_blocks'
9
9
  require_relative '../../nodes'
10
10
  require_relative '../../pages'
11
11
  require_relative '../../projects'
12
+ require_relative '../../stat'
12
13
  require_relative '../card'
13
14
  require_relative '../endpoint'
14
15
  require_relative '../../code_blocks'
@@ -39,7 +40,8 @@ module Raindown
39
40
  Outline.new(lines: File.readlines(path, chomp: true), headings: document.headings, directives: document.directives)
40
41
  end
41
42
 
42
- # Every chart in the project's files, drawn from its data, and the files that couldn't be read.
43
+ # Every chart and stat in the project's files, or any node that draws a card, drawn from its data, and the files
44
+ # that couldn't be read.
43
45
  # The charts are found again when asked, and otherwise drawn again, so they follow changes to their data.
44
46
  def charts(find: false)
45
47
  if find || @charts.nil?
@@ -87,7 +89,7 @@ module Raindown
87
89
  headings = Document.parse(content, bare:).headings
88
90
 
89
91
  NodeBlocks.find(lines, front_matter: FrontMatter.parse(content).first, bare:)
90
- .select { |block| block[:node].is_a?(Raindown::Chart) }
92
+ .select { it[:node].respond_to?(:card_lines) }
91
93
  .map { |block| chart(block, path:, lines:, headings:) }
92
94
  rescue StandardError => e
93
95
  @file_errors << "#{path.delete_prefix(File.join(root, ''))}: #{e.message}"
@@ -109,11 +111,9 @@ module Raindown
109
111
  { path:, line:, heading: headings.select { it.line < line }.last&.text, directive:, node: block[:node] }
110
112
  end
111
113
 
112
- # Draws the chart as its Markdown would be written back, with the files its project lists.
114
+ # Draws the chart or stat as its Markdown would be written back, with the files its project lists.
113
115
  def lines(chart, metadata)
114
- node = chart[:node]
115
- points = node.points(metadata:)
116
- points.empty? ? ['No data'] : node.draw(points).map(&:rstrip)
116
+ chart[:node].card_lines(metadata:)
117
117
  rescue StandardError => e
118
118
  ["Can't chart: #{e.message}"]
119
119
  end
data/lib/raindown/tui.rb CHANGED
@@ -22,6 +22,8 @@ module Raindown
22
22
  # so two projects' nodes of the same name stay apart.
23
23
  workers = Workers.new(root:)
24
24
 
25
+ # Rouge loads here, rather than with Raindown, which doesn't highlight code outside the TUI.
26
+ require 'rouge'
25
27
  Screen.new(Navigator.new(root:, path: (path unless File.directory?(path)), workers:)).run
26
28
  ensure
27
29
  workers&.stop
data/lib/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Raindown
4
- VERSION = '0.6.0'
4
+ VERSION = '0.7.0'
5
5
  end