terminal-notes 0.0.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 +7 -0
- data/README.md +69 -0
- data/bin/terminal-notes +16 -0
- data/lib/terminal-notes.rb +175 -0
- data/lib/terminal-notes/cursor.rb +26 -0
- data/lib/terminal-notes/file_list.rb +217 -0
- data/lib/terminal-notes/info_bar.rb +25 -0
- data/lib/terminal-notes/rurses.rb +66 -0
- data/lib/terminal-notes/rurses/panel_stack.rb +35 -0
- data/lib/terminal-notes/rurses/version.rb +3 -0
- data/lib/terminal-notes/rurses/window.rb +148 -0
- data/lib/terminal-notes/search_field.rb +46 -0
- data/lib/terminal-notes/text_field.rb +89 -0
- data/lib/terminal-notes/widget.rb +96 -0
- metadata +156 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 3a0256e2d1947621a55db84286b5a621e6acc0711a0861b41ca95b04622871c6
|
4
|
+
data.tar.gz: b9af1e67c2f847cb0b2c56e889ae4e31cf5a63ccd1185972d045d017f0cf070c
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 98dda1f386c2e92b67d9cf81fb5132c0a2388ab4fbd802482000578f050836ab06a816d14ee0a3f461bf6c79a1c5a5829b372e36c7ad9c79a34b7440caafa0a8
|
7
|
+
data.tar.gz: aa8dfc344c7618dd2190398b2d253946e4d5fa4386d7244dfd29d16e148baf3b6e9deab51cf3624d309f4d1d7134c747d9f855eca9b86a1ece2c56be02a427a7
|
data/README.md
ADDED
@@ -0,0 +1,69 @@
|
|
1
|
+
# Note Transpose
|
2
|
+
[](https://badge.fury.io/rb/terminal-notes)
|
3
|
+
[](https://travis-ci.org/vyder/terminal-notes)
|
4
|
+
[](http://inch-ci.org/github/vyder/terminal-notes)
|
5
|
+
|
6
|
+
Searchable notes in your terminal! What's not to like?
|
7
|
+
|
8
|
+
## Table of Contents
|
9
|
+
|
10
|
+
- [Install](#install)
|
11
|
+
- [Usage](#usage)
|
12
|
+
- [Documentation](#documentation)
|
13
|
+
- [Roadmap](#roadmap)
|
14
|
+
- [Support](#support)
|
15
|
+
- [Contributing](#contributing)
|
16
|
+
|
17
|
+
## Install
|
18
|
+
|
19
|
+
❯ gem install terminal-notes
|
20
|
+
|
21
|
+
On first run, you will be asked to confirm the destination of your install. It defaults to `~/.notesrc/`.
|
22
|
+
|
23
|
+
The main configuration file is located at `~/.notesrc/config`. It's a YAML file.
|
24
|
+
|
25
|
+
All your notes are saved in `~/.notesrc/db` as text files.
|
26
|
+
|
27
|
+
Additionally, I like to symlink the binary to my `~/bin` folder for ease of access. You can do that with:
|
28
|
+
|
29
|
+
❯ ln -sn $(which terminal-notes) ~/bin/notes
|
30
|
+
|
31
|
+
Make sure you have your `~/bin` in your `PATH` for this to work.
|
32
|
+
|
33
|
+
## Usage
|
34
|
+
|
35
|
+
❯ terminal-notes
|
36
|
+
|
37
|
+
TODO: Write this section
|
38
|
+
|
39
|
+
## Documentation
|
40
|
+
|
41
|
+
You can find the documentation [here](https://vyder.github.io/terminal-notes/)
|
42
|
+
|
43
|
+
## Roadmap
|
44
|
+
|
45
|
+
Here is my planned roadmap:
|
46
|
+
|
47
|
+
(Last updated Aug 26th, 2020)
|
48
|
+
|
49
|
+
- [ ] Draw a shortcuts info bar like `nano` has
|
50
|
+
- [ ] Draw a title bar at the top
|
51
|
+
- [ ] Implement `.notesrc` and database
|
52
|
+
- [ ] Create an install flow (as described in the README)
|
53
|
+
- [ ] Implement responsive layout
|
54
|
+
- [ ] Implement a better file matcher
|
55
|
+
- [ ] Create a non fancy mode which works better in smaller terminal screens
|
56
|
+
- [ ] Update status line to display:
|
57
|
+
- Matcher
|
58
|
+
- [ ] Abstract out UI work to Layout module that is stateful and tracks x,y widget positions
|
59
|
+
|
60
|
+
Future:
|
61
|
+
- Implement file previews with `tty-markdown` when in full screen mode
|
62
|
+
|
63
|
+
## Support
|
64
|
+
|
65
|
+
Please [open an issue](https://github.com/vyder/terminal-notes/issues/new) for support.
|
66
|
+
|
67
|
+
## Contributing
|
68
|
+
|
69
|
+
Please contribute using [Github Flow](https://guides.github.com/introduction/flow/). Create a branch, add commits, and [open a pull request](https://github.com/vyder/terminal-notes/compare/).
|
data/bin/terminal-notes
ADDED
@@ -0,0 +1,175 @@
|
|
1
|
+
require_relative 'terminal-notes/rurses'
|
2
|
+
|
3
|
+
require_relative 'terminal-notes/cursor'
|
4
|
+
require_relative 'terminal-notes/widget'
|
5
|
+
require_relative 'terminal-notes/text_field'
|
6
|
+
require_relative 'terminal-notes/search_field'
|
7
|
+
require_relative 'terminal-notes/file_list'
|
8
|
+
require_relative 'terminal-notes/info_bar'
|
9
|
+
|
10
|
+
module TerminalNotes
|
11
|
+
class App
|
12
|
+
@has_initialized = false
|
13
|
+
|
14
|
+
def start(config)
|
15
|
+
directory = config[:directory]
|
16
|
+
|
17
|
+
Rurses.program(modes: %i[c_break no_echo keypad]) do |screen|
|
18
|
+
@screen = screen
|
19
|
+
|
20
|
+
@widgets = []
|
21
|
+
|
22
|
+
@search_field = SearchField.new(parent: @screen)
|
23
|
+
@widgets << @search_field
|
24
|
+
|
25
|
+
@info_bar = InfoBar.new(parent: @screen) { get_context }
|
26
|
+
@widgets << @info_bar
|
27
|
+
|
28
|
+
padding = 2
|
29
|
+
|
30
|
+
# file_list_height = screen.size[:lines]
|
31
|
+
# - @search_field.height
|
32
|
+
# - @info_bar.height
|
33
|
+
# - padding
|
34
|
+
file_list_height = 40
|
35
|
+
@file_list = FileList.new(parent: @screen,
|
36
|
+
height: file_list_height,
|
37
|
+
y: @search_field.height + padding,
|
38
|
+
directory: directory)
|
39
|
+
@widgets << @file_list
|
40
|
+
|
41
|
+
# Register the text_field filter as a delegate
|
42
|
+
# of the search field
|
43
|
+
@search_field.on_text_changed do |pattern|
|
44
|
+
@file_list.filter(pattern)
|
45
|
+
end
|
46
|
+
|
47
|
+
# Register file open handler
|
48
|
+
@file_list.on_file_opened do |file|
|
49
|
+
open_file(file)
|
50
|
+
end
|
51
|
+
|
52
|
+
# A PanelStack handles how windows are drawn
|
53
|
+
# over each other - i.e. overlapping
|
54
|
+
#
|
55
|
+
# I'm not really using this ability, however, in
|
56
|
+
# Rurses, the PanelStack seems to be the only way
|
57
|
+
# to delete/deallocate a window completely
|
58
|
+
#
|
59
|
+
@panels = Rurses::PanelStack.new
|
60
|
+
@widgets.each { |widget| @panels.add(widget.window) }
|
61
|
+
@panels.refresh_in_memory
|
62
|
+
|
63
|
+
# Events
|
64
|
+
# watch_for_resize
|
65
|
+
|
66
|
+
# Set initial state
|
67
|
+
set_context :search
|
68
|
+
|
69
|
+
# Actually draw the screen
|
70
|
+
# @panels.refresh_in_memory
|
71
|
+
Rurses.update_screen
|
72
|
+
|
73
|
+
@has_initialized = true
|
74
|
+
|
75
|
+
loop do
|
76
|
+
key = Rurses.get_key
|
77
|
+
|
78
|
+
if @context == :browse
|
79
|
+
case key
|
80
|
+
when "q", :CTRL_X
|
81
|
+
break # quit
|
82
|
+
when "m"
|
83
|
+
@file_list.toggle_matcher
|
84
|
+
@file_list.filter(@search_field.text)
|
85
|
+
|
86
|
+
when "\t", "/"
|
87
|
+
set_context :search
|
88
|
+
else
|
89
|
+
@file_list.on_key(key)
|
90
|
+
end
|
91
|
+
else
|
92
|
+
case key
|
93
|
+
when :CTRL_X
|
94
|
+
break # quit
|
95
|
+
when "\t", :ENTER
|
96
|
+
set_context :browse
|
97
|
+
else
|
98
|
+
@search_field.on_key(key)
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
# When all the downstream widgets are done updating
|
103
|
+
# in-memory, actually redraw the screen
|
104
|
+
# @panels.refresh_in_memory
|
105
|
+
Rurses.update_screen
|
106
|
+
end
|
107
|
+
|
108
|
+
@screen.move_cursor(x: 0, y: 0)
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
def set_context context
|
113
|
+
@context = context
|
114
|
+
|
115
|
+
if @context == :browse
|
116
|
+
Rurses.curses.curs_set(0)
|
117
|
+
@search_field.unfocus
|
118
|
+
@file_list.focus
|
119
|
+
|
120
|
+
elsif @context == :search
|
121
|
+
Rurses.curses.curs_set(1)
|
122
|
+
@file_list.unfocus
|
123
|
+
@search_field.focus
|
124
|
+
|
125
|
+
end
|
126
|
+
|
127
|
+
@info_bar.draw
|
128
|
+
end
|
129
|
+
|
130
|
+
def get_context
|
131
|
+
{ mode: @context }
|
132
|
+
end
|
133
|
+
|
134
|
+
def open_file(file)
|
135
|
+
system(editor, file)
|
136
|
+
redraw
|
137
|
+
|
138
|
+
# Hack to redraw focus correctly
|
139
|
+
# TODO: Figure out why this works
|
140
|
+
set_context :search
|
141
|
+
set_context :browse
|
142
|
+
end
|
143
|
+
|
144
|
+
def editor
|
145
|
+
ENV['EDITOR'] || DEFAULT_EDITOR
|
146
|
+
end
|
147
|
+
|
148
|
+
# This method can be invoked independent of the wait for
|
149
|
+
# keyboard input loop, so explicitly call for a screen
|
150
|
+
# update after widgets have been drawn
|
151
|
+
def redraw(resize: false)
|
152
|
+
return unless @has_initialized
|
153
|
+
|
154
|
+
@widgets.each(&:redraw)
|
155
|
+
# @widgets.each do |widget|
|
156
|
+
# widget.window.clear
|
157
|
+
# @panels.remove(widget.window)
|
158
|
+
# widget.draw(resize: resize)
|
159
|
+
# @panels.add(widget.window)
|
160
|
+
# end
|
161
|
+
|
162
|
+
set_context @context
|
163
|
+
|
164
|
+
@panels.refresh_in_memory
|
165
|
+
Rurses.update_screen
|
166
|
+
end
|
167
|
+
|
168
|
+
def watch_for_resize
|
169
|
+
# SIGWINCH
|
170
|
+
# The SIGWINCH signal is sent to a process when its
|
171
|
+
# controlling terminal changes its size (a window change).
|
172
|
+
Signal.trap(:SIGWINCH) { redraw(resize: true) }
|
173
|
+
end
|
174
|
+
end
|
175
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
module TerminalNotes
|
2
|
+
class Cursor
|
3
|
+
attr_reader :x, :y
|
4
|
+
|
5
|
+
def initialize(x: 0, y: 0)
|
6
|
+
@x = x.to_i
|
7
|
+
@y = y.to_i
|
8
|
+
end
|
9
|
+
|
10
|
+
def moveBy(deltaX = 0, deltaY = 0)
|
11
|
+
@x += deltaX
|
12
|
+
@y += deltaY
|
13
|
+
self
|
14
|
+
end
|
15
|
+
|
16
|
+
def moveTo(x: nil, y: nil)
|
17
|
+
@x = x unless x.nil?
|
18
|
+
@y = y unless y.nil?
|
19
|
+
self
|
20
|
+
end
|
21
|
+
|
22
|
+
def to_hash
|
23
|
+
{ x: @x, y: @y }
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,217 @@
|
|
1
|
+
require 'fuzzy_match'
|
2
|
+
|
3
|
+
module TerminalNotes
|
4
|
+
class FileList < Widget
|
5
|
+
WIDTH = 1
|
6
|
+
|
7
|
+
EXTENSIONS = ['.md', '.txt']
|
8
|
+
DEFAULT_EDITOR = "nano"
|
9
|
+
|
10
|
+
CONTENT_PADDING = 2
|
11
|
+
FILE_INDICATOR = "❯ "
|
12
|
+
INTER_COL_GAP = " "
|
13
|
+
|
14
|
+
MATCHERS = [:regex, :fuzzy]
|
15
|
+
|
16
|
+
def initialize(parent:, height:, y:, directory:,
|
17
|
+
extensions: EXTENSIONS)
|
18
|
+
super(parent: parent, title: "Files",
|
19
|
+
width: WIDTH, height: height, y: y)
|
20
|
+
|
21
|
+
@content_start = Cursor.new(x: 0, y: CONTENT_PADDING)
|
22
|
+
|
23
|
+
# Find all the files that match specified extensions
|
24
|
+
@all_files = Dir.glob("#{directory}/*")
|
25
|
+
@all_files = @all_files.find_all do |f|
|
26
|
+
f.match /(#{EXTENSIONS.join("|")})$/
|
27
|
+
end
|
28
|
+
@all_files = @all_files.map do |f|
|
29
|
+
{
|
30
|
+
name: f.gsub("#{directory}/", ''),
|
31
|
+
path: f,
|
32
|
+
date: "Jun 24, 2012",
|
33
|
+
time: "05:32pm"
|
34
|
+
}
|
35
|
+
end
|
36
|
+
@filtered_files = @all_files
|
37
|
+
|
38
|
+
@fuzzy_matcher = FuzzyMatch.new(@all_files, read: :name)
|
39
|
+
@curr_matcher = 0
|
40
|
+
|
41
|
+
@current_line = 0
|
42
|
+
@has_focus = false
|
43
|
+
|
44
|
+
@on_file_opened_delegates = []
|
45
|
+
|
46
|
+
draw
|
47
|
+
end
|
48
|
+
|
49
|
+
# TODO
|
50
|
+
def focus
|
51
|
+
@has_focus = true
|
52
|
+
draw
|
53
|
+
end
|
54
|
+
|
55
|
+
def unfocus
|
56
|
+
@has_focus = false
|
57
|
+
draw
|
58
|
+
end
|
59
|
+
|
60
|
+
def draw
|
61
|
+
super do
|
62
|
+
# Calculate where to start drawing the table
|
63
|
+
#
|
64
|
+
table_width = 0
|
65
|
+
|
66
|
+
# Calculate NAME col width
|
67
|
+
longest_file = @all_files.max { |a, b| a[:name].size <=> b[:name].size }
|
68
|
+
if longest_file.nil?
|
69
|
+
name_col_width = 10
|
70
|
+
else
|
71
|
+
name_col_width = 2 + longest_file[:name].size
|
72
|
+
end
|
73
|
+
|
74
|
+
# Calculate DATE col width, e.g. Jun 24, 2012
|
75
|
+
date_col_width = 12
|
76
|
+
|
77
|
+
# Calculate TIME col width, e.g. 05:24pm
|
78
|
+
time_col_width = 7
|
79
|
+
|
80
|
+
table_width += name_col_width + INTER_COL_GAP.size
|
81
|
+
table_width += date_col_width + INTER_COL_GAP.size
|
82
|
+
table_width += time_col_width
|
83
|
+
|
84
|
+
# Move cursor to center the table
|
85
|
+
cursor = @content_start.dup
|
86
|
+
cursor.moveTo(x: (@parent.size[:columns] - table_width) / 2)
|
87
|
+
@window.move_cursor(cursor)
|
88
|
+
|
89
|
+
# Start actually drawing
|
90
|
+
#
|
91
|
+
|
92
|
+
# Draw header
|
93
|
+
line = "NAME".ljust(name_col_width) + INTER_COL_GAP
|
94
|
+
line += "DATE".ljust(date_col_width) + INTER_COL_GAP
|
95
|
+
line += "TIME".ljust(time_col_width)
|
96
|
+
@window.draw_string(line)
|
97
|
+
|
98
|
+
# Move cursor back to accomodate the file indicator
|
99
|
+
cursor.moveBy(-FILE_INDICATOR.size, 1)
|
100
|
+
@window.move_cursor(cursor)
|
101
|
+
|
102
|
+
no_indicator = " " * FILE_INDICATOR.size
|
103
|
+
|
104
|
+
index = 0
|
105
|
+
@filtered_files.each do |file|
|
106
|
+
line = no_indicator
|
107
|
+
line += file[:name].ljust(name_col_width) + INTER_COL_GAP
|
108
|
+
line += file[:date].rjust(date_col_width) + INTER_COL_GAP
|
109
|
+
line += file[:time].rjust(time_col_width)
|
110
|
+
|
111
|
+
@window.draw_string(line)
|
112
|
+
|
113
|
+
# Draw the arrow
|
114
|
+
if @has_focus && index == @current_line
|
115
|
+
@window.move_cursor(cursor)
|
116
|
+
@window.draw_string(FILE_INDICATOR)
|
117
|
+
end
|
118
|
+
|
119
|
+
cursor.moveBy(0, 1)
|
120
|
+
@window.move_cursor(cursor)
|
121
|
+
|
122
|
+
index += 1
|
123
|
+
end
|
124
|
+
|
125
|
+
clear_line = no_indicator + " " * table_width
|
126
|
+
while index < @all_files.size
|
127
|
+
@window.draw_string(clear_line)
|
128
|
+
cursor.moveBy(0, 1)
|
129
|
+
@window.move_cursor(cursor)
|
130
|
+
|
131
|
+
index += 1
|
132
|
+
end
|
133
|
+
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
def on_key key
|
138
|
+
return unless @has_focus
|
139
|
+
|
140
|
+
case key
|
141
|
+
when "j"
|
142
|
+
scroll_down
|
143
|
+
when "k"
|
144
|
+
scroll_up
|
145
|
+
when :ENTER
|
146
|
+
open_current
|
147
|
+
else
|
148
|
+
end
|
149
|
+
|
150
|
+
draw
|
151
|
+
end
|
152
|
+
|
153
|
+
def filter pattern
|
154
|
+
old_file_list = @filtered_files
|
155
|
+
|
156
|
+
if pattern.empty?
|
157
|
+
@filtered_files = @all_files
|
158
|
+
else
|
159
|
+
matcher_type = MATCHERS[@curr_matcher]
|
160
|
+
case matcher_type
|
161
|
+
when :regex
|
162
|
+
@filtered_files = @all_files.find_all do |f|
|
163
|
+
f[:name].match /#{pattern}/
|
164
|
+
end
|
165
|
+
when :fuzzy
|
166
|
+
@filtered_files = @fuzzy_matcher.find_all(pattern)
|
167
|
+
else
|
168
|
+
raise "Unknown Matcher! '#{matcher_type}'"
|
169
|
+
end
|
170
|
+
end
|
171
|
+
|
172
|
+
# If the file list has changed, reset current line marker
|
173
|
+
@current_line = 0 unless old_file_list == @filtered_files
|
174
|
+
|
175
|
+
draw
|
176
|
+
end
|
177
|
+
|
178
|
+
def toggle_matcher
|
179
|
+
@curr_matcher += 1
|
180
|
+
@curr_matcher = 0 if @curr_matcher >= MATCHERS.size
|
181
|
+
end
|
182
|
+
|
183
|
+
def on_file_opened &block
|
184
|
+
@on_file_opened_delegates << block
|
185
|
+
end
|
186
|
+
|
187
|
+
def notify_file_opened(file)
|
188
|
+
@on_file_opened_delegates.each do |delegate|
|
189
|
+
delegate.call(file)
|
190
|
+
end
|
191
|
+
end
|
192
|
+
|
193
|
+
private
|
194
|
+
|
195
|
+
def open_current
|
196
|
+
file = @filtered_files[@current_line]
|
197
|
+
notify_file_opened(file[:path])
|
198
|
+
end
|
199
|
+
|
200
|
+
def editor
|
201
|
+
# %x(echo $EDITOR).chomp || DEFAULT_EDITOR
|
202
|
+
ENV['EDITOR'] || DEFAULT_EDITOR
|
203
|
+
end
|
204
|
+
|
205
|
+
def scroll_down
|
206
|
+
if @current_line < (@filtered_files.size - 1)
|
207
|
+
@current_line += 1
|
208
|
+
end
|
209
|
+
end
|
210
|
+
|
211
|
+
def scroll_up
|
212
|
+
if @current_line > 0
|
213
|
+
@current_line -= 1
|
214
|
+
end
|
215
|
+
end
|
216
|
+
end
|
217
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
module TerminalNotes
|
2
|
+
class InfoBar < Widget
|
3
|
+
HEIGHT = 3
|
4
|
+
|
5
|
+
def initialize(config: {}, parent:, &block)
|
6
|
+
super(parent: parent, title: "", height: HEIGHT,
|
7
|
+
y: (parent.size[:lines] - HEIGHT), border: false)
|
8
|
+
|
9
|
+
@get_context = block
|
10
|
+
draw
|
11
|
+
end
|
12
|
+
|
13
|
+
def draw
|
14
|
+
super do
|
15
|
+
context = @get_context.call
|
16
|
+
title = "Mode: #{context[:mode].to_s.capitalize}"
|
17
|
+
|
18
|
+
cursor = Cursor.new(x: (@parent.size[:columns] - title.size) / 2,
|
19
|
+
y: 0)
|
20
|
+
@window.move_cursor(cursor)
|
21
|
+
@window.draw_string(title)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
require "ffi-ncurses"
|
2
|
+
|
3
|
+
require_relative "rurses/version"
|
4
|
+
require_relative "rurses/panel_stack"
|
5
|
+
require_relative "rurses/window"
|
6
|
+
|
7
|
+
module Rurses
|
8
|
+
SPECIAL_KEYS = Hash[
|
9
|
+
FFI::NCurses::KeyDefs
|
10
|
+
.constants
|
11
|
+
.select { |name| name.to_s.start_with?("KEY_") }
|
12
|
+
.map { |name|
|
13
|
+
[ FFI::NCurses::KeyDefs.const_get(name),
|
14
|
+
name.to_s.sub(/\AKEY_/, "").to_sym ]
|
15
|
+
}
|
16
|
+
]
|
17
|
+
|
18
|
+
module_function
|
19
|
+
|
20
|
+
def curses
|
21
|
+
FFI::NCurses
|
22
|
+
end
|
23
|
+
|
24
|
+
def program(modes: [ ])
|
25
|
+
@stdscr = Window.new(curses_ref: curses.initscr, standard_screen: true)
|
26
|
+
@stdscr.change_modes(modes)
|
27
|
+
yield(@stdscr)
|
28
|
+
ensure
|
29
|
+
curses.endwin
|
30
|
+
end
|
31
|
+
|
32
|
+
def stdscr
|
33
|
+
@stdscr
|
34
|
+
end
|
35
|
+
|
36
|
+
def get_key
|
37
|
+
case (char = curses.getch)
|
38
|
+
when curses::KeyDefs::KEY_CODE_YES..curses::KeyDefs::KEY_MAX
|
39
|
+
SPECIAL_KEYS[char]
|
40
|
+
when curses::ERR
|
41
|
+
nil
|
42
|
+
when 1
|
43
|
+
:CTRL_A
|
44
|
+
when 5
|
45
|
+
:CTRL_E
|
46
|
+
when 10
|
47
|
+
:ENTER
|
48
|
+
when 11
|
49
|
+
:CTRL_K
|
50
|
+
when 14
|
51
|
+
:CTRL_N
|
52
|
+
when 23
|
53
|
+
:CTRL_W
|
54
|
+
when 24
|
55
|
+
:CTRL_X
|
56
|
+
when 127
|
57
|
+
:BACKSPACE
|
58
|
+
else
|
59
|
+
char.chr
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
def update_screen
|
64
|
+
curses.doupdate
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
module Rurses
|
2
|
+
class PanelStack
|
3
|
+
def initialize
|
4
|
+
@window_to_panel_map = { }
|
5
|
+
end
|
6
|
+
|
7
|
+
attr_reader :window_to_panel_map
|
8
|
+
private :window_to_panel_map
|
9
|
+
|
10
|
+
def add(window, add_subwindows: true)
|
11
|
+
window_to_panel_map[window] = Rurses.curses.new_panel(window.curses_ref)
|
12
|
+
if add_subwindows
|
13
|
+
window.subwindows.each_value do |subwindow|
|
14
|
+
add(subwindow, add_subwindows: add_subwindows)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
alias_method :<<, :add
|
19
|
+
|
20
|
+
def remove(window, remove_subwindows: true)
|
21
|
+
if remove_subwindows
|
22
|
+
window.subwindows.each_value do |subwindow|
|
23
|
+
remove(subwindow, remove_subwindows: remove_subwindows)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
window.clear
|
27
|
+
Rurses.curses.del_panel(window_to_panel_map[window])
|
28
|
+
Rurses.curses.delwin(window.curses_ref)
|
29
|
+
end
|
30
|
+
|
31
|
+
def refresh_in_memory
|
32
|
+
Rurses.curses.update_panels
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,148 @@
|
|
1
|
+
module Rurses
|
2
|
+
class Window
|
3
|
+
MODE_NAMES = {
|
4
|
+
c_break: :cbreak,
|
5
|
+
no_echo: :noecho,
|
6
|
+
keypad: [:keypad, :window, true],
|
7
|
+
hide_cursor: [:curs_set, 0],
|
8
|
+
non_blocking: [:timeout, 0]
|
9
|
+
}
|
10
|
+
ATTRIBUTES = Hash.new { |all, name|
|
11
|
+
all[name] = Rurses.curses.const_get("A_#{name.upcase}")
|
12
|
+
}
|
13
|
+
|
14
|
+
def initialize(**details)
|
15
|
+
@curses_ref = details.fetch(:curses_ref) {
|
16
|
+
Rurses.curses.newwin(
|
17
|
+
details.fetch(:lines),
|
18
|
+
details.fetch(:columns),
|
19
|
+
details.fetch(:y),
|
20
|
+
details.fetch(:x)
|
21
|
+
)
|
22
|
+
}
|
23
|
+
@standard_screen = details.fetch(:standard_screen) { false }
|
24
|
+
@subwindows = { }
|
25
|
+
end
|
26
|
+
|
27
|
+
attr_reader :curses_ref, :subwindows
|
28
|
+
|
29
|
+
def standard_screen?
|
30
|
+
@standard_screen
|
31
|
+
end
|
32
|
+
|
33
|
+
def cursor_x
|
34
|
+
Rurses.curses.getcurx(curses_ref)
|
35
|
+
end
|
36
|
+
|
37
|
+
def cursor_y
|
38
|
+
Rurses.curses.getcury(curses_ref)
|
39
|
+
end
|
40
|
+
|
41
|
+
def cursor_xy
|
42
|
+
y, x = Rurses.curses.getyx(curses_ref)
|
43
|
+
{x: x, y: y}
|
44
|
+
end
|
45
|
+
|
46
|
+
def columns
|
47
|
+
Rurses.curses.getmaxx(curses_ref)
|
48
|
+
end
|
49
|
+
|
50
|
+
def lines
|
51
|
+
Rurses.curses.getmaxy(curses_ref)
|
52
|
+
end
|
53
|
+
|
54
|
+
def size
|
55
|
+
{columns: columns, lines: lines}
|
56
|
+
end
|
57
|
+
|
58
|
+
def resize(lines: , columns: )
|
59
|
+
Rurses.curses.resizeterm(lines, columns) if standard_screen?
|
60
|
+
Rurses.curses.wresize(curses_ref, lines, columns)
|
61
|
+
end
|
62
|
+
|
63
|
+
def change_modes(modes)
|
64
|
+
modes.each do |name|
|
65
|
+
mode = Array(MODE_NAMES[name] || name)
|
66
|
+
Rurses.curses.send(*mode.map { |arg| arg == :window ? curses_ref : arg })
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
def draw_border( left: 0, right: 0, top: 0, bottom: 0,
|
71
|
+
top_left: 0, top_right: 0, bottom_left: 0, bottom_right: 0 )
|
72
|
+
args = [
|
73
|
+
left, right, top, bottom,
|
74
|
+
top_left, top_right, bottom_left, bottom_right
|
75
|
+
].map { |c| c.is_a?(String) ? c.encode("UTF-8").codepoints.first : c }
|
76
|
+
if args.any? { |c| c >= 128 }
|
77
|
+
Rurses.curses.wborder_set(
|
78
|
+
curses_ref,
|
79
|
+
*args.map { |c|
|
80
|
+
char = Rurses.curses::WinStruct::CCharT.new
|
81
|
+
char[:chars][0] = c
|
82
|
+
}
|
83
|
+
)
|
84
|
+
else
|
85
|
+
Rurses.curses.wborder(curses_ref, *args)
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
def move_cursor(x: , y: )
|
90
|
+
Rurses.curses.wmove(curses_ref, y, x)
|
91
|
+
end
|
92
|
+
|
93
|
+
def draw_string(content)
|
94
|
+
Rurses.curses.waddstr(curses_ref, content)
|
95
|
+
end
|
96
|
+
|
97
|
+
def draw_string_on_a_line(content)
|
98
|
+
old_y = cursor_y
|
99
|
+
draw_string(content)
|
100
|
+
new_y = cursor_y
|
101
|
+
move_cursor(x: 0, y: new_y + 1) if new_y == old_y
|
102
|
+
end
|
103
|
+
|
104
|
+
def skip_line
|
105
|
+
move_cursor(x: 0, y: cursor_y + 1)
|
106
|
+
end
|
107
|
+
|
108
|
+
def style(*attributes)
|
109
|
+
attributes.each do |attribute|
|
110
|
+
Rurses.curses.wattron(curses_ref, ATTRIBUTES[attribute])
|
111
|
+
end
|
112
|
+
yield
|
113
|
+
ensure
|
114
|
+
attributes.each do |attribute|
|
115
|
+
Rurses.curses.wattroff(curses_ref, ATTRIBUTES[attribute])
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
def clear(reset_cursor: true)
|
120
|
+
Rurses.curses.wclear(curses_ref)
|
121
|
+
move_cursor(x: 0, y: 0) if reset_cursor
|
122
|
+
end
|
123
|
+
|
124
|
+
def refresh_in_memory
|
125
|
+
Rurses.curses.wnoutrefresh(curses_ref)
|
126
|
+
end
|
127
|
+
|
128
|
+
def create_subwindow( name: , top_padding: 0, left_padding: 0,
|
129
|
+
right_padding: 0, bottom_padding: 0 )
|
130
|
+
s = size
|
131
|
+
xy = cursor_xy
|
132
|
+
subwindows[name] =
|
133
|
+
self.class.new(
|
134
|
+
curses_ref: Rurses.curses.derwin(
|
135
|
+
curses_ref,
|
136
|
+
s[:lines] - (top_padding + bottom_padding),
|
137
|
+
s[:columns] - (left_padding + right_padding),
|
138
|
+
xy[:y] + top_padding,
|
139
|
+
xy[:x] + left_padding
|
140
|
+
)
|
141
|
+
)
|
142
|
+
end
|
143
|
+
|
144
|
+
def subwindow(name)
|
145
|
+
subwindows[name]
|
146
|
+
end
|
147
|
+
end
|
148
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
module TerminalNotes
|
2
|
+
class SearchField < Widget
|
3
|
+
WIDTH = 0.5
|
4
|
+
HEIGHT = 5
|
5
|
+
POS_X = 0
|
6
|
+
POS_Y = 1
|
7
|
+
ALIGN = :center
|
8
|
+
|
9
|
+
def initialize(parent:)
|
10
|
+
super(parent: parent, title: "Search",
|
11
|
+
width: WIDTH, height: HEIGHT,
|
12
|
+
x: POS_X, y: POS_Y,
|
13
|
+
align: ALIGN)
|
14
|
+
|
15
|
+
@text_field = TextField.new(@parent,
|
16
|
+
position: @position.dup.moveBy(2, 2),
|
17
|
+
width: @width - 4)
|
18
|
+
|
19
|
+
@has_focus = false
|
20
|
+
|
21
|
+
draw
|
22
|
+
end
|
23
|
+
|
24
|
+
def text
|
25
|
+
@text_field.text
|
26
|
+
end
|
27
|
+
|
28
|
+
def focus
|
29
|
+
@has_focus = true
|
30
|
+
@text_field.draw
|
31
|
+
end
|
32
|
+
|
33
|
+
def unfocus
|
34
|
+
@has_focus = false
|
35
|
+
end
|
36
|
+
|
37
|
+
def on_text_changed &delegate
|
38
|
+
@text_field.on_text_changed { |p| delegate.call(p) }
|
39
|
+
end
|
40
|
+
|
41
|
+
def on_key key
|
42
|
+
return unless @has_focus
|
43
|
+
@text_field.on_key(key)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,89 @@
|
|
1
|
+
module TerminalNotes
|
2
|
+
class TextField
|
3
|
+
attr_reader :text
|
4
|
+
|
5
|
+
DEFAULT_WIDTH = 10
|
6
|
+
BREAK_CHARS = " -_+=".split('')
|
7
|
+
|
8
|
+
def initialize(screen, position: Cursor.new, width: DEFAULT_WIDTH)
|
9
|
+
@screen = screen
|
10
|
+
@start = position.dup
|
11
|
+
@end = @start.dup.moveBy(width, 0)
|
12
|
+
@width = width
|
13
|
+
@cursor = @start.dup
|
14
|
+
|
15
|
+
@clear_text = " " * @width
|
16
|
+
@text = ""
|
17
|
+
|
18
|
+
@on_text_changed_delegates = []
|
19
|
+
end
|
20
|
+
|
21
|
+
def on_text_changed &block
|
22
|
+
@on_text_changed_delegates << block
|
23
|
+
end
|
24
|
+
|
25
|
+
def notify_text_changed
|
26
|
+
@on_text_changed_delegates.each do |delegate|
|
27
|
+
delegate.call(@text)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def on_key(key)
|
32
|
+
old_text = @text
|
33
|
+
case key
|
34
|
+
when :BACKSPACE
|
35
|
+
@text = @text[0...-1]
|
36
|
+
@cursor.moveBy(-1) unless @text.size == (@width - 1)
|
37
|
+
|
38
|
+
when :UP, :DOWN
|
39
|
+
when :LEFT
|
40
|
+
@cursor.moveBy(-1)
|
41
|
+
|
42
|
+
when :RIGHT
|
43
|
+
@cursor.moveBy(1)
|
44
|
+
|
45
|
+
when :CTRL_K
|
46
|
+
@text = ""
|
47
|
+
|
48
|
+
when :CTRL_W
|
49
|
+
last = @text.size - 2
|
50
|
+
while last >= 0
|
51
|
+
break if BREAK_CHARS.include? @text[last]
|
52
|
+
last -= 1
|
53
|
+
end
|
54
|
+
@text = @text[0...(last+1)]
|
55
|
+
|
56
|
+
when :CTRL_E
|
57
|
+
@cursor.moveTo(x: @end.x)
|
58
|
+
|
59
|
+
when :CTRL_A
|
60
|
+
@cursor.moveTo(x: @start.x)
|
61
|
+
|
62
|
+
else
|
63
|
+
if !key.nil? && !key.is_a?(Symbol) && @text.size < @width
|
64
|
+
@text += key
|
65
|
+
@cursor.moveBy(1)
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
# Make sure we don't move cursor too far left
|
70
|
+
@cursor.moveTo(x: @start.x) if @cursor.x < @start.x
|
71
|
+
|
72
|
+
# Make sure we don't move cursor too far right
|
73
|
+
max_x = @start.x + @text.size
|
74
|
+
max_x -= 1 if @text.size == @width
|
75
|
+
@cursor.moveTo(x: max_x) if @cursor.x > max_x
|
76
|
+
|
77
|
+
draw
|
78
|
+
|
79
|
+
notify_text_changed if old_text != @text
|
80
|
+
end
|
81
|
+
|
82
|
+
def draw
|
83
|
+
@screen.move_cursor(@start.to_hash)
|
84
|
+
@screen.draw_string(@text.ljust(@width))
|
85
|
+
|
86
|
+
@screen.move_cursor(@cursor.to_hash)
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
@@ -0,0 +1,96 @@
|
|
1
|
+
module TerminalNotes
|
2
|
+
class Widget
|
3
|
+
attr_reader :window, :width, :height
|
4
|
+
|
5
|
+
TITLE_PADDING = 4
|
6
|
+
|
7
|
+
def initialize(parent:, title:, width: 1, height: 1,
|
8
|
+
x: 0, y: 0, align: :center, valign: :center,
|
9
|
+
border: true)
|
10
|
+
@parent = parent
|
11
|
+
@title = title || ""
|
12
|
+
|
13
|
+
@width = width
|
14
|
+
@height = height
|
15
|
+
|
16
|
+
parent_size = @parent.size
|
17
|
+
|
18
|
+
# Relative layout calculation
|
19
|
+
remaining_width = @width
|
20
|
+
if @width <= 1
|
21
|
+
remaining_width = parent_size[:columns] - x
|
22
|
+
@width = (@width * remaining_width).to_i
|
23
|
+
end
|
24
|
+
|
25
|
+
remaining_height = @height
|
26
|
+
if @height <= 1
|
27
|
+
remaining_height = parent_size[:lines] - y
|
28
|
+
@height = (@height * remaining_height).to_i
|
29
|
+
end
|
30
|
+
|
31
|
+
if align == :center
|
32
|
+
pos_x = x + (remaining_width - @width) / 2
|
33
|
+
@title_x = (@width - @title.size) / 2
|
34
|
+
elsif align == :right
|
35
|
+
pos_x = (remaining_width - @width)
|
36
|
+
@title_x = @width - @title.size - TITLE_PADDING
|
37
|
+
else
|
38
|
+
pos_x = x # do nothing
|
39
|
+
@title_x = TITLE_PADDING
|
40
|
+
end
|
41
|
+
|
42
|
+
if valign == :center
|
43
|
+
pos_y = y + (remaining_height - @height) / 2
|
44
|
+
elsif valign == :bottom
|
45
|
+
pos_y = y + (remaining_height - @height)
|
46
|
+
else
|
47
|
+
pos_y = y # do nothing
|
48
|
+
end
|
49
|
+
|
50
|
+
@position = Cursor.new(x: pos_x, y: pos_y)
|
51
|
+
|
52
|
+
@window = Rurses::Window.new(
|
53
|
+
lines: @height, columns: @width,
|
54
|
+
x: @position.x, y: @position.y)
|
55
|
+
|
56
|
+
@has_border = border
|
57
|
+
|
58
|
+
# @window.refresh_in_memory
|
59
|
+
end
|
60
|
+
|
61
|
+
def redraw
|
62
|
+
@window.clear
|
63
|
+
draw
|
64
|
+
end
|
65
|
+
|
66
|
+
def draw
|
67
|
+
old_cursor = @window.cursor_xy
|
68
|
+
|
69
|
+
# Draw title
|
70
|
+
if @has_border || !@title.empty?
|
71
|
+
@window.draw_border
|
72
|
+
@window.move_cursor(x: @title_x, y: 0)
|
73
|
+
@window.draw_string(" #{@title} ")
|
74
|
+
end
|
75
|
+
|
76
|
+
yield if block_given?
|
77
|
+
|
78
|
+
@window.move_cursor(old_cursor)
|
79
|
+
@window.refresh_in_memory
|
80
|
+
end
|
81
|
+
|
82
|
+
def focus
|
83
|
+
end
|
84
|
+
|
85
|
+
def resize
|
86
|
+
calculate_and_draw
|
87
|
+
end
|
88
|
+
|
89
|
+
private
|
90
|
+
|
91
|
+
def calculate_and_draw(new_window: false)
|
92
|
+
@window.clear
|
93
|
+
@window.refresh_in_memory
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
metadata
ADDED
@@ -0,0 +1,156 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: terminal-notes
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Vidur Murali
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2020-08-26 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: ffi-ncurses
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 0.4.0
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: 0.4.0
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: fuzzy_match
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: 2.1.0
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: 2.1.0
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: rake
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: 13.0.1
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: 13.0.1
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: rspec
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: 3.9.0
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: 3.9.0
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: rubygems-tasks
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - "~>"
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: 0.2.5
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - "~>"
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: 0.2.5
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: simplecov
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - "~>"
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: 0.19.0
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - "~>"
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: 0.19.0
|
97
|
+
- !ruby/object:Gem::Dependency
|
98
|
+
name: yard
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
100
|
+
requirements:
|
101
|
+
- - "~>"
|
102
|
+
- !ruby/object:Gem::Version
|
103
|
+
version: 0.9.25
|
104
|
+
type: :development
|
105
|
+
prerelease: false
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
107
|
+
requirements:
|
108
|
+
- - "~>"
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: 0.9.25
|
111
|
+
description:
|
112
|
+
email:
|
113
|
+
- vidur@monkeychai.com
|
114
|
+
executables:
|
115
|
+
- terminal-notes
|
116
|
+
extensions: []
|
117
|
+
extra_rdoc_files: []
|
118
|
+
files:
|
119
|
+
- README.md
|
120
|
+
- bin/terminal-notes
|
121
|
+
- lib/terminal-notes.rb
|
122
|
+
- lib/terminal-notes/cursor.rb
|
123
|
+
- lib/terminal-notes/file_list.rb
|
124
|
+
- lib/terminal-notes/info_bar.rb
|
125
|
+
- lib/terminal-notes/rurses.rb
|
126
|
+
- lib/terminal-notes/rurses/panel_stack.rb
|
127
|
+
- lib/terminal-notes/rurses/version.rb
|
128
|
+
- lib/terminal-notes/rurses/window.rb
|
129
|
+
- lib/terminal-notes/search_field.rb
|
130
|
+
- lib/terminal-notes/text_field.rb
|
131
|
+
- lib/terminal-notes/widget.rb
|
132
|
+
homepage: https://github.com/vyder/terminal-notes
|
133
|
+
licenses:
|
134
|
+
- MIT
|
135
|
+
metadata:
|
136
|
+
documentation_uri: https://vyder.github.io/terminal-notes
|
137
|
+
post_install_message:
|
138
|
+
rdoc_options: []
|
139
|
+
require_paths:
|
140
|
+
- lib
|
141
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
142
|
+
requirements:
|
143
|
+
- - ">="
|
144
|
+
- !ruby/object:Gem::Version
|
145
|
+
version: '0'
|
146
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
147
|
+
requirements:
|
148
|
+
- - ">="
|
149
|
+
- !ruby/object:Gem::Version
|
150
|
+
version: '0'
|
151
|
+
requirements: []
|
152
|
+
rubygems_version: 3.1.4
|
153
|
+
signing_key:
|
154
|
+
specification_version: 4
|
155
|
+
summary: Searchable notes in your terminal! What's not to like?
|
156
|
+
test_files: []
|