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