]> jfr.im git - irc/weechat/qweechat.git/blob - qweechat/qweechat.py
Fix the imports to support Python3 style absolute imports.
[irc/weechat/qweechat.git] / qweechat / qweechat.py
1 # -*- coding: utf-8 -*-
2 #
3 # qweechat.py - WeeChat remote GUI using Qt toolkit
4 #
5 # Copyright (C) 2011-2021 Sébastien Helleu <flashcode@flashtux.org>
6 #
7 # This file is part of QWeeChat, a Qt remote GUI for WeeChat.
8 #
9 # QWeeChat is free software; you can redistribute it and/or modify
10 # it under the terms of the GNU General Public License as published by
11 # the Free Software Foundation; either version 3 of the License, or
12 # (at your option) any later version.
13 #
14 # QWeeChat is distributed in the hope that it will be useful,
15 # but WITHOUT ANY WARRANTY; without even the implied warranty of
16 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17 # GNU General Public License for more details.
18 #
19 # You should have received a copy of the GNU General Public License
20 # along with QWeeChat. If not, see <http://www.gnu.org/licenses/>.
21 #
22
23 """
24 QWeeChat is a WeeChat remote GUI using Qt toolkit.
25
26 It requires requires WeeChat 0.3.7 or newer, running on local or remote host.
27 """
28
29 #
30 # History:
31 #
32 # 2011-05-27, Sébastien Helleu <flashcode@flashtux.org>:
33 # start dev
34 #
35
36 import sys
37 import traceback
38 from pkg_resources import resource_filename
39 # import qt_compat
40 from qweechat import config
41 from qweechat.weechat import protocol
42 from qweechat.network import Network
43 from qweechat.connection import ConnectionDialog
44 from qweechat.buffer import BufferListWidget, Buffer
45 from qweechat.debug import DebugDialog
46 from qweechat.about import AboutDialog
47 from qweechat.version import qweechat_version
48
49 from PySide6.QtWidgets import (
50 QApplication, QLabel, QPushButton, QVBoxLayout, QWidget)
51 from PySide6.QtCore import Qt, Slot
52 from PySide6 import QtGui, QtWidgets, QtCore
53
54
55 # QtCore = qt_compat.import_module('QtCore')
56 # QtGui = qt_compat.import_module('QtGui')
57
58 NAME = 'QWeeChat'
59 AUTHOR = 'Sébastien Helleu'
60 AUTHOR_MAIL = 'flashcode@flashtux.org'
61 WEECHAT_SITE = 'https://weechat.org/'
62
63 # number of lines in buffer for debug window
64 DEBUG_NUM_LINES = 50
65
66
67 class MainWindow(QtWidgets.QMainWindow):
68 """Main window."""
69
70 def __init__(self, *args):
71 super().__init__()
72
73 self.config = config.read()
74
75 self.resize(1000, 600)
76 self.setWindowTitle(NAME)
77
78 self.debug_dialog = None
79 self.debug_lines = []
80
81 self.about_dialog = None
82 self.connection_dialog = None
83 self.preferences_dialog = None
84
85 # network
86 self.network = Network()
87 self.network.statusChanged.connect(self._network_status_changed)
88 self.network.messageFromWeechat.connect(self._network_weechat_msg)
89
90 # list of buffers
91 self.list_buffers = BufferListWidget()
92 self.list_buffers.currentRowChanged.connect(self._buffer_switch)
93
94 # default buffer
95 self.buffers = [Buffer()]
96 self.stacked_buffers = QtWidgets.QStackedWidget()
97 self.stacked_buffers.addWidget(self.buffers[0].widget)
98
99 # splitter with buffers + chat/input
100 splitter = QtWidgets.QSplitter()
101 splitter.addWidget(self.list_buffers)
102 splitter.addWidget(self.stacked_buffers)
103
104 self.setCentralWidget(splitter)
105
106 if self.config.getboolean('look', 'statusbar'):
107 self.statusBar().visible = True
108
109 # actions for menu and toolbar
110 actions_def = {
111 'connect': [
112 'network-connect.png', 'Connect to WeeChat',
113 'Ctrl+O', self.open_connection_dialog],
114 'disconnect': [
115 'network-disconnect.png', 'Disconnect from WeeChat',
116 'Ctrl+D', self.network.disconnect_weechat],
117 'debug': [
118 'edit-find.png', 'Debug console window',
119 'Ctrl+B', self.open_debug_dialog],
120 'preferences': [
121 'preferences-other.png', 'Preferences',
122 'Ctrl+P', self.open_preferences_dialog],
123 'about': [
124 'help-about.png', 'About',
125 'Ctrl+H', self.open_about_dialog],
126 'save connection': [
127 'document-save.png', 'Save connection configuration',
128 'Ctrl+S', self.save_connection],
129 'quit': [
130 'application-exit.png', 'Quit application',
131 'Ctrl+Q', self.close],
132 }
133 self.actions = {}
134 for name, action in list(actions_def.items()):
135 self.actions[name] = QtGui.QAction(
136 QtGui.QIcon(
137 resource_filename(__name__, 'data/icons/%s' % action[0])),
138 name.capitalize(), self)
139 self.actions[name].setStatusTip(action[1])
140 self.actions[name].setShortcut(action[2])
141 self.actions[name].triggered.connect(action[3])
142
143 # menu
144 self.menu = self.menuBar()
145 menu_file = self.menu.addMenu('&File')
146 menu_file.addActions([self.actions['connect'],
147 self.actions['disconnect'],
148 self.actions['preferences'],
149 self.actions['save connection'],
150 self.actions['quit']])
151 menu_window = self.menu.addMenu('&Window')
152 menu_window.addAction(self.actions['debug'])
153 menu_help = self.menu.addMenu('&Help')
154 menu_help.addAction(self.actions['about'])
155 self.network_status = QtWidgets.QLabel()
156 self.network_status.setFixedHeight(20)
157 self.network_status.setFixedWidth(200)
158 self.network_status.setContentsMargins(0, 0, 10, 0)
159 self.network_status.setAlignment(QtCore.Qt.AlignRight)
160 if hasattr(self.menu, 'setCornerWidget'):
161 self.menu.setCornerWidget(self.network_status,
162 QtCore.Qt.TopRightCorner)
163 self.network_status_set(self.network.status_disconnected)
164
165 # toolbar
166 toolbar = self.addToolBar('toolBar')
167 toolbar.setToolButtonStyle(QtCore.Qt.ToolButtonTextUnderIcon)
168 toolbar.addActions([self.actions['connect'],
169 self.actions['disconnect'],
170 self.actions['debug'],
171 self.actions['preferences'],
172 self.actions['about'],
173 self.actions['quit']])
174
175 self.buffers[0].widget.input.setFocus()
176
177 # open debug dialog
178 if self.config.getboolean('look', 'debug'):
179 self.open_debug_dialog()
180
181 # auto-connect to relay
182 if self.config.getboolean('relay', 'autoconnect'):
183 self.network.connect_weechat(self.config.get('relay', 'server'),
184 self.config.get('relay', 'port'),
185 self.config.getboolean('relay',
186 'ssl'),
187 self.config.get('relay', 'password'),
188 self.config.get('relay', 'lines'))
189
190 self.show()
191
192 def _buffer_switch(self, index):
193 """Switch to a buffer."""
194 if index >= 0:
195 self.stacked_buffers.setCurrentIndex(index)
196 self.stacked_buffers.widget(index).input.setFocus()
197
198 def buffer_input(self, full_name, text):
199 """Send buffer input to WeeChat."""
200 if self.network.is_connected():
201 message = 'input %s %s\n' % (full_name, text)
202 self.network.send_to_weechat(message)
203 self.debug_display(0, '<==', message, forcecolor='#AA0000')
204
205 def open_preferences_dialog(self):
206 """Open a dialog with preferences."""
207 # TODO: implement the preferences dialog box
208 messages = ['Not yet implemented!',
209 '']
210 self.preferences_dialog = AboutDialog('Preferences', messages, self)
211
212 def save_connection(self):
213 """Save connection configuration."""
214 if self.network:
215 options = self.network.get_options()
216 for option in options.keys():
217 self.config.set('relay', option, options[option])
218
219 def debug_display(self, *args, **kwargs):
220 """Display a debug message."""
221 self.debug_lines.append((args, kwargs))
222 self.debug_lines = self.debug_lines[-DEBUG_NUM_LINES:]
223 if self.debug_dialog:
224 self.debug_dialog.chat.display(*args, **kwargs)
225
226 def open_debug_dialog(self):
227 """Open a dialog with debug messages."""
228 if not self.debug_dialog:
229 self.debug_dialog = DebugDialog(self)
230 self.debug_dialog.input.textSent.connect(
231 self.debug_input_text_sent)
232 self.debug_dialog.finished.connect(self._debug_dialog_closed)
233 self.debug_dialog.display_lines(self.debug_lines)
234 self.debug_dialog.chat.scroll_bottom()
235
236 def debug_input_text_sent(self, text):
237 """Send debug buffer input to WeeChat."""
238 if self.network.is_connected():
239 text = str(text)
240 pos = text.find(')')
241 if text.startswith('(') and pos >= 0:
242 text = '(debug_%s)%s' % (text[1:pos], text[pos+1:])
243 else:
244 text = '(debug) %s' % text
245 self.debug_display(0, '<==', text, forcecolor='#AA0000')
246 self.network.send_to_weechat(text + '\n')
247
248 def _debug_dialog_closed(self, result):
249 """Called when debug dialog is closed."""
250 self.debug_dialog = None
251
252 def open_about_dialog(self):
253 """Open a dialog with info about QWeeChat."""
254 messages = ['<b>%s</b> %s' % (NAME, qweechat_version()),
255 '&copy; 2011-2020 %s &lt;<a href="mailto:%s">%s</a>&gt;'
256 % (AUTHOR, AUTHOR_MAIL, AUTHOR_MAIL),
257 '',
258 'Running with %s' % ('PySide' if qt_compat.uses_pyside
259 else 'PyQt4'),
260 '',
261 'WeeChat site: <a href="%s">%s</a>'
262 % (WEECHAT_SITE, WEECHAT_SITE),
263 '']
264 self.about_dialog = AboutDialog(NAME, messages, self)
265
266 def open_connection_dialog(self):
267 """Open a dialog with connection settings."""
268 values = {}
269 for option in ('server', 'port', 'ssl', 'password', 'lines'):
270 values[option] = self.config.get('relay', option)
271 self.connection_dialog = ConnectionDialog(values, self)
272 self.connection_dialog.dialog_buttons.accepted.connect(
273 self.connect_weechat)
274
275 def connect_weechat(self):
276 """Connect to WeeChat."""
277 self.network.connect_weechat(
278 self.connection_dialog.fields['server'].text(),
279 self.connection_dialog.fields['port'].text(),
280 self.connection_dialog.fields['ssl'].isChecked(),
281 self.connection_dialog.fields['password'].text(),
282 int(self.connection_dialog.fields['lines'].text()))
283 self.connection_dialog.close()
284
285 def _network_status_changed(self, status, extra):
286 """Called when the network status has changed."""
287 if self.config.getboolean('look', 'statusbar'):
288 self.statusBar().showMessage(status)
289 self.debug_display(0, '', status, forcecolor='#0000AA')
290 self.network_status_set(status)
291
292 def network_status_set(self, status):
293 """Set the network status."""
294 pal = self.network_status.palette()
295 if status == self.network.status_connected:
296 pal.setColor(self.network_status.foregroundRole(),
297 QtGui.QColor('green'))
298 else:
299 pal.setColor(self.network_status.foregroundRole(),
300 QtGui.QColor('#aa0000'))
301 ssl = ' (SSL)' if status != self.network.status_disconnected \
302 and self.network.is_ssl() else ''
303 self.network_status.setPalette(pal)
304 icon = self.network.status_icon(status)
305 if icon:
306 self.network_status.setText(
307 '<img src="%s"> %s' %
308 (resource_filename(__name__, 'data/icons/%s' % icon),
309 status.capitalize() + ssl))
310 else:
311 self.network_status.setText(status.capitalize())
312 if status == self.network.status_disconnected:
313 self.actions['connect'].setEnabled(True)
314 self.actions['disconnect'].setEnabled(False)
315 else:
316 self.actions['connect'].setEnabled(False)
317 self.actions['disconnect'].setEnabled(True)
318
319 def _network_weechat_msg(self, message):
320 """Called when a message is received from WeeChat."""
321 # self.debug_display(0, '==>',
322 # 'message (%d bytes):\n%s'
323 # % (len(message),
324 # protocol.hex_and_ascii(message, 20)),
325 # forcecolor='#008800')
326 try:
327 proto = protocol.Protocol()
328 message = proto.decode(message.data())
329 if message.uncompressed:
330 self.debug_display(
331 0, '==>',
332 'message uncompressed (%d bytes):\n%s'
333 % (message.size_uncompressed,
334 protocol.hex_and_ascii(message.uncompressed, 20)),
335 forcecolor='#008800')
336 self.debug_display(0, '', 'Message: %s' % message)
337 self.parse_message(message)
338 except Exception: # noqa: E722
339 print('Error while decoding message from WeeChat:\n%s'
340 % traceback.format_exc())
341 self.network.disconnect_weechat()
342
343 def _parse_listbuffers(self, message):
344 """Parse a WeeChat with list of buffers."""
345 for obj in message.objects:
346 if obj.objtype != 'hda' or obj.value['path'][-1] != 'buffer':
347 continue
348 self.list_buffers.clear()
349 while self.stacked_buffers.count() > 0:
350 buf = self.stacked_buffers.widget(0)
351 self.stacked_buffers.removeWidget(buf)
352 self.buffers = []
353 for item in obj.value['items']:
354 buf = self.create_buffer(item)
355 self.insert_buffer(len(self.buffers), buf)
356 self.list_buffers.setCurrentRow(0)
357 self.buffers[0].widget.input.setFocus()
358
359 def _parse_line(self, message):
360 """Parse a WeeChat message with a buffer line."""
361 for obj in message.objects:
362 lines = []
363 if obj.objtype != 'hda' or obj.value['path'][-1] != 'line_data':
364 continue
365 for item in obj.value['items']:
366 if message.msgid == 'listlines':
367 ptrbuf = item['__path'][0]
368 else:
369 ptrbuf = item['buffer']
370 index = [i for i, b in enumerate(self.buffers)
371 if b.pointer() == ptrbuf]
372 if index:
373 lines.append(
374 (index[0],
375 (item['date'], item['prefix'],
376 item['message']))
377 )
378 if message.msgid == 'listlines':
379 lines.reverse()
380 for line in lines:
381 self.buffers[line[0]].widget.chat.display(*line[1])
382
383 def _parse_nicklist(self, message):
384 """Parse a WeeChat message with a buffer nicklist."""
385 buffer_refresh = {}
386 for obj in message.objects:
387 if obj.objtype != 'hda' or \
388 obj.value['path'][-1] != 'nicklist_item':
389 continue
390 group = '__root'
391 for item in obj.value['items']:
392 index = [i for i, b in enumerate(self.buffers)
393 if b.pointer() == item['__path'][0]]
394 if index:
395 if not index[0] in buffer_refresh:
396 self.buffers[index[0]].nicklist = {}
397 buffer_refresh[index[0]] = True
398 if item['group']:
399 group = item['name']
400 self.buffers[index[0]].nicklist_add_item(
401 group, item['group'], item['prefix'], item['name'],
402 item['visible'])
403 for index in buffer_refresh:
404 self.buffers[index].nicklist_refresh()
405
406 def _parse_nicklist_diff(self, message):
407 """Parse a WeeChat message with a buffer nicklist diff."""
408 buffer_refresh = {}
409 for obj in message.objects:
410 if obj.objtype != 'hda' or \
411 obj.value['path'][-1] != 'nicklist_item':
412 continue
413 group = '__root'
414 for item in obj.value['items']:
415 index = [i for i, b in enumerate(self.buffers)
416 if b.pointer() == item['__path'][0]]
417 if not index:
418 continue
419 buffer_refresh[index[0]] = True
420 if item['_diff'] == ord('^'):
421 group = item['name']
422 elif item['_diff'] == ord('+'):
423 self.buffers[index[0]].nicklist_add_item(
424 group, item['group'], item['prefix'], item['name'],
425 item['visible'])
426 elif item['_diff'] == ord('-'):
427 self.buffers[index[0]].nicklist_remove_item(
428 group, item['group'], item['name'])
429 elif item['_diff'] == ord('*'):
430 self.buffers[index[0]].nicklist_update_item(
431 group, item['group'], item['prefix'], item['name'],
432 item['visible'])
433 for index in buffer_refresh:
434 self.buffers[index].nicklist_refresh()
435
436 def _parse_buffer_opened(self, message):
437 """Parse a WeeChat message with a new buffer (opened)."""
438 for obj in message.objects:
439 if obj.objtype != 'hda' or obj.value['path'][-1] != 'buffer':
440 continue
441 for item in obj.value['items']:
442 buf = self.create_buffer(item)
443 index = self.find_buffer_index_for_insert(item['next_buffer'])
444 self.insert_buffer(index, buf)
445
446 def _parse_buffer(self, message):
447 """Parse a WeeChat message with a buffer event
448 (anything except a new buffer).
449 """
450 for obj in message.objects:
451 if obj.objtype != 'hda' or obj.value['path'][-1] != 'buffer':
452 continue
453 for item in obj.value['items']:
454 index = [i for i, b in enumerate(self.buffers)
455 if b.pointer() == item['__path'][0]]
456 if not index:
457 continue
458 index = index[0]
459 if message.msgid == '_buffer_type_changed':
460 self.buffers[index].data['type'] = item['type']
461 elif message.msgid in ('_buffer_moved', '_buffer_merged',
462 '_buffer_unmerged'):
463 buf = self.buffers[index]
464 buf.data['number'] = item['number']
465 self.remove_buffer(index)
466 index2 = self.find_buffer_index_for_insert(
467 item['next_buffer'])
468 self.insert_buffer(index2, buf)
469 elif message.msgid == '_buffer_renamed':
470 self.buffers[index].data['full_name'] = item['full_name']
471 self.buffers[index].data['short_name'] = item['short_name']
472 elif message.msgid == '_buffer_title_changed':
473 self.buffers[index].data['title'] = item['title']
474 self.buffers[index].update_title()
475 elif message.msgid == '_buffer_cleared':
476 self.buffers[index].widget.chat.clear()
477 elif message.msgid.startswith('_buffer_localvar_'):
478 self.buffers[index].data['local_variables'] = \
479 item['local_variables']
480 self.buffers[index].update_prompt()
481 elif message.msgid == '_buffer_closing':
482 self.remove_buffer(index)
483
484 def parse_message(self, message):
485 """Parse a WeeChat message."""
486 if message.msgid.startswith('debug'):
487 self.debug_display(0, '', '(debug message, ignored)')
488 elif message.msgid == 'listbuffers':
489 self._parse_listbuffers(message)
490 elif message.msgid in ('listlines', '_buffer_line_added'):
491 self._parse_line(message)
492 elif message.msgid in ('_nicklist', 'nicklist'):
493 self._parse_nicklist(message)
494 elif message.msgid == '_nicklist_diff':
495 self._parse_nicklist_diff(message)
496 elif message.msgid == '_buffer_opened':
497 self._parse_buffer_opened(message)
498 elif message.msgid.startswith('_buffer_'):
499 self._parse_buffer(message)
500 elif message.msgid == '_upgrade':
501 self.network.desync_weechat()
502 elif message.msgid == '_upgrade_ended':
503 self.network.sync_weechat()
504 else:
505 print(f"Unknown message with id {message.msgid}")
506
507 def create_buffer(self, item):
508 """Create a new buffer."""
509 buf = Buffer(item)
510 buf.bufferInput.connect(self.buffer_input)
511 buf.widget.input.bufferSwitchPrev.connect(
512 self.list_buffers.switch_prev_buffer)
513 buf.widget.input.bufferSwitchNext.connect(
514 self.list_buffers.switch_next_buffer)
515 return buf
516
517 def insert_buffer(self, index, buf):
518 """Insert a buffer in list."""
519 self.buffers.insert(index, buf)
520 self.list_buffers.insertItem(index, '%d. %s'
521 % (buf.data['number'],
522 buf.data['full_name']))
523 self.stacked_buffers.insertWidget(index, buf.widget)
524
525 def remove_buffer(self, index):
526 """Remove a buffer."""
527 if self.list_buffers.currentRow == index and index > 0:
528 self.list_buffers.setCurrentRow(index - 1)
529 self.list_buffers.takeItem(index)
530 self.stacked_buffers.removeWidget(self.stacked_buffers.widget(index))
531 self.buffers.pop(index)
532
533 def find_buffer_index_for_insert(self, next_buffer):
534 """Find position to insert a buffer in list."""
535 index = -1
536 if next_buffer == '0x0':
537 index = len(self.buffers)
538 else:
539 index = [i for i, b in enumerate(self.buffers)
540 if b.pointer() == next_buffer]
541 if index:
542 index = index[0]
543 if index < 0:
544 print('Warning: unable to find position for buffer, using end of '
545 'list by default')
546 index = len(self.buffers)
547 return index
548
549 def closeEvent(self, event):
550 """Called when QWeeChat window is closed."""
551 self.network.disconnect_weechat()
552 if self.debug_dialog:
553 self.debug_dialog.close()
554 config.write(self.config)
555 QtWidgets.QMainWindow.closeEvent(self, event)
556
557
558 app = QApplication(sys.argv)
559 app.setStyle(QtWidgets.QStyleFactory.create('Cleanlooks'))
560 app.setWindowIcon(QtGui.QIcon(
561 resource_filename(__name__, 'data/icons/weechat.png')))
562 main = MainWindow()
563 main.show()
564 sys.exit(app.exec_())