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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9e35dfde9371c5f805bff304124c0abe22ca89254f14c5c70d2996e14d0dbb0f
4
- data.tar.gz: 76774aa6b06fec8b50e0929b9ac86259511fb1ab9417ce48fcfa56afd20c90cf
3
+ metadata.gz: ad2e1df6e9cbfbac50533a9d6dd7f1d8bedb254aa6277a1889fb380871c52ec1
4
+ data.tar.gz: d32bbefdbcae559679033db6b1fc7e1dc4846f6a6356c0944e711cf50699eab0
5
5
  SHA512:
6
- metadata.gz: 454c35bfd6c74cc2c125d8229ffe971cb178fb40f4d87affad0fc5d345301fd4d5a0e32a15852f05eb35231e0054b2a5b55ff2a4eb7253cc85aef17f4d17e178
7
- data.tar.gz: 33b41ed491f7cc8238f3c3d99b2d72e2b0197dce29146dd14bdc704dd0ea6fa64788c97ed5d60bda60ef4602e9e6dedb2d22b02a1f8886a5c4592a522d96886e
6
+ metadata.gz: 0dd6247d9f49558b5dc53b9eae6860d841c34c1e03edbaca61ba02ff59ec59a932d647c7f70eff731b1fe195ece4f3d2e999be2a783e0e9f1b57f97a166a5b25
7
+ data.tar.gz: bfc9d3fed994284ae6f0ec94a51e6599a754e1064e648092e595bd80d6224f8f4acc4e258a790de9fe5bbb9e9cfe07ede3d4c6d7bd633c3574ff9f8ec9a3e90a
@@ -19,7 +19,8 @@ module Raindown
19
19
 
20
20
  # Label and value are the columns to chart, by name or index, and minus a column to chart against the values.
21
21
  # Width is in characters, for bars and lines, and height in lines, for columns and lines.
22
- def initialize(data = nil, format: nil, label: nil, value: nil, minus: nil, width: 40, height: 8) # rubocop:disable Metrics/ParameterLists -- each is a prop
22
+ # Tail keeps only the last points, like the latest months of a file that grows.
23
+ def initialize(data = nil, format: nil, label: nil, value: nil, minus: nil, width: 40, height: 8, tail: nil) # rubocop:disable Metrics/ParameterLists -- each is a prop
23
24
  @data = data
24
25
  @format = format
25
26
  @label = label
@@ -27,6 +28,7 @@ module Raindown
27
28
  @minus = minus
28
29
  @width = width
29
30
  @height = height
31
+ @tail = tail
30
32
  end
31
33
 
32
34
  # The data is read every time, so the chart follows changes to its file.
@@ -45,7 +47,16 @@ module Raindown
45
47
  Points.new(label: @label, value: @value, minus: @minus, root:).from(@data)
46
48
  end
47
49
 
50
+ # The chart's lines, as a card on the terminal UI's Dashboard shows them, or that there's no data.
51
+ def card_lines(metadata: NoMetadata)
52
+ points = points(metadata:)
53
+ return ['No data'] if points.empty?
54
+
55
+ draw(points).map(&:rstrip)
56
+ end
57
+
48
58
  def draw(points)
59
+ points = points.last(@tail) if @tail
49
60
  format = @format || (points.any?(&:first) ? 'bars' : 'sparkline')
50
61
  drawing = FORMATS[format] || raise(Error, "format: is #{FORMATS.keys.join(', ')}, not #{format.inspect}")
51
62
 
data/lib/raindown/cli.rb CHANGED
@@ -23,8 +23,8 @@ module Raindown
23
23
 
24
24
  Commands:
25
25
  init [DIRECTORY] Create a .raindown folder for the project's config and nodes (default: current directory)
26
- tui [PATH] Browse a project's files and outlines in the terminal, fold directives, run endpoints
27
- and open the editor at a line
26
+ tui [PATH] Browse a project's files and outlines in the terminal, fold directives, run endpoints,
27
+ open the editor at a line and see a dashboard of the project's charts
28
28
 
29
29
  Options:
30
30
  --html [OUTPUT] Render FILE to HTML once, writing to OUTPUT or else printing to stdout
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'commonmarker'
4
4
 
5
+ require_relative '../raindown'
5
6
  require_relative 'bare_directives'
6
7
  require_relative 'code_blocks'
7
8
  require_relative 'endpoint/endpoint_runner'
@@ -17,9 +18,10 @@ module Raindown
17
18
 
18
19
  # A content directive: a node, like toc: or a project's Changelog:, or an endpoint.
19
20
  # It spans line to end_line, its end tag, or the last line of a bare block.
21
+ # Body is the lines inside it, between it and its end tag, like an endpoint's request and response.
20
22
  # Output is the lines Raindown writes back, like a node's Markdown or an endpoint's response, and is nil
21
23
  # when nothing's written yet. A self-closing directive like <{ :toc }> only renders, so it never writes.
22
- Directive = Data.define(:name, :args, :line, :end_line, :output, :writes) do
24
+ Directive = Data.define(:name, :args, :line, :end_line, :body, :output, :writes) do
23
25
  def endpoint? = name == 'endpoint'
24
26
  end
25
27
 
@@ -27,6 +29,7 @@ module Raindown
27
29
  TEXT_NODES = %i[text code].freeze
28
30
  BREAK_NODES = %i[softbreak linebreak].freeze
29
31
  ENDPOINT_END_TAG = /\A {0,3}<\{\s*:endpoint\s*\}>[ \t]*$/
32
+ END_TAG = /\A {0,3}<\{\s*:[\w:]+\s*\}>[ \t]*$/
30
33
 
31
34
  def self.parse(markdown, bare: false)
32
35
  new(markdown:, bare:)
@@ -46,7 +49,7 @@ module Raindown
46
49
 
47
50
  # The headings in the order they appear, with their source text, like "Notes for {name}" before it's rendered.
48
51
  def headings
49
- @headings ||= nodes(:heading).map do |node|
52
+ @headings ||= root.walk.select { |node| node.type == :heading }.map do |node|
50
53
  Heading.new(level: node.header_level, text: text(node), line: node.source_position[:start_line])
51
54
  end
52
55
  end
@@ -68,10 +71,6 @@ module Raindown
68
71
  @root ||= Commonmarker.parse(@markdown, options: MARKDOWN_OPTIONS)
69
72
  end
70
73
 
71
- def nodes(type)
72
- [].tap { |nodes| root.walk { |node| nodes << node if node.type == type } }
73
- end
74
-
75
74
  def text(heading)
76
75
  parts = []
77
76
  heading.walk do |node|
@@ -139,10 +138,18 @@ module Raindown
139
138
  first..last if first <= last
140
139
  end
141
140
 
142
- # From a range of indexes to line numbers.
141
+ # From a range of indexes to line numbers. The body is the lines after the directive, up to its end tag,
142
+ # or to the end of a bare block, which has none.
143
143
  def directive(name, args, span, output:, writes:)
144
- output = (output.begin + 1)..(output.end + 1) if output
145
- Directive.new(name:, args:, line: span.begin + 1, end_line: span.end + 1, output:, writes:)
144
+ end_tag = span.end > span.begin && @lines[span.end].match?(END_TAG)
145
+ body = trimmed(span.begin + 1, end_tag ? span.end - 1 : span.end)
146
+
147
+ Directive.new(name:, args:, line: span.begin + 1, end_line: span.end + 1, body: lines(body), output: lines(output),
148
+ writes:)
149
+ end
150
+
151
+ def lines(range)
152
+ (range.begin + 1)..(range.end + 1) if range
146
153
  end
147
154
  end
148
155
  end
@@ -10,6 +10,7 @@ module Raindown
10
10
  # are allowed: nothing that reads or writes local files, loads a config, or uses another protocol.
11
11
  module Curl
12
12
  class Error < StandardError; end
13
+ Request = Data.define(:method, :url)
13
14
 
14
15
  TIMEOUT = '30'
15
16
  # --disable must come first and skips ~/.curlrc. The protocol options stop redirects to file:// and the like.
@@ -64,6 +65,24 @@ module Raindown
64
65
  raise Error, 'curl is not installed'
65
66
  end
66
67
 
68
+ # The snippet's method and URL, like GET https://httpbin.org/uuid, or nil when it can't be read.
69
+ def request(snippet)
70
+ args = arguments(snippet)
71
+ Request.new(method: request_method(args), url: args.find { it.match?(URL) })
72
+ rescue Error
73
+ nil
74
+ end
75
+
76
+ # A snippet that sends data is a POST, unless it says otherwise, as with curl.
77
+ def request_method(args)
78
+ index = args.index('--request')
79
+ return args[index + 1].upcase if index
80
+ return 'HEAD' if args.include?('--head')
81
+ return 'POST' if args.intersect?(VALUE_CHECKS.keys.grep(/data|json/))
82
+
83
+ 'GET'
84
+ end
85
+
67
86
  def arguments(snippet)
68
87
  words = Shellwords.split(snippet.gsub(/\\\r?\n/, ' ').strip.delete_prefix('$ '))
69
88
  raise Error, 'Endpoint snippets must start with curl' unless words.first == 'curl'
@@ -77,13 +77,16 @@ module Raindown
77
77
  end
78
78
 
79
79
  def rendered_lines(lines, block, markdown:, metadata:, bare:)
80
- node = block[:node]
81
- output = if Nodes.html?(node) then ["#{Nodes.placeholder(node)}\n"]
82
- else "#{Nodes.call(node, :replace, document: markdown, metadata:, bare:).to_s.chomp}\n".lines
83
- end
84
- output = [] if output == ["\n"]
80
+ output = node_markdown(block[:node], markdown:, metadata:, bare:).chomp
81
+ close = marker(lines[block[:close]]) if block[:close]
85
82
 
86
- [marker(lines[block[:open]]), *output, *(marker(lines[block[:close]]) if block[:close])]
83
+ [marker(lines[block[:open]]), *("#{output}\n".lines unless output.empty?), *close]
84
+ end
85
+
86
+ def node_markdown(node, markdown:, metadata:, bare:)
87
+ return Nodes.placeholder(node) if Nodes.html?(node)
88
+
89
+ Nodes.call(node, :replace, document: markdown, metadata:, bare:).to_s
87
90
  end
88
91
 
89
92
  def marker(line)
@@ -3,6 +3,7 @@
3
3
  require_relative 'chart/chart'
4
4
  require_relative 'files'
5
5
  require_relative 'node'
6
+ require_relative 'stat'
6
7
  require_relative 'toc'
7
8
 
8
9
  module Raindown
@@ -35,14 +35,14 @@ module Raindown
35
35
  folder && File.dirname(folder)
36
36
  end
37
37
 
38
- # The project's Markdown files, sorted. Ignored folders and the projects inside it are left out,
39
- # since those projects have nodes of their own.
40
- def files(root)
38
+ # The project's Markdown files, sorted, leaving out ignored folders. The projects inside it are left out too,
39
+ # since they have nodes of their own, unless they're asked for, like by the terminal UI, which lists them all.
40
+ def files(root, nested: false)
41
41
  files = []
42
42
 
43
43
  Find.find(root) do |path|
44
44
  if File.directory?(path)
45
- Find.prune if path != root && (ignored?(File.basename(path)) || File.directory?(File.join(path, Nodes::FOLDER)))
45
+ Find.prune if path != root && (ignored?(File.basename(path)) || (!nested && project?(path)))
46
46
  elsif markdown?(path, root:)
47
47
  files << path
48
48
  end
@@ -64,6 +64,10 @@ module Raindown
64
64
  File.expand_path(path).start_with?(File.join(root, Nodes::FOLDER, '')) && %w[.rb .rbx].include?(File.extname(path))
65
65
  end
66
66
 
67
+ def project?(folder)
68
+ File.directory?(File.join(folder, Nodes::FOLDER))
69
+ end
70
+
67
71
  def ignored?(folder)
68
72
  folder.start_with?('.') || IGNORED.include?(folder)
69
73
  end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'node'
4
+
5
+ module Raindown
6
+ # The base of a project's stats: one big number in block digits, above its label, written into the Markdown as code,
7
+ # and shown on the terminal UI's Dashboard as a card, like a chart:
8
+ # ▀█ ▀▀█
9
+ # █ █▀▀
10
+ # ▀▀▀ ▀▀▀
11
+ # open issues
12
+ # A stat's a node in .raindown that says its value, and its label:
13
+ # class Issues < Raindown::Stat
14
+ # def value(metadata:) = Issue.open.count
15
+ # def label = 'open issues'
16
+ # end
17
+ class Stat < Node
18
+ # Each character's three lines in block digits, which end on the top half of the third, where a point sits too.
19
+ # Others are left as they are, on the middle line.
20
+ DIGITS = { '0' => ['█▀█', '█ █', '▀▀▀'], '1' => ['▀█ ', ' █ ', '▀▀▀'], '2' => ['▀▀█', '█▀▀', '▀▀▀'],
21
+ '3' => ['▀▀█', ' ▀█', '▀▀▀'], '4' => ['█ █', '▀▀█', ' ▀'], '5' => ['█▀▀', '▀▀█', '▀▀▀'],
22
+ '6' => ['█▀▀', '█▀█', '▀▀▀'], '7' => ['▀▀█', ' █', ' ▀'], '8' => ['█▀█', '█▀█', '▀▀▀'],
23
+ '9' => ['█▀█', '▀▀█', '▀▀▀'], '-' => [' ', '▀▀▀', ' '], '+' => [' ▄ ', '▀█▀', ' '],
24
+ '.' => [' ', ' ', '▀'], ',' => [' ', ' ', '▀'] }.freeze
25
+
26
+ def replace(metadata: NoMetadata) = "```\n#{card_lines(metadata:).join("\n")}\n```"
27
+
28
+ # The number in block digits, then the label, as a card on the Dashboard shows them.
29
+ def card_lines(metadata: NoMetadata)
30
+ characters = number(value(metadata:)).chars.map { digit(it) }
31
+ [*Array.new(3) { |line| characters.map { it[line] }.join(' ').rstrip }, label]
32
+ end
33
+
34
+ def value(metadata: NoMetadata) = raise(NotImplementedError, "#{self.class} needs a value")
35
+
36
+ def label = ''
37
+
38
+ # How the value's written, which a stat can change, like with a sign.
39
+ def number(value) = value.to_s
40
+
41
+ private
42
+
43
+ # The character's three lines in block digits, or itself on the middle line.
44
+ def digit(character) = DIGITS.fetch(character) { [' ', character, ' '] }
45
+
46
+ # The project's folder, from the page being rendered, or else the current one.
47
+ def root(metadata)
48
+ return metadata.root if metadata.respond_to?(:root)
49
+
50
+ Dir.pwd
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'unicode/display_width'
4
+
5
+ module Raindown
6
+ module TUI
7
+ # A chart on the dashboard, drawn from its data by its project's Worker: a title of its file, from its project,
8
+ # and the heading above it, then its directive, then the chart, each line with its Layout style,
9
+ # in a border with rounded corners. It's as wide as the chart, so the title and directive are cut to fit it:
10
+ # ╭───────────────────────────────────────╮
11
+ # │ markdown.md › Charts │
12
+ # │ chart: 'data/downloads.csv' width: 30 │
13
+ # │ Jun ██████▊ 640 │
14
+ # ╰───────────────────────────────────────╯
15
+ class Card < Data.define(:project, :path, :line, :heading, :directive, :chart)
16
+ # The border's columns either side of a card's lines, "│ " and " │", and its lines above and below them.
17
+ BORDER_WIDTH = 4
18
+ BORDER_HEIGHT = 2
19
+
20
+ def title = [path.delete_prefix(File.join(project, '')), heading].compact.join(' › ')
21
+
22
+ def lines = parts.map(&:first)
23
+
24
+ # Each line, with its style.
25
+ def parts = [[title, :heading], [directive, :directive], *chart.map { [it, :chart] }]
26
+
27
+ # As wide as its chart.
28
+ def width = chart.map { Unicode::DisplayWidth.of(it) }.max || 0
29
+
30
+ def height = lines.length
31
+
32
+ # With its border, within a range of widths, like a pane's.
33
+ def box_width(within: nil) = within ? (width + BORDER_WIDTH).clamp(within) : width + BORDER_WIDTH
34
+ def box_height = height + BORDER_HEIGHT
35
+
36
+ # A line of the card in its border, as [text, style, width] parts, for a card the box width wide.
37
+ # The border's top is line 0, and below its bottom, the lines are blank, for a card shorter than its row.
38
+ # The title's style is title_style, like when the card's selected.
39
+ def format_line(line, box_width:, title_style: :heading)
40
+ width = [box_width - BORDER_WIDTH, 0].max
41
+ return [border(width, '╭', '╮')] if line.zero?
42
+ return [border(width, '╰', '╯')] if line == height + 1
43
+ return [['', :blank, width + BORDER_WIDTH]] if line > height + 1
44
+
45
+ text, style = parts[line - 1]
46
+ [['│ ', :border, 2], [text, style == :heading ? title_style : style, width], [' │', :border, 2]]
47
+ end
48
+
49
+ private
50
+
51
+ def border(width, left, right)
52
+ ["#{left}#{'─' * (width + 2)}#{right}", :border, width + BORDER_WIDTH]
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raindown
4
+ module TUI
5
+ # An endpoint in a project's files, listed in the nav pane by its request, like "GET httpbin.org/uuid",
6
+ # with its method in its style, like GET in :method_get.
7
+ Endpoint = Data.define(:project, :path, :line, :method, :url) do
8
+ def text = "#{method} #{address}"
9
+
10
+ # The URL without its scheme.
11
+ def address = url.sub(%r{\Ahttps?://}i, '')
12
+
13
+ def style = :"method_#{method.downcase}"
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'endpoint'
4
+ require_relative 'views/endpoint_view'
5
+ require_relative 'row'
6
+ require_relative 'workers/worker'
7
+
8
+ module Raindown
9
+ module TUI
10
+ # Every endpoint in the projects' files, found by each project's Worker, listed below the files.
11
+ # Like the dashboard, it can be narrowed to a project's own.
12
+ class Endpoints
13
+ # The project the endpoints are narrowed to, or nil for every project's.
14
+ attr_accessor :project
15
+
16
+ def initialize(workers:)
17
+ @workers = workers
18
+ load
19
+ end
20
+
21
+ # Finds the endpoints again, like after a file's changed.
22
+ def load
23
+ @endpoints = @workers.each.flat_map do |worker|
24
+ worker.call(:endpoints)
25
+ rescue Worker::Error
26
+ []
27
+ end
28
+ end
29
+
30
+ def all = project ? @endpoints.select { it.project == project } : @endpoints
31
+
32
+ def rows
33
+ all.map do
34
+ Row.new(text: it.address, tag: it, depth: 0, kind: :endpoint, target: it,
35
+ open: OpenView.new(view: EndpointView.new(it, workers: @workers)))
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raindown
4
+ module TUI
5
+ # A click on the screen: the cursor moves to the pane's row, or the dashboard's card or the menu's item,
6
+ # then the action's done there, like :enter, or a button's, like :toggle or :execute.
7
+ # Keys are their events' names, like :down or :enter.
8
+ Click = Data.define(:pane, :index, :action)
9
+
10
+ # The mouse moving over what a click there would send, if anything, to show it, like a method's cells in bold.
11
+ Hover = Data.define(:target)
12
+
13
+ # A character typed while typing, like in the search, which other keys' events would take, like "j" for :down.
14
+ Type = Data.define(:text)
15
+
16
+ # What the Screen does after an event, other than drawing again, or stopping for :quit.
17
+ # Leaves the screen for a command, like the editor, then tells the Navigator it ran.
18
+ Run = Data.define(:command)
19
+
20
+ # Shows the message while the task runs, like an endpoint's request.
21
+ Wait = Data.define(:message, :task)
22
+
23
+ # Steps a row or a view asks of the Navigator, which each takes on it, returning what the Screen does next: to open
24
+ # a view right of the files, or a file in the outline at a directive, to edit a file at a line, to run an endpoint,
25
+ # to draw the page a feeling was noted down in, or to ask Claude Code to fix a method.
26
+ OpenView = Data.define(:view) do
27
+ def take(navigator) = navigator.open_view(view)
28
+ end
29
+
30
+ Open = Data.define(:path, :line) do
31
+ def take(navigator) = navigator.open_at(path, line)
32
+ end
33
+
34
+ Edit = Data.define(:path, :line) do
35
+ def take(navigator) = navigator.edit_at(path, line)
36
+ end
37
+
38
+ Execute = Data.define(:path, :line, :name) do
39
+ def take(navigator) = navigator.run(path, line, name)
40
+ end
41
+
42
+ Felt = Data.define(:feeling) do
43
+ def take(navigator) = navigator.felt(feeling)
44
+ end
45
+
46
+ Fix = Data.define(:feeling) do
47
+ def take(navigator) = navigator.fix_with_agent(feeling)
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+ require 'zlib'
5
+
6
+ module Raindown
7
+ module TUI
8
+ # Files to feel, like the files Severance's refiners work through: the first in the week's shuffled order, as many as
9
+ # fill the grid's cells, with their methods shuffled together. The order is random, but the same all week, so the
10
+ # grid stays put through reloads, edits and restarts, and once it's refined, that's the week's work done.
11
+ Batch = Data.define(:files, :code_methods, :log, :week) do
12
+ # Like 2026-W44, from the ISO year and week.
13
+ def self.this_week(date = Date.today) = "#{date.cwyear}-W#{date.cweek}"
14
+
15
+ def self.pick(every, log:, cells:, week: this_week)
16
+ files = filling(every.group_by(&:path).sort_by { |path, _| Zlib.crc32("#{week}:#{path}") }, cells)
17
+
18
+ new(files:, code_methods: [], log:, week:).reading(every)
19
+ end
20
+
21
+ # The files, in order, whose methods' cells fit in the grid's, passing over any too big for what's left, and
22
+ # at least the first.
23
+ def self.filling(files, cells)
24
+ used = 0
25
+ files.each_with_object([]) do |(path, methods), picked|
26
+ size = methods.sum(&:size)
27
+ next unless picked.empty? || used + size <= cells
28
+
29
+ picked << path
30
+ used += size
31
+ end
32
+ end
33
+
34
+ # The same files, with their methods read again, like after one's edited.
35
+ def reading(every)
36
+ with(code_methods: every.select { files.include?(it.path) }.sort_by { Zlib.crc32("#{week}:#{it.path}:#{it.label}") })
37
+ end
38
+
39
+ def felt?(method) = log.felt?(method)
40
+ def refined? = code_methods.all? { felt?(it) }
41
+
42
+ def progress
43
+ "#{code_methods.count { felt?(it) } * 100 / [code_methods.length, 1].max}% refined"
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'feelings_log'
4
+
5
+ module Raindown
6
+ module TUI
7
+ # Code feelings' bins below the code box, one for each temper, best to worst, each a box with its key's number, its
8
+ # temper and a bar of how many feelings it holds, highlighted when it's the chosen temper:
9
+ # ╭─ 01 ────╮ ╭─ 02 ────╮ ╭─ 03 ────╮ ╭─ 04 ────╮ ╭─ 05 ────╮ ╭─ 06 ────╮
10
+ # │ FROLIC │ │ HOPE │ │ NUMB │ │ WOE │ │ DREAD │ │ MALICE │
11
+ # │ ███░ 9 │ │ ██░░ 5 │ │ ████ 41 │ │ ██░░ 6 │ │ █░░░ 3 │ │ █░░░ 2 │
12
+ # ╰─────────╯ ╰─────────╯ ╰─────────╯ ╰─────────╯ ╰─────────╯ ╰─────────╯
13
+ module Bins
14
+ # A bin's lines.
15
+ BIN_LINES = 4
16
+
17
+ private
18
+
19
+ # The six bins, each a box with its number, its temper, and a bar of how many feelings it holds.
20
+ def bins
21
+ counts = view.log.counts
22
+ most = [counts.values.max, 1].max
23
+ chosen = chosen_temper
24
+ boxes = counts.each_with_index.map { |(temper, count), index| bin(index + 1, temper, count, most, chosen) }
25
+
26
+ Array.new(BIN_LINES) do |line|
27
+ padded([segment('', :blank, width: 1), *boxes.flat_map { [it[line], segment(' ', :blank, width: 1)] }])
28
+ end
29
+ end
30
+
31
+ # The temper being noted, or else how the selected method felt, if it's been felt.
32
+ def chosen_temper = view.noting || view.log.temper(view.selected)
33
+
34
+ # As wide as they all fit, which cuts their borders short in a pane too narrow for them.
35
+ def bin_width = [(width - 1 - FeelingsLog::TEMPERS.length) / FeelingsLog::TEMPERS.length, 1].max
36
+
37
+ # A space inside a bin's sides, unless the longest temper would only fit without it.
38
+ def bin_inside
39
+ return '' if bin_width < FeelingsLog::TEMPERS.map(&:length).max + 4
40
+
41
+ ' '
42
+ end
43
+
44
+ # The bin's lines, highlighted when it's the chosen temper: the one being noted, or how the selected method felt.
45
+ def bin(number, temper, count, most, chosen)
46
+ style = :feelings
47
+ style = :selected if temper == chosen
48
+ bin_lines(number, temper, count, most).map { segment(it, style, width: bin_width) }
49
+ end
50
+
51
+ # Its key's number on its top line, its temper, the bar of its count, and its bottom.
52
+ def bin_lines(number, temper, count, most)
53
+ inside = bin_inside
54
+ name = temper.upcase.ljust(bin_width - 2 - (2 * inside.length))
55
+ ["╭─ 0#{number} #{'─' * [bin_width - 7, 0].max}╮", "│#{inside}#{name}#{inside}│",
56
+ "│#{inside}#{bar(count, most)} #{count}#{inside}│", "╰#{'─' * (bin_width - 2)}╯"]
57
+ end
58
+
59
+ # As long as there's room beside the count, and filled as far as its share of the fullest bin's.
60
+ def bar(count, most)
61
+ length = bin_width - 3 - (2 * bin_inside.length) - count.to_s.length
62
+ filled = (length * count / most.to_f).round
63
+ "#{'█' * filled}#{'░' * (length - filled)}"
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raindown
4
+ module TUI
5
+ # When a batch of code feelings is refined, like Severance's waffle party: rain, a frame each tick, for about five
6
+ # seconds of the Screen's 60 ms frames, then a waffle, until a key's pressed.
7
+ class Celebration
8
+ RAIN_FRAMES = 83
9
+
10
+ attr_reader :frame
11
+
12
+ def initialize
13
+ @frame = 0
14
+ end
15
+
16
+ def raining? = frame < RAIN_FRAMES
17
+
18
+ # Each tick's a frame, and any other key puts the waffle away, which is when it's over.
19
+ def over?(key)
20
+ @frame += 1 if key == :tick
21
+ key != :tick
22
+ end
23
+ end
24
+ end
25
+ end