kv 0.2.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.
- checksums.yaml +7 -0
- data/.gitignore +8 -0
- data/LICENSE +25 -0
- data/README.md +100 -0
- data/Rakefile +10 -0
- data/exe/kv +11 -0
- data/kv.gemspec +31 -0
- data/lib/kv/version.rb +3 -0
- data/lib/kv.rb +666 -0
- metadata +68 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 07f9b8b9be509c1a29acef17374146c9381713514e19c91a8a2942f9c2ba09e9
|
4
|
+
data.tar.gz: fb877ac8dbf8b5fac3f9b2c4e6690aa01fc25bca3b11fcf7abf88c52ef41fa23
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: c8c646ba69a9a977ea232dedaaff65db9f781d054e76d846913763821678c93c5362e7f4e153574d252505181da0a73a01b7865c38ac2c380c4bc9abaadcd6bc
|
7
|
+
data.tar.gz: eb9f37454be0ce707fbc02f6caae238541fe1865460e67de6c730a2eb81e2ba90d2fbaefa97f418426f19a8343e651b843f9943c61cd5e49cb2d22d15e5cde32
|
data/.gitignore
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,25 @@
|
|
1
|
+
BSD 2-Clause License
|
2
|
+
|
3
|
+
Copyright (c) 2020, Koichi Sasada
|
4
|
+
All rights reserved.
|
5
|
+
|
6
|
+
Redistribution and use in source and binary forms, with or without
|
7
|
+
modification, are permitted provided that the following conditions are met:
|
8
|
+
|
9
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
10
|
+
list of conditions and the following disclaimer.
|
11
|
+
|
12
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
13
|
+
this list of conditions and the following disclaimer in the documentation
|
14
|
+
and/or other materials provided with the distribution.
|
15
|
+
|
16
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
17
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
18
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
19
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
20
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
21
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
22
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
23
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
24
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
25
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
data/README.md
ADDED
@@ -0,0 +1,100 @@
|
|
1
|
+
# kv: A page viewer written by Ruby
|
2
|
+
|
3
|
+
kv is a page viewer designed for streaming data written by Ruby.
|
4
|
+
|
5
|
+
## Installation
|
6
|
+
|
7
|
+
Install it yourself as:
|
8
|
+
|
9
|
+
$ gem install kv
|
10
|
+
|
11
|
+
# Usage
|
12
|
+
|
13
|
+
kv requires Ruby and curses gem.
|
14
|
+
|
15
|
+
## Use kv
|
16
|
+
|
17
|
+
```
|
18
|
+
# View [FILE]
|
19
|
+
$ kv [OPTIONS] [FILE]
|
20
|
+
|
21
|
+
# View results of [CMD]
|
22
|
+
$ [CMD] | kv [OPTIONS]
|
23
|
+
|
24
|
+
# View command help
|
25
|
+
$ kv
|
26
|
+
|
27
|
+
Options:
|
28
|
+
-f following mode like "tail -f"
|
29
|
+
-n, --line-number LINE goto LINE
|
30
|
+
```
|
31
|
+
|
32
|
+
## Command on a pager
|
33
|
+
|
34
|
+
```
|
35
|
+
kv: A pager by Ruby Command list
|
36
|
+
|
37
|
+
?: show the help message
|
38
|
+
q: quit
|
39
|
+
|
40
|
+
# Moving
|
41
|
+
k, j, [UP], [DOWN]: move cursor (y)
|
42
|
+
h, l, [LEFT], [RIGTH]: move cursor (x)
|
43
|
+
Ctrl-U, [PAGE UP]: page up
|
44
|
+
Ctrl-D, [PAGE DOWN], [SPACE]: page down
|
45
|
+
|
46
|
+
g: Goto first line
|
47
|
+
G: Goto last line (current last line)
|
48
|
+
\d+: Goto specified line
|
49
|
+
|
50
|
+
# Loading
|
51
|
+
You can load a huge file or infinite input from a pipe.
|
52
|
+
10,000 lines ahead current line will be loaded.
|
53
|
+
If you want to load further lines the follwoing commands will help you.
|
54
|
+
|
55
|
+
F: Load remaining data or monitor a specified file and scroll forward.
|
56
|
+
Pushing any keys stops loading.
|
57
|
+
If a search string (specified by commadn "/") is specified,
|
58
|
+
stop scroll if the further input lines contains the string.
|
59
|
+
|
60
|
+
L: Toggle unlimited input mode
|
61
|
+
|
62
|
+
# Searching
|
63
|
+
/: search
|
64
|
+
When you enter a search string, you can choose
|
65
|
+
the following mode by Control key combination:
|
66
|
+
Ctrl-R: toggle Regexp mode (Ruby's regexp)
|
67
|
+
Ctrl-I: toggle ignore case mode
|
68
|
+
To clear search string, research with an empty string.
|
69
|
+
n: search next
|
70
|
+
p: search preview
|
71
|
+
f: filter mode (show only matched lines)
|
72
|
+
|
73
|
+
# Output
|
74
|
+
s: Save screen buffer to file
|
75
|
+
|
76
|
+
# Modes
|
77
|
+
N: toggle line mode
|
78
|
+
m: toggle mouse mode
|
79
|
+
t: terminal (REPL) mode
|
80
|
+
v: vi ("vi filename +[LINE]")
|
81
|
+
```
|
82
|
+
|
83
|
+
`G` is notable feature, `less` doesn't have. This feature jumps to "current" last line even if the pipe source command does not close output (== input for kv). You can refresh the last line by putting any command.
|
84
|
+
|
85
|
+
## Mouse mode
|
86
|
+
|
87
|
+
Not yet.
|
88
|
+
|
89
|
+
## Terminal (REPL) mode
|
90
|
+
|
91
|
+
Not yet.
|
92
|
+
|
93
|
+
# Contributing
|
94
|
+
|
95
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/ko1/kv.
|
96
|
+
|
97
|
+
# Credit
|
98
|
+
|
99
|
+
Created by Koichi Sasada at 2020.
|
100
|
+
|
data/Rakefile
ADDED
data/exe/kv
ADDED
data/kv.gemspec
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
require_relative 'lib/kv/version'
|
2
|
+
|
3
|
+
Gem::Specification.new do |spec|
|
4
|
+
spec.name = "kv"
|
5
|
+
spec.version = KV::VERSION
|
6
|
+
spec.authors = ["Koichi Sasada"]
|
7
|
+
spec.email = ["ko1@atdot.net"]
|
8
|
+
|
9
|
+
spec.summary = %q{kv: A page viewer written by Ruby}
|
10
|
+
spec.description = %q{kv is a page viewer designed for streaming data written by Ruby.}
|
11
|
+
spec.homepage = "https://github.com/ko1/kv"
|
12
|
+
spec.license = "MIT"
|
13
|
+
spec.required_ruby_version = Gem::Requirement.new(">= 2.3.0")
|
14
|
+
|
15
|
+
# spec.metadata["allowed_push_host"] = "TODO: Set to 'http://mygemserver.com'"
|
16
|
+
|
17
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
18
|
+
spec.metadata["source_code_uri"] = "https://github.com/ko1/kv"
|
19
|
+
# spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here."
|
20
|
+
|
21
|
+
# Specify which files should be added to the gem when it is released.
|
22
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
23
|
+
spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
|
24
|
+
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
25
|
+
end
|
26
|
+
spec.bindir = "exe"
|
27
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
28
|
+
spec.require_paths = ["lib"]
|
29
|
+
|
30
|
+
spec.add_runtime_dependency 'curses', '~> 1.3'
|
31
|
+
end
|
data/lib/kv/version.rb
ADDED
data/lib/kv.rb
ADDED
@@ -0,0 +1,666 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "kv/version"
|
4
|
+
require "curses"
|
5
|
+
require 'stringio'
|
6
|
+
require 'optparse'
|
7
|
+
|
8
|
+
module KV
|
9
|
+
class KV_PushScreen < Exception
|
10
|
+
attr_reader :screen
|
11
|
+
def initialize screen
|
12
|
+
@screen = screen
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
class KV_PopScreen < Exception
|
17
|
+
end
|
18
|
+
|
19
|
+
class KV_Screen
|
20
|
+
RenderStatus = Struct.new(
|
21
|
+
:c_cols, :c_lines, :x, :y,
|
22
|
+
:search, :goto, :line_mode, :render_full, :last_lineno
|
23
|
+
)
|
24
|
+
class RenderStatus
|
25
|
+
def to_s
|
26
|
+
'kv'
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def initialize input, lines: [], search: nil, name: nil, following_mode: false, first_line: 0, line_mode: false
|
31
|
+
@rs = RenderStatus.new
|
32
|
+
@last_rs = nil
|
33
|
+
@rs.y = first_line
|
34
|
+
@rs.goto = first_line if first_line > 0
|
35
|
+
@rs.x = 0
|
36
|
+
@rs.last_lineno = 0
|
37
|
+
@rs.line_mode = line_mode
|
38
|
+
@rs.search = search
|
39
|
+
|
40
|
+
@name = name
|
41
|
+
@filename = @name if @name && File.exist?(@name)
|
42
|
+
|
43
|
+
@lines = lines
|
44
|
+
@mode = :screen
|
45
|
+
|
46
|
+
@following_mode = following_mode
|
47
|
+
|
48
|
+
@mouse = false
|
49
|
+
@search_ignore_case = false
|
50
|
+
@search_regexp = true
|
51
|
+
@loading = false
|
52
|
+
@buffer_lines = 10_000
|
53
|
+
@yq = Queue.new
|
54
|
+
@load_unlimited = false
|
55
|
+
@prev_render = {}
|
56
|
+
|
57
|
+
read_async input if input
|
58
|
+
end
|
59
|
+
|
60
|
+
def setup_line line
|
61
|
+
line = line.chomp
|
62
|
+
line.instance_variable_set(:@lineno, @rs.last_lineno += 1)
|
63
|
+
line
|
64
|
+
end
|
65
|
+
|
66
|
+
def read_async input
|
67
|
+
@loading = true
|
68
|
+
data = input.read_nonblock(4096)
|
69
|
+
|
70
|
+
lines = data.each_line.to_a
|
71
|
+
last_line = nil
|
72
|
+
lines.each{|line|
|
73
|
+
if line[-1] != "\n"
|
74
|
+
last_line = line
|
75
|
+
break
|
76
|
+
end
|
77
|
+
@lines << setup_line(line)
|
78
|
+
}
|
79
|
+
|
80
|
+
Thread.abort_on_exception = true
|
81
|
+
@reader_thread = Thread.new do
|
82
|
+
while line = input.gets
|
83
|
+
if last_line
|
84
|
+
line = last_line + line
|
85
|
+
last_line = nil
|
86
|
+
end
|
87
|
+
|
88
|
+
@lines << setup_line(line)
|
89
|
+
while !@load_unlimited && @lines.size > self.y + @buffer_lines
|
90
|
+
@yq.pop; @yq.clear
|
91
|
+
end
|
92
|
+
@yq.clear
|
93
|
+
end
|
94
|
+
ensure
|
95
|
+
if @filename
|
96
|
+
@file_mtime = File.mtime(@filename)
|
97
|
+
@file_lastpos = input.tell
|
98
|
+
end
|
99
|
+
input.close
|
100
|
+
@loading = false
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
def y_max
|
105
|
+
@lines.size - Curses.lines + 2
|
106
|
+
end
|
107
|
+
|
108
|
+
def y
|
109
|
+
@rs.y
|
110
|
+
end
|
111
|
+
|
112
|
+
def y=(y)
|
113
|
+
if y > (ym = self.y_max)
|
114
|
+
@rs.y = ym
|
115
|
+
else
|
116
|
+
@rs.y = y
|
117
|
+
end
|
118
|
+
|
119
|
+
@rs.y = 0 if @rs.y < 0
|
120
|
+
@yq << nil if @loading
|
121
|
+
end
|
122
|
+
|
123
|
+
attr_reader :x
|
124
|
+
|
125
|
+
def x=(x)
|
126
|
+
@rs.x = x
|
127
|
+
@rs.x = 0 if @rs.x < 0
|
128
|
+
end
|
129
|
+
|
130
|
+
def x
|
131
|
+
@rs.x
|
132
|
+
end
|
133
|
+
|
134
|
+
def init_screen
|
135
|
+
Curses.init_screen
|
136
|
+
Curses.stdscr.keypad(true)
|
137
|
+
|
138
|
+
if @mouse
|
139
|
+
Curses.mousemask(Curses::BUTTON1_CLICKED | Curses::BUTTON2_CLICKED |
|
140
|
+
Curses::BUTTON3_CLICKED | Curses::BUTTON4_CLICKED)
|
141
|
+
else
|
142
|
+
Curses.mousemask(0)
|
143
|
+
end
|
144
|
+
self.y = @rs.y
|
145
|
+
end
|
146
|
+
|
147
|
+
|
148
|
+
def standout
|
149
|
+
Curses.standout
|
150
|
+
yield
|
151
|
+
Curses.standend
|
152
|
+
end
|
153
|
+
|
154
|
+
def cattr attr
|
155
|
+
Curses.attron attr
|
156
|
+
begin
|
157
|
+
yield
|
158
|
+
ensure
|
159
|
+
Curses.attroff attr
|
160
|
+
end
|
161
|
+
end
|
162
|
+
|
163
|
+
def ctimeout ms
|
164
|
+
Curses.timeout = ms
|
165
|
+
begin
|
166
|
+
yield
|
167
|
+
ensure
|
168
|
+
Curses.timeout = -1
|
169
|
+
end
|
170
|
+
end
|
171
|
+
|
172
|
+
def screen_status status, post = nil
|
173
|
+
Curses.setpos Curses.lines-1, 0
|
174
|
+
Curses.addstr ' '.ljust(Curses.cols)
|
175
|
+
|
176
|
+
standout{
|
177
|
+
Curses.setpos Curses.lines-1, 0
|
178
|
+
Curses.addstr status
|
179
|
+
}
|
180
|
+
Curses.addstr post if post
|
181
|
+
Curses.standend
|
182
|
+
end
|
183
|
+
|
184
|
+
LINE_ATTR = Curses::A_DIM
|
185
|
+
|
186
|
+
def render_data
|
187
|
+
# check update
|
188
|
+
c_lines = Curses.lines
|
189
|
+
c_cols = Curses.cols
|
190
|
+
|
191
|
+
if @rs != @last_rs
|
192
|
+
@last_rs = @rs.dup
|
193
|
+
else
|
194
|
+
return
|
195
|
+
end
|
196
|
+
|
197
|
+
Curses.clear
|
198
|
+
|
199
|
+
(c_lines-1).times{|i|
|
200
|
+
lno = i + self.y
|
201
|
+
line = @lines[lno]
|
202
|
+
cols = c_cols
|
203
|
+
|
204
|
+
unless line
|
205
|
+
if lno == @lines.size
|
206
|
+
Curses.setpos i, 0
|
207
|
+
cattr LINE_ATTR do
|
208
|
+
Curses.addstr '(END)'
|
209
|
+
end
|
210
|
+
end
|
211
|
+
|
212
|
+
break
|
213
|
+
end
|
214
|
+
|
215
|
+
Curses.setpos i, 0
|
216
|
+
|
217
|
+
if @rs.line_mode
|
218
|
+
cattr LINE_ATTR do
|
219
|
+
lineno = line.instance_variable_get(:@lineno)
|
220
|
+
ln_str = '%5d |' % lineno
|
221
|
+
if @rs.goto == lineno - 1 || (@rs.search && (@rs.search === line))
|
222
|
+
standout do
|
223
|
+
Curses.addstr(ln_str)
|
224
|
+
end
|
225
|
+
else
|
226
|
+
Curses.addstr(ln_str)
|
227
|
+
end
|
228
|
+
cols -= ln_str.size
|
229
|
+
end
|
230
|
+
end
|
231
|
+
|
232
|
+
line = line[self.x, cols] || ''
|
233
|
+
|
234
|
+
if !@rs.search || !(Regexp === @rs.search)
|
235
|
+
Curses.addstr line
|
236
|
+
else
|
237
|
+
partition(line, @rs.search).each{|(matched, str)|
|
238
|
+
if matched == :match
|
239
|
+
standout{
|
240
|
+
Curses.addstr str
|
241
|
+
}
|
242
|
+
else
|
243
|
+
Curses.addstr str
|
244
|
+
end
|
245
|
+
}
|
246
|
+
end
|
247
|
+
}
|
248
|
+
end
|
249
|
+
|
250
|
+
def search_str
|
251
|
+
if @rs.search
|
252
|
+
if str = @rs.search.instance_variable_get(:@search_str)
|
253
|
+
str
|
254
|
+
else
|
255
|
+
@rs.search.inspect
|
256
|
+
end
|
257
|
+
else
|
258
|
+
nil
|
259
|
+
end
|
260
|
+
end
|
261
|
+
|
262
|
+
def render_status
|
263
|
+
name = @name ? "<#{@name}>" : ''
|
264
|
+
mouse = @mouse ? ' [MOUSE]' : ''
|
265
|
+
search = @rs.search ? " search[#{search_str}]" : ''
|
266
|
+
loading = @loading ? " (loading...#{@load_unlimited ? '!' : nil}#{@following_mode ? ' following' : ''}) " : ''
|
267
|
+
x = self.x > 0 ? " x:#{self.x}" : ''
|
268
|
+
screen_status "#{name} lines:#{self.y+1}/#{@lines.size}#{x}#{loading}#{search}#{mouse}"
|
269
|
+
end
|
270
|
+
|
271
|
+
def check_update
|
272
|
+
if @loading == false
|
273
|
+
if @filename && File.exist?(@filename) && File.mtime(@filename) > @file_mtime
|
274
|
+
input = open(@filename)
|
275
|
+
|
276
|
+
if input.size < @file_lastpos
|
277
|
+
screen_status "#{@filename} is truncated. Rewinded."
|
278
|
+
pause
|
279
|
+
@lineno = 0
|
280
|
+
else
|
281
|
+
input.seek @file_lastpos
|
282
|
+
end
|
283
|
+
read_async input
|
284
|
+
end
|
285
|
+
end
|
286
|
+
end
|
287
|
+
|
288
|
+
def render_screen
|
289
|
+
ev = nil
|
290
|
+
|
291
|
+
ms = @following_mode ? 100 : 500
|
292
|
+
|
293
|
+
ctimeout ms do
|
294
|
+
while ev == nil
|
295
|
+
render_data
|
296
|
+
render_status
|
297
|
+
ev = Curses.getch
|
298
|
+
check_update
|
299
|
+
if @following_mode
|
300
|
+
if @rs.search && search_next_move(self.y + 1)
|
301
|
+
break
|
302
|
+
end
|
303
|
+
self.y = self.y_max
|
304
|
+
end
|
305
|
+
end
|
306
|
+
|
307
|
+
if @following_mode
|
308
|
+
@following_mode = false
|
309
|
+
@load_unlimited = false
|
310
|
+
end
|
311
|
+
|
312
|
+
return ev
|
313
|
+
end
|
314
|
+
end
|
315
|
+
|
316
|
+
def search_next_move start
|
317
|
+
(start...@lines.size).each{|i|
|
318
|
+
if @rs.search === @lines[i]
|
319
|
+
self.y = i
|
320
|
+
return true
|
321
|
+
end
|
322
|
+
}
|
323
|
+
return false
|
324
|
+
end
|
325
|
+
|
326
|
+
def search_next start
|
327
|
+
if search_next_move start
|
328
|
+
# OK
|
329
|
+
else
|
330
|
+
screen_status "not found: [#{self.search_str}]"
|
331
|
+
pause
|
332
|
+
end
|
333
|
+
end
|
334
|
+
|
335
|
+
def search_prev start
|
336
|
+
start.downto(0){|i|
|
337
|
+
if @rs.search === @lines[i]
|
338
|
+
self.y = i
|
339
|
+
return true
|
340
|
+
end
|
341
|
+
}
|
342
|
+
screen_status "not found: [#{self.search_str}]"
|
343
|
+
pause
|
344
|
+
end
|
345
|
+
|
346
|
+
def key_name ev
|
347
|
+
Curses.constants.grep(/KEY/){|c|
|
348
|
+
return c if Curses.const_get(c) == ev
|
349
|
+
}
|
350
|
+
ev
|
351
|
+
end
|
352
|
+
|
353
|
+
def input_str pattern, str = ''.dup, other_actions: nil
|
354
|
+
loop{
|
355
|
+
ev = Curses.getch
|
356
|
+
|
357
|
+
case ev
|
358
|
+
when 10
|
359
|
+
return str
|
360
|
+
when Curses::KEY_BACKSPACE
|
361
|
+
str.chop!
|
362
|
+
when pattern
|
363
|
+
str << ev
|
364
|
+
else
|
365
|
+
if other_actions && (action = other_actions[ev])
|
366
|
+
action.call(ev)
|
367
|
+
else
|
368
|
+
log "failure: #{key_name ev}"
|
369
|
+
return nil
|
370
|
+
end
|
371
|
+
end
|
372
|
+
}
|
373
|
+
end
|
374
|
+
|
375
|
+
def pause
|
376
|
+
ev = Curses.getch
|
377
|
+
Curses.ungetch ev if ev
|
378
|
+
end
|
379
|
+
|
380
|
+
def control_screen
|
381
|
+
ev = render_screen
|
382
|
+
|
383
|
+
case ev
|
384
|
+
when 'q'
|
385
|
+
raise KV_PopScreen
|
386
|
+
|
387
|
+
when Curses::KEY_UP, 'k'
|
388
|
+
self.y -= 1
|
389
|
+
when Curses::KEY_DOWN, 'j'
|
390
|
+
self.y += 1
|
391
|
+
when Curses::KEY_LEFT, 'h'
|
392
|
+
self.x -= 1
|
393
|
+
when Curses::KEY_RIGHT, 'l'
|
394
|
+
self.x += 1
|
395
|
+
when 'g'
|
396
|
+
self.y = 0
|
397
|
+
self.x = 0
|
398
|
+
when 'G'
|
399
|
+
self.y = self.y_max
|
400
|
+
self.x = 0
|
401
|
+
when ' ', Curses::KEY_NPAGE, Curses::KEY_CTRL_D
|
402
|
+
self.y += Curses.lines-1
|
403
|
+
when Curses::KEY_PPAGE, Curses::KEY_CTRL_U
|
404
|
+
self.y -= Curses.lines-1
|
405
|
+
|
406
|
+
when /[0-9]/
|
407
|
+
screen_status "Goto:", ev
|
408
|
+
ystr = input_str(/\d/, ev)
|
409
|
+
if ystr && !ystr.empty?
|
410
|
+
@rs.goto = ystr.to_i - 1
|
411
|
+
self.y = @rs.goto
|
412
|
+
end
|
413
|
+
|
414
|
+
when 'F'
|
415
|
+
@following_mode = true
|
416
|
+
@load_unlimited = true
|
417
|
+
@yq << true
|
418
|
+
|
419
|
+
when 'L'
|
420
|
+
@load_unlimited = !@load_unlimited
|
421
|
+
@yq << true
|
422
|
+
|
423
|
+
when '/'
|
424
|
+
search_str = ''.dup
|
425
|
+
|
426
|
+
update_search_status = -> do
|
427
|
+
regexp = @search_regexp ? 'regexp' : 'string'
|
428
|
+
ignore = @search_ignore_case ? '/ignore' : ''
|
429
|
+
screen_status "Search[#{regexp}#{ignore}]:", search_str
|
430
|
+
end
|
431
|
+
|
432
|
+
update_search_status[]
|
433
|
+
input_str(/./, search_str, other_actions: {
|
434
|
+
Curses::KEY_CTRL_I => -> ev do
|
435
|
+
@search_ignore_case = !@search_ignore_case
|
436
|
+
update_search_status[]
|
437
|
+
end,
|
438
|
+
Curses::KEY_CTRL_R => -> ev do
|
439
|
+
@search_regexp = !@search_regexp
|
440
|
+
update_search_status[]
|
441
|
+
end,
|
442
|
+
})
|
443
|
+
|
444
|
+
if search_str && !search_str.empty?
|
445
|
+
ic = @search_ignore_case ? [Regexp::IGNORECASE] : []
|
446
|
+
if @search_regexp
|
447
|
+
begin
|
448
|
+
@rs.search = Regexp.compile(search_str, *ic)
|
449
|
+
rescue RegexpError => e
|
450
|
+
@rs.search = nil
|
451
|
+
screen_status "regexp compile error: #{e.message}"
|
452
|
+
pause
|
453
|
+
end
|
454
|
+
else
|
455
|
+
@rs.search = Regexp.compile(Regexp.escape(search_str), *ic)
|
456
|
+
end
|
457
|
+
else
|
458
|
+
@rs.search = nil
|
459
|
+
end
|
460
|
+
if @rs.search
|
461
|
+
@rs.search.instance_variable_set(:@search_str, search_str)
|
462
|
+
search_next self.y
|
463
|
+
end
|
464
|
+
when 'n'
|
465
|
+
search_next self.y+1 if @rs.search
|
466
|
+
when 'p'
|
467
|
+
search_prev self.y-1 if @rs.search
|
468
|
+
when 'f'
|
469
|
+
if @rs.search
|
470
|
+
filter_mode_title = "*filter mode [#{self.search_str}]*"
|
471
|
+
if @name != filter_mode_title
|
472
|
+
lines = @lines.grep(@rs.search)
|
473
|
+
fscr = KV_Screen.new nil, lines: lines, search: @rs.search, name: filter_mode_title
|
474
|
+
raise KV_PushScreen.new(fscr)
|
475
|
+
end
|
476
|
+
end
|
477
|
+
|
478
|
+
when 's'
|
479
|
+
screen_status "Save file:"
|
480
|
+
file = input_str /./
|
481
|
+
begin
|
482
|
+
if file && !file.empty?
|
483
|
+
if File.exist? file
|
484
|
+
screen_status "#{file.dump} exists. Override? [y/n] "
|
485
|
+
yn = input_str(/[yn]/)
|
486
|
+
if yn == 'y'
|
487
|
+
File.write(file, @lines.join("\n"))
|
488
|
+
else
|
489
|
+
# do nothing
|
490
|
+
end
|
491
|
+
else
|
492
|
+
File.write(file, @lines.join("\n"))
|
493
|
+
end
|
494
|
+
end
|
495
|
+
rescue SystemCallError
|
496
|
+
# TODO: status line
|
497
|
+
end
|
498
|
+
|
499
|
+
when 'v'
|
500
|
+
if @filename
|
501
|
+
system("vi #{@filename} +#{self.y + 1}")
|
502
|
+
@last_rs = nil
|
503
|
+
end
|
504
|
+
|
505
|
+
when 'm'
|
506
|
+
@mouse = !@mouse
|
507
|
+
Curses.close_screen
|
508
|
+
init_screen
|
509
|
+
when Curses::KEY_MOUSE
|
510
|
+
m = Curses.getmouse
|
511
|
+
log m, "mouse ->"
|
512
|
+
# log [m.bstate, m.x, m.y, m.z, m.eid]
|
513
|
+
log @lines[self.y + m.y]
|
514
|
+
|
515
|
+
when 'N'
|
516
|
+
@rs.line_mode = !@rs.line_mode
|
517
|
+
when 't'
|
518
|
+
Curses.close_screen
|
519
|
+
@mode = :terminal
|
520
|
+
|
521
|
+
when '?'
|
522
|
+
raise KV_PushScreen.new(KV_Screen.new help_io)
|
523
|
+
|
524
|
+
when nil
|
525
|
+
# ignore
|
526
|
+
|
527
|
+
else
|
528
|
+
screen_status "unknown: #{key_name(ev)}"
|
529
|
+
pause
|
530
|
+
end
|
531
|
+
end
|
532
|
+
|
533
|
+
def control_terminal
|
534
|
+
@rs.instance_eval('binding').irb
|
535
|
+
|
536
|
+
@mode = :screen
|
537
|
+
init_screen
|
538
|
+
@last_rs = nil
|
539
|
+
end
|
540
|
+
|
541
|
+
def control
|
542
|
+
case @mode
|
543
|
+
when :screen
|
544
|
+
control_screen
|
545
|
+
when :terminal
|
546
|
+
control_terminal
|
547
|
+
else
|
548
|
+
raise
|
549
|
+
end
|
550
|
+
end
|
551
|
+
end
|
552
|
+
|
553
|
+
class KV
|
554
|
+
def initialize argv
|
555
|
+
@opts = {
|
556
|
+
following_mode: false,
|
557
|
+
first_line: 0,
|
558
|
+
line_mode: false,
|
559
|
+
}
|
560
|
+
|
561
|
+
files = parse_option(argv)
|
562
|
+
|
563
|
+
@pipe_in = nil
|
564
|
+
|
565
|
+
if files.empty?
|
566
|
+
if STDIN.isatty
|
567
|
+
input = help_io
|
568
|
+
name = 'HELP'
|
569
|
+
else
|
570
|
+
input = STDIN.dup
|
571
|
+
STDIN.reopen('/dev/tty')
|
572
|
+
name = nil
|
573
|
+
@pipe_in = input
|
574
|
+
end
|
575
|
+
else
|
576
|
+
name = files.shift
|
577
|
+
begin
|
578
|
+
input = open(name)
|
579
|
+
rescue
|
580
|
+
if /(.+):(\d+)/ =~ name
|
581
|
+
name = $1
|
582
|
+
@first_line = $2.to_i - 1
|
583
|
+
retry
|
584
|
+
end
|
585
|
+
raise
|
586
|
+
end
|
587
|
+
end
|
588
|
+
|
589
|
+
trap(:INT){
|
590
|
+
log "SIGINT"
|
591
|
+
}
|
592
|
+
|
593
|
+
@screens = [KV_Screen.new(input, name: name, **@opts)]
|
594
|
+
end
|
595
|
+
|
596
|
+
def parse_option argv
|
597
|
+
opts = OptionParser.new
|
598
|
+
opts.on('-f', 'following mode like "tail -f"'){
|
599
|
+
@opts[:following_mode] = true
|
600
|
+
}
|
601
|
+
opts.on('-n', '--line-number LINE', 'goto LINE'){|n|
|
602
|
+
@opts[:first_line] = n.to_i - 1
|
603
|
+
}
|
604
|
+
opts.on('-N', 'Show lines'){
|
605
|
+
@opts[:line_mode] = true
|
606
|
+
}
|
607
|
+
opts.parse!(argv)
|
608
|
+
end
|
609
|
+
|
610
|
+
def control
|
611
|
+
@screens.last.init_screen
|
612
|
+
until @screens.empty?
|
613
|
+
begin
|
614
|
+
@screens.last.control
|
615
|
+
rescue KV_PopScreen
|
616
|
+
@screens.pop
|
617
|
+
rescue KV_PushScreen => e
|
618
|
+
@screens.push e.screen
|
619
|
+
end
|
620
|
+
end
|
621
|
+
ensure
|
622
|
+
Curses.close_screen
|
623
|
+
log "terminate"
|
624
|
+
end
|
625
|
+
end
|
626
|
+
end
|
627
|
+
|
628
|
+
$debug_log = ENV['KV_DEBUG']
|
629
|
+
|
630
|
+
def log obj, prefix = ''
|
631
|
+
if $debug_log
|
632
|
+
File.open($debug_log, 'a'){|f|
|
633
|
+
f.puts "#{$$} #{prefix}#{obj.inspect}"
|
634
|
+
}
|
635
|
+
end
|
636
|
+
end
|
637
|
+
|
638
|
+
def partition str, search
|
639
|
+
results = []
|
640
|
+
loop{
|
641
|
+
r = str.match(search){|m|
|
642
|
+
break if m.post_match == str
|
643
|
+
results << [:unmatch, m.pre_match]
|
644
|
+
results << [:match, m.to_s]
|
645
|
+
str = m.post_match
|
646
|
+
}
|
647
|
+
break unless r
|
648
|
+
}
|
649
|
+
results << [:unmatch, str]
|
650
|
+
end
|
651
|
+
|
652
|
+
def help_io
|
653
|
+
readme = File.read(File.join(__dir__, '../README.md'))
|
654
|
+
help = []
|
655
|
+
readme.each_line{|line|
|
656
|
+
if /kv: A pager by Ruby Command list/ =~ line
|
657
|
+
help << line
|
658
|
+
elsif /^```/ =~ line && !help.empty?
|
659
|
+
break
|
660
|
+
elsif !help.empty?
|
661
|
+
help << line
|
662
|
+
end
|
663
|
+
}
|
664
|
+
|
665
|
+
help_io = StringIO.new(help.join)
|
666
|
+
end
|
metadata
ADDED
@@ -0,0 +1,68 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: kv
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.2.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Koichi Sasada
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2020-02-08 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: curses
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '1.3'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '1.3'
|
27
|
+
description: kv is a page viewer designed for streaming data written by Ruby.
|
28
|
+
email:
|
29
|
+
- ko1@atdot.net
|
30
|
+
executables:
|
31
|
+
- kv
|
32
|
+
extensions: []
|
33
|
+
extra_rdoc_files: []
|
34
|
+
files:
|
35
|
+
- ".gitignore"
|
36
|
+
- LICENSE
|
37
|
+
- README.md
|
38
|
+
- Rakefile
|
39
|
+
- exe/kv
|
40
|
+
- kv.gemspec
|
41
|
+
- lib/kv.rb
|
42
|
+
- lib/kv/version.rb
|
43
|
+
homepage: https://github.com/ko1/kv
|
44
|
+
licenses:
|
45
|
+
- MIT
|
46
|
+
metadata:
|
47
|
+
homepage_uri: https://github.com/ko1/kv
|
48
|
+
source_code_uri: https://github.com/ko1/kv
|
49
|
+
post_install_message:
|
50
|
+
rdoc_options: []
|
51
|
+
require_paths:
|
52
|
+
- lib
|
53
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
54
|
+
requirements:
|
55
|
+
- - ">="
|
56
|
+
- !ruby/object:Gem::Version
|
57
|
+
version: 2.3.0
|
58
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
59
|
+
requirements:
|
60
|
+
- - ">="
|
61
|
+
- !ruby/object:Gem::Version
|
62
|
+
version: '0'
|
63
|
+
requirements: []
|
64
|
+
rubygems_version: 3.2.0.pre1
|
65
|
+
signing_key:
|
66
|
+
specification_version: 4
|
67
|
+
summary: 'kv: A page viewer written by Ruby'
|
68
|
+
test_files: []
|