listpager 1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +9 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +111 -0
- data/Rakefile +2 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/doc/screenshot.png +0 -0
- data/exe/listpager +17 -0
- data/lib/listpager/client_terminal.rb +182 -0
- data/lib/listpager/color.rb +33 -0
- data/lib/listpager/list.rb +150 -0
- data/lib/listpager/scrollbar.rb +107 -0
- data/lib/listpager/version.rb +3 -0
- data/lib/listpager.rb +5 -0
- data/listpager.gemspec +33 -0
- metadata +104 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 9d70bf27f951f700d53cc6fb25c3c62d652ae150
|
4
|
+
data.tar.gz: 8483fcf02b0b1373a10c4d1f5ef0585ef202cc60
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 25c7f7684495706aad4efe72393af0228c02c2a00a258b5c00fdcc1a4b1740a40fe8f7a5e841c9fa2ddc6b7fc6521cfd001e2e5f9ffafe3ca4e9d8fbeabf2027
|
7
|
+
data.tar.gz: 22e83c69890005484f1e75cac5332965f24992c646bdddab4b81aa5d2ccbcce2392515266fa10b924763ec5cd14578b2c20f45a06c6c0ec8f846155369491bdd
|
data/.gitignore
ADDED
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2016 Mike Owens <mike@meter.md>
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,111 @@
|
|
1
|
+
# listpager
|
2
|
+
|
3
|
+
## Introduction
|
4
|
+
listpager is a terminal listbox. It reads `stdin` for a list of items, goes all
|
5
|
+
interactive, and writes events to `stdout` as the user interacts with it.
|
6
|
+
|
7
|
+
![listpager in action](./doc/screenshot.png)
|
8
|
+
|
9
|
+
*listpager is in the left-hand panel*
|
10
|
+
|
11
|
+
listpager has proportional scroll bars, and can handle lists of any length. It
|
12
|
+
handles terminal resizing just fine.
|
13
|
+
|
14
|
+
listpager was written as a component of [Cult][1], a fleet management tool. Cult's
|
15
|
+
interactive mode is a specially-crafted tmux session consisting of tools that
|
16
|
+
talk to each other. listpager is the node selection widget.
|
17
|
+
|
18
|
+
So basically, you may want to `popen` listpager, print a list of somethings to
|
19
|
+
its `stdin`, and listen on its `stdout`.
|
20
|
+
|
21
|
+
```ruby
|
22
|
+
listpager = IO.popen('listpager', 'r+')
|
23
|
+
|
24
|
+
# You don't want buffered IO
|
25
|
+
listpager.sync = true
|
26
|
+
|
27
|
+
50.times do |i|
|
28
|
+
listpager.puts "Item #{i}"
|
29
|
+
end
|
30
|
+
|
31
|
+
# Enter command mode.
|
32
|
+
listpager.puts "%%"
|
33
|
+
listpager.puts "select 35"
|
34
|
+
```
|
35
|
+
|
36
|
+
If you want to play with the protocol, it's easiest to use two terminals and
|
37
|
+
`socat` (some implementations of `nc`/`netcat` do weird FD juggling which
|
38
|
+
ends up sending raw keyboard character input back to the client.).
|
39
|
+
|
40
|
+
Set up one like:
|
41
|
+
|
42
|
+
```bash
|
43
|
+
socat TCP-LISTEN:4500,reuseaddr EXEC:'listpager'
|
44
|
+
```
|
45
|
+
|
46
|
+
And a "client", like:
|
47
|
+
```bash
|
48
|
+
socat TCP:localhost:4500 -
|
49
|
+
```
|
50
|
+
|
51
|
+
Add some items on your keyboard, then enter `%%` to enter command mode. Another
|
52
|
+
`%%` will put you back in command mode. If you need a literal `%%` list item,
|
53
|
+
escape it with `\%%`. If you need a literal `\\%%`, you're out of luck, because
|
54
|
+
complete escaping isn't available yet.
|
55
|
+
|
56
|
+
There are a lot of obvious things the protocol could do, that it doesn't
|
57
|
+
currently. It's way low-hanging fruit for any contributors (`clear`, `rename`,
|
58
|
+
`move`, etc.)
|
59
|
+
|
60
|
+
## Protocol
|
61
|
+
listpager reads each item from stdin, and it becomes a list item. As the user
|
62
|
+
arrows through the list, it outputs messages like:
|
63
|
+
|
64
|
+
`select 21 apples` where `21` is the index into the list, and `abacate` is the
|
65
|
+
caption. Any other keys pressed on an item are written out like
|
66
|
+
`keypress enter apples`.
|
67
|
+
|
68
|
+
listpager stops considering input bulk list items once it sees: `%%`, where it
|
69
|
+
enters command mode. Currently, command mode does nothing, but in the future,
|
70
|
+
it will allow the calling program to instruct listpager to select certain items,
|
71
|
+
ask for statuses, manipulate the list, add badges, change captions, etc.
|
72
|
+
|
73
|
+
|
74
|
+
## Dependencies and Installation
|
75
|
+
Install listpager with `gem install listpager`. It has few dependencies:
|
76
|
+
currently only `ncurses-ruby`.
|
77
|
+
|
78
|
+
|
79
|
+
## Implementation Notes
|
80
|
+
curses is terrible but portable. 'curses' doesn't expose enough to be useful,
|
81
|
+
'ncurses-ruby' is about as good as you'll do in Ruby.
|
82
|
+
|
83
|
+
|
84
|
+
## Upcoming Features
|
85
|
+
Right now, listpager does exactly what Cult needs, and nothing more. For it to
|
86
|
+
be more functional, I'd like to add a few features:
|
87
|
+
|
88
|
+
* `listpager -1`, for displaying a list, and just outputting the first item
|
89
|
+
the user selected with enter, ala Zenity/dialog.
|
90
|
+
* A search/filter activated with the `/` key
|
91
|
+
* Mouse support, with scroll wheels.
|
92
|
+
* Checkboxes
|
93
|
+
* Extend command mode
|
94
|
+
|
95
|
+
|
96
|
+
## Contributing
|
97
|
+
~~I'm trying to keep listpager a single-file, small project, preferably under
|
98
|
+
500 lines of code.~~ I've given up on the idea that this can be functional,
|
99
|
+
correct, and maintainable in a single file that can be copied to a bin
|
100
|
+
directory. If you use listpager and know Ruby, please dig in and PR at its
|
101
|
+
github: https://github.com/mieko/listpager
|
102
|
+
|
103
|
+
|
104
|
+
## License
|
105
|
+
listpager is released under the MIT license. Check out LICENSE.txt
|
106
|
+
|
107
|
+
|
108
|
+
## Authors
|
109
|
+
listpager was written by Mike A. Owens at meter.md. mike@meter.md
|
110
|
+
|
111
|
+
[1]: https://github.com/metermd/cult "Cult"
|
data/Rakefile
ADDED
data/bin/console
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "bundler/setup"
|
4
|
+
require "listpager"
|
5
|
+
|
6
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
7
|
+
# with your gem easier. You can also use a different console, if you like.
|
8
|
+
|
9
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
10
|
+
# require "pry"
|
11
|
+
# Pry.start
|
12
|
+
|
13
|
+
require "irb"
|
14
|
+
IRB.start
|
data/bin/setup
ADDED
data/doc/screenshot.png
ADDED
Binary file
|
data/exe/listpager
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
require 'listpager/client_terminal'
|
3
|
+
|
4
|
+
[$stdin, $stdout].each do |io|
|
5
|
+
io.sync = true
|
6
|
+
io.reopen('/dev/null') if io.tty?
|
7
|
+
end
|
8
|
+
|
9
|
+
begin
|
10
|
+
Listpager::ClientTerminal.new.run
|
11
|
+
rescue Interrupt
|
12
|
+
$stdout.puts "interrupt"
|
13
|
+
$stdout.flush
|
14
|
+
rescue SystemCallError => e
|
15
|
+
$stderr.puts "#{$0}: #{e.message}"
|
16
|
+
exit 1
|
17
|
+
end
|
@@ -0,0 +1,182 @@
|
|
1
|
+
require 'ncurses'
|
2
|
+
require 'shellwords'
|
3
|
+
require 'optparse'
|
4
|
+
require 'io/console'
|
5
|
+
|
6
|
+
require 'listpager/color'
|
7
|
+
require 'listpager/list'
|
8
|
+
|
9
|
+
module Listpager
|
10
|
+
class ClientTerminal
|
11
|
+
attr_reader :tty
|
12
|
+
attr_reader :self_pipe
|
13
|
+
attr_reader :list
|
14
|
+
attr_reader :mode
|
15
|
+
|
16
|
+
def initialize
|
17
|
+
@tty = File.open('/dev/tty', 'r+')
|
18
|
+
@self_pipe = IO.pipe
|
19
|
+
|
20
|
+
[@tty, *self_pipe].each do |io|
|
21
|
+
io.sync = true
|
22
|
+
end
|
23
|
+
|
24
|
+
initialize_curses
|
25
|
+
|
26
|
+
@list = List.new(Ncurses.stdscr)
|
27
|
+
@mode = :append
|
28
|
+
@buffer = ''
|
29
|
+
end
|
30
|
+
|
31
|
+
def initialize_curses
|
32
|
+
screen = Ncurses.newterm(nil, @tty, @tty)
|
33
|
+
Ncurses.set_term(screen)
|
34
|
+
Ncurses.start_color
|
35
|
+
Color.init
|
36
|
+
Ncurses.use_default_colors
|
37
|
+
Ncurses.cbreak
|
38
|
+
Ncurses.stdscr.scrollok(false)
|
39
|
+
Ncurses.stdscr.keypad(true)
|
40
|
+
Ncurses.curs_set(0)
|
41
|
+
Ncurses.noecho
|
42
|
+
Ncurses.timeout(0)
|
43
|
+
end
|
44
|
+
|
45
|
+
def deinitialize
|
46
|
+
Ncurses.echo
|
47
|
+
Ncurses.nocbreak
|
48
|
+
Ncurses.nl
|
49
|
+
Ncurses.endwin
|
50
|
+
@tty.close
|
51
|
+
end
|
52
|
+
|
53
|
+
def append_mode?
|
54
|
+
@mode == :append
|
55
|
+
end
|
56
|
+
|
57
|
+
def process_command(line)
|
58
|
+
if append_mode?
|
59
|
+
case line
|
60
|
+
when '\%%'
|
61
|
+
list.values.push('%%')
|
62
|
+
list.dirty!
|
63
|
+
when '%%'
|
64
|
+
@mode = :command
|
65
|
+
else
|
66
|
+
list.values.push(line)
|
67
|
+
list.dirty!
|
68
|
+
end
|
69
|
+
else
|
70
|
+
cmd, *args = Shellwords.split(line)
|
71
|
+
begin
|
72
|
+
case cmd
|
73
|
+
when '%%', 'append-mode'
|
74
|
+
@mode = :append
|
75
|
+
when 'get-selected'
|
76
|
+
list.selection_changed
|
77
|
+
when 'select'
|
78
|
+
list.selected = args.fetch(0).to_i
|
79
|
+
when 'get-item'
|
80
|
+
puts ["item", args.fetch(0), list.values[args.fetch(0).to_i]].join ' '
|
81
|
+
when 'quit'
|
82
|
+
raise Interrupt
|
83
|
+
end
|
84
|
+
rescue IndexError => e
|
85
|
+
puts "error bad-command #{line}"
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
def consume_stdin(handles, fd)
|
91
|
+
loop do
|
92
|
+
begin
|
93
|
+
@buffer << fd.read_nonblock(512)
|
94
|
+
rescue EOFError
|
95
|
+
handles.delete(fd)
|
96
|
+
break
|
97
|
+
rescue IO::WaitReadable
|
98
|
+
break
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
unless @buffer.empty?
|
103
|
+
used = 0
|
104
|
+
StringIO.new(@buffer).each_line do |line|
|
105
|
+
if line[-1] == "\n"
|
106
|
+
process_command(line.chomp)
|
107
|
+
used += line.size
|
108
|
+
else
|
109
|
+
break
|
110
|
+
end
|
111
|
+
end
|
112
|
+
@buffer = @buffer[used...-1]
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
def consume_tty(handles, fd)
|
117
|
+
while (ch = Ncurses.getch) != -1
|
118
|
+
list.key_input(ch)
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
# We get a character from self_pipe here telling us the window
|
123
|
+
# has resized.
|
124
|
+
def consume_self_pipe(handles, fd)
|
125
|
+
code = fd.read(1)
|
126
|
+
case code
|
127
|
+
when 'R'
|
128
|
+
new_size = IO.console.winsize
|
129
|
+
Ncurses.resizeterm(*new_size)
|
130
|
+
Ncurses.stdscr.clear
|
131
|
+
list.dirty!
|
132
|
+
list.render
|
133
|
+
Ncurses.refresh
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
def dispatch_fd(handles, fd)
|
138
|
+
case fd
|
139
|
+
when $stdin
|
140
|
+
consume_stdin(@handles, fd)
|
141
|
+
when tty
|
142
|
+
consume_tty(@handles, fd)
|
143
|
+
when self_pipe[0]
|
144
|
+
consume_self_pipe(@handles, fd)
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
def process_events
|
149
|
+
@handles ||= [$stdin, tty, self_pipe[0]]
|
150
|
+
|
151
|
+
return if @handles.empty?
|
152
|
+
|
153
|
+
res = IO.select(@handles)
|
154
|
+
if res && (readers = res[0])
|
155
|
+
readers.each do |fd|
|
156
|
+
dispatch_fd(@handles, fd)
|
157
|
+
end
|
158
|
+
end
|
159
|
+
end
|
160
|
+
|
161
|
+
def run
|
162
|
+
trap 'WINCH' do
|
163
|
+
self_pipe[1].tap do |fd|
|
164
|
+
fd.write 'R'
|
165
|
+
fd.flush
|
166
|
+
end
|
167
|
+
end
|
168
|
+
|
169
|
+
begin
|
170
|
+
loop do
|
171
|
+
process_events
|
172
|
+
if list.render
|
173
|
+
Ncurses.redrawwin(list.window)
|
174
|
+
Ncurses.refresh
|
175
|
+
end
|
176
|
+
end
|
177
|
+
ensure
|
178
|
+
deinitialize
|
179
|
+
end
|
180
|
+
end
|
181
|
+
end
|
182
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
require 'ncurses'
|
2
|
+
|
3
|
+
module Listpager
|
4
|
+
class Color
|
5
|
+
VALUES = {
|
6
|
+
list_default: 0,
|
7
|
+
list_selected: 1,
|
8
|
+
scroll_track: 2,
|
9
|
+
scroll_thumb: 3,
|
10
|
+
scroll_arrow: 4,
|
11
|
+
}.freeze
|
12
|
+
|
13
|
+
def self.curses_lookup(c)
|
14
|
+
Ncurses.const_get(c)
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.init_color(c, fg, bg)
|
18
|
+
fail "Invalid color: #{c.inspect}" if VALUES[c].nil?
|
19
|
+
Ncurses.init_pair(VALUES[c], curses_lookup(fg), curses_lookup(bg))
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.init
|
23
|
+
init_color(:list_selected, :COLOR_BLACK, :COLOR_WHITE)
|
24
|
+
init_color(:scroll_track, :COLOR_BLACK, :COLOR_BLACK)
|
25
|
+
init_color(:scroll_thumb, :COLOR_WHITE, :COLOR_WHITE)
|
26
|
+
init_color(:scroll_arrow, :COLOR_WHITE, :COLOR_BLACK)
|
27
|
+
end
|
28
|
+
|
29
|
+
def self.[](name)
|
30
|
+
VALUES[name] or fail "invalid color name: #{name.inspect}"
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,150 @@
|
|
1
|
+
require 'ncurses'
|
2
|
+
|
3
|
+
require 'listpager/color'
|
4
|
+
require 'listpager/scrollbar'
|
5
|
+
|
6
|
+
module Listpager
|
7
|
+
class List
|
8
|
+
INDICATOR = ' ➤ '
|
9
|
+
NO_INDICATOR = ' '
|
10
|
+
|
11
|
+
# U+2003 "EM SPACE". Ncurses' range-combining "optimizations" fuck up normal
|
12
|
+
# and non-breaking spaces. For display, this is fine. For copying, you'd
|
13
|
+
# have a scrollbar in the way anyway. I fear newer releases of ncurses will
|
14
|
+
# get smarter and also consider this "blank" for optimizations.
|
15
|
+
BLANK_SPACE = ' '
|
16
|
+
|
17
|
+
def on_select_change
|
18
|
+
puts "select #{selected} #{values[selected]}"
|
19
|
+
end
|
20
|
+
|
21
|
+
def on_key_press(k)
|
22
|
+
puts "keypress #{key_name(k)} #{selected} #{values[selected]}"
|
23
|
+
end
|
24
|
+
|
25
|
+
attr_reader :window
|
26
|
+
attr_accessor :values
|
27
|
+
attr_reader :selected
|
28
|
+
attr_reader :offset
|
29
|
+
attr_reader :scrollbar
|
30
|
+
|
31
|
+
def initialize(window)
|
32
|
+
@window = window
|
33
|
+
@values = []
|
34
|
+
@selected = 0
|
35
|
+
@offset = 0
|
36
|
+
@dirty = true
|
37
|
+
@scrollbar = Scrollbar.new(self)
|
38
|
+
end
|
39
|
+
|
40
|
+
def dirty!(value = true)
|
41
|
+
@dirty = value
|
42
|
+
end
|
43
|
+
|
44
|
+
def dirty?
|
45
|
+
@dirty
|
46
|
+
end
|
47
|
+
|
48
|
+
def offset=(v)
|
49
|
+
dirty! if v != @offset
|
50
|
+
@offset = v
|
51
|
+
end
|
52
|
+
|
53
|
+
attr_reader :selected
|
54
|
+
def selected=(v)
|
55
|
+
maxx, maxy = getmaxxy
|
56
|
+
|
57
|
+
v = [0, v, values.size - 1].sort[1]
|
58
|
+
self.offset = [v - maxy + 1, offset, v].sort[1]
|
59
|
+
|
60
|
+
if v != @selected
|
61
|
+
dirty!
|
62
|
+
on_select_change
|
63
|
+
end
|
64
|
+
|
65
|
+
return (@selected = v)
|
66
|
+
end
|
67
|
+
|
68
|
+
def key_name(v)
|
69
|
+
@m ||= {
|
70
|
+
27 => 'esc',
|
71
|
+
10 => 'enter',
|
72
|
+
260 => 'left',
|
73
|
+
261 => 'right',
|
74
|
+
127 => 'backspace',
|
75
|
+
330 => 'delete',
|
76
|
+
' ' => 'space',
|
77
|
+
}
|
78
|
+
@m[v] || (v < 255 && v.chr.match(/[[:print:]]/) ? v.chr : "\##{v}")
|
79
|
+
end
|
80
|
+
|
81
|
+
def key_input(value)
|
82
|
+
maxx, maxy = getmaxxy
|
83
|
+
|
84
|
+
case value
|
85
|
+
when Ncurses::KEY_UP
|
86
|
+
self.selected -= 1
|
87
|
+
when Ncurses::KEY_DOWN
|
88
|
+
self.selected += 1
|
89
|
+
when Ncurses::KEY_PPAGE
|
90
|
+
self.selected -= maxy - 1
|
91
|
+
when Ncurses::KEY_NPAGE
|
92
|
+
self.selected += maxy - 1
|
93
|
+
else
|
94
|
+
on_key_press(value)
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
def dirty!(v = true)
|
99
|
+
@dirty = v
|
100
|
+
end
|
101
|
+
|
102
|
+
def normalize(s)
|
103
|
+
s.gsub(/[^[:print:]]/, '')
|
104
|
+
end
|
105
|
+
|
106
|
+
def getmaxxy
|
107
|
+
maxx, maxy = [], []
|
108
|
+
window.getmaxyx(maxy, maxx)
|
109
|
+
[maxx[0], maxy[0]]
|
110
|
+
end
|
111
|
+
|
112
|
+
def render
|
113
|
+
return false if ! dirty?
|
114
|
+
|
115
|
+
maxx, maxy = getmaxxy
|
116
|
+
|
117
|
+
(0...maxy).each do |i|
|
118
|
+
window.color_set(Color[:list_default], nil)
|
119
|
+
|
120
|
+
list_index = offset + i
|
121
|
+
window.move(i, 0)
|
122
|
+
indicator = nil
|
123
|
+
|
124
|
+
fixed_len = maxx - scrollbar.width
|
125
|
+
|
126
|
+
if list_index == selected
|
127
|
+
window.color_set(Color[:list_selected], nil)
|
128
|
+
indicator = INDICATOR
|
129
|
+
else
|
130
|
+
indicator = NO_INDICATOR
|
131
|
+
end
|
132
|
+
|
133
|
+
string = values[list_index] || ''
|
134
|
+
string = normalize(string)
|
135
|
+
string = indicator + string
|
136
|
+
|
137
|
+
if string.size < fixed_len
|
138
|
+
string += (BLANK_SPACE * (fixed_len - string.size))
|
139
|
+
elsif string.size > fixed_len
|
140
|
+
string = string[0...fixed_len]
|
141
|
+
end
|
142
|
+
window.addstr(string)
|
143
|
+
end
|
144
|
+
|
145
|
+
scrollbar.render
|
146
|
+
dirty!(false)
|
147
|
+
return true
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
@@ -0,0 +1,107 @@
|
|
1
|
+
require 'listpager/color'
|
2
|
+
|
3
|
+
module Listpager
|
4
|
+
class Scrollbar
|
5
|
+
UP_ARROW = '▴'
|
6
|
+
DOWN_ARROW = '▾'
|
7
|
+
|
8
|
+
attr_reader :list
|
9
|
+
|
10
|
+
def initialize(list)
|
11
|
+
@list = list
|
12
|
+
@memo_keys = nil
|
13
|
+
@memo_val = nil
|
14
|
+
end
|
15
|
+
|
16
|
+
def width
|
17
|
+
1
|
18
|
+
end
|
19
|
+
|
20
|
+
def scroll_thumb_range
|
21
|
+
maxx, maxy = getmaxxy
|
22
|
+
|
23
|
+
# We memoize these based on the inputs that effect the output
|
24
|
+
memo_keys = [list.values.size, list.offset, maxy]
|
25
|
+
return @memo_val if @memo_keys == memo_keys
|
26
|
+
|
27
|
+
# Ref: http://csdgn.org/inform/scrollbar-mechanics
|
28
|
+
# using original camelCasedVariableNames for clarity with the source.
|
29
|
+
contentSize = list.values.size.to_f
|
30
|
+
windowSize = maxy.to_f
|
31
|
+
trackSize = windowSize - 2
|
32
|
+
windowContentRatio = windowSize / contentSize
|
33
|
+
gripSize = trackSize * windowContentRatio
|
34
|
+
|
35
|
+
minimalGripSize = 1.0
|
36
|
+
if gripSize < minimalGripSize
|
37
|
+
gripSize = minimalGripSize
|
38
|
+
end
|
39
|
+
|
40
|
+
windowScrollAreaSize = contentSize - windowSize
|
41
|
+
windowPosition = list.offset.to_f
|
42
|
+
windowPositionRatio = windowPosition / windowScrollAreaSize
|
43
|
+
|
44
|
+
trackScrollAreaSize = trackSize - gripSize
|
45
|
+
gripPositionOnTrack = trackScrollAreaSize * windowPositionRatio
|
46
|
+
|
47
|
+
st = 1 + gripPositionOnTrack.floor.to_i
|
48
|
+
e = (st + gripSize.ceil).to_i
|
49
|
+
|
50
|
+
st = 1 if st < 1
|
51
|
+
e = maxy - 1 if e > maxy - 1
|
52
|
+
|
53
|
+
@memo_keys = memo_keys
|
54
|
+
return (@memo_val = st ... e)
|
55
|
+
end
|
56
|
+
|
57
|
+
def window
|
58
|
+
list.window
|
59
|
+
end
|
60
|
+
|
61
|
+
def getmaxxy
|
62
|
+
list.getmaxxy
|
63
|
+
end
|
64
|
+
|
65
|
+
def render
|
66
|
+
maxx, maxy = getmaxxy
|
67
|
+
x = maxx - self.width
|
68
|
+
|
69
|
+
# If we don't need a scroll bar...
|
70
|
+
return if list.values.size <= maxy
|
71
|
+
|
72
|
+
# Both arrows
|
73
|
+
window.color_set(Color[:scroll_arrow], nil)
|
74
|
+
window.move(0, x)
|
75
|
+
window.addstr(UP_ARROW)
|
76
|
+
window.move(maxy - 1, x)
|
77
|
+
window.addstr(DOWN_ARROW)
|
78
|
+
|
79
|
+
# The full track
|
80
|
+
window.color_set(Color[:scroll_track], nil)
|
81
|
+
(1 ... maxy - 1).each do |y|
|
82
|
+
window.move(y, x)
|
83
|
+
window.addstr(' ')
|
84
|
+
end
|
85
|
+
|
86
|
+
# Scroll thumb on top of it
|
87
|
+
window.color_set(Color[:scroll_thumb], nil)
|
88
|
+
scroll_thumb_range.each do |y|
|
89
|
+
window.move(y, x)
|
90
|
+
window.addstr(' ')
|
91
|
+
end
|
92
|
+
|
93
|
+
# Set our drawing cursor at the origin
|
94
|
+
window.color_set(Color[:list_default], nil)
|
95
|
+
window.move(0, 0)
|
96
|
+
end
|
97
|
+
|
98
|
+
def can_scroll_up?
|
99
|
+
list.offset > 0
|
100
|
+
end
|
101
|
+
|
102
|
+
def can_scroll_down?
|
103
|
+
_, maxy = getmaxxy
|
104
|
+
list.values.size > list.offset + maxy
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
data/lib/listpager.rb
ADDED
data/listpager.gemspec
ADDED
@@ -0,0 +1,33 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'listpager/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "listpager"
|
8
|
+
spec.version = Listpager::VERSION
|
9
|
+
spec.authors = ["Mike Owens"]
|
10
|
+
spec.email = ["mike@meter.md"]
|
11
|
+
|
12
|
+
spec.summary = "Interactive terminal pager for lists"
|
13
|
+
spec.description = "Ncurses list pager, controllable via stdin and stdout"
|
14
|
+
spec.homepage = "https://github.com/mieko/listpager"
|
15
|
+
spec.license = "MIT"
|
16
|
+
|
17
|
+
# Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
|
18
|
+
# to allow pushing to a single host or delete this section to allow pushing to any host.
|
19
|
+
if spec.respond_to?(:metadata)
|
20
|
+
spec.metadata['allowed_push_host'] = "https://rubygems.org/"
|
21
|
+
else
|
22
|
+
raise "RubyGems 2.0 or newer is required to protect against public gem pushes."
|
23
|
+
end
|
24
|
+
|
25
|
+
spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
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 "ncurses-ruby", "~> 1.2.4"
|
31
|
+
spec.add_development_dependency "bundler", "~> 1.12"
|
32
|
+
spec.add_development_dependency "rake", "~> 10.0"
|
33
|
+
end
|
metadata
ADDED
@@ -0,0 +1,104 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: listpager
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: '1.0'
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Mike Owens
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2016-08-01 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: ncurses-ruby
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 1.2.4
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: 1.2.4
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: bundler
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '1.12'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '1.12'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: rake
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '10.0'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '10.0'
|
55
|
+
description: Ncurses list pager, controllable via stdin and stdout
|
56
|
+
email:
|
57
|
+
- mike@meter.md
|
58
|
+
executables:
|
59
|
+
- listpager
|
60
|
+
extensions: []
|
61
|
+
extra_rdoc_files: []
|
62
|
+
files:
|
63
|
+
- ".gitignore"
|
64
|
+
- Gemfile
|
65
|
+
- LICENSE.txt
|
66
|
+
- README.md
|
67
|
+
- Rakefile
|
68
|
+
- bin/console
|
69
|
+
- bin/setup
|
70
|
+
- doc/screenshot.png
|
71
|
+
- exe/listpager
|
72
|
+
- lib/listpager.rb
|
73
|
+
- lib/listpager/client_terminal.rb
|
74
|
+
- lib/listpager/color.rb
|
75
|
+
- lib/listpager/list.rb
|
76
|
+
- lib/listpager/scrollbar.rb
|
77
|
+
- lib/listpager/version.rb
|
78
|
+
- listpager.gemspec
|
79
|
+
homepage: https://github.com/mieko/listpager
|
80
|
+
licenses:
|
81
|
+
- MIT
|
82
|
+
metadata:
|
83
|
+
allowed_push_host: https://rubygems.org/
|
84
|
+
post_install_message:
|
85
|
+
rdoc_options: []
|
86
|
+
require_paths:
|
87
|
+
- lib
|
88
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
89
|
+
requirements:
|
90
|
+
- - ">="
|
91
|
+
- !ruby/object:Gem::Version
|
92
|
+
version: '0'
|
93
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
94
|
+
requirements:
|
95
|
+
- - ">="
|
96
|
+
- !ruby/object:Gem::Version
|
97
|
+
version: '0'
|
98
|
+
requirements: []
|
99
|
+
rubyforge_project:
|
100
|
+
rubygems_version: 2.5.1
|
101
|
+
signing_key:
|
102
|
+
specification_version: 4
|
103
|
+
summary: Interactive terminal pager for lists
|
104
|
+
test_files: []
|