listpager 1.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 +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
|
+

|
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: []
|