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