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