raindown 0.9.0 → 0.9.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ad54482b1bd78c23942aefb02bc8a8b9cce56e369ecbb67366668187ed521323
4
- data.tar.gz: 9d8475c719c52063424a6215c762a926e67641700981b6834dd56e07d410d535
3
+ metadata.gz: 3288235ed874ddbeae5b31b5ef6707e59864f5cca305bb9bc74fcf74a9c36366
4
+ data.tar.gz: c7bfc9dcdec92857c433d4abacbc80d221bb6029617972d362af43163c235c39
5
5
  SHA512:
6
- metadata.gz: 8789f0135781bfe782ae2af47f2bd537cafef9d55a522ba592bbcc1c8f27e0364cfbb860cb0aa106617af0bd6e82d2bd0174084ca4bd3ca7779b82e2e284ff79
7
- data.tar.gz: 88237d0429270a0651434511d462b0051b9a752a2e8aae315e4d2bafc0ef833fa1818d8424cf5e025b12655c9df3de6a22e6eab7e23e1b40ec0063d7fe4f3396
6
+ metadata.gz: 1cb1aaaf0f325d154393656cad4040aa8a2205464e577ce69f23931bd2f2727d28222aba9a6d3a059bd2f7531e59050f0056b413a1a72cb72fe66e5e785b300a
7
+ data.tar.gz: f89b1e87119328272a1df69a6c2f9c04159f98db03c749774bac1ae533307eff32bc833b111c41ef569456ed9e0f2ec421afc938744b31102e4f6c8c848fcace
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raindown
4
+ # The folders .gitignore files leave out, like a Drupal site's web/core, so finding the projects in a folder of
5
+ # them, like ~/dev, skips them. Only lines naming a folder are read, like /vendor/, node_modules or
6
+ # /web/modules/contrib/*, as a wildcard could leave out a project, and a ! takes a folder back out.
7
+ class Gitignore
8
+ WILDCARD = /[*?\[]/
9
+
10
+ # The rules for the folders inside a folder: its .gitignore's and those of the folders above it, up to its
11
+ # repository, as a repository's rules don't reach the repositories inside it.
12
+ def initialize(folder, above = nil)
13
+ above = nil if File.exist?(File.join(folder, '.git'))
14
+ @paths = Set.new(above&.paths)
15
+ @names = Set.new(above&.names)
16
+ file = File.join(folder, '.gitignore')
17
+ File.foreach(file, chomp: true) { read(it.strip, folder) } if File.file?(file)
18
+ end
19
+
20
+ def ignores?(path)
21
+ @paths.include?(path) || @names.include?(File.basename(path))
22
+ end
23
+
24
+ protected
25
+
26
+ attr_reader :paths, :names
27
+
28
+ private
29
+
30
+ # A folder with a slash, like /vendor or web/files, is from the .gitignore's folder, and one without, like
31
+ # node_modules, is any folder of that name below it.
32
+ def read(line, folder)
33
+ pattern = line.delete_suffix('/*').delete_suffix('/')
34
+ negated = pattern.delete_prefix!('!')
35
+ return if pattern.empty? || pattern.start_with?('#') || pattern.match?(WILDCARD)
36
+
37
+ if pattern.include?('/')
38
+ update(@paths, File.join(folder, pattern.delete_prefix('/')), negated:)
39
+ else
40
+ update(@names, pattern, negated:)
41
+ end
42
+ end
43
+
44
+ def update(rules, rule, negated:)
45
+ return rules.delete(rule) if negated
46
+
47
+ rules.add(rule)
48
+ end
49
+ end
50
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'find'
4
+ require_relative 'gitignore'
4
5
  require_relative 'nodes'
5
6
  require_relative 'pages'
6
7
 
@@ -18,16 +19,21 @@ module Raindown
18
19
  # The roots of the projects in a directory: the project the directory is in, if any, and every project below it.
19
20
  def roots(directory)
20
21
  directory = File.expand_path(directory)
21
- roots = [root(directory)].compact
22
+ [root(directory), *project_folders(directory)].compact.uniq
23
+ end
22
24
 
23
- Find.find(directory) do |path|
24
- next unless File.directory?(path)
25
+ # The folders below a directory with a .raindown folder. Only folders are walked, leaving out ignored, linked and
26
+ # gitignored ones, so it stays quick in a big folder like ~/dev.
27
+ def project_folders(directory, gitignore = Gitignore.new(directory))
28
+ Dir.glob('*/', base: directory).flat_map do |folder|
29
+ path = File.join(directory, folder.chomp('/'))
30
+ next [] if ignored?(folder.chomp('/')) || File.symlink?(path) || gitignore.ignores?(path)
25
31
 
26
- Find.prune if path != directory && ignored?(File.basename(path))
27
- roots << path if File.directory?(File.join(path, Nodes::FOLDER))
28
- end
32
+ inside = project_folders(path, Gitignore.new(path, gitignore))
33
+ next inside unless project?(path)
29
34
 
30
- roots.uniq
35
+ [path, *inside]
36
+ end
31
37
  end
32
38
 
33
39
  # The root of the project a file or directory is in, or nil when it isn't in one.
@@ -38,13 +44,13 @@ module Raindown
38
44
  end
39
45
 
40
46
  # The project's Markdown files, sorted, leaving out ignored folders. The projects inside it are left out too,
41
- # since they have nodes of their own, unless they're asked for, like by the terminal UI, which lists them all.
42
- def files(root, nested: false)
47
+ # since they have nodes of their own.
48
+ def files(root)
43
49
  files = []
44
50
 
45
51
  Find.find(root) do |path|
46
52
  if File.directory?(path)
47
- Find.prune if path != root && (ignored?(File.basename(path)) || (!nested && project?(path)))
53
+ Find.prune if path != root && (ignored?(File.basename(path)) || project?(path))
48
54
  elsif markdown?(path, root:)
49
55
  files << path
50
56
  end
@@ -66,6 +72,11 @@ module Raindown
66
72
  File.expand_path(path).start_with?(File.join(root, Nodes::FOLDER, '')) && NODE_EXTENSIONS.include?(File.extname(path))
67
73
  end
68
74
 
75
+ # The roots that aren't inside another of them, as a project's code includes the projects inside it.
76
+ def outer_roots(roots)
77
+ roots.reject { |root| roots.any? { root != it && root.start_with?(File.join(it, '')) } }
78
+ end
79
+
69
80
  def project?(folder)
70
81
  File.directory?(File.join(folder, Nodes::FOLDER))
71
82
  end
@@ -3,6 +3,7 @@
3
3
  require_relative '../code_box'
4
4
  require_relative '../events'
5
5
  require_relative '../../blueprints'
6
+ require_relative '../../projects'
6
7
  require_relative 'traced_code'
7
8
  require_relative 'canvas'
8
9
  require_relative 'code_comments'
@@ -32,9 +33,10 @@ module Raindown
32
33
 
33
34
  attr_reader :cursor
34
35
 
35
- def initialize(root:)
36
+ def initialize(root:, projects: [root])
36
37
  @root = root
37
- @path = File.join(root, Blueprints::FILE)
38
+ @projects = projects
39
+ @paths = projects.map { File.join(it, Blueprints::FILE) }
38
40
  @all = []
39
41
  @cursor = 0
40
42
  select_box(0)
@@ -51,7 +53,7 @@ module Raindown
51
53
  end
52
54
 
53
55
  # Whether there are any blueprints yet, saved or in the code's comments, which are looked for once until it's loaded.
54
- def any? = File.exist?(@path) || commented.any?
56
+ def any? = @paths.any? { File.exist?(it) } || commented.any?
55
57
 
56
58
  # Whether the file's changed since it was read, so it's read again on the next tick.
57
59
  def changed? = mtime != @read_at
@@ -60,13 +62,26 @@ module Raindown
60
62
  @read_at = mtime
61
63
  newest = @all.first&.time
62
64
  @commented = nil
63
- @all = Blueprints.read(@root) + commented
65
+ @all = saved + commented
64
66
  self.cursor = 0 unless @all.first&.time == newest
65
67
  self.cursor = @cursor
66
68
  end
67
69
 
68
70
  def selected = blueprints[@cursor]
69
71
 
72
+ # Narrows the blueprints to a project's, or with nil, shows every project's.
73
+ def project=(project)
74
+ @project = project
75
+ self.cursor = 0
76
+ end
77
+
78
+ # The blueprints in the project they're narrowed to, if any, which the filter's keywords narrow further.
79
+ def in_project
80
+ return @all unless @project
81
+
82
+ @all.select { File.expand_path(it.path, @root).start_with?(File.join(@project, '')) }
83
+ end
84
+
70
85
  # Another blueprint's shown from its first box, while the same one keeps its box and canvas, like when it's clicked.
71
86
  def cursor=(index)
72
87
  shown = selected
@@ -115,7 +130,7 @@ module Raindown
115
130
  method = code
116
131
  return Edit.new(path: method.path, line: method.line) if method
117
132
 
118
- Edit.new(path: File.join(@root, selected.path), line: selected.line)
133
+ Edit.new(path: File.expand_path(selected.path, @root), line: selected.line)
119
134
  end
120
135
 
121
136
  # A click on a box drawn in the blueprint, which moves on to the next code it names when it's already selected.
@@ -138,9 +153,20 @@ module Raindown
138
153
 
139
154
  def count(number, noun) = "#{number} #{number == 1 ? noun : "#{noun}s"}"
140
155
 
141
- def mtime = File.exist?(@path) ? File.mtime(@path) : nil
156
+ def mtime = @paths.map { File.exist?(it) ? File.mtime(it) : nil }
157
+
158
+ # Every project's saved blueprints, newest first, each by its file from the root.
159
+ def saved
160
+ @projects.flat_map { |project| Blueprints.read(project).map { it.with(path: from_root(File.join(project, it.path))) } }
161
+ .sort_by(&:time).reverse
162
+ end
163
+
164
+ def from_root(path) = path.delete_prefix(File.join(@root, ''))
165
+
166
+ # The projects whose code is read, as a project's code includes the projects inside it.
167
+ def code_projects = @code_projects ||= Projects.outer_roots(@projects)
142
168
 
143
- def commented = @commented ||= CodeComments.blueprints(@root)
169
+ def commented = @commented ||= CodeComments.blueprints(@root, projects: code_projects)
144
170
  end
145
171
  end
146
172
  end
@@ -16,9 +16,10 @@ module Raindown
16
16
 
17
17
  module_function
18
18
 
19
- # The blueprints in the comments of the project's code, by file, from the top of each.
20
- def blueprints(root)
21
- CodeMethods.files(root).flat_map { |path| in_file(path, path.delete_prefix(File.join(root, ''))) }
19
+ # The blueprints in the comments of the projects' code, by file from the root, from the top of each. In a folder of
20
+ # projects, like ~/dev, only the projects' code is read.
21
+ def blueprints(root, projects: [root])
22
+ projects.flat_map { CodeMethods.files(it) }.flat_map { |path| in_file(path, path.delete_prefix(File.join(root, ''))) }
22
23
  end
23
24
 
24
25
  def in_file(path, relative)
@@ -15,10 +15,11 @@ module Raindown
15
15
 
16
16
  def typing? = @filtering == true
17
17
 
18
- # The blueprints with every keyword, or all of them, filtered again only when the keywords or the blueprints change,
18
+ # The blueprints with every keyword, or all of them, in the project they're narrowed to, if any, filtered again only when
19
+ # the keywords, the project or the blueprints change,
19
20
  # as what's drawn asks for the selected one many times a frame.
20
21
  def blueprints
21
- @filtered = [query, @all, filtered(query.to_s.downcase.split)] unless filtered_now?
22
+ @filtered = [query, @all, @project, filtered(query.to_s.downcase.split)] unless filtered_now?
22
23
  @filtered.last
23
24
  end
24
25
 
@@ -38,12 +39,12 @@ module Raindown
38
39
 
39
40
  private
40
41
 
41
- def filtered_now? = @filtered && @filtered[0] == query && @filtered[1].equal?(@all)
42
+ def filtered_now? = @filtered && @filtered[0] == query && @filtered[1].equal?(@all) && @filtered[2] == @project
42
43
 
43
44
  def filtered(keywords)
44
- return @all if keywords.empty?
45
+ return in_project if keywords.empty?
45
46
 
46
- @all.select { |blueprint| keywords.all? { blueprint.words.downcase.include?(it) || blueprint.kind.include?(it) } }
47
+ in_project.select { |blueprint| keywords.all? { blueprint.words.downcase.include?(it) || blueprint.kind.include?(it) } }
47
48
  end
48
49
 
49
50
  def open_filter
@@ -50,7 +50,8 @@ module Raindown
50
50
 
51
51
  def first_method(owner) = methods.select { it.owner == owner }.min_by { [it.path, it.line] }
52
52
 
53
- def methods = @methods ||= CodeMethods.list(@root)
53
+ # The projects' methods. In a folder of projects, like ~/dev, only the projects' code is read.
54
+ def methods = @methods ||= code_projects.flat_map { CodeMethods.list(it) }
54
55
  end
55
56
  end
56
57
  end
@@ -18,7 +18,7 @@ module Raindown
18
18
 
19
19
  # The five bins, each a box with its number, its temper, and a bar of how many feelings it holds.
20
20
  def bins
21
- counts = view.log.counts
21
+ counts = view.counts
22
22
  most = [counts.values.max, 1].max
23
23
  chosen = chosen_temper
24
24
  boxes = counts.each_with_index.map { |(temper, count), index| bin(index + 1, temper, count, most, chosen) }
@@ -41,9 +41,24 @@ module Raindown
41
41
 
42
42
  module_function
43
43
 
44
+ # Git log runs in a thread of its own while the methods are read, as it's as slow as reading them. Waiting on git's
45
+ # output frees the main thread to read, and the two meet when each method gets its file's changes, for its drift:
46
+ # Thread main thread
47
+ # ┌──────────────────────────┐ ┌─────────────────────────┐
48
+ # │ CodeMethods.file_changes │ │ CodeMethods.tagged │
49
+ # │ git log, in a subprocess │ │ or CodeMethods.indented │
50
+ # └────────────┬─────────────┘ └────────────┬────────────┘
51
+ # │ changes.value waits for it │
52
+ # └───────────────────┬───────────────────┘
53
+ # │
54
+ # ▼
55
+ # ┌──────────────────────┐
56
+ # │ CodeMethods.drifting │
57
+ # └──────────────────────┘
44
58
  def list(root)
45
- changes = file_changes(root)
46
- drifting((tagged(root) || indented(root)).map { it.with(changes: changes.fetch(it.path, 0)) })
59
+ changes = Thread.new { file_changes(root) }
60
+ methods = tagged(root) || indented(root)
61
+ drifting(methods.map { it.with(changes: changes.value.fetch(it.path, 0)) })
47
62
  end
48
63
 
49
64
  # Each method with its drift, from 0 for the lightest to 9 for the heaviest, where methods that weigh the same
@@ -81,9 +81,9 @@ module Raindown
81
81
  # How the method last felt, while its code's the same as when it was felt, or nil.
82
82
  def temper(method) = rows.reverse.find { felt_row?(it, method) }&.fetch(:temper)
83
83
 
84
- # How many feelings there are of each temper, in the bins' order.
85
- def counts
86
- tally = rows.map { it[:temper] }.tally
84
+ # How many feelings there are of each temper, in the bins' order, of those within a folder, like a project, if one's given.
85
+ def counts(within: nil)
86
+ tally = rows.select { within.nil? || File.join(@root, path(it)).start_with?(File.join(within, '')) }.map { it[:temper] }.tally
87
87
  TEMPERS.to_h { [it, tally.fetch(it, 0)] }
88
88
  end
89
89
 
@@ -132,5 +132,33 @@ module Raindown
132
132
  nil
133
133
  end
134
134
  end
135
+
136
+ # The feelings about each project's code, in its own FeelingsLog, so in a folder of projects, like ~/dev, each project
137
+ # keeps its own feelings, and the folder isn't made a project.
138
+ class FeelingsLogs
139
+ def initialize(projects:)
140
+ @logs = projects.to_h { [it, FeelingsLog.new(root: it)] }
141
+ end
142
+
143
+ def add(feeling) = log_of(feeling.method).add(feeling)
144
+ def felt?(method) = log_of(method).felt?(method)
145
+ def temper(method) = log_of(method).temper(method)
146
+ def rows = @logs.values.flat_map(&:rows)
147
+
148
+ # Each project's feelings about methods that have changed, or gone, from its methods there are now, to its memories.
149
+ def move_stale_to_memories(methods)
150
+ by_log = methods.group_by { log_of(it) }
151
+ @logs.each_value { it.move_stale_to_memories(by_log.fetch(it, [])) }
152
+ end
153
+
154
+ def counts(within: nil)
155
+ empty = FeelingsLog::TEMPERS.to_h { [it, 0] }
156
+ @logs.values.map { it.counts(within:) }.reduce(empty) { |sum, counts| sum.merge(counts) { |_, felt, more| felt + more } }
157
+ end
158
+
159
+ private
160
+
161
+ def log_of(method) = @logs.find { |root, _| method.path.start_with?(File.join(root, '')) }&.last
162
+ end
135
163
  end
136
164
  end
@@ -76,9 +76,10 @@ module Raindown
76
76
 
77
77
  def view = navigator.view
78
78
 
79
- # Instead of the grid, that there's nothing to feel, or the celebration.
79
+ # Instead of the grid, that there's nothing to feel, as there are no projects or no methods, or the celebration.
80
80
  def instead
81
- return blank(" No functions or methods in #{File.basename(navigator.root)} to feel") if view.code_methods.empty?
81
+ return alert(NO_PROJECTS) if navigator.projects.empty?
82
+ return alert(["No functions or methods in #{File.basename(navigator.root)} to feel."]) if view.code_methods.empty?
82
83
 
83
84
  celebration(view.celebration)
84
85
  end
@@ -174,10 +175,6 @@ module Raindown
174
175
  lines[(height - shown.length) / 2, shown.length] = shown
175
176
  lines.map { [segment(it.center(width), :too_small)] }
176
177
  end
177
-
178
- def blank(message)
179
- [[segment(message, :feelings)], *Array.new([height - 1, 0].max) { [segment('', :blank)] }].first(height)
180
- end
181
178
  end
182
179
  end
183
180
  end
@@ -29,12 +29,13 @@ module Raindown
29
29
  KEYS = %i[up down left right edit execute enter toggle expand celebrate].freeze
30
30
 
31
31
  # The temper being noted, and the note typed so far, while it's asking why, whether the selected method's code
32
- # shows all that fits, until another's selected, and the rain then the waffle, once the batch is refined.
33
- attr_reader :noting, :note, :cursor, :log, :celebration
32
+ # shows all that fits, until another's selected, the rain then the waffle, once the batch is refined, and the project
33
+ # its methods and counts are narrowed to, if any.
34
+ attr_reader :noting, :note, :cursor, :log, :celebration, :project
34
35
 
35
- def initialize(root:)
36
- @root = root
37
- @log = FeelingsLog.new(root:)
36
+ def initialize(projects:)
37
+ @projects = projects
38
+ @log = FeelingsLogs.new(projects:)
38
39
  @last_feelings = {}
39
40
  @cursor = 0
40
41
  @week = WeeklyBatch.new(log: @log)
@@ -63,12 +64,20 @@ module Raindown
63
64
  # the memories.
64
65
  def load
65
66
  was = selected
66
- every = CodeMethods.list(@root)
67
- @log.move_stale_to_memories(every)
68
- @week.read(every)
67
+ @every = @projects.flat_map { CodeMethods.list(it) }
68
+ @log.move_stale_to_memories(@every)
69
+ @week.read(in_project)
69
70
  reselect(was)
70
71
  end
71
72
 
73
+ # Narrows the methods to a project's, or with nil, shows every project's, picking the batch again from them.
74
+ def project=(project)
75
+ @project = project
76
+ @week.read(in_project)
77
+ @week.pick_again
78
+ self.cursor = 0
79
+ end
80
+
72
81
  # Selects the method again, by its file and name, wherever it is now, or keeps the cursor where it was.
73
82
  def reselect(was)
74
83
  self.cursor = code_methods.index { [it.path, it.label] == [was&.path, was&.label] } || @cursor
@@ -78,6 +87,9 @@ module Raindown
78
87
  def batch = @week.batch
79
88
  def code_methods = batch.code_methods
80
89
 
90
+ # How many feelings there are of each temper, in the project the methods are narrowed to, if any.
91
+ def counts = log.counts(within: project)
92
+
81
93
  def selected = code_methods[@cursor]
82
94
 
83
95
  def cursor=(index)
@@ -106,6 +118,11 @@ module Raindown
106
118
 
107
119
  private
108
120
 
121
+ # The methods of the project they're narrowed to, if any, which are none until they're read.
122
+ def in_project
123
+ @every.to_a.select { @project.nil? || it.path.start_with?(File.join(@project, '')) }
124
+ end
125
+
109
126
  # Moves between the methods, or acts on the selected one.
110
127
  def method_step(key)
111
128
  case key
@@ -38,6 +38,11 @@ module Raindown
38
38
  refill(grid_cells)
39
39
  end
40
40
 
41
+ # Picks the batch again on the next fill, like when the methods are narrowed to a project's.
42
+ def pick_again
43
+ @filled_for = nil
44
+ end
45
+
41
46
  private
42
47
 
43
48
  def refill(grid_cells)
@@ -28,8 +28,8 @@ module Raindown
28
28
  PROJECT = '🌧'
29
29
 
30
30
  def load
31
- @files = Projects.files(root, nested: true)
32
- @projects = Projects.roots(root).to_set
31
+ @projects = @workers.roots
32
+ @files = @projects.flat_map { Projects.files(it) }.sort
33
33
  end
34
34
 
35
35
  # The folder the tree starts from: the scope's, or the root's.
@@ -45,10 +45,10 @@ module Raindown
45
45
  @files = FileTree.new(root:, workers:)
46
46
  @endpoints = Endpoints.new(workers:)
47
47
  @dashboard = DashboardView.new(root:, workers:)
48
- @feelings = FeelingsView.new(root:)
49
- @blueprints = BlueprintsView.new(root:)
48
+ @feelings = FeelingsView.new(projects: Projects.outer_roots(workers.roots))
49
+ @blueprints = BlueprintsView.new(root:, projects: workers.roots)
50
50
  @nav = ListView.new { nav_rows }
51
- first = Projects.files(root, nested: true).first
51
+ first = @files.files.first
52
52
  # The dashboard is the first page, unless a file is asked for, and "d" goes from it to the first file.
53
53
  @view = OutlineView.new(first, workers:) if first
54
54
  path ? open_at(path, nil) : open_view(dashboard)
@@ -3,8 +3,8 @@
3
3
  module Raindown
4
4
  module TUI
5
5
  # The list of projects that drops down from the header's path, with "p" or a click, when there's more than one.
6
- # Choosing one narrows the files and the dashboard to its own, and "All projects" shows every project's again.
7
- # It stays open until a project's chosen, with Enter or a click, while up and down move its cursor.
6
+ # Choosing one narrows the files, the dashboard, Code feelings and Blueprints to its own, and "All projects" shows every
7
+ # project's again. It stays open until a project's chosen, with Enter or a click, while up and down move its cursor.
8
8
  # Esc or "p" close it without choosing, and other keys and clicks do nothing while it's open.
9
9
  module ProjectMenu
10
10
  # The menu's cursor, while it's open.
@@ -54,6 +54,8 @@ module Raindown
54
54
  @files.scope = project
55
55
  @endpoints.project = project
56
56
  dashboard.project = project
57
+ feelings.project = project
58
+ blueprints.project = project
57
59
  open_view(dashboard)
58
60
  end
59
61
  end
@@ -28,8 +28,10 @@ module Raindown
28
28
  def page = 'Dashboard'
29
29
 
30
30
  def lines
31
+ return alert(NO_PROJECTS) if navigator.projects.empty?
32
+
31
33
  cards = dashboard.cards
32
- return blank(height, " No charts in #{File.basename(navigator.root)} yet, like <{ chart: '3 5 2 8' }>") if cards.empty?
34
+ return alert(["No charts in #{File.basename(navigator.root)} yet.", "Add one like <{ chart: '3 5 2 8' }>"]) if cards.empty?
33
35
 
34
36
  rows = dashboard.rows(width)
35
37
  @headings = headings(rows, cards)
@@ -94,9 +96,7 @@ module Raindown
94
96
  [segment(" #{from_home(project)}", :project)]
95
97
  end
96
98
 
97
- def blank(count, message = '')
98
- [[segment(message, :info)], *Array.new([count - 1, 0].max) { [segment('', :blank)] }].first(count)
99
- end
99
+ def blank(count) = Array.new(count) { [segment('', :blank)] }
100
100
  end
101
101
  end
102
102
  end
@@ -6,6 +6,9 @@ require_relative '../text'
6
6
 
7
7
  module Raindown
8
8
  module TUI
9
+ # Said in place of a page, like the charts or the code to feel, when the folder has no projects, with how to make one.
10
+ NO_PROJECTS = ['No projects found in this directory.', %(Run "raindown init" in a project's folder.)].freeze
11
+
9
12
  # Draws what's in a pane, :nav or :main, as lines of the pane's width and height, which the Layout puts in it.
10
13
  # Like the Layout, it's made again for each draw, so it draws what the Navigator and its view hold then.
11
14
  Renderer = Struct.new(:navigator, :pane, :width, :height) do
@@ -14,6 +17,23 @@ module Raindown
14
17
 
15
18
  # Its side of the line above the panes, which is blank unless it has something to say there, like a progress.
16
19
  def spacer = [segment('', :blank)]
20
+
21
+ # The lines in a purple box, like an alert, at the top left, with blank below it, like when there's nothing to show.
22
+ # It's no wider than the pane, cutting the lines to fit.
23
+ def alert(messages)
24
+ inner = [messages.map { display_width(it) }.max, width - 5].min.clamp(0..)
25
+ box = [alert_edge('╔', '╗', inner), *messages.map { alert_line(it, inner) }, alert_edge('╚', '╝', inner)]
26
+ (box + Array.new(height) { [segment('', :blank)] }).first(height)
27
+ end
28
+
29
+ def alert_edge(left, right, inner)
30
+ padded([segment('', :blank, width: 1), segment("#{left}#{'═' * (inner + 2)}#{right}", :alert, width: inner + 4)])
31
+ end
32
+
33
+ def alert_line(message, inner)
34
+ padded([segment('', :blank, width: 1), segment('║ ', :alert, width: 2), segment(message, :alert, width: inner),
35
+ segment(' ║', :alert, width: 2)])
36
+ end
17
37
  end
18
38
  end
19
39
  end
@@ -85,7 +85,7 @@ module Raindown
85
85
  cursor: Curses::A_UNDERLINE, cursor_button: Curses::A_UNDERLINE | Curses::A_BOLD, heading: Curses::A_BOLD,
86
86
  body: Curses::A_DIM, error: Curses::A_BOLD, button: Curses::A_BOLD, folder: Curses::A_BOLD, project: Curses::A_BOLD, page: Curses::A_BOLD,
87
87
  key: Curses::A_REVERSE, border: Curses::A_DIM, divider: Curses::A_BOLD,
88
- code_keyword: Curses::A_BOLD, code_comment: Curses::A_DIM
88
+ code_keyword: Curses::A_BOLD, code_comment: Curses::A_DIM, alert: Curses::A_BOLD
89
89
  )
90
90
  end
91
91
 
@@ -112,11 +112,12 @@ module Raindown
112
112
  Hash.new(text).merge(plain.transform_values { it | text })
113
113
  end
114
114
 
115
- # Headings, the waffle's butter's shine and its syrup, and Code feelings' text and lines, bold around the method the
115
+ # Headings, alerts, the waffle's butter's shine and its syrup, and Code feelings' text and lines, bold around the method the
116
116
  # mouse is over, and its purple.
117
117
  def emphasis
118
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) }
119
+ feelings_purple: pair(:feelings_purple), alert: pair(:feelings_purple) | Curses::A_BOLD,
120
+ shine: pair(:shine) | Curses::A_BOLD, syrup: pair(:syrup) }
120
121
  end
121
122
 
122
123
  # Highlighted code's tokens, like keywords and comments, with comments dim in 8 colours, where there's no grey.
@@ -25,7 +25,7 @@ module Raindown
25
25
  end
26
26
 
27
27
  # The folder's project and those inside it, found when they're first asked for, and again after a restart.
28
- def roots = @roots ||= [root, *Projects.roots(root)].uniq.sort
28
+ def roots = @roots ||= Projects.roots(root).sort
29
29
 
30
30
  # A worker for each project, as they were found when the workers last started.
31
31
  def each(&)
data/lib/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Raindown
4
- VERSION = '0.9.0'
4
+ VERSION = '0.9.1'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: raindown
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.0
4
+ version: 0.9.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - maedi
@@ -216,6 +216,7 @@ files:
216
216
  - lib/raindown/files.rb
217
217
  - lib/raindown/fragments.rb
218
218
  - lib/raindown/front_matter.rb
219
+ - lib/raindown/gitignore.rb
219
220
  - lib/raindown/heading_ids.rb
220
221
  - lib/raindown/js/host.mjs
221
222
  - lib/raindown/js/js_nodes.rb