termcontroller 0.2 → 0.3
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 +4 -4
- data/Gemfile +1 -0
- data/README.md +36 -1
- data/lib/termcontroller/controller.rb +221 -189
- data/lib/termcontroller/version.rb +1 -1
- data/lib/termcontroller.rb +0 -1
- data/termcontroller.gemspec +1 -0
- metadata +17 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 137ab6e058cbc71d9ac66d57479fcb01a9ab87fb403c795c553c880498c21410
|
4
|
+
data.tar.gz: b51e7f7353b269fc8e0375d858e9c0ee8605ea0e340b1c4f000ec4557e6ac281
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 4a1d7893783340263e8d92ed7503ef239b77407e4466305f2fbbb1700b73108bb34d23eb34f8fcb3f8680bf5ac40f191cce9eba7ff8d3188d4213c0885c04d16
|
7
|
+
data.tar.gz: a6270a9cb7c27ee6744540013e6abc599851f846cc5b8b1a76bc08f34a4bc1784cf60ef89e999e588d653afa38f3e407f3d12c9ae4bfddf61e01529e8d5638ce
|
data/Gemfile
CHANGED
data/README.md
CHANGED
@@ -4,6 +4,12 @@ A very basic Controller (in the MVC sense) for Ruby terminal
|
|
4
4
|
applications. This was pulled out of my text editor,
|
5
5
|
[Re](https://github.com/vidarh/re).
|
6
6
|
|
7
|
+
**YOU ALMOST CERTAINLY DO NOT YET WANT TO USE THIS FOR YOUR OWN CODE**
|
8
|
+
|
9
|
+
I do intend to clean it up and make it usable for others, but it's not
|
10
|
+
there yet - if you think it'd be useful for you, feel free to drop me a
|
11
|
+
message.
|
12
|
+
|
7
13
|
## Installation
|
8
14
|
|
9
15
|
Add this line to your application's Gemfile:
|
@@ -22,7 +28,36 @@ Or install it yourself as:
|
|
22
28
|
|
23
29
|
## Usage
|
24
30
|
|
25
|
-
|
31
|
+
```ruby
|
32
|
+
require 'termcontroller'
|
33
|
+
|
34
|
+
class Target
|
35
|
+
def initialize
|
36
|
+
# Second argument is a keyboard mapping.
|
37
|
+
@ctrl = Termcontroller::Controller.new(self, {
|
38
|
+
:f1 => :help
|
39
|
+
})
|
40
|
+
end
|
41
|
+
|
42
|
+
def help
|
43
|
+
puts "This is a help text"
|
44
|
+
end
|
45
|
+
|
46
|
+
def run
|
47
|
+
loop do
|
48
|
+
p @ctrl.handle_input
|
49
|
+
print "\r"
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
def ctrl_c; exit; end
|
54
|
+
end
|
55
|
+
|
56
|
+
puts "Ctrl+c to quit"
|
57
|
+
Target.new.run
|
58
|
+
```
|
59
|
+
|
60
|
+
|
26
61
|
|
27
62
|
## Development
|
28
63
|
|
@@ -1,6 +1,7 @@
|
|
1
|
-
# coding
|
1
|
+
# coding utf-8
|
2
2
|
|
3
3
|
require 'io/console'
|
4
|
+
require 'fcntl'
|
4
5
|
|
5
6
|
# # Controller
|
6
7
|
#
|
@@ -11,248 +12,279 @@ require 'io/console'
|
|
11
12
|
# the command directly to a specified target object based on the
|
12
13
|
# key bindings.
|
13
14
|
#
|
15
|
+
# Note that *currently* `#handle_input` will block on retrieving a
|
16
|
+
# command with no timeout. This is because Re is the only current
|
17
|
+
# client of this class, and *currently* does not have a need for
|
18
|
+
# anything async. That *will* change, as Re will eventually start
|
19
|
+
# receiving evented updates. Current "hack" to work around this:
|
20
|
+
# push things into the `commands` queue. This may well be the ongoing
|
21
|
+
# way to do it, and I might consider breaking it out.
|
22
|
+
#
|
14
23
|
# It works well enough to e.g. allow temporarily pausing the processing
|
15
24
|
# and then dispatching "binding.pry" and re-enable the processing when
|
16
25
|
# it returns.
|
17
26
|
#
|
18
|
-
#
|
27
|
+
# That said there are lots of hacks in here that needs to be cleaned up
|
19
28
|
#
|
20
29
|
# FIXME: Should probably treat this as a singleton.
|
21
30
|
#
|
22
31
|
#
|
23
32
|
|
33
|
+
require 'keyboard_map'
|
34
|
+
|
24
35
|
module Termcontroller
|
36
|
+
DOUBLE_CLICK_INTERVAL=0.5
|
37
|
+
|
25
38
|
class Controller
|
39
|
+
@@controllers = []
|
26
40
|
|
27
|
-
attr_reader :lastcmd
|
28
|
-
attr_accessor :mode
|
29
|
-
|
30
|
-
@@con = IO.console
|
31
|
-
|
32
|
-
# Pause *any* Controller instance
|
33
|
-
@@pause = false
|
34
|
-
def self.pause!
|
35
|
-
old = @@pause
|
36
|
-
@@pause = true
|
37
|
-
@@con.cooked do
|
38
|
-
yield
|
39
|
-
end
|
40
|
-
ensure
|
41
|
-
@@pause = old
|
42
|
-
end
|
41
|
+
attr_reader :lastcmd, :commands
|
43
42
|
|
44
|
-
def
|
45
|
-
@
|
46
|
-
end
|
43
|
+
def initialize(target=nil, keybindings={})
|
44
|
+
@m = Mutex.new
|
47
45
|
|
48
|
-
def initialize(target, keybindings)
|
49
46
|
@target = target
|
47
|
+
@target_stack = []
|
48
|
+
|
50
49
|
@keybindings = keybindings
|
51
50
|
@buf = ""
|
52
|
-
@commands =
|
51
|
+
@commands = Queue.new
|
53
52
|
@mode = :cooked
|
54
53
|
|
55
54
|
@kb = KeyboardMap.new
|
56
|
-
|
55
|
+
@con = IO.console
|
57
56
|
raise if !@con
|
58
|
-
|
59
|
-
at_exit do
|
60
|
-
cleanup
|
61
|
-
end
|
62
|
-
|
63
|
-
trap("CONT") { resume }
|
64
57
|
|
65
|
-
|
66
|
-
|
58
|
+
at_exit { quit }
|
59
|
+
trap("CONT") { resume }
|
60
|
+
trap("WINCH") { @commands << :resize }
|
67
61
|
|
68
|
-
|
69
|
-
STDOUT.print "\e[?2004h" # Enable bracketed paste
|
70
|
-
STDOUT.print "\e[?1000h" # Enable mouse reporting
|
71
|
-
STDOUT.print "\e[?1006h" # Enable extended reporting
|
72
|
-
end
|
62
|
+
setup
|
73
63
|
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
end
|
64
|
+
@t = Thread.new { readloop }
|
65
|
+
|
66
|
+
@@controllers << @t
|
78
67
|
|
79
|
-
def readloop
|
80
|
-
loop do
|
81
|
-
if paused?
|
82
|
-
sleep(0.05)
|
83
|
-
elsif @mode == :cooked
|
84
|
-
read_input
|
85
|
-
else
|
86
|
-
fill_buf
|
87
|
-
end
|
88
68
|
end
|
89
|
-
end
|
90
69
|
|
91
|
-
|
92
|
-
old = @mode
|
93
|
-
@mode = :pause
|
94
|
-
sleep(0.1)
|
95
|
-
IO.console.cooked!
|
96
|
-
yield
|
97
|
-
rescue Interrupt
|
98
|
-
ensure
|
99
|
-
@mode = old
|
100
|
-
end
|
70
|
+
def paused?; @mode == :pause; end
|
101
71
|
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
Thread.pass
|
106
|
-
return
|
72
|
+
def push_target(t)
|
73
|
+
@target_stack << @target
|
74
|
+
@target = t
|
107
75
|
end
|
108
|
-
@con.raw!
|
109
|
-
return if !IO.select([$stdin],nil,nil,0.1)
|
110
|
-
str = $stdin.read_nonblock(4096)
|
111
|
-
str.force_encoding("utf-8")
|
112
|
-
@buf << str
|
113
|
-
rescue IO::WaitReadable
|
114
|
-
end
|
115
76
|
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
end
|
121
|
-
@buf.slice!(0) if !paused? && @mode == :cooked
|
122
|
-
else
|
123
|
-
sleep(0.1)
|
124
|
-
Thread.pass
|
125
|
-
return nil
|
77
|
+
def pop_target
|
78
|
+
t = @target
|
79
|
+
@target = @target_stack.pop
|
80
|
+
t
|
126
81
|
end
|
127
|
-
rescue Interrupt
|
128
|
-
end
|
129
|
-
|
130
|
-
def raw
|
131
|
-
@mode = :raw
|
132
|
-
yield
|
133
|
-
rescue Interrupt
|
134
|
-
ensure
|
135
|
-
@mode = :cooked
|
136
|
-
end
|
137
82
|
|
138
|
-
def read_char
|
139
|
-
sleep(0.01) if @buf.empty?
|
140
|
-
@buf.slice!(0)
|
141
|
-
rescue Interrupt
|
142
|
-
end
|
143
83
|
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
84
|
+
#
|
85
|
+
# Pause processing so you can read directly from stdin
|
86
|
+
# yourself. E.g. to use Readline, or to suspend
|
87
|
+
#
|
88
|
+
def pause
|
89
|
+
@m.synchronize do
|
90
|
+
old = @mode
|
91
|
+
begin
|
92
|
+
@mode = :pause
|
93
|
+
IO.console.cooked!
|
94
|
+
cleanup
|
95
|
+
r = yield
|
96
|
+
setup
|
97
|
+
r
|
98
|
+
rescue Interrupt
|
99
|
+
ensure
|
100
|
+
@mode = old
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
150
104
|
|
151
|
-
|
152
|
-
|
105
|
+
# FIXME: The first event after the yield
|
106
|
+
# appears to fail to pass the mapping.
|
107
|
+
# Maybe move the mapping to the client thread?
|
108
|
+
def raw
|
109
|
+
keybindings = @keybindings
|
110
|
+
@keybindings = {}
|
111
|
+
push_target(nil)
|
112
|
+
yield
|
113
|
+
rescue Interrupt
|
114
|
+
ensure
|
115
|
+
@keybindings = keybindings
|
116
|
+
pop_target
|
117
|
+
end
|
153
118
|
|
154
|
-
|
155
|
-
|
119
|
+
def handle_input
|
120
|
+
if c = @commands.pop
|
121
|
+
do_command(c)
|
156
122
|
end
|
123
|
+
return Array(c)
|
124
|
+
end
|
157
125
|
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
return Array(c1.to_sym).concat(args || [])
|
163
|
-
else
|
164
|
-
@lastchar = char.inspect
|
165
|
-
return nil
|
166
|
-
end
|
126
|
+
def suspend
|
127
|
+
pause do
|
128
|
+
yield if block_given?
|
129
|
+
Process.kill("STOP", 0)
|
167
130
|
end
|
131
|
+
end
|
168
132
|
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
@lastchar += " (#{c.to_s})" if c.to_s != @lastchar
|
174
|
-
return c
|
175
|
-
end
|
133
|
+
def resume
|
134
|
+
@mode = :cooked
|
135
|
+
setup
|
136
|
+
@commands << [:resume]
|
176
137
|
end
|
177
|
-
end
|
178
138
|
|
179
|
-
|
180
|
-
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
|
139
|
+
private # # ########################################################
|
140
|
+
|
141
|
+
def setup
|
142
|
+
STDOUT.print "\e[?2004h" # Enable bracketed paste
|
143
|
+
STDOUT.print "\e[?1000h" # Enable mouse reporting
|
144
|
+
STDOUT.print "\e[?1002h" # Enable mouse *move when clicked* reporting
|
145
|
+
STDOUT.print "\e[?1006h" # Enable extended reporting
|
146
|
+
STDOUT.print "\e[?25l" # Hide cursor
|
147
|
+
@con.raw!
|
148
|
+
end
|
149
|
+
|
150
|
+
def cleanup
|
151
|
+
# Some programs, like more and docker struggles if the terminal
|
152
|
+
# is not returned to blocking mode
|
153
|
+
stdin_flags = STDIN.fcntl(Fcntl::F_GETFL)
|
154
|
+
STDIN.fcntl(Fcntl::F_SETFL, stdin_flags & ~Fcntl::O_NONBLOCK) #
|
155
|
+
|
156
|
+
@con.cooked!
|
157
|
+
STDOUT.print "\e[?2004l" #Disable bracketed paste
|
158
|
+
STDOUT.print "\e[?1000l" #Disable mouse reporting
|
159
|
+
STDOUT.print "\e[?25h" # Show cursor
|
186
160
|
end
|
187
|
-
end
|
188
161
|
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
Thread.pass
|
193
|
-
return
|
162
|
+
def quit
|
163
|
+
@@controllers.each {|t| t.kill }
|
164
|
+
cleanup
|
194
165
|
end
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
#
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
166
|
+
|
167
|
+
|
168
|
+
def fill_buf
|
169
|
+
# We do this to ensure the other thread gets a chance
|
170
|
+
sleep(0.01)
|
171
|
+
|
172
|
+
@m.synchronize do
|
173
|
+
@con.raw!
|
174
|
+
return if !IO.select([$stdin],nil,nil,0.1)
|
175
|
+
str = $stdin.read_nonblock(4096)
|
176
|
+
|
177
|
+
# FIXME...
|
178
|
+
str.force_encoding("utf-8")
|
179
|
+
@buf << str
|
180
|
+
end
|
181
|
+
rescue IO::WaitReadable
|
204
182
|
end
|
205
|
-
@commands << c
|
206
|
-
Thread.pass
|
207
|
-
end
|
208
183
|
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
|
184
|
+
|
185
|
+
def getc
|
186
|
+
while @buf.empty?
|
187
|
+
fill_buf
|
188
|
+
end
|
189
|
+
@buf.slice!(0) if !paused? && @mode == :cooked
|
190
|
+
rescue Interrupt
|
213
191
|
end
|
214
|
-
@commands.shift
|
215
|
-
end
|
216
192
|
|
217
|
-
|
218
|
-
if
|
219
|
-
|
193
|
+
# We do this because @keybindings can be changed
|
194
|
+
# on the main thread if the client changes the bindings,
|
195
|
+
# e.g. by calling `#raw`. This is the *only* sanctioned
|
196
|
+
# way to look up the binding.
|
197
|
+
#
|
198
|
+
def map(key, map = @keybindings)
|
199
|
+
map && key ? map[key.to_sym] : nil
|
220
200
|
end
|
221
|
-
return c
|
222
|
-
end
|
223
201
|
|
224
|
-
|
225
|
-
|
226
|
-
|
227
|
-
|
228
|
-
|
202
|
+
def get_command
|
203
|
+
# Keep track of compound mapping
|
204
|
+
cmap = nil
|
205
|
+
cmdstr = ""
|
206
|
+
|
207
|
+
loop do
|
208
|
+
c = nil
|
209
|
+
char = getc
|
210
|
+
return nil if !char
|
211
|
+
|
212
|
+
a = Array(@kb.call(char))
|
213
|
+
c1 = a.first
|
214
|
+
|
215
|
+
if c1 == :mouse_up
|
216
|
+
t = Time.now
|
217
|
+
dt = @lastclick ? t - @lastclick : 999
|
218
|
+
if dt < DOUBLE_CLICK_INTERVAL
|
219
|
+
c1 = :mouse_doubleclick
|
220
|
+
else
|
221
|
+
c1 = :mouse_click
|
222
|
+
end
|
223
|
+
@lastclick = t
|
224
|
+
end
|
225
|
+
|
226
|
+
c = map(c1, cmap || @keybindings)
|
227
|
+
|
228
|
+
if c.nil? && c1.kind_of?(String)
|
229
|
+
return [:char, c1]
|
230
|
+
end
|
231
|
+
|
232
|
+
if c.nil?
|
233
|
+
if c1
|
234
|
+
args = c1.respond_to?(:args) ? c1.args : []
|
235
|
+
@lastcmd = cmdstr + c1.to_sym.to_s
|
236
|
+
return Array(c1.to_sym).concat(args || [])
|
237
|
+
else
|
238
|
+
@lastcmd = cmdstr + char.inspect
|
239
|
+
return nil
|
240
|
+
end
|
241
|
+
end
|
242
|
+
|
243
|
+
str = c1.to_sym.to_s.split("_").join(" ")
|
244
|
+
if cmdstr.empty?
|
245
|
+
cmdstr = str
|
246
|
+
else
|
247
|
+
cmdstr += " + " + str
|
248
|
+
end
|
249
|
+
|
250
|
+
if c.kind_of?(Hash) # Compound mapping
|
251
|
+
cmap = c
|
252
|
+
else
|
253
|
+
@lastcmd = cmdstr + " (#{c.to_s})" if c.to_s != @lastcmd
|
254
|
+
return c
|
255
|
+
end
|
256
|
+
end
|
229
257
|
end
|
230
|
-
setup
|
231
|
-
end
|
232
258
|
|
233
|
-
|
234
|
-
|
235
|
-
|
236
|
-
|
259
|
+
def read_input
|
260
|
+
c = get_command
|
261
|
+
if !c
|
262
|
+
Thread.pass
|
263
|
+
return
|
264
|
+
end
|
265
|
+
|
266
|
+
@commands << c
|
237
267
|
end
|
238
|
-
end
|
239
268
|
|
240
|
-
|
241
|
-
|
242
|
-
|
243
|
-
|
244
|
-
|
245
|
-
|
269
|
+
def readloop
|
270
|
+
loop do
|
271
|
+
if @mode == :cooked
|
272
|
+
read_input
|
273
|
+
else
|
274
|
+
fill_buf
|
275
|
+
end
|
276
|
+
end
|
277
|
+
end
|
278
|
+
|
279
|
+
def do_command(c)
|
280
|
+
return nil if !c || !@target
|
281
|
+
a = Array(c)
|
282
|
+
if @target.respond_to?(a.first)
|
283
|
+
@target.instance_eval { send(*a) }
|
284
|
+
else
|
285
|
+
@lastcmd = "Unbound: #{a.first.inspect}"
|
286
|
+
end
|
287
|
+
end
|
246
288
|
|
247
|
-
at_exit do
|
248
|
-
#
|
249
|
-
# FIXME: This is a workaround for Controller putting
|
250
|
-
# STDIN into nonblocking mode and not cleaning up, which
|
251
|
-
# causes all kind of problems with a variety of tools (more,
|
252
|
-
# docker etc.) which expect it to be blocking.
|
253
|
-
Termcontroller::Controller.pause! do
|
254
|
-
stdin_flags = STDIN.fcntl(Fcntl::F_GETFL)
|
255
|
-
STDIN.fcntl(Fcntl::F_SETFL, stdin_flags & ~Fcntl::O_NONBLOCK) #
|
256
|
-
IO.console.cooked!
|
257
289
|
end
|
258
290
|
end
|
data/lib/termcontroller.rb
CHANGED
data/termcontroller.gemspec
CHANGED
metadata
CHANGED
@@ -1,15 +1,29 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: termcontroller
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: '0.
|
4
|
+
version: '0.3'
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Vidar Hokstad
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
12
|
-
dependencies:
|
11
|
+
date: 2023-08-02 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: keyboard_map
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 0.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: 0.1.3
|
13
27
|
description:
|
14
28
|
email:
|
15
29
|
- vidar@hokstad.com
|