termcontroller 0.2 → 0.5

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: 04632d599e81a07a9dcda4090091356de0fe257b01342d31719937fd366ccf8f
4
+ data.tar.gz: 8f411109feb2e00122c80bb7be2978a19e4e1af94f131b66ff6a25476fd1eb99
5
5
  SHA512:
6
- metadata.gz: d6c8d516c1a855d46833a6ead5e0a28ab2a682b1ce18a8039d301933acb2811d627b65c62b2ecbd0298c8379af98a7502d86a542342fc3c48911f996ab57cfba
7
- data.tar.gz: 5f313f8f34231eaa7715c0df26e7abd470ee770af60cdbffb4c4dc7f1f8c08132df5e1f4558b713e26a9be307e5f496c51cfd7817efb12c6ac557dbe87d8e5e5
6
+ metadata.gz: 8b693ada8e7fab935972a7ad40944eac7da728f5292f81b7217c94839dd0ff06896f909ca13ed0dddc630258020d7fa0ed3c9e4eba36efe430b679a962526b68
7
+ data.tar.gz: 5b189f18f46ed86916cef9fedbc7b70766c3fe3d4d229c22c4f704c1b37e05610b2ed8396b324218ed865ba0f8a23ddb34b178a2eba1f80aa61728f06ac7a27f
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"
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
 
data/examples/draw.rb ADDED
@@ -0,0 +1,57 @@
1
+
2
+ $: << File.expand_path(File.dirname(__FILE__)+"/../lib/")
3
+
4
+ require 'termcontroller'
5
+
6
+ class Target
7
+
8
+ def initialize
9
+ keymap = { :ctrl_c => :quit }
10
+ @ctrl = Termcontroller::Controller.new(self, keymap)
11
+ @c = " "
12
+ @col = 1
13
+ end
14
+
15
+ # If a method exists in the target class passed to Controller.new
16
+ # that matches the keymap, then it is called directly.
17
+ #
18
+ # In this case `quit` is defined in the map above,
19
+ # while `char` is a default.
20
+ #
21
+ # If nothing is mapped, then `handle_input` will return the
22
+ # symbol instead, as shown for `mouse_down`.
23
+ #
24
+ def quit = (@running = false)
25
+ def char(c) = (@c = c)
26
+
27
+ def call
28
+ @running = true
29
+ col = 1
30
+ while @running
31
+ cmd = @ctrl.handle_input
32
+ case cmd.first
33
+ when :mouse_down
34
+ print "\e[5;1H#{cmd.inspect}"
35
+ print "\e[#{cmd[3]};#{cmd[2]}H"
36
+ if cmd[1] & 3 == 0
37
+ # left
38
+ print "\e[4#{col}m#{@c}\e[49m"
39
+ print "\e[1;1H[DRAWING] "
40
+ elsif cmd[1] & 3 == 2
41
+ # right
42
+ print " "
43
+ print "\e[1;1H[ERASING] "
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+
50
+
51
+ print "\e[2J" # Clear screen
52
+ print "\e[4;1HHold left button and move mouse to draw; Hold right button and move to clear"
53
+ print "\e[3;1HPress any letter to set a character to fill with on draw."
54
+ print "\e[2;1HCtrl+c to quit"
55
+ Target.new.call
56
+ print "\e[2J" # Clear screen
57
+ print "\e[1;1H" # Move top left
@@ -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,297 @@ 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 }
78
65
 
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
66
+ @@controllers << @t
67
+
68
+ end
69
+
70
+ def paused?; @mode == :pause; end
71
+
72
+ def push_target(t)
73
+ @target_stack << @target
74
+ @target = t
75
+ end
76
+
77
+ def pop_target
78
+ t = @target
79
+ @target = @target_stack.pop
80
+ t
81
+ end
82
+
83
+
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
+ r
97
+ rescue Interrupt
98
+ ensure
99
+ @mode = old
100
+ setup
101
+ end
87
102
  end
88
103
  end
89
- end
90
104
 
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
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
101
118
 
102
- def fill_buf(timeout=0.1)
103
- if paused?
104
- sleep(0.1)
105
- Thread.pass
106
- return
119
+ def handle_input
120
+ if c = @commands.pop
121
+ do_command(c)
122
+ end
123
+ return Array(c)
107
124
  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
125
 
116
- def getc(timeout=0.1)
117
- if !paused?
118
- while @buf.empty?
119
- fill_buf
126
+ # USE WITH CAUTION. This will pause processing,
127
+ # yield to the provided block if any, and then send
128
+ # SIGSTOP to the process.
129
+ #
130
+ # This is meant to allow for handling ctrl-z to
131
+ # suspend in processes that need to be able to
132
+ # reset the terminal to a better state before stopping.
133
+ #
134
+ # FIXME: It seems like it does not work as expected.
135
+ #
136
+ def suspend
137
+ pause do
138
+ yield if block_given?
139
+ Process.kill("STOP", 0)
120
140
  end
121
- @buf.slice!(0) if !paused? && @mode == :cooked
122
- else
123
- sleep(0.1)
124
- Thread.pass
125
- return nil
126
141
  end
127
- rescue Interrupt
128
- end
129
142
 
130
- def raw
131
- @mode = :raw
132
- yield
133
- rescue Interrupt
134
- ensure
135
- @mode = :cooked
136
- end
143
+ def resume
144
+ @mode = :cooked
145
+ setup
146
+ @commands << [:resume]
147
+ end
137
148
 
138
- def read_char
139
- sleep(0.01) if @buf.empty?
140
- @buf.slice!(0)
141
- rescue Interrupt
142
- end
149
+ def hide_cursor
150
+ STDOUT.print "\e[?25l" # Hide cursor
151
+ end
143
152
 
144
- def get_command
145
- map = @keybindings
146
- loop do
147
- c = nil
148
- char = getc
149
- return nil if !char
153
+ def show_cursor
154
+ STDOUT.print "\e[?25h" # Show cursor
155
+ end
150
156
 
151
- c1 = Array(@kb.call(char)).first
152
- c = map[c1.to_sym] if c1
157
+ private # # ########################################################
153
158
 
154
- if c.nil? && c1.kind_of?(String)
155
- return [:insert_char, c1]
156
- end
159
+ def setup
160
+ STDOUT.print "\e[?2004h" # Enable bracketed paste
161
+ STDOUT.print "\e[?1000h" # Enable mouse reporting
162
+ STDOUT.print "\e[?1002h" # Enable mouse *move when clicked* reporting
163
+ STDOUT.print "\e[?1006h" # Enable extended reporting
164
+ hide_cursor
165
+ @con.raw!
166
+ end
157
167
 
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
167
- end
168
+ def cleanup
169
+ # Some programs, like more and docker struggles if the terminal
170
+ # is not returned to blocking mode
171
+ stdin_flags = STDIN.fcntl(Fcntl::F_GETFL)
172
+ STDIN.fcntl(Fcntl::F_SETFL, stdin_flags & ~Fcntl::O_NONBLOCK) #
168
173
 
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
174
+ @con.cooked!
175
+ STDOUT.print "\e[?2004l" #Disable bracketed paste
176
+ STDOUT.print "\e[?1000l" #Disable mouse reporting
177
+ show_cursor
176
178
  end
177
- end
178
179
 
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}"
180
+ def quit
181
+ @@controllers.each {|t| t.kill }
182
+ cleanup
186
183
  end
187
- end
188
184
 
189
- def read_input
190
- c = get_command
191
- if !c
192
- Thread.pass
193
- return
185
+
186
+ def fill_buf
187
+ # We do this to ensure the other thread gets a chance
188
+ sleep(0.01)
189
+
190
+ @m.synchronize do
191
+ @con.raw!
192
+ return if !IO.select([$stdin],nil,nil,0.1)
193
+ str = $stdin.read_nonblock(4096)
194
+
195
+ # FIXME...
196
+ str.force_encoding("utf-8")
197
+ @buf << str
198
+ end
199
+ rescue IO::WaitReadable
194
200
  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
201
+
202
+
203
+ def getc
204
+ while @buf.empty?
205
+ fill_buf
206
+ end
207
+ @buf.slice!(0) if !paused? && @mode == :cooked
208
+ rescue Interrupt
204
209
  end
205
- @commands << c
206
- Thread.pass
207
- end
208
210
 
209
- def next_command
210
- if @commands.empty?
211
- sleep(0.001)
212
- Thread.pass
211
+ # We do this because @keybindings can be changed
212
+ # on the main thread if the client changes the bindings,
213
+ # e.g. by calling `#raw`. This is the *only* sanctioned
214
+ # way to look up the binding.
215
+ #
216
+ def map(key, map = @keybindings)
217
+ map && key ? map[key.to_sym] : nil
213
218
  end
214
- @commands.shift
215
- end
216
219
 
217
- def handle_input(prefix="",timeout=0.1)
218
- if c = next_command
219
- do_command(c)
220
+ def get_command
221
+ # Keep track of compound mapping
222
+ cmap = nil
223
+ cmdstr = ""
224
+
225
+ loop do
226
+ c = nil
227
+ char = getc
228
+ return nil if !char
229
+
230
+ a = Array(@kb.call(char))
231
+ c1 = a.first
232
+
233
+ if c1 == :mouse_up
234
+ t = Time.now
235
+ dt = @lastclick ? t - @lastclick : 999
236
+ if dt < DOUBLE_CLICK_INTERVAL
237
+ c1 = :mouse_doubleclick
238
+ else
239
+ c1 = :mouse_click
240
+ end
241
+ @lastclick = t
242
+ end
243
+
244
+ c = map(c1, cmap || @keybindings)
245
+
246
+ if c.nil? && c1.kind_of?(String)
247
+ return [:char, c1]
248
+ end
249
+
250
+ if c.nil?
251
+ if c1
252
+ args = c1.respond_to?(:args) ? c1.args : []
253
+ @lastcmd = cmdstr + c1.to_sym.to_s
254
+ return Array(c1.to_sym).concat(args || [])
255
+ else
256
+ @lastcmd = cmdstr + char.inspect
257
+ return nil
258
+ end
259
+ end
260
+
261
+ str = c1.to_sym.to_s.split("_").join(" ")
262
+ if cmdstr.empty?
263
+ cmdstr = str
264
+ else
265
+ cmdstr += " + " + str
266
+ end
267
+
268
+ if c.kind_of?(Hash) # Compound mapping
269
+ cmap = c
270
+ else
271
+ @lastcmd = cmdstr + " (#{c.to_s})" if c.to_s != @lastcmd
272
+ return c
273
+ end
274
+ end
220
275
  end
221
- return c
222
- end
223
276
 
224
- def pry(e=nil)
225
- pause do
226
- cleanup
227
- puts ANSI.cls
228
- binding.pry
277
+ def read_input
278
+ c = get_command
279
+ if !c
280
+ Thread.pass
281
+ return
282
+ end
283
+
284
+ @commands << c
229
285
  end
230
- setup
231
- end
232
286
 
233
- def suspend
234
- pause do
235
- yield if block_given?
236
- Process.kill("STOP", 0)
287
+ def readloop
288
+ loop do
289
+ if @mode == :cooked
290
+ read_input
291
+ else
292
+ fill_buf
293
+ end
294
+ end
237
295
  end
238
- end
239
296
 
240
- def resume
241
- @mode = :cooked
242
- setup
243
- end
244
- end
245
- end
297
+ def do_command(c)
298
+ return nil if !c || !@target
299
+ a = Array(c)
300
+ if @target.respond_to?(a.first)
301
+ @target.instance_eval { send(*a) }
302
+ else
303
+ @lastcmd = "Unbound: #{a.first.inspect}"
304
+ end
305
+ end
246
306
 
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
307
  end
258
308
  end
@@ -1,3 +1,3 @@
1
1
  module Termcontroller
2
- VERSION = "0.2"
2
+ VERSION = "0.5"
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.5'
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: 2025-06-07 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
@@ -26,6 +40,7 @@ files:
26
40
  - Rakefile
27
41
  - bin/console
28
42
  - bin/setup
43
+ - examples/draw.rb
29
44
  - lib/termcontroller.rb
30
45
  - lib/termcontroller/controller.rb
31
46
  - lib/termcontroller/version.rb
@@ -50,7 +65,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
50
65
  - !ruby/object:Gem::Version
51
66
  version: '0'
52
67
  requirements: []
53
- rubygems_version: 3.1.4
68
+ rubygems_version: 3.4.10
54
69
  signing_key:
55
70
  specification_version: 4
56
71
  summary: Controller/input processing for terminal applications