termcontroller 0.2 → 0.3

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ccc884e481a6ba710cb7edbd7edc1b05c20ed49e7f8e81cb0288813472006466
4
- data.tar.gz: 6738a465db9c70c9d4d67f1626957de5209fbfbee95557cf9115f8734ff970e9
3
+ metadata.gz: 137ab6e058cbc71d9ac66d57479fcb01a9ab87fb403c795c553c880498c21410
4
+ data.tar.gz: b51e7f7353b269fc8e0375d858e9c0ee8605ea0e340b1c4f000ec4557e6ac281
5
5
  SHA512:
6
- metadata.gz: d6c8d516c1a855d46833a6ead5e0a28ab2a682b1ce18a8039d301933acb2811d627b65c62b2ecbd0298c8379af98a7502d86a542342fc3c48911f996ab57cfba
7
- data.tar.gz: 5f313f8f34231eaa7715c0df26e7abd470ee770af60cdbffb4c4dc7f1f8c08132df5e1f4558b713e26a9be307e5f496c51cfd7817efb12c6ac557dbe87d8e5e5
6
+ metadata.gz: 4a1d7893783340263e8d92ed7503ef239b77407e4466305f2fbbb1700b73108bb34d23eb34f8fcb3f8680bf5ac40f191cce9eba7ff8d3188d4213c0885c04d16
7
+ data.tar.gz: a6270a9cb7c27ee6744540013e6abc599851f846cc5b8b1a76bc08f34a4bc1784cf60ef89e999e588d653afa38f3e407f3d12c9ae4bfddf61e01529e8d5638ce
data/Gemfile CHANGED
@@ -3,5 +3,6 @@ source "https://rubygems.org"
3
3
  # Specify your gem's dependencies in termcontroller.gemspec
4
4
  gemspec
5
5
 
6
+ gem "keyboard_map" #, path: "../keyboard_map"
6
7
  gem "rake", "~> 12.0"
7
8
  gem "rspec", "~> 3.0"
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
- TODO: Write usage instructions here
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: utf-8
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
- # FIXME: Split it into a separate gem.
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,:lastkey,:lastchar
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 paused?
45
- @mode == :pause || @@pause
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
- @@con = @con = IO.console
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
- @t = Thread.new { readloop }
66
- end
58
+ at_exit { quit }
59
+ trap("CONT") { resume }
60
+ trap("WINCH") { @commands << :resize }
67
61
 
68
- def setup
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
- def cleanup
75
- STDOUT.print "\e[?2004l" #Disable bracketed paste
76
- STDOUT.print "\e[?1000l" #Disable mouse reporting
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
- def pause
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
- def fill_buf(timeout=0.1)
103
- if paused?
104
- sleep(0.1)
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
- def getc(timeout=0.1)
117
- if !paused?
118
- while @buf.empty?
119
- fill_buf
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
- def get_command
145
- map = @keybindings
146
- loop do
147
- c = nil
148
- char = getc
149
- return nil if !char
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
- c1 = Array(@kb.call(char)).first
152
- c = map[c1.to_sym] if c1
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
- if c.nil? && c1.kind_of?(String)
155
- return [:insert_char, c1]
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
- if c.nil?
159
- if c1
160
- @lastchar = c1.to_sym
161
- args = c1.respond_to?(:args) ? c1.args : []
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
- if c.kind_of?(Hash)
170
- map = c
171
- else
172
- @lastchar = c1.to_sym.to_s.split("_").join(" ")
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
- def do_command(c)
180
- return nil if !c
181
- if @target.respond_to?(Array(c).first)
182
- @lastcmd = c
183
- @target.instance_eval { send(*Array(c)) }
184
- else
185
- @lastchar = "Unbound: #{Array(c).first.inspect}"
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
- def read_input
190
- c = get_command
191
- if !c
192
- Thread.pass
193
- return
162
+ def quit
163
+ @@controllers.each {|t| t.kill }
164
+ cleanup
194
165
  end
195
- if Array(c).first == :insert_char
196
- # FIXME: Attempt to combine multiple :insert_char into one.
197
- #Probably should happen in get_command
198
- #while (c2 = get_command) && Array(c2).first == :insert_char
199
- # c.last << c2.last
200
- #end
201
- #@commands << c
202
- #c = c2
203
- #return nil if !c
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
- def next_command
210
- if @commands.empty?
211
- sleep(0.001)
212
- Thread.pass
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
- def handle_input(prefix="",timeout=0.1)
218
- if c = next_command
219
- do_command(c)
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
- def pry(e=nil)
225
- pause do
226
- cleanup
227
- puts ANSI.cls
228
- binding.pry
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
- def suspend
234
- pause do
235
- yield if block_given?
236
- Process.kill("STOP", 0)
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
- def resume
241
- @mode = :cooked
242
- setup
243
- end
244
- end
245
- end
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
@@ -1,3 +1,3 @@
1
1
  module Termcontroller
2
- VERSION = "0.2"
2
+ VERSION = "0.3"
3
3
  end
@@ -3,5 +3,4 @@ require "termcontroller/controller"
3
3
 
4
4
  module Termcontroller
5
5
  class Error < StandardError; end
6
- # Your code goes here...
7
6
  end
@@ -26,4 +26,5 @@ Gem::Specification.new do |spec|
26
26
  spec.bindir = "exe"
27
27
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
28
28
  spec.require_paths = ["lib"]
29
+ spec.add_dependency "keyboard_map", ">=0.1.3"
29
30
  end
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.2'
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: 2021-11-17 00:00:00.000000000 Z
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