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 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