├── README.rdoc └── tt /README.rdoc: -------------------------------------------------------------------------------- 1 | = tt, a 9term-compatible terminal in Ruby/Tk 2 | 3 | This is tt, a very lightweight terminal emulator implemented using 4 | Ruby 1.9 with no dependencies beyond the standard libraries with Tk 5 | enabled. 6 | 7 | tt provides a very limited feature set, akin to the 9term terminal. 8 | In fact, it does just about nothing: 9 | 10 | % infocmp 9term 11 | # Reconstructed via infocmp from file: /usr/share/terminfo/9/9term 12 | 9term|Plan9 terminal emulator for X, 13 | am, 14 | bel=^G, cud1=^J, 15 | 16 | This means you will not be able to run vi or other curses software. 17 | Also, there is no support for ANSI color sequences. 18 | Note that "tt" is only two thirds of a real "tty". 19 | 20 | However, tt also has some benefits over classic vt100-compatible 21 | terminal emulators: 22 | 23 | * You can fully edit the buffer, fix commands there and resend them 24 | (with middle-button-swipe) or add comments. 25 | 26 | * By default, scroll mode is enabled so it feels like an ordinary 27 | terminal. If you disable scroll mode, commands displaying more 28 | output than fits on the screen will be scrolled no further; use 29 | PageUp/PageDown keys to scroll yourself. 30 | 31 | * You can easily search in the buffer. Select text, right-click and 32 | select Fwd or Bwd. The context menu will remember your last action, 33 | so searching again works like a breeze. 34 | 35 | * The last two features more or less replace more(1) or less(1). 36 | 37 | * There is a hold-buffer which you can pull out from the bottom 38 | border: Edit text here as you wish and send it with one press of 39 | Shift-Return to the terminal at once. This makes ed(1) or mail(1) 40 | way more fun to use. 41 | 42 | * There is a command bar which you can pull out from the top border 43 | (or prefill with the -border argument): Insert comands there and either 44 | middle-click on single words or middle-button-swipe a selection to 45 | run them in the terminal. This is nice for frequent users of 46 | make(1) as well as users of MH (just put next/prev/etc. there). 47 | 48 | * URLs are clickable and open in your browser if you left-click them. 49 | 50 | * You can use non-proportional fonts. 51 | 52 | == Tips and tricks 53 | 54 | Text is only ever appended at the end of the buffer. However, tt 55 | tries to make sense of carriage-returns, so wget etc. will show proper 56 | progress bars. 57 | 58 | Old-school line discipline editing with Backspace, C-w (delete last 59 | word) and C-u (delete all input) works well; readline editing of 60 | course doesn't (most tab-completion systems will work, though). 61 | 62 | If your cursor is not at the end of buffer, tt should behave like a 63 | simple text editor with Emacs-resembling keybindings. 64 | Home and End keys always move to the very top and very end of buffer. 65 | 66 | You can set the tt window title with an xterm-compatible escape sequence, 67 | for example using: 68 | 69 | label() { 70 | echo "$@" | awk '{printf("\033];%s\007", $0);}' >/dev/tty 71 | } 72 | 73 | There is a "ziconbeep" feature, that is, if there is any output while 74 | the window is iconfied, the window title is prefixed with three stars. 75 | (This has nothing much to do with what ziconbeep originally meant.) 76 | 77 | == Sweep commands 78 | 79 | If you sweep these commands either in the menu bar or the main window, 80 | they trigger special options: 81 | 82 | Font toggles the font between fixed and proportional 83 | Font FNT sets the font to FNT 84 | Scroll toggles autoscroll 85 | Cut deletes the current selection, keeping it as primary selection 86 | Paste pastes the primary selection at *insertion point* 87 | Send sends the current selection to the terminal 88 | Clear clears the terminal 89 | Fwd searches for the current selection 90 | Fwd STR searches for STR 91 | /STR searches for STR 92 | Bwd searches backward for the current selection 93 | Bwd STR searches backward for STR 94 | ?STR searches backward for STR 95 | Intr sends SIGINT to the terminal 96 | Quit exits tt 97 | 98 | All other swept text is sent to the terminal. 99 | 100 | The commands Fwd, Bwd, Cut, Paste, Send, Clear, Font, Scroll also can 101 | be run from the right-button context menu. 102 | 103 | == Copying 104 | 105 | Written by Christian Neukirchen in 2012. 106 | 107 | To the extent possible under law, Christian Neukirchen has waived 108 | all copyright and related or neighboring rights to this work. 109 | 110 | http://creativecommons.org/publicdomain/zero/1.0/ 111 | -------------------------------------------------------------------------------- /tt: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # tt - a 9term-compatible terminal in Ruby/Tk. 3 | 4 | # Written by Christian Neukirchen in 2012. 5 | # To the extent possible under law, Christian Neukirchen has waived 6 | # all copyright and related or neighboring rights to this work. 7 | # http://creativecommons.org/publicdomain/zero/1.0/ 8 | 9 | require 'tk' 10 | require 'pty' 11 | 12 | Thread.abort_on_exception = true 13 | 14 | SIGINT = 2 15 | case RUBY_PLATFORM 16 | when /linux/ 17 | TIOCSWINSZ = 0x5414 18 | TIOCSIG = 0x40045436 19 | else 20 | TIOCSWINSZ = nil 21 | TIOCSIG = nil 22 | end 23 | 24 | DEFAULT_FONT = '-misc-fixed-bold-r-normal--15-140-75-75-c-90-iso8859-1' # 9x15bold 25 | ALTERNATE_FONT = '-b&h-lucida-medium-r-normal-*-12-120-75-75-p-71-iso10646-1' 26 | 27 | class TT 28 | VERSION = '0.1' 29 | 30 | def initialize(opts={}) 31 | @opts = opts 32 | 33 | @root = TkRoot.new { 34 | geometry(opts["geom"]) if opts["geom"] 35 | } 36 | self.title = opts["title"] 37 | @root.bind("Map") { @mapped = true; self.title = self.title } 38 | @root.bind("Unmap") { @mapped = false } 39 | 40 | if @opts["sb"] 41 | @yscroll = TkScrollbar.new(@root) { 42 | pack 'side' => 'left', 'fill' => 'y' 43 | } 44 | end 45 | 46 | @scroll = TkVariable.new 47 | @scroll.value = opts["sc"] 48 | @mono = TkVariable.new 49 | @mono.value = opts["fw"] 50 | 51 | @panes = TkPanedwindow.new(@root) { 52 | orient 'vertical' 53 | } 54 | 55 | @bar = TkText.new(@panes) { 56 | height opts["bar"].to_s.count("\n") + 1 57 | foreground opts["fg"] 58 | background opts["bg"] 59 | insertbackground opts["cr"] || opts["fg"] 60 | insertofftime 0 # don't blink 61 | tabstyle 'wordprocessor' 62 | pack 'fill' => 'both', 'expand' => 'yes' 63 | } 64 | 65 | @bar.insert 'end', opts["bar"] 66 | 67 | @bar.tag_configure 'run', 'underline' => true 68 | 69 | @bar.bind('B2-Motion') { |ev| 70 | @bar.tag_remove 'run', '0.0', 'end' 71 | @bar.tag_add 'run', 'run', "@#{ev.x},#{ev.y}" 72 | @bar.tag_add 'run', "@#{ev.x},#{ev.y}", 'run' 73 | Tk.callback_break 74 | } 75 | 76 | @bar.bind('ButtonPress-2') { |ev| 77 | @bar.tag_remove 'run', '0.0', 'end' 78 | @bar.mark_set 'run', "@#{ev.x},#{ev.y}" 79 | } 80 | 81 | @bar.bind('ButtonRelease-2') { |ev| 82 | a, b = @bar.tag_ranges('run').first 83 | if a.nil? # use single word 84 | cmd = @bar.get("@#{ev.x},#{ev.y} wordstart", 85 | "@#{ev.x},#{ev.y} wordend") 86 | else 87 | cmd = @bar.get(a, b) 88 | end 89 | 90 | bar_cmd cmd 91 | 92 | @bar.tag_remove 'run', '0.0', 'end' 93 | Tk.callback_break 94 | } 95 | 96 | # XXX provide text via socket? 97 | @text = TkText.new(@panes) { 98 | yscrollbar @yscroll if opts["sb"] 99 | height opts["height"] 100 | width opts["width"] 101 | foreground opts["fg"] 102 | background opts["bg"] 103 | insertbackground opts["cr"] || opts["fg"] 104 | insertofftime 0 # don't blink 105 | tabstyle 'wordprocessor' 106 | pack 'fill' => 'both', 'expand' => 'yes' 107 | } 108 | 109 | @text.bind("KeyPress") { |o| 110 | case o.keysym 111 | when "Next" 112 | # Put cursor at end on last Next. 113 | if @text.yview[1] > 0.99999 114 | @text.mark_set 'insert', 'end' 115 | end 116 | # PASSTHRU, no callback_break! 117 | when "Prior", "Up", "Down", "Left", "Right" 118 | # XXX good idea left/right? 119 | # PASSTHRU, no callback_break! 120 | when "Home" 121 | @text.yview_moveto 0 122 | @text.mark_set('insert', '1.0') 123 | Tk.callback_break 124 | when "End" 125 | @text.yview_moveto 1 126 | @text.mark_set('insert', 'end') 127 | Tk.callback_break 128 | else 129 | # are we at the end? 130 | if @text.index('end') == @text.index('insert + 1 chars') 131 | @output.write o.char 132 | @text.see('end') 133 | @text.mark_set('lastinsert', 'insert') 134 | Tk.callback_break 135 | elsif o.char == ?\C-w 136 | # XXX skip whitespace first 137 | @text.delete('insert - 1c wordstart', 'insert') 138 | elsif o.char == ?\C-u 139 | @text.delete('insert linestart', 'insert') 140 | else 141 | # PASSTHRU, no callback_break! 142 | end 143 | end 144 | 145 | } 146 | 147 | @text.bind('B2-Motion') { |ev| 148 | @text.tag_remove 'run', '0.0', 'end' 149 | @text.tag_add 'run', 'run', "@#{ev.x},#{ev.y}" 150 | @text.tag_add 'run', "@#{ev.x},#{ev.y}", 'run' 151 | Tk.callback_break 152 | } 153 | 154 | @text.bind('ButtonPress-2') { |ev| 155 | @text.tag_remove 'run', '0.0', 'end' 156 | @text.mark_set 'run', "@#{ev.x},#{ev.y}" 157 | } 158 | 159 | @text.bind('ButtonRelease-2') { |ev| 160 | a, b = @text.tag_ranges('run').first 161 | if a.nil? # no selection 162 | paste_callback(nil) 163 | else 164 | cmd = @text.get(a, b) 165 | end 166 | 167 | bar_cmd cmd 168 | 169 | @text.tag_remove 'run', '0.0', 'end' 170 | Tk.callback_break 171 | } 172 | 173 | @text.tag_configure "href", :underline => true 174 | @text.tag_configure "run", :underline => true 175 | 176 | @text.mark_set 'lastinsert', '1.0' 177 | @text.mark_gravity 'lastinsert', 'left' 178 | 179 | @hold = TkText.new(@panes) { 180 | foreground opts["fg"] 181 | background opts["bg"] 182 | insertbackground opts["cr"] || opts["fg"] 183 | insertofftime 0 # don't blink 184 | tabstyle 'wordprocessor' 185 | pack 'fill' => 'both', 'expand' => 'yes' 186 | } 187 | @hold.bind("Shift-Return") { 188 | @text.mark_set('insert', 'end') 189 | @output.write @hold.get('1.0', 'end') 190 | @text.mark_set('insert', 'end') 191 | @text.mark_set('lastinsert', 'insert') 192 | @hold.delete('1.0', 'end') 193 | Tk.callback_break 194 | } 195 | 196 | if opts["bar"] 197 | @panes.add @bar, :stretch => 'never' 198 | else 199 | @panes.add @bar, :stretch => 'never', :height => 1 200 | end 201 | @panes.add @text, :stretch => 'always' 202 | @panes.add @hold, :stretch => 'never', :height => 1 203 | @panes.pack 'fill' => 'both', 'expand' => 'yes' 204 | 205 | font_cmd 206 | 207 | menu = TkMenu.new(@root) { tearoff false } 208 | 209 | menu.add_command :label => "fwd", :command => method(:fwd_cmd) 210 | menu.add_command :label => "bwd", :command => method(:bwd_cmd) 211 | menu.add_command :label => "cut", :command => method(:cut_cmd) 212 | menu.add_command :label => "paste", :command => method(:paste_cmd) 213 | menu.add_command :label => "send", :command => method(:send_cmd) 214 | menu.add_command :label => "clear", :command => method(:clear_cmd) 215 | menu.add_checkbutton :label => "mono", :variable => @mono, :command => method(:font_cmd) 216 | menu.add_checkbutton :label => "scroll", :variable => @scroll 217 | @lastcmd = 0 218 | 219 | # hacky 220 | menufontheight = TkFont.metrics("TkMenuFont", "linespace")+5 221 | @root.bind 'Button-3', lambda { |ev| 222 | menu.popup(ev.x_root-10,ev.y_root-10-@lastcmd*menufontheight) 223 | } 224 | 225 | if TIOCSWINSZ 226 | pw = ph = 0 227 | timer = nil 228 | @root.bind('Configure') { |o| 229 | if @pid 230 | unless pw == o.width && ph == o.height 231 | pw = o.width 232 | ph = o.height 233 | 234 | # wait 500ms before setting terminal size to avoid stutter 235 | timer ||= TkTimer.start(500) { 236 | w = @text.winfo_width / @font_width 237 | h = @text.winfo_height / @font_height 238 | 239 | @output.ioctl(TIOCSWINSZ, [h, w, ph, pw].pack('SSSS')) 240 | 241 | timer.cancel 242 | timer = nil 243 | } 244 | end 245 | end 246 | } 247 | end 248 | 249 | @text.focus 250 | end 251 | 252 | def paste_callback(event) 253 | sel = TkSelection.get(:type => "UTF8_STRING") rescue "" 254 | @text.mark_set('insert', 'end') 255 | @output.write sel 256 | @text.mark_set('insert', 'end') 257 | @text.mark_set('lastinsert', 'insert') 258 | Tk.callback_break 259 | end 260 | 261 | attr_reader :font 262 | def font=(font) 263 | @bar.font = font 264 | @text.font = font 265 | @hold.font = font 266 | @font = font 267 | @font_height = TkFont.metrics(font, "linespace") 268 | @font_width = TkFont.measure(font, "0") 269 | end 270 | 271 | attr_reader :title 272 | def title=(title) 273 | if @opts["zi"] && !@mapped 274 | if @oldtitle != "*** #{title}" 275 | @root.title = "*** #{title}" 276 | end 277 | else 278 | if @oldtitle != title 279 | @root.title = title 280 | end 281 | end 282 | @oldtitle = @root.title 283 | @title = title 284 | end 285 | 286 | # hack 287 | def fix_utf8(buf) 288 | buf.force_encoding("UTF-8") 289 | buf.encode!("UTF-16", :invalid => :replace, :undef => :replace) 290 | buf.encode!("UTF-8") 291 | buf 292 | end 293 | 294 | URL_RE = %r{((?:https?://|ftp://|news://|mailto:|file://|\bwww\.)[a-zA-Z0-9\-\@;\/?:&=%\$_.+!*\x27,~#]*(?:\([a-zA-Z0-9\-\@;\/?:&=%\$_.+!*\x27,~#]*\)|[a-zA-Z0-9\-\@;\/?:&=%\$_+*~])+)} 295 | 296 | def run(command=nil) 297 | command ||= @opts["cmd"] 298 | ENV["TERM"] = @opts["tn"] 299 | ENV["WINDOWID"] = (Integer(Tk::Wm.frame(@root)) + 1).to_s # ugh 300 | @input, @output, @pid = PTY.spawn(*command) 301 | 302 | Thread.new { 303 | begin 304 | while buf = @input.readpartial(4096) 305 | # Try to sanitize CRLF. 306 | buf = fix_utf8(buf).gsub(/\r+\n/, "\n").gsub(/\r {10,}\r/, "") 307 | 308 | out = "" 309 | buf.each_char { |char| 310 | if char == "\b" 311 | if out.empty? 312 | @text.delete('end - 2 chars') 313 | else 314 | out.chop! 315 | end 316 | elsif char == "\r" 317 | if out.empty? 318 | # delete to beginning of line 319 | @text.delete('end - 1c linestart', 'end - 1c') 320 | end 321 | # else ignore them 322 | else 323 | out << char 324 | end 325 | } 326 | 327 | out.gsub!(/\033\];(.*?)\007/) { 328 | self.title = $1 329 | "" 330 | } 331 | 332 | if @opts["cu"] 333 | out.split(URL_RE).each { |piece| 334 | if piece =~ URL_RE 335 | @text.insert 'end', piece, "href" 336 | @text.tag_bind "href", "1", method(:link_action) 337 | else 338 | @text.insert 'end', piece 339 | end 340 | } 341 | else 342 | @text.insert 'end', out 343 | end 344 | 345 | self.title = self.title 346 | 347 | if @scroll.value == "1" 348 | @text.yview_moveto 1 349 | else 350 | @text.yview 'lastinsert' 351 | end 352 | end 353 | rescue Errno::EIO 354 | # XXX too late now anyway? 355 | exit 356 | end 357 | } 358 | end 359 | 360 | def fwd_cmd 361 | @lastcmd = 0 362 | sel = @text.get('sel.first', 'sel.last') rescue return 363 | if match = @text.tksearch(sel, 'sel.last', 'end') 364 | @text.tag_remove 'sel', 'sel.first', 'sel.last' 365 | @text.tag_add 'sel', match, "#{match} + #{sel.size} chars" 366 | @text.mark_set('insert', 'sel.first') 367 | @text.see('insert') 368 | end 369 | end 370 | 371 | def bwd_cmd 372 | @lastcmd = 1 373 | sel = @text.get('sel.first', 'sel.last') rescue return 374 | if match = @text.tksearch(['backwards'], sel, 'sel.first', '1.0') 375 | @text.tag_remove 'sel', 'sel.first', 'sel.last' 376 | @text.tag_add 'sel', match, "#{match} + #{sel.size} chars" 377 | @text.mark_set('insert', 'sel.first') 378 | @text.see('insert') 379 | end 380 | end 381 | 382 | def cut_cmd 383 | @lastcmd = 2 384 | sel = @text.get('sel.first', 'sel.last') rescue return 385 | @text.delete('sel.first', 'sel.last') 386 | 387 | TkSelection.clear 388 | TkSelection.handle(Tk.root, :selection => "PRIMARY", 389 | :type => "UTF8_STRING") { sel } 390 | TkSelection.set_owner(Tk.root, :selection => "PRIMARY") 391 | end 392 | 393 | def paste_cmd 394 | @lastcmd = 3 395 | sel = TkSelection.get(:type => "UTF8_STRING") rescue "" 396 | @text.insert('insert', sel) 397 | end 398 | 399 | def send_cmd 400 | @lastcmd = 4 401 | sel = @text.get('sel.first', 'sel.last') rescue return 402 | sel << "\n" unless sel[-1] == "\n" 403 | @text.mark_set('insert', 'end') 404 | @output.write sel 405 | @text.mark_set('insert', 'end') 406 | @text.mark_set('lastinsert', 'insert') 407 | end 408 | 409 | def clear_cmd 410 | @lastcmd = 5 411 | @text.delete('1.0', 'end') 412 | end 413 | 414 | def font_cmd 415 | if @mono.value == "1" 416 | self.font = @opts["fn"] 417 | else 418 | self.font = @opts["af"] 419 | end 420 | end 421 | 422 | def bar_cmd(cmd) 423 | case cmd.lstrip 424 | when "Font" 425 | @mono.value = 1 - @mono.value.to_i 426 | font_cmd 427 | when /\AFont / 428 | begin 429 | self.font = $' 430 | rescue 431 | end 432 | when "Scroll" 433 | @scroll.value = 1 - @scroll.value.to_i 434 | when "Paste" 435 | paste_cmd 436 | when "Cut" 437 | cut_cmd 438 | when "Fwd" 439 | fwd_cmd 440 | when "Bwd" 441 | bwd_cmd 442 | when "Send" 443 | send_cmd 444 | when "Clear" 445 | clear_cmd 446 | when "Intr" 447 | @input.ioctl(TIOCSIG, 2) 448 | when /\A(\/|Fwd )/ 449 | if match = @text.tksearch($', 'insert + 1c', 'end') 450 | @text.tag_remove 'sel', 'sel.first', 'sel.last' rescue nil 451 | @text.tag_add 'sel', match, "#{match} + #{$'.size} chars" 452 | @text.mark_set('insert', 'sel.first') 453 | @text.see('insert') 454 | end 455 | when /\A(\?|Bwd )/ 456 | if match = @text.tksearch(['backwards'], $', 'insert', '1.0') 457 | @text.tag_remove 'sel', 'sel.first', 'sel.last' rescue nil 458 | @text.tag_add 'sel', match, "#{match} + #{$'.size} chars" 459 | @text.mark_set('insert', 'sel.first') 460 | @text.see('insert') 461 | end 462 | when "Quit" 463 | exit 464 | else 465 | @output.write cmd + "\n" 466 | @text.mark_set('insert', 'end') 467 | @text.mark_set('lastinsert', 'insert') 468 | end 469 | end 470 | 471 | def link_action(ev) 472 | a, b = @text.tag_prevrange("href", @text.index("@#{ev.x},#{ev.y}")) 473 | url = @text.get(a, b) 474 | system @opts["browser"], url 475 | end 476 | end 477 | 478 | opts = { 479 | "sb" => true, 480 | "fw" => true, 481 | "sc" => true, 482 | "cu" => true, 483 | "zi" => true, 484 | "bg" => "#ffffff", 485 | "fg" => "#000000", 486 | "fn" => DEFAULT_FONT, 487 | "af" => ALTERNATE_FONT, 488 | "browser" => "xdg-open", 489 | "width" => "80", 490 | "height" => "24", 491 | "tn" => "9term", 492 | "title" => "tt", 493 | "cmd" => [ENV["SHELL"] || "/bin/sh"], 494 | "bar" => nil, 495 | } 496 | until ARGV.empty? 497 | case arg = ARGV.shift 498 | when /\A([+-])(sb|sc|fw|cu|zi)\z/ 499 | opts[$2] = ($1 == "-") 500 | when /\A-(fn|af|tn|fg|bg|cr|title|browser|bar)\z/ 501 | opts[$1] = ARGV.shift 502 | when /\A-(g(eometry)?)\z/ 503 | g = ARGV.shift 504 | if g =~ /\A(\d+x\d+)?([+-]\d+[+-]\d+)?\z/ 505 | opts["width"], opts["height"] = $1.split("x") if $1 506 | opts["geom"] = $2 507 | else 508 | abort "invalid geometry #{g}" 509 | end 510 | when "-e" 511 | opts["cmd"] = ARGV 512 | break 513 | when "-h", "-help", "--help" 514 | puts "tt #{TT::VERSION}" 515 | puts "Usage: tt [-help] [--help] 516 | [-tn string] [-geometry geometry] [-/+fw] [-/+sb] [-/+sc] [-/+zi] 517 | [-bg color] [-fg color] [-cr color] [-fn fontname] [-af fontname] 518 | [-bar string] [-browser string] [-title string] [-e command arg...]" 519 | exit 520 | else 521 | abort "invalid argument #{arg}" 522 | end 523 | end 524 | 525 | t = TT.new(opts) 526 | t.run 527 | Tk.mainloop 528 | --------------------------------------------------------------------------------