rubyterm 0.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/README.md +144 -0
- data/bin/record +3 -0
- data/bin/rubyterm +11 -0
- data/example-config.toml +61 -0
- data/lib/ansibackend.rb +155 -0
- data/lib/bitmapwindow.rb +176 -0
- data/lib/charsets.rb +52 -0
- data/lib/controller.rb +80 -0
- data/lib/escapeparser.rb +71 -0
- data/lib/keymap.rb +112 -0
- data/lib/palette.rb +14 -0
- data/lib/rubyterm/app.rb +580 -0
- data/lib/rubyterm/version.rb +5 -0
- data/lib/rubyterm.rb +47 -0
- data/lib/term.rb +657 -0
- data/lib/termbuffer.rb +365 -0
- data/lib/trackchanges.rb +319 -0
- data/lib/utf8decoder.rb +77 -0
- data/lib/window.rb +410 -0
- data/lib/windowadapter.rb +161 -0
- metadata +127 -0
data/lib/rubyterm/app.rb
ADDED
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
# The X11 terminal application: owns the X window, the pty controller, the
|
|
2
|
+
# input-processing thread and the blink/flush timers, and wires the
|
|
3
|
+
# interpreter (Term) + damage tracker (TrackChanges) + buffer to the X11
|
|
4
|
+
# backend (Window via WindowAdapter). The reusable terminal engine lives in
|
|
5
|
+
# the other lib/ files; this class is the executable front end (bin/rubyterm).
|
|
6
|
+
#
|
|
7
|
+
# Component classes and the X11/skrift/toml dependencies are loaded by
|
|
8
|
+
# lib/rubyterm.rb, which requires this file last.
|
|
9
|
+
class RubyTerm
|
|
10
|
+
TOPMOST= 0
|
|
11
|
+
LEFTMOST=0
|
|
12
|
+
|
|
13
|
+
attr_reader :blink_state, :rblink_state
|
|
14
|
+
|
|
15
|
+
def char_w = @adapter.char_w
|
|
16
|
+
def char_h = @adapter.char_h
|
|
17
|
+
def term_width = @term.width
|
|
18
|
+
def term_height = @term.height
|
|
19
|
+
|
|
20
|
+
def initconfig
|
|
21
|
+
cname = File.expand_path("~/.config/rterm/config.toml")
|
|
22
|
+
if File.exist?(cname)
|
|
23
|
+
@config = TomlRB.load_file(cname, symbolize_keys: true)
|
|
24
|
+
end
|
|
25
|
+
@config ||= {}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def inspect = "<RubyTerm #{self.object_id}>"
|
|
29
|
+
|
|
30
|
+
def get_x = @term.x
|
|
31
|
+
def get_y = @term.y
|
|
32
|
+
|
|
33
|
+
def initialize(args)
|
|
34
|
+
initconfig
|
|
35
|
+
|
|
36
|
+
@queue = Queue.new
|
|
37
|
+
# Coalesce redraw-causing events (resize/expose): a drag fires a
|
|
38
|
+
# flood of ConfigureNotify + Expose, and repainting on each one makes
|
|
39
|
+
# the display lag while it drains the backlog. Keep at most one
|
|
40
|
+
# redraw request queued, always for the latest pending size.
|
|
41
|
+
@redraw_mutex = Mutex.new
|
|
42
|
+
@redraw_pending = false
|
|
43
|
+
@pending_resize = nil
|
|
44
|
+
|
|
45
|
+
# DECCOLM (80/132 column switch) mode: :font rescales the glyph cell to
|
|
46
|
+
# fit the new column count in the current window (reliable everywhere);
|
|
47
|
+
# :window asks the WM to resize the window. Default :font.
|
|
48
|
+
@deccolm_mode = (@config[:deccolm] || "font").to_s.to_sym
|
|
49
|
+
|
|
50
|
+
@window = Window.new(fonts: @config[:fonts], fontsize: @config[:fontsize])
|
|
51
|
+
@adapter = WindowAdapter.new(@window, self)
|
|
52
|
+
|
|
53
|
+
# Yes, this is "bad" and we should define our
|
|
54
|
+
# own, however, I'd prefer to match rxvt or similar
|
|
55
|
+
# sufficiently that we can rely on a TERM setting that
|
|
56
|
+
# "everyone" already has in their termcap. rxvt seems to
|
|
57
|
+
# work better than xterm, but will adjust and consider
|
|
58
|
+
# providing multiple modes
|
|
59
|
+
ENV["TERM"] = "rxvt-256color"
|
|
60
|
+
ENV["COLORTERM"] = "truecolor"
|
|
61
|
+
|
|
62
|
+
while args[0].to_s[0] == ?-
|
|
63
|
+
case args[0]
|
|
64
|
+
when "--"
|
|
65
|
+
args.shift
|
|
66
|
+
break
|
|
67
|
+
when "-c"
|
|
68
|
+
args.shift
|
|
69
|
+
@term_instance = args.shift
|
|
70
|
+
else break
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
@window.dpy.change_property(:replace,
|
|
75
|
+
@window.wid, "WM_CLASS",
|
|
76
|
+
@window.dpy.atom("STRING"), 8, "rterm\0#{@term_instance||'rterm'}\0".unpack("C*"))
|
|
77
|
+
|
|
78
|
+
@window.map_window
|
|
79
|
+
|
|
80
|
+
@buffer = TrackChanges.new(TermBuffer.new, @adapter)
|
|
81
|
+
@buffer.defer = true # damage-driven rendering: set() mutates, flush draws
|
|
82
|
+
@term = Term.new(@buffer)
|
|
83
|
+
@buffer.on_resize(@term.width, @term.height)
|
|
84
|
+
|
|
85
|
+
# Give window access to the buffer for scrollback
|
|
86
|
+
@window.set_buffer(@buffer)
|
|
87
|
+
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def redraw
|
|
91
|
+
# Always clear the entire screen before redrawing to ensure all areas are cleaned up
|
|
92
|
+
# This ensures no artifacts remain at the ends of lines
|
|
93
|
+
@window.clear(0, 0, @window.width, @window.height)
|
|
94
|
+
|
|
95
|
+
if @window.scrollback_mode
|
|
96
|
+
@buffer.redraw_all(@window.scrollback_count)
|
|
97
|
+
else
|
|
98
|
+
@buffer.redraw_all(0)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Make sure changes are rendered and cursor is shown
|
|
102
|
+
@buffer.draw_flush
|
|
103
|
+
@term.draw_cursor
|
|
104
|
+
# A full repaint draws only buffer content; restore the selection
|
|
105
|
+
# overlay on top so it survives resizes, exposes and scrollback redraws.
|
|
106
|
+
reapply_selection
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def render_text_buffer
|
|
110
|
+
(0...@term.height).each {|y|
|
|
111
|
+
puts @buffer.buffer.getline(y).map {|a| a ? a[0].chr(Encoding::UTF_8):" " }.join
|
|
112
|
+
}
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def each_character(&block) = @buffer.each_character(&block)
|
|
116
|
+
|
|
117
|
+
def resize(w,h)
|
|
118
|
+
@pixelw||=0
|
|
119
|
+
@pixelh||=0
|
|
120
|
+
should_redraw = w >= @pixelw || h >= @pixelh
|
|
121
|
+
@pixelw=w
|
|
122
|
+
@pixelh=h
|
|
123
|
+
@window.on_resize(w,h)
|
|
124
|
+
|
|
125
|
+
w = w/char_w
|
|
126
|
+
h = h/char_h
|
|
127
|
+
return if w <= 0 || h <= 0 # FIXME: WTF?!?
|
|
128
|
+
#if w != @term.width && h == @term.height
|
|
129
|
+
@buffer.on_resize(w,h)
|
|
130
|
+
ow, oh = @term.width, @term.height
|
|
131
|
+
|
|
132
|
+
if ow != w || oh != h
|
|
133
|
+
@term.resize(w,h)
|
|
134
|
+
@controller.report_size(w,h)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
if should_redraw
|
|
138
|
+
redraw
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# DECCOLM: realise an 80/132 column switch (called via the adapter from
|
|
143
|
+
# Term#set_width_and_clear). font mode rescales the glyph cell so `cols`
|
|
144
|
+
# columns fit the current window, keeping the row count; window mode asks
|
|
145
|
+
# the WM to resize. Either way the pty is told the new size.
|
|
146
|
+
def set_columns(cols)
|
|
147
|
+
return if @deccolm_mode == :off # ignore DECCOLM entirely
|
|
148
|
+
cols = cols.to_i
|
|
149
|
+
return if cols <= 0 || @pixelw.to_i <= 0
|
|
150
|
+
|
|
151
|
+
if @deccolm_mode == :window
|
|
152
|
+
# WM-driven: the resulting ConfigureNotify completes the change via resize().
|
|
153
|
+
@window.request_pixel_size(cols * char_w, @pixelh.to_i)
|
|
154
|
+
return
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# font mode. Rescale the glyph cell (up or down) so `cols` columns fit
|
|
158
|
+
# the current window, keeping the row count. The window is NOT resized;
|
|
159
|
+
# integer cell widths mean the rightmost columns may not reach the window
|
|
160
|
+
# edge exactly (an accepted artefact - we don't do sub-pixel placement).
|
|
161
|
+
rows = @term.height
|
|
162
|
+
@window.fit_columns(cols, @pixelw.to_i)
|
|
163
|
+
@buffer.on_resize(cols, rows)
|
|
164
|
+
@term.resize(cols, rows)
|
|
165
|
+
@controller.report_size(cols, rows)
|
|
166
|
+
redraw
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def fg = @term.fg
|
|
171
|
+
def bg = @term.bg
|
|
172
|
+
|
|
173
|
+
# Escape/control/character interpretation lives in Term (lib/term.rb).
|
|
174
|
+
# RubyTerm only owns the X11 window, the pty controller and the
|
|
175
|
+
# threading; this keeps the terminal core testable headlessly (see
|
|
176
|
+
# harness/).
|
|
177
|
+
|
|
178
|
+
def write(str)
|
|
179
|
+
@queue << str
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def process_queue = process_chunk(@queue.shift)
|
|
183
|
+
|
|
184
|
+
# Everything that touches the buffer/window runs here, on the single
|
|
185
|
+
# input-processing thread. The blink and flush timers enqueue :blink
|
|
186
|
+
# and :flush rather than touching the buffer from their own threads:
|
|
187
|
+
# the buffer/renderer is not thread-safe, and concurrent mutation
|
|
188
|
+
# (e.g. blink's redraw racing a feed mid-scroll) corrupts cells
|
|
189
|
+
# non-deterministically. Serializing through the queue is the
|
|
190
|
+
# synchronization.
|
|
191
|
+
# When more than this many items are already queued behind the chunk we
|
|
192
|
+
# just processed, we treat it as a flood and jump-scroll: keep
|
|
193
|
+
# interpreting (the buffer + scrollback still update) but stop rendering
|
|
194
|
+
# every frame. The screen catches up at the flush tick or when we drain.
|
|
195
|
+
JUMP_BACKLOG = 8
|
|
196
|
+
|
|
197
|
+
def process_chunk(str)
|
|
198
|
+
case str
|
|
199
|
+
when :blink then return blink
|
|
200
|
+
when :flush
|
|
201
|
+
# Mid-flood: jump the display to the current state at the flush rate
|
|
202
|
+
# instead of painting every scrolled-off frame.
|
|
203
|
+
jump_redraw if @buffer.suspend
|
|
204
|
+
return @window.flush
|
|
205
|
+
when :do_redraw then return coalesced_redraw
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# FIXME: Could be smarter about this; it's only needed if the
|
|
209
|
+
# first character being written won't clear the same square.
|
|
210
|
+
@term.clear_cursor
|
|
211
|
+
|
|
212
|
+
@term.feed(str)
|
|
213
|
+
|
|
214
|
+
if @queue.size > JUMP_BACKLOG
|
|
215
|
+
# A flood is backing up. Render this frame, then suspend per-chunk
|
|
216
|
+
# rendering: subsequent chunks only mutate the buffer until the flush
|
|
217
|
+
# tick jumps the display forward or we catch up.
|
|
218
|
+
@buffer.draw_flush
|
|
219
|
+
@buffer.suspend = true
|
|
220
|
+
elsif @buffer.suspend
|
|
221
|
+
jump_redraw # drained after a flood: redraw the final state
|
|
222
|
+
else
|
|
223
|
+
# Draw the chunk's damaged content (damage-driven flush), then the
|
|
224
|
+
# cursor overlay on top.
|
|
225
|
+
@buffer.draw_flush
|
|
226
|
+
@term.draw_cursor
|
|
227
|
+
@buffer.draw_flush # Ensure everything has been rendered
|
|
228
|
+
# Output just repainted cells without the selection overlay; re-stamp
|
|
229
|
+
# it so a streaming program (top, full-screen apps) doesn't erase the
|
|
230
|
+
# highlight out from under an in-progress copy.
|
|
231
|
+
reapply_selection
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Resume rendering and repaint the whole current screen at once - the
|
|
236
|
+
# jump-scroll "catch up", skipping every frame that scrolled past while
|
|
237
|
+
# suspended.
|
|
238
|
+
def jump_redraw
|
|
239
|
+
@buffer.suspend = false
|
|
240
|
+
redraw
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Request a redraw (from the event thread). Records the latest pending
|
|
244
|
+
# size and enqueues a single :do_redraw marker; while one is already
|
|
245
|
+
# queued, further requests just update the target. This collapses a
|
|
246
|
+
# drag's flood of ConfigureNotify+Expose into one repaint per
|
|
247
|
+
# processing slot at the most recent size, instead of one per event.
|
|
248
|
+
def request_redraw(size = nil)
|
|
249
|
+
enqueue = false
|
|
250
|
+
@redraw_mutex.synchronize do
|
|
251
|
+
@pending_resize = size if size
|
|
252
|
+
enqueue = true unless @redraw_pending
|
|
253
|
+
@redraw_pending = true
|
|
254
|
+
end
|
|
255
|
+
@queue << :do_redraw if enqueue
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Process a coalesced redraw on the processing thread: resize to the
|
|
259
|
+
# latest pending size if it changed, otherwise just repaint.
|
|
260
|
+
def coalesced_redraw
|
|
261
|
+
size = @redraw_mutex.synchronize do
|
|
262
|
+
@redraw_pending = false
|
|
263
|
+
@pending_resize
|
|
264
|
+
end
|
|
265
|
+
if size && size != @last_resize
|
|
266
|
+
@last_resize = size
|
|
267
|
+
resize(size[0], size[1]) # resize repaints as needed
|
|
268
|
+
else
|
|
269
|
+
redraw
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def adjust_fontsize(delta)
|
|
274
|
+
@window.adjust_fontsize(delta)
|
|
275
|
+
resize(@pixelw,@pixelh)
|
|
276
|
+
@window.clear(0,0,@pixelw,@pixelh)
|
|
277
|
+
redraw
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def key(event)
|
|
281
|
+
#p event
|
|
282
|
+
ks, str = lookup_string(@window.dpy, event)
|
|
283
|
+
case ks
|
|
284
|
+
|
|
285
|
+
when :"ctrl_+" then adjust_fontsize(1.0)
|
|
286
|
+
when :"ctrl_-" then adjust_fontsize(-1.0)
|
|
287
|
+
when :shift_page_up
|
|
288
|
+
@window.scrollback_page_up
|
|
289
|
+
# Full redraw to show scrollback buffer
|
|
290
|
+
redraw
|
|
291
|
+
return
|
|
292
|
+
when :shift_page_down
|
|
293
|
+
# Don't do anything if not in scrollback mode
|
|
294
|
+
return if !@window.scrollback_mode
|
|
295
|
+
|
|
296
|
+
# If we're exiting scrollback mode or still in it
|
|
297
|
+
changed = @window.scrollback_page_down
|
|
298
|
+
|
|
299
|
+
# Redraw everything from scratch
|
|
300
|
+
redraw
|
|
301
|
+
|
|
302
|
+
# Explicitly force cursor to be redrawn if exiting scrollback mode
|
|
303
|
+
if changed
|
|
304
|
+
# Clear cursor if it exists
|
|
305
|
+
@term.clear_cursor
|
|
306
|
+
# Force draw cursor again
|
|
307
|
+
@term.draw_cursor
|
|
308
|
+
# Ensure changes are flushed
|
|
309
|
+
@buffer.draw_flush
|
|
310
|
+
@window.flush
|
|
311
|
+
end
|
|
312
|
+
return
|
|
313
|
+
when :XK_Insert # Paste primary selection
|
|
314
|
+
# FIXME: Giant hack
|
|
315
|
+
primary = `xsel -p`
|
|
316
|
+
if primary.chomp.empty?
|
|
317
|
+
primary = @primary
|
|
318
|
+
else
|
|
319
|
+
@primary = primary
|
|
320
|
+
end
|
|
321
|
+
exit_scrollback
|
|
322
|
+
@controller.paste(primary)
|
|
323
|
+
return
|
|
324
|
+
when "C"
|
|
325
|
+
# FIXME. Cstrl + shift + c
|
|
326
|
+
if str == "\x03" # Copy primary selection into clipboard
|
|
327
|
+
system("xsel -o -p | xsel -i -b")
|
|
328
|
+
return
|
|
329
|
+
end
|
|
330
|
+
when "V"
|
|
331
|
+
# FIXME. Cstrl + shift + v
|
|
332
|
+
if str == "\x16" # Paste clipboard
|
|
333
|
+
clipboard = `xsel -b`
|
|
334
|
+
if clipboard.chomp.empty?
|
|
335
|
+
clipboard = @clipboard
|
|
336
|
+
else
|
|
337
|
+
@clipboard = clipboard
|
|
338
|
+
end
|
|
339
|
+
exit_scrollback
|
|
340
|
+
@controller.paste(clipboard)
|
|
341
|
+
return
|
|
342
|
+
end
|
|
343
|
+
when :XK_Menu;
|
|
344
|
+
# FIXME: Want deskmenu here, but as long as we're not running the shell, we don't know pwd.
|
|
345
|
+
puts "FIXME: deskmenu"
|
|
346
|
+
# FIXME: In the meantime we use it as a debugging tool to force redraw.
|
|
347
|
+
redraw
|
|
348
|
+
render_text_buffer
|
|
349
|
+
end
|
|
350
|
+
payload = keysym_to_vt102(ks) || str
|
|
351
|
+
# Only snap to the live screen when we're actually sending input;
|
|
352
|
+
# bare modifiers (Ctrl/Shift) produce no payload and must not disturb
|
|
353
|
+
# scrollback (e.g. while setting up a Ctrl+Shift+C copy from history).
|
|
354
|
+
exit_scrollback unless payload.nil? || payload.empty?
|
|
355
|
+
@controller.keypress(payload)
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def blink
|
|
359
|
+
t = Time.now
|
|
360
|
+
doblink = false
|
|
361
|
+
#p [@blink_state, @rblink_state, @lastblink, @lastrblink]
|
|
362
|
+
if ((t - @lastblink)*10).to_i > 6
|
|
363
|
+
@lastblink = t
|
|
364
|
+
@blink_state = !@blink_state
|
|
365
|
+
doblink = true
|
|
366
|
+
end
|
|
367
|
+
if ((t - @lastrblink)*10).to_i >= 2
|
|
368
|
+
@lastrblink = t
|
|
369
|
+
@rblink_state = !@rblink_state
|
|
370
|
+
doblink = true
|
|
371
|
+
end
|
|
372
|
+
# FIXME: It bugs out at some point?
|
|
373
|
+
@buffer.redraw_blink if doblink
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# Sending input to the pty must snap the view back to the live screen;
|
|
377
|
+
# otherwise typed/echoed output is drawn over the scrolled-back display.
|
|
378
|
+
def exit_scrollback
|
|
379
|
+
redraw if @window.scrollback_reset
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def redraw_positions(positions) = positions.each { |pos| @buffer.redraw(*pos) }
|
|
383
|
+
|
|
384
|
+
# Re-stamp the active selection highlight on top of freshly drawn
|
|
385
|
+
# content. The selection is an overlay that is NOT stored in the buffer,
|
|
386
|
+
# so any output - or a full redraw - that repaints those cells erases the
|
|
387
|
+
# highlight. Re-applying it after each draw keeps the selection visible
|
|
388
|
+
# while a program streams output (e.g. top repainting, or a full-screen
|
|
389
|
+
# app), which a one-shot paint at mouse-time cannot do.
|
|
390
|
+
def reapply_selection
|
|
391
|
+
return unless @select_startpos && @select_endpos
|
|
392
|
+
sb = @window.scrollback_count
|
|
393
|
+
@buffer.each_character_between(@select_startpos[0]..@select_startpos[1], @select_endpos[0]..@select_endpos[1]) do |x,y,cell|
|
|
394
|
+
sy = y + sb
|
|
395
|
+
next if sy < 0 || sy >= @term.height
|
|
396
|
+
@buffer.redraw_cell_at(x, sy, cell, fg: 0xffffff, bg: 0xff00ff)
|
|
397
|
+
end
|
|
398
|
+
@buffer.draw_flush
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
# FIXME: Cursor, selection etc. are "special" overlays on top of attributes.
|
|
402
|
+
# Allow the terminal to set a set of positions + fg/bg, and a set of ranges.
|
|
403
|
+
def render_selection
|
|
404
|
+
# Selection positions are in *buffer* coordinates (negative rows are
|
|
405
|
+
# scrollback); the screen row is buffer_row + scrollback_count. Damage
|
|
406
|
+
# is tracked in screen coordinates so it can be repainted later.
|
|
407
|
+
sb = @window.scrollback_count
|
|
408
|
+
olddamage = @selection_damage || Set.new
|
|
409
|
+
@selection_damage = Set.new
|
|
410
|
+
@buffer.each_character_between(@select_startpos[0]..@select_startpos[1], @select_endpos[0]..@select_endpos[1]) do |x,y,cell|
|
|
411
|
+
sy = y + sb
|
|
412
|
+
next if sy < 0 || sy >= @term.height
|
|
413
|
+
@selection_damage << [x,sy]
|
|
414
|
+
@buffer.redraw_cell_at(x, sy, cell, fg: 0xffffff, bg: 0xff00ff)
|
|
415
|
+
end
|
|
416
|
+
# Repaint cells that left the selection with their displayed content.
|
|
417
|
+
(olddamage - @selection_damage).each { |x,sy| @buffer.redraw_display(x, sy, sb) }
|
|
418
|
+
@buffer.draw_flush
|
|
419
|
+
#@window.flush
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def get_selection
|
|
423
|
+
startpos = @select_startpos
|
|
424
|
+
endpos = @select_endpos
|
|
425
|
+
str = ""
|
|
426
|
+
ypos = nil
|
|
427
|
+
@buffer.each_character_between(startpos[0]..startpos[1], endpos[0]..endpos[1]) do |x,y,cell|
|
|
428
|
+
str += "\n" if ypos && y != ypos
|
|
429
|
+
ypos = y
|
|
430
|
+
str << (cell[0].chr(Encoding::UTF_8) rescue "")
|
|
431
|
+
end
|
|
432
|
+
str
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
def clear_selection_if_set
|
|
436
|
+
return if !@select_startpos
|
|
437
|
+
sb = @window.scrollback_count
|
|
438
|
+
(@selection_damage || []).each { |x,sy| @buffer.redraw_display(x, sy, sb) }
|
|
439
|
+
@select_startpos = nil
|
|
440
|
+
# FIXME
|
|
441
|
+
redraw
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
def handle_mouse(pkt)
|
|
445
|
+
@term.mouse_buttons = button = pkt.detail > 0 ? pkt.detail : @term.mouse_buttons
|
|
446
|
+
release = pkt.is_a?(X11::Form::ButtonRelease)
|
|
447
|
+
x = pkt.event_x / char_w
|
|
448
|
+
y = pkt.event_y / char_h
|
|
449
|
+
# Holding Shift forces a local text selection even when the
|
|
450
|
+
# application has grabbed the mouse (mouse reporting on - e.g. Claude's
|
|
451
|
+
# agent picker, or any full-screen app with clickable UI). This is the
|
|
452
|
+
# standard xterm override so you can always select/copy.
|
|
453
|
+
shift = pkt.state.anybits?(0x01) # ShiftMask
|
|
454
|
+
case shift ? nil : @term.mouse_mode
|
|
455
|
+
when nil
|
|
456
|
+
# Selection works in buffer coordinates: when scrolled back, the
|
|
457
|
+
# row under the pointer is a scrollback line (buffer row
|
|
458
|
+
# screen_y - scrollback_count, negative for scrollback). Without
|
|
459
|
+
# this, selection/copy reads the live screen instead of what is
|
|
460
|
+
# actually displayed.
|
|
461
|
+
y -= @window.scrollback_count
|
|
462
|
+
# New selection, but the old has not been cleared yet
|
|
463
|
+
if @released
|
|
464
|
+
clear_selection_if_set
|
|
465
|
+
@released = false
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
@select_startpos ||= [x,y]
|
|
469
|
+
if [x,y] != @select_endpos
|
|
470
|
+
@select_endpos = [x,y]
|
|
471
|
+
# FIXME: Optimize rendering of selection further
|
|
472
|
+
render_selection
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
if release
|
|
476
|
+
@released = true
|
|
477
|
+
if @select_startpos != @select_endpos
|
|
478
|
+
sel = get_selection
|
|
479
|
+
io = IO.popen("xsel -i", "a+")
|
|
480
|
+
io.write(sel)
|
|
481
|
+
io.close
|
|
482
|
+
else
|
|
483
|
+
clear_selection_if_set
|
|
484
|
+
end
|
|
485
|
+
end
|
|
486
|
+
when :vt200, :btn_event
|
|
487
|
+
# FIXME: This is only right for @mouse_reporting == :digits
|
|
488
|
+
# FIXME: Report modifiers.
|
|
489
|
+
# Not reporting release for scroll wheel
|
|
490
|
+
return if release && button >= 4
|
|
491
|
+
event = [0,1,2,64,65][button-1]
|
|
492
|
+
event += 32 if pkt.is_a?(X11::Form::MotionNotify)
|
|
493
|
+
#button = [0,1,2,4,5][button-1]
|
|
494
|
+
@controller.mouse_report(@term.mouse_reporting, event, x,y, release)
|
|
495
|
+
end
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
def process(pkt)
|
|
499
|
+
# p pkt
|
|
500
|
+
case pkt
|
|
501
|
+
when X11::Form::ButtonPress, X11::Form::MotionNotify, X11::Form::ButtonRelease
|
|
502
|
+
handle_mouse(pkt)
|
|
503
|
+
when X11::Form::KeyPress
|
|
504
|
+
key(pkt)
|
|
505
|
+
when X11::Form::KeyRelease,
|
|
506
|
+
X11::Form::NoExposure
|
|
507
|
+
# Intentionally ignored
|
|
508
|
+
when X11::Form::ConfigureNotify
|
|
509
|
+
# Real size change: pkt.width/height are the new window size.
|
|
510
|
+
request_redraw([pkt.width, pkt.height])
|
|
511
|
+
when X11::Form::Expose
|
|
512
|
+
# Damage, NOT a size change: pkt.width/height are the exposed
|
|
513
|
+
# rectangle, not the window. Repaint only; never resize here (that
|
|
514
|
+
# would shrink the terminal to the strip size).
|
|
515
|
+
request_redraw
|
|
516
|
+
else
|
|
517
|
+
# Other X events (MapNotify, etc.) are not acted on.
|
|
518
|
+
end
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
def event_thread
|
|
522
|
+
Thread.new do
|
|
523
|
+
loop do
|
|
524
|
+
pkt = @window.dpy.next_packet
|
|
525
|
+
process(pkt)
|
|
526
|
+
Thread.pass
|
|
527
|
+
end
|
|
528
|
+
end
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def run(args)
|
|
533
|
+
@controller = Controller.new(self, @config)
|
|
534
|
+
@controller.run(*args)
|
|
535
|
+
@term.responder = @controller
|
|
536
|
+
|
|
537
|
+
@lastblink ||= Time.now
|
|
538
|
+
@lastrblink ||= Time.now
|
|
539
|
+
|
|
540
|
+
Thread.abort_on_exception = true
|
|
541
|
+
threads =[]
|
|
542
|
+
|
|
543
|
+
threads << Thread.new do
|
|
544
|
+
loop do
|
|
545
|
+
process_queue
|
|
546
|
+
Thread.pass
|
|
547
|
+
end
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
threads << event_thread
|
|
551
|
+
|
|
552
|
+
threads << Thread.new do
|
|
553
|
+
loop do
|
|
554
|
+
sleep(0.1)
|
|
555
|
+
# Enqueue rather than calling blink directly: blink redraws and
|
|
556
|
+
# so touches the buffer, which must only be mutated on the
|
|
557
|
+
# processing thread. blink self-gates on elapsed time, so a
|
|
558
|
+
# fixed tick is fine.
|
|
559
|
+
@queue << :blink
|
|
560
|
+
end
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
# Flush on the processing thread too, so the @buf->window copy never
|
|
564
|
+
# races a concurrent buffer mutation.
|
|
565
|
+
threads << Thread.new do
|
|
566
|
+
loop do
|
|
567
|
+
@queue << :flush
|
|
568
|
+
sleep(1/30.0)
|
|
569
|
+
end
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
if ENV["DEBUG"].to_s.strip != ""
|
|
573
|
+
while cmd = STDIN.gets&.strip
|
|
574
|
+
binding.pry if cmd == "pry"
|
|
575
|
+
end
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
threads.each(&:join)
|
|
579
|
+
end
|
|
580
|
+
end
|
data/lib/rubyterm.rb
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rubyterm - a pure-Ruby X11 terminal emulator and reusable terminal engine.
|
|
4
|
+
#
|
|
5
|
+
# Requiring "rubyterm" loads the whole stack:
|
|
6
|
+
#
|
|
7
|
+
# * the terminal *engine* - escape interpreter, virtual buffer, damage
|
|
8
|
+
# tracking and the swappable backends (Term, TrackChanges, TermBuffer,
|
|
9
|
+
# AnsiBackend, BitmapWindow); usable headlessly / embedded in a TUI; and
|
|
10
|
+
# * the X11 *front end* - the Window backend, pty Controller, keymap and
|
|
11
|
+
# the RubyTerm application class that bin/rubyterm runs.
|
|
12
|
+
#
|
|
13
|
+
# The engine carries no X11 dependency itself, but this entry point pulls in
|
|
14
|
+
# the X11/skrift libraries so a single `require "rubyterm"` gives you a
|
|
15
|
+
# runnable terminal. (They are pure Ruby, so requiring them needs no running
|
|
16
|
+
# X server - only instantiating Window does.)
|
|
17
|
+
|
|
18
|
+
require "pty"
|
|
19
|
+
require "io/console"
|
|
20
|
+
require "set"
|
|
21
|
+
|
|
22
|
+
require "X11"
|
|
23
|
+
require "skrift"
|
|
24
|
+
require "skrift/x11"
|
|
25
|
+
require "toml-rb"
|
|
26
|
+
|
|
27
|
+
require_relative "rubyterm/version"
|
|
28
|
+
|
|
29
|
+
# Engine
|
|
30
|
+
require_relative "palette"
|
|
31
|
+
require_relative "charsets"
|
|
32
|
+
require_relative "escapeparser"
|
|
33
|
+
require_relative "utf8decoder"
|
|
34
|
+
require_relative "termbuffer"
|
|
35
|
+
require_relative "trackchanges"
|
|
36
|
+
require_relative "term"
|
|
37
|
+
|
|
38
|
+
# Backends
|
|
39
|
+
require_relative "ansibackend"
|
|
40
|
+
require_relative "bitmapwindow"
|
|
41
|
+
|
|
42
|
+
# X11 front end
|
|
43
|
+
require_relative "keymap"
|
|
44
|
+
require_relative "window"
|
|
45
|
+
require_relative "windowadapter"
|
|
46
|
+
require_relative "controller"
|
|
47
|
+
require_relative "rubyterm/app"
|