1# -*- test-case-name: twisted.manhole.ui.test.test_gtk2manhole -*- 2# Copyright (c) Twisted Matrix Laboratories. 3# See LICENSE for details. 4 5""" 6Manhole client with a GTK v2.x front-end. 7""" 8 9__version__ = '$Revision: 1.9 $'[11:-2] 10 11from twisted import copyright 12from twisted.internet import reactor 13from twisted.python import components, failure, log, util 14from twisted.python.reflect import prefixedMethodNames 15from twisted.spread import pb 16from twisted.spread.ui import gtk2util 17 18from twisted.manhole.service import IManholeClient 19from zope.interface import implements 20 21# The pygtk.require for version 2.0 has already been done by the reactor. 22import gtk 23 24import code, types, inspect 25 26# TODO: 27# Make wrap-mode a run-time option. 28# Explorer. 29# Code doesn't cleanly handle opening a second connection. Fix that. 30# Make some acknowledgement of when a command has completed, even if 31# it has no return value so it doesn't print anything to the console. 32 33class OfflineError(Exception): 34 pass 35 36class ManholeWindow(components.Componentized, gtk2util.GladeKeeper): 37 gladefile = util.sibpath(__file__, "gtk2manhole.glade") 38 39 _widgets = ('input','output','manholeWindow') 40 41 def __init__(self): 42 self.defaults = {} 43 gtk2util.GladeKeeper.__init__(self) 44 components.Componentized.__init__(self) 45 46 self.input = ConsoleInput(self._input) 47 self.input.toplevel = self 48 self.output = ConsoleOutput(self._output) 49 50 # Ugh. GladeKeeper actually isn't so good for composite objects. 51 # I want this connected to the ConsoleInput's handler, not something 52 # on this class. 53 self._input.connect("key_press_event", self.input._on_key_press_event) 54 55 def setDefaults(self, defaults): 56 self.defaults = defaults 57 58 def login(self): 59 client = self.getComponent(IManholeClient) 60 d = gtk2util.login(client, **self.defaults) 61 d.addCallback(self._cbLogin) 62 d.addCallback(client._cbLogin) 63 d.addErrback(self._ebLogin) 64 65 def _cbDisconnected(self, perspective): 66 self.output.append("%s went away. :(\n" % (perspective,), "local") 67 self._manholeWindow.set_title("Manhole") 68 69 def _cbLogin(self, perspective): 70 peer = perspective.broker.transport.getPeer() 71 self.output.append("Connected to %s\n" % (peer,), "local") 72 perspective.notifyOnDisconnect(self._cbDisconnected) 73 self._manholeWindow.set_title("Manhole - %s" % (peer)) 74 return perspective 75 76 def _ebLogin(self, reason): 77 self.output.append("Login FAILED %s\n" % (reason.value,), "exception") 78 79 def _on_aboutMenuItem_activate(self, widget, *unused): 80 import sys 81 from os import path 82 self.output.append("""\ 83a Twisted Manhole client 84 Versions: 85 %(twistedVer)s 86 Python %(pythonVer)s on %(platform)s 87 GTK %(gtkVer)s / PyGTK %(pygtkVer)s 88 %(module)s %(modVer)s 89http://twistedmatrix.com/ 90""" % {'twistedVer': copyright.longversion, 91 'pythonVer': sys.version.replace('\n', '\n '), 92 'platform': sys.platform, 93 'gtkVer': ".".join(map(str, gtk.gtk_version)), 94 'pygtkVer': ".".join(map(str, gtk.pygtk_version)), 95 'module': path.basename(__file__), 96 'modVer': __version__, 97 }, "local") 98 99 def _on_openMenuItem_activate(self, widget, userdata=None): 100 self.login() 101 102 def _on_manholeWindow_delete_event(self, widget, *unused): 103 reactor.stop() 104 105 def _on_quitMenuItem_activate(self, widget, *unused): 106 reactor.stop() 107 108 def on_reload_self_activate(self, *unused): 109 from twisted.python import rebuild 110 rebuild.rebuild(inspect.getmodule(self.__class__)) 111 112 113tagdefs = { 114 'default': {"family": "monospace"}, 115 # These are message types we get from the server. 116 'stdout': {"foreground": "black"}, 117 'stderr': {"foreground": "#AA8000"}, 118 'result': {"foreground": "blue"}, 119 'exception': {"foreground": "red"}, 120 # Messages generate locally. 121 'local': {"foreground": "#008000"}, 122 'log': {"foreground": "#000080"}, 123 'command': {"foreground": "#666666"}, 124 } 125 126# TODO: Factor Python console stuff back out to pywidgets. 127 128class ConsoleOutput: 129 _willScroll = None 130 def __init__(self, textView): 131 self.textView = textView 132 self.buffer = textView.get_buffer() 133 134 # TODO: Make this a singleton tag table. 135 for name, props in tagdefs.iteritems(): 136 tag = self.buffer.create_tag(name) 137 # This can be done in the constructor in newer pygtk (post 1.99.14) 138 for k, v in props.iteritems(): 139 tag.set_property(k, v) 140 141 self.buffer.tag_table.lookup("default").set_priority(0) 142 143 self._captureLocalLog() 144 145 def _captureLocalLog(self): 146 return log.startLogging(_Notafile(self, "log"), setStdout=False) 147 148 def append(self, text, kind=None): 149 # XXX: It seems weird to have to do this thing with always applying 150 # a 'default' tag. Can't we change the fundamental look instead? 151 tags = ["default"] 152 if kind is not None: 153 tags.append(kind) 154 155 self.buffer.insert_with_tags_by_name(self.buffer.get_end_iter(), 156 text, *tags) 157 # Silly things, the TextView needs to update itself before it knows 158 # where the bottom is. 159 if self._willScroll is None: 160 self._willScroll = gtk.idle_add(self._scrollDown) 161 162 def _scrollDown(self, *unused): 163 self.textView.scroll_to_iter(self.buffer.get_end_iter(), 0, 164 True, 1.0, 1.0) 165 self._willScroll = None 166 return False 167 168class History: 169 def __init__(self, maxhist=10000): 170 self.ringbuffer = [''] 171 self.maxhist = maxhist 172 self.histCursor = 0 173 174 def append(self, htext): 175 self.ringbuffer.insert(-1, htext) 176 if len(self.ringbuffer) > self.maxhist: 177 self.ringbuffer.pop(0) 178 self.histCursor = len(self.ringbuffer) - 1 179 self.ringbuffer[-1] = '' 180 181 def move(self, prevnext=1): 182 ''' 183 Return next/previous item in the history, stopping at top/bottom. 184 ''' 185 hcpn = self.histCursor + prevnext 186 if hcpn >= 0 and hcpn < len(self.ringbuffer): 187 self.histCursor = hcpn 188 return self.ringbuffer[hcpn] 189 else: 190 return None 191 192 def histup(self, textbuffer): 193 if self.histCursor == len(self.ringbuffer) - 1: 194 si, ei = textbuffer.get_start_iter(), textbuffer.get_end_iter() 195 self.ringbuffer[-1] = textbuffer.get_text(si,ei) 196 newtext = self.move(-1) 197 if newtext is None: 198 return 199 textbuffer.set_text(newtext) 200 201 def histdown(self, textbuffer): 202 newtext = self.move(1) 203 if newtext is None: 204 return 205 textbuffer.set_text(newtext) 206 207 208class ConsoleInput: 209 toplevel, rkeymap = None, None 210 __debug = False 211 212 def __init__(self, textView): 213 self.textView=textView 214 self.rkeymap = {} 215 self.history = History() 216 for name in prefixedMethodNames(self.__class__, "key_"): 217 keysymName = name.split("_")[-1] 218 self.rkeymap[getattr(gtk.keysyms, keysymName)] = keysymName 219 220 def _on_key_press_event(self, entry, event): 221 ksym = self.rkeymap.get(event.keyval, None) 222 223 mods = [] 224 for prefix, mask in [('ctrl', gtk.gdk.CONTROL_MASK), ('shift', gtk.gdk.SHIFT_MASK)]: 225 if event.state & mask: 226 mods.append(prefix) 227 228 if mods: 229 ksym = '_'.join(mods + [ksym]) 230 231 if ksym: 232 rvalue = getattr( 233 self, 'key_%s' % ksym, lambda *a, **kw: None)(entry, event) 234 235 if self.__debug: 236 print ksym 237 return rvalue 238 239 def getText(self): 240 buffer = self.textView.get_buffer() 241 iter1, iter2 = buffer.get_bounds() 242 text = buffer.get_text(iter1, iter2, False) 243 return text 244 245 def setText(self, text): 246 self.textView.get_buffer().set_text(text) 247 248 def key_Return(self, entry, event): 249 text = self.getText() 250 # Figure out if that Return meant "next line" or "execute." 251 try: 252 c = code.compile_command(text) 253 except SyntaxError, e: 254 # This could conceivably piss you off if the client's python 255 # doesn't accept keywords that are known to the manhole's 256 # python. 257 point = buffer.get_iter_at_line_offset(e.lineno, e.offset) 258 buffer.place(point) 259 # TODO: Componentize! 260 self.toplevel.output.append(str(e), "exception") 261 except (OverflowError, ValueError), e: 262 self.toplevel.output.append(str(e), "exception") 263 else: 264 if c is not None: 265 self.sendMessage() 266 # Don't insert Return as a newline in the buffer. 267 self.history.append(text) 268 self.clear() 269 # entry.emit_stop_by_name("key_press_event") 270 return True 271 else: 272 # not a complete code block 273 return False 274 275 return False 276 277 def key_Up(self, entry, event): 278 # if I'm at the top, previous history item. 279 textbuffer = self.textView.get_buffer() 280 if textbuffer.get_iter_at_mark(textbuffer.get_insert()).get_line() == 0: 281 self.history.histup(textbuffer) 282 return True 283 return False 284 285 def key_Down(self, entry, event): 286 textbuffer = self.textView.get_buffer() 287 if textbuffer.get_iter_at_mark(textbuffer.get_insert()).get_line() == ( 288 textbuffer.get_line_count() - 1): 289 self.history.histdown(textbuffer) 290 return True 291 return False 292 293 key_ctrl_p = key_Up 294 key_ctrl_n = key_Down 295 296 def key_ctrl_shift_F9(self, entry, event): 297 if self.__debug: 298 import pdb; pdb.set_trace() 299 300 def clear(self): 301 buffer = self.textView.get_buffer() 302 buffer.delete(*buffer.get_bounds()) 303 304 def sendMessage(self): 305 buffer = self.textView.get_buffer() 306 iter1, iter2 = buffer.get_bounds() 307 text = buffer.get_text(iter1, iter2, False) 308 self.toplevel.output.append(pythonify(text), 'command') 309 # TODO: Componentize better! 310 try: 311 return self.toplevel.getComponent(IManholeClient).do(text) 312 except OfflineError: 313 self.toplevel.output.append("Not connected, command not sent.\n", 314 "exception") 315 316 317def pythonify(text): 318 ''' 319 Make some text appear as though it was typed in at a Python prompt. 320 ''' 321 lines = text.split('\n') 322 lines[0] = '>>> ' + lines[0] 323 return '\n... '.join(lines) + '\n' 324 325class _Notafile: 326 """Curry to make failure.printTraceback work with the output widget.""" 327 def __init__(self, output, kind): 328 self.output = output 329 self.kind = kind 330 331 def write(self, txt): 332 self.output.append(txt, self.kind) 333 334 def flush(self): 335 pass 336 337class ManholeClient(components.Adapter, pb.Referenceable): 338 implements(IManholeClient) 339 340 capabilities = { 341# "Explorer": 'Set', 342 "Failure": 'Set' 343 } 344 345 def _cbLogin(self, perspective): 346 self.perspective = perspective 347 perspective.notifyOnDisconnect(self._cbDisconnected) 348 return perspective 349 350 def remote_console(self, messages): 351 for kind, content in messages: 352 if isinstance(content, types.StringTypes): 353 self.original.output.append(content, kind) 354 elif (kind == "exception") and isinstance(content, failure.Failure): 355 content.printTraceback(_Notafile(self.original.output, 356 "exception")) 357 else: 358 self.original.output.append(str(content), kind) 359 360 def remote_receiveExplorer(self, xplorer): 361 pass 362 363 def remote_listCapabilities(self): 364 return self.capabilities 365 366 def _cbDisconnected(self, perspective): 367 self.perspective = None 368 369 def do(self, text): 370 if self.perspective is None: 371 raise OfflineError 372 return self.perspective.callRemote("do", text) 373 374components.registerAdapter(ManholeClient, ManholeWindow, IManholeClient) 375