Daniel Brötzmann pushed to branch pinned-chat-dnd at gajim / gajim
Commits: e4245494 by lovetox at 2022-08-20T14:07:50+02:00 cq: Settings: Add overload for workspace_order setting
- - - - - a0d69074 by lovetox at 2022-08-20T14:10:53+02:00 cq: Avatar: Respect AvatarCacheT when setting values
- - - - - 801bec06 by lovetox at 2022-08-20T14:11:20+02:00 cq: Update pyrightconfig.json
- - - - - 923a07f4 by lovetox at 2022-08-20T14:19:10+02:00 refactor: WorkspaceSidebar: Move building menu to menu module
- - - - - 90c796c5 by lovetox at 2022-08-20T15:41:21+02:00 imprv: WorkspaceSidebar: Allow removing workspace from context menu
- - - - - 287ef4f7 by lovetox at 2022-08-20T20:06:32+02:00 cq: Use correct cairo.Surface type
- - - - - b14c590d by lovetox at 2022-08-20T20:23:07+02:00 cq: Avatar: Fix type check errors
- - - - - 51ab5fe8 by lovetox at 2022-08-20T21:03:52+02:00 cq: Sort includes in pyrightconfig.json
- - - - - 18ac2084 by lovetox at 2022-08-20T23:36:28+02:00 refactor: Commands: Raise singals instead of exceptions
- Raise signals instead of exceptions - ChatStack: Connect directly to command signals - Add /help command
- - - - - 12669c51 by lovetox at 2022-08-21T00:28:36+02:00 fix: Don’t reset setting when closing single chat
- confirm_close_muc was reset to False whenever a single chat was closed - Refactor code
- - - - - ae699c18 by lovetox at 2022-08-21T11:29:53+02:00 refactor: ChatBanner: Use asserts instead of return
There is no case where client or contact should be None
- - - - - 32c0d3e3 by lovetox at 2022-08-21T11:42:29+02:00 refactor: Chatstates: Fix TODOs and refactor checks for different contacts
- Lower logging level to DEBUG - Don’t send chatstates to unsubscribed contacts
- - - - - ca5f740c by lovetox at 2022-08-21T11:57:39+02:00 change: Remove IPython support
- - - - - afdee60e by lovetox at 2022-08-21T15:55:46+02:00 Initial
- - - - - 585107ce by wurstsalat at 2022-08-21T15:55:46+02:00 feature: ChatList: Add drag and drop for pinned chats
- - - - -
27 changed files:
- data/gajim-remote.1 - gajim/common/app.py - gajim/common/commands.py - gajim/common/modules/chatstates.py - gajim/common/setting_values.py - gajim/common/settings.py - gajim/common/types.py - gajim/data/other/shortcuts.json - − gajim/dev/__init__.py - − gajim/dev/ipython_view.py - gajim/gtk/application.py - gajim/gtk/avatar.py - gajim/gtk/avatar_selector.py - gajim/gtk/chat_banner.py - gajim/gtk/chat_list.py - gajim/gtk/chat_list_stack.py - gajim/gtk/chat_page.py - gajim/gtk/chat_stack.py - gajim/gtk/const.py - gajim/gtk/main.py - gajim/gtk/menus.py - gajim/gtk/message_actions_box.py - gajim/gtk/profile.py - gajim/gtk/util.py - gajim/gtk/workspace_side_bar.py - gajim/gui_interface.py - pyrightconfig.json
Changes:
===================================== data/gajim-remote.1 ===================================== @@ -53,8 +53,6 @@ Sends custom XML Changes the priority of account or accounts .Ss show_next_pending_event Pops up a window with the next pending event -.Ss toggle_ipython -Shows or hides the ipython window .Ss toggle_roster_appearance Shows or hides the roster window .Sh EXAMPLES
===================================== gajim/common/app.py ===================================== @@ -75,7 +75,6 @@ connections: dict[str, types.Client] = {} avatar_cache: dict[str, dict[str, Any]] = {} bob_cache: dict[str, bytes] = {} -ipython_window = None app = None # type: GajimApplication window = None # type: MainWindow commands = None # type: ChatCommands
===================================== gajim/common/commands.py ===================================== @@ -52,12 +52,15 @@ def split_argument_string(string: str) -> list[str]: return result
-def get_usage_from_command(cmd: argparse.ArgumentParser) -> str: +def get_usage_from_command(cmd: argparse.ArgumentParser, + command_name: str + ) -> str: + with io.StringIO() as output: cmd.print_usage(file=output) usage = output.getvalue()
- usage = usage.split('[-h] ')[1] + usage = usage.split(f'ChatCommands {command_name}')[1] return usage.strip()
@@ -69,7 +72,7 @@ class CommandError(Exception): pass
-class CommandNotFoundError(Exception): +class CommandFailed(Exception): pass
@@ -82,7 +85,7 @@ class ChatCommands(Observable): def __init__(self) -> None: Observable.__init__(self) self._parser = ArgumentParser(prog='ChatCommands') - self._sub_parser = self._parser.add_subparsers() + self._sub_parser = self._parser.add_subparsers(title='Commands') self._commands: dict[str, tuple[list[str], str]] = {}
self._create_commands() @@ -95,6 +98,16 @@ def get_commands(self, used_in: str) -> list[tuple[str, str]]: commands.sort(key=operator.itemgetter(0)) return commands
+ def _generate_help(self, used_in: str) -> str: + help_text = 'Commands:\n\n' + commands = self.get_commands(used_in) + for command in commands: + name, usage = command + if name == 'help': + continue + help_text += f' {name:<15} {usage}\n' + return help_text + def make_parser(self, command_name: str, callback: Callable[..., Any], @@ -104,9 +117,11 @@ def make_parser(self, it with the command name. '''
- parser = self._sub_parser.add_parser(command_name, **kwargs) - parser.set_defaults(command_name=command_name) - self.connect(command_name, callback) + parser = self._sub_parser.add_parser(command_name, + add_help=False, + **kwargs) + parser.set_defaults(command_name=command_name, + exec=callback) return parser
def add_command(self, @@ -115,7 +130,7 @@ def add_command(self, cmd: argparse.ArgumentParser ) -> None:
- usage = get_usage_from_command(cmd) + usage = get_usage_from_command(cmd, command_name) self._commands[command_name] = (used_in, usage)
def parse(self, type_: str, arg_string: str) -> None: @@ -124,13 +139,33 @@ def parse(self, type_: str, arg_string: str) -> None: command_name = arg_list[0] command = self._commands.get(command_name) if command is None or type_ not in command[0]: - raise CommandNotFoundError(_('Unknown command: %s' % command_name)) + self.notify('command-not-found', + _('Unknown command: %s' % command_name)) + raise CommandFailed + + try: + args = self._parser.parse_args(arg_list) + except ArgumentParserError as error: + self.notify('command-error', str(error)) + raise CommandFailed + + args.used_in = type_ + + try: + result = args.exec(args) + except CommandError as error: + self.notify('command-error', str(error)) + raise CommandFailed
- args = self._parser.parse_args(arg_list) + if result is None: + result = _('Command executed successfully')
- self.notify(args.command_name, args) + self.notify('command-result', result)
def _create_commands(self) -> None: + parser = self.make_parser('help', self._help_command) + self.add_command('help', ['chat', 'groupchat', 'pm'], parser) + parser = self.make_parser('status', self._status_command) parser.add_argument('status', choices=['online', 'away', 'xa', 'dnd']) @@ -164,12 +199,10 @@ def _create_commands(self) -> None: choices=['moderator', 'participant', 'visitor']) self.add_command('role', ['groupchat'], parser)
- def _status_command(self, - chat_commands: Any, - signal_name: str, - args: Any - ) -> None: + def _help_command(self, args: Any) -> str: + return self._generate_help(args.used_in)
+ def _status_command(self, args: Any) -> None: for client in app.get_clients(): if not app.settings.get_account_setting(client.account, 'sync_with_global_status'): @@ -189,12 +222,7 @@ def _check_if_joined(self) -> GroupchatContact: 'joined this group chat')) return contact
- def _invite_command(self, - chat_commands: Any, - signal_name: str, - args: Any - ) -> None: - + def _invite_command(self, args: Any) -> None: contact = self._check_if_joined()
try: @@ -242,20 +270,10 @@ def _change_affiliation(self, {jid: {'affiliation': affiliation, 'reason': reason}})
- def _ban_command(self, - chat_commands: Any, - signal_name: str, - args: Any - ) -> None: - + def _ban_command(self, args: Any) -> None: self._change_affiliation(args.who, 'outcast', args.reason)
- def _affiliate_command(self, - chat_commands: Any, - signal_name: str, - args: Any - ) -> None: - + def _affiliate_command(self, args: Any) -> None: self._change_affiliation(args.who, args.affiliation, None)
def _change_role(self, nick: str, role: str, reason: Optional[str]) -> None: @@ -278,18 +296,8 @@ def _change_role(self, nick: str, role: str, reason: Optional[str]) -> None: role, reason)
- def _kick_command(self, - chat_commands: Any, - signal_name: str, - args: Any - ) -> None: - + def _kick_command(self, args: Any) -> None: self._change_role(args.who, 'none', args.reason)
- def _role_command(self, - chat_commands: Any, - signal_name: str, - args: Any - ) -> None: - + def _role_command(self, args: Any) -> None: self._change_role(args.who, args.role, None)
===================================== gajim/common/modules/chatstates.py ===================================== @@ -36,6 +36,8 @@ from gajim.common.const import ClientState from gajim.common.structs import OutgoingMessage from gajim.common.modules.base import BaseModule +from gajim.common.modules.contacts import GroupchatParticipant +from gajim.common.modules.contacts import BareContact
INACTIVE_AFTER = 60 PAUSED_AFTER = 10 @@ -270,9 +272,9 @@ def set_chatstate(self, contact: types.ChatContactT, state: State) -> None: if setting == 'disabled': # Send a last 'active' state after user disabled chatstates if current_state is not None: - self._log.info('Disabled for %s', contact.jid) + self._log.info('Disabled for %s', contact) self._log.info('Send last state: %-10s - %s', - State.ACTIVE, contact.jid) + State.ACTIVE, contact)
self._send_chatstate(contact, State.ACTIVE)
@@ -281,22 +283,23 @@ def set_chatstate(self, contact: types.ChatContactT, state: State) -> None: self._last_keyboard_activity.pop(contact.jid, None) return
- if not contact.is_groupchat: - # Don’t leak presence to contacts - # which are not allowed to see our status - if not contact.is_pm_contact: - # TODO - # if contact and contact.sub in ('to', 'none'): - # self._log.info('Contact not subscribed: %s', contact.jid) - # return - pass + if isinstance(contact, BareContact): + if not contact.is_subscribed: + self._log.debug('Contact not subscribed: %s', contact) + return
if not contact.is_available: - self._log.info('Contact offline: %s', contact.jid) + self._log.debug('Contact offline: %s', contact) return
- if not contact.supports(Namespace.CHATSTATES): - self._log.info('Chatstates not supported: %s', contact.jid) + elif isinstance(contact, GroupchatParticipant): + if not contact.is_available: + self._log.debug('Contact offline: %s', contact) + return + + else: + if not contact.is_joined: + self._log.debug('Groupchat not joined: %s', contact) return
if state in (State.ACTIVE, State.COMPOSING): @@ -309,7 +312,7 @@ def set_chatstate(self, contact: types.ChatContactT, state: State) -> None: if current_state == state: return
- self._log.info('Send: %-10s - %s', state, contact.jid) + self._log.info('Send: %-10s - %s', state, contact)
self._send_chatstate(contact, state)
===================================== gajim/common/setting_values.py ===================================== @@ -164,7 +164,7 @@ class _ACCOUNT_DEFAULT: ]
AllSettings = Literal[BoolSettings, IntSettings, StringSettings] -AllSettingsT = Union[str, int, bool] +AllSettingsT = Union[str, int, bool, list[str]]
APP_SETTINGS = { 'always_english_wikipedia': False, @@ -508,15 +508,24 @@ class _ACCOUNT_DEFAULT:
AllWorkspaceSettings = Literal[StringWorkspaceSettings, 'open_chats']
-OpenChatSettingT = list[tuple[str, JID, str, bool]] -AllWorkspaceSettingsT = Union[str, OpenChatSettingT] + +class OpenChatSettingDetails(TypedDict): + account: str + jid: JID + type: str + pinned: bool + position: int + + +OpenChatsSettingT = list[OpenChatSettingDetails] +AllWorkspaceSettingsT = Union[str, OpenChatsSettingT]
class WorkspaceSettings(TypedDict): name: str color: str avatar_sha: str - open_chats: OpenChatSettingT + open_chats: OpenChatsSettingT
WORKSPACE_SETTINGS: WorkspaceSettings = {
===================================== gajim/common/settings.py ===================================== @@ -56,7 +56,7 @@ from gajim.common.setting_values import StringGroupChatSettings from gajim.common.setting_values import AllWorkspaceSettings from gajim.common.setting_values import AllWorkspaceSettingsT -from gajim.common.setting_values import OpenChatSettingT +from gajim.common.setting_values import OpenChatsSettingT from gajim.common.setting_values import StringWorkspaceSettings from gajim.common.setting_values import ACCOUNT_SETTINGS from gajim.common.setting_values import PROXY_SETTINGS @@ -82,6 +82,8 @@
log = logging.getLogger('gajim.c.settings')
+CURRENT_USER_VERSION = 2 + CREATE_SQL = ''' CREATE TABLE settings ( name TEXT UNIQUE, @@ -100,10 +102,11 @@ INSERT INTO settings(name, settings) VALUES ('plugins', '{}'); INSERT INTO settings(name, settings) VALUES ('workspaces', '%s');
- PRAGMA user_version=1; + PRAGMA user_version=%s; ''' % (json.dumps(STATUS_PRESET_EXAMPLES), json.dumps(PROXY_EXAMPLES), - json.dumps(INITAL_WORKSPACE)) + json.dumps(INITAL_WORKSPACE), + CURRENT_USER_VERSION)
_SignalCallable = Callable[[Any, str, Optional[str], Optional[JID]], Any] @@ -217,6 +220,7 @@ def init(self) -> None: self._commit() self._migrate_database() self._load_app_overrides() + self._commit()
@staticmethod def _setup_installation_defaults() -> None: @@ -310,6 +314,8 @@ def _migrate_database(self) -> None: self._con.close() log.exception('Error') sys.exit() + else: + self._commit_settings('workspaces')
def _migrate(self) -> None: version = self._get_user_version() @@ -320,6 +326,21 @@ def _migrate(self) -> None: self._settings['workspaces'] = INITAL_WORKSPACE self._set_user_version(1)
+ if version < 2: + # Migrate open chats tuple to dict + for workspace in self._settings['workspaces'].values(): + open_chats = [] + for open_chat in workspace.get('open_chats', []): + account, jid, type_, pinned = open_chat + open_chats.append({'account': account, + 'jid': jid, + 'type': type_, + 'pinned': pinned, + 'position': -1}) + + workspace['open_chats'] = open_chats + self._set_user_version(2) + def _migrate_old_config(self) -> None: config_file = configpaths.get('CONFIG_FILE') if not config_file.exists(): @@ -585,6 +606,12 @@ def set_app_setting(self, value: Optional[int]) -> None: ...
+ @overload + def set_app_setting(self, + setting: Literal['workspace_order'], + value: list[str]) -> None: + ... + def set_app_setting(self, setting: str, value: Optional[AllSettingsT]) -> None: @@ -1175,7 +1202,7 @@ def set_workspace_setting(self, def set_workspace_setting(self, workspace_id: str, setting: Literal['open_chats'], - value: list[tuple[str, JID, str, bool]] + value: OpenChatsSettingT ) -> None: ...
@@ -1202,7 +1229,7 @@ def set_workspace_setting(self, def get_workspace_setting(self, workspace_id: str, setting: Literal['open_chats'] - ) -> OpenChatSettingT: + ) -> OpenChatsSettingT: ...
@overload
===================================== gajim/common/types.py ===================================== @@ -25,9 +25,12 @@ from gi.repository import GdkPixbuf
import nbxmpp +from nbxmpp.const import PresenceShow from nbxmpp.protocol import JID from nbxmpp.structs import BookmarkData
+from gajim.common.const import PresenceShowExt + if TYPE_CHECKING: from gajim.common.client import Client from nbxmpp.client import Client as xmppClient # noqa: F401 @@ -49,7 +52,6 @@ GroupchatContact, GroupchatParticipant]
- InterfaceT = Union['Interface'] PluginManagerT = Union['PluginManager'] PluginRepositoryT = Union['PluginRepository'] @@ -78,3 +80,5 @@ ChatContactT = Union['BareContact', 'GroupchatContact', 'GroupchatParticipant'] OneOnOneContactT = Union['BareContact', 'GroupchatParticipant'] GroupchatContactT = Union['GroupchatContact'] + +PresenceShowT = Union[PresenceShowExt, PresenceShow]
===================================== gajim/data/other/shortcuts.json ===================================== @@ -5,7 +5,6 @@ "app.plugins": ["<Primary>E"], "app.xml-console": ["<Primary><Shift>X"], "app.file-transfer": ["<Primary>T"], - "app.ipython": ["<Primary><Alt>I"], "app.start-chat::": ["<Primary>N"], "app.create-groupchat::": ["<Primary>G"], "win.change-nickname": ["<Primary><Shift>N"],
===================================== gajim/dev/__init__.py deleted =====================================
===================================== gajim/dev/ipython_view.py deleted ===================================== @@ -1,672 +0,0 @@ -#!/usr/bin/python - -# Copyright (C) 2008-2014 Yann Leboulanger <asterix AT lagaule.org> -# -# This file is part of Gajim. -# -# Gajim is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published -# by the Free Software Foundation; version 3 only. -# -# Gajim is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with Gajim. If not, see http://www.gnu.org/licenses/. -# -# Copyright (c) 2007, IBM Corporation -# All rights reserved. - -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions are met: - -# * Redistributions of source code must retain the above copyright notice, -# this list of conditions and the following disclaimer. -# * Redistributions in binary form must reproduce the above copyright notice, -# this list of conditions and the following disclaimer in the documentation -# and/or other materials provided with the distribution. -# * Neither the name of the IBM Corporation nor the names of its contributors -# may be used to endorse or promote products derived from this software -# without specific prior written permission. - -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE -# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE -# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR -# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF -# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS -# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN -# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) -# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE -# POSSIBILITY OF SUCH DAMAGE. - -''' -Provides IPython console widget - -@author: Eitan Isaacson -@organization: IBM Corporation -@copyright: Copyright (c) 2007 IBM Corporation -@license: BSD - -All rights reserved. This program and the accompanying materials are made -available under the terms of the BSD which accompanies this distribution, and -is available at U{http://www.opensource.org/licenses/bsd-license.php%7D -''' - -import re -import sys -import os -from io import StringIO -from functools import reduce - -from gi.repository import Gtk -from gi.repository import Gdk -from gi.repository import GObject -from gi.repository import GLib -from gi.repository import Pango - -from pygments.token import Token - -import IPython -from IPython.core.displayhook import DisplayHook -from IPython.core.display_trap import DisplayTrap - - -class MyPromptDisplayHook(DisplayHook): - def __init__(self, shell, view): - DisplayHook.__init__(self, shell=shell) - self.view = view - - def write_output_prompt(self): - tokens = self.shell.prompts.out_prompt_tokens() - self.view.write('\n') - self.view.write(tokens) - - -class IterableIPShell: - ''' - Create an IPython instance. Does not start a blocking event loop, - instead allow single iterations. This allows embedding in GTK - without blockage - - @ivar IP: IPython instance. - @type IP: IPython.iplib.InteractiveShell - @ivar iter_more: Indicates if the line executed was a complete command, - or we should wait for more. - @type iter_more: integer - @ivar history_level: The place in history where we currently are - when pressing up/down. - @type history_level: integer - @ivar complete_sep: Separation delimiters for completion function. - @type complete_sep: _sre.SRE_Pattern - ''' - def __init__(self, argv=None, user_ns=None, user_global_ns=None, cin=None, - cout=None, cerr=None, input_func=None): - ''' - @param argv: Command line options for IPython - @type argv: list - @param user_ns: User namespace. - @type user_ns: dictionary - @param user_global_ns: User global namespace. - @type user_global_ns: dictionary. - @param cin: Console standard input. - @type cin: IO stream - @param cout: Console standard output. - @type cout: IO stream - @param cerr: Console standard error. - @type cerr: IO stream - @param input_func: Replacement for builtin raw_input() - @type input_func: function - ''' - if argv is None: - argv = [] - - io = IPython.utils.io - if input_func: - IPython.terminal.interactiveshell.raw_input_original = input_func - - # This is to get rid of the blockage that occurs during - # IPython.Shell.InteractiveShell.user_setup() - io.raw_input = lambda x: None - - os.environ['TERM'] = 'dumb' - excepthook = sys.excepthook - - from traitlets.config.loader import Config - - cfg = Config() - cfg.InteractiveShell.colors = 'Linux' - - # InteractiveShell's __init__ gets a reference of stdout and stderr - # so we save the standard here to revert it after init - old_stdout, old_stderr = sys.stdout, sys.stderr - sys.stdout, sys.stderr = cout, cerr - - # InteractiveShell inherits from SingletonConfigurable so use instance() - self.IP = IPython.terminal.embed.InteractiveShellEmbed.instance( - config=cfg, user_ns=user_ns, user_module=user_global_ns) - - # Set back stdout and stderr to what it was before - sys.stdout, sys.stderr = old_stdout, old_stderr - - self.IP.system = lambda cmd: self.shell(self.IP.var_expand(cmd), - header='IPython system call: ', - local_ns=user_ns) - - self.IP.raw_input = input_func - sys.excepthook = excepthook - self.iter_more = 0 - self.history_level = 0 - self.complete_sep = re.compile(r'[\s{}[]()]') - self.updateNamespace({'exit': lambda: None}) - self.updateNamespace({'quit': lambda: None}) - - # Workaround for updating namespace with sys.modules - # - self.__update_namespace() - self.lines = [] - - def __update_namespace(self): - ''' - Update self.IP namespace for autocompletion with sys.modules - ''' - for k, v in sys.modules.items(): - if '.' not in k: - self.IP.user_ns.update({k: v}) - - def execute(self): - ''' - Execute the current line provided by the shell object - ''' - self.history_level = 0 - orig_stdout = sys.stdout - sys.stdout = IPython.utils.io.stdout - - orig_stdin = sys.stdin - sys.stdin = IPython.utils.io.stdin - self.prompt = self.generatePrompt(self.iter_more) - - self.IP.hooks.pre_prompt_hook() - if self.iter_more: - try: - self.prompt = self.generatePrompt(True) - except Exception: - self.IP.showtraceback() - if self.IP.autoindent: - self.IP.rl_do_indent = True - - try: - self.IP.raw_input(self.prompt) - except KeyboardInterrupt: - self.IP.write('\nKeyboardInterrupt\n') - self.IP.input_splitter.reset() - except Exception: - self.IP.showtraceback() - else: - self.lines.append(self.IP.raw_input(self.prompt)) - self.iter_more = self.IP.check_complete( - '\n'.join(self.lines))[0] == 'incomplete' - - self.prompt = self.generatePrompt(self.iter_more) - if not self.iter_more: - source_raw = '\n'.join(self.lines) - self.lines = [] - - self.IP.run_cell(source_raw, store_history=True) - self.IP.rl_do_indent = False - else: - # TODO: Auto-indent - # - self.IP.rl_do_indent = True - - sys.stdout = orig_stdout - sys.stdin = orig_stdin - - def generatePrompt(self, is_continuation): - ''' - Generate prompt depending on is_continuation value - - @param is_continuation - @type is_continuation: boolean - - @return: The prompt string representation - @rtype: string - - ''' - # TODO: Update to IPython 5.x and later - prompt = 'In [%d]: ' % self.IP.execution_count - - return prompt - - def historyBack(self): - ''' - Provide one history command back - - @return: The command string. - @rtype: string - ''' - self.history_level -= 1 - return self._getHistory() - - def historyForward(self): - ''' - Provide one history command forward - - @return: The command string. - @rtype: string - ''' - self.history_level += 1 - return self._getHistory() - - def _getHistory(self): - ''' - Get the command string of the current history level - - @return: Historic command string. - @rtype: string - ''' - try: - rv = self.IP.user_ns['In'][self.history_level].strip('\n') - except IndexError: - self.history_level = 0 - rv = '' - return rv - - def updateNamespace(self, ns_dict): - ''' - Add the current dictionary to the shell namespace - - @param ns_dict: A dictionary of symbol-values. - @type ns_dict: dictionary - ''' - self.IP.user_ns.update(ns_dict) - - def complete(self, line): - ''' - Returns an auto completed line and/or possibilities for completion - - @param line: Given line so far. - @type line: string - - @return: Line completed as for as possible, - and possible further completions. - @rtype: tuple - ''' - split_line = self.complete_sep.split(line) - if split_line[-1]: - possibilities = self.IP.complete(split_line[-1]) - else: - completed = line - possibilities = ['', []] - if possibilities: - def _commonPrefix(str1, str2): - ''' - Reduction function. returns common prefix of two given strings. - - @param str1: First string. - @type str1: string - @param str2: Second string - @type str2: string - - @return: Common prefix to both strings. - @rtype: string - ''' - for i in range(len(str1)): - if not str2.startswith(str1[:i + 1]): - return str1[:i] - return str1 - - if possibilities[1]: - common_prefix = \ - reduce(_commonPrefix, possibilities[1]) or line[-1] - completed = line[:-len(split_line[-1])] + common_prefix - else: - completed = line - else: - completed = line - return completed, possibilities[1] - - -class ConsoleView(Gtk.TextView): - ''' - Specialized text view for console-like workflow - - @cvar ANSI_COLORS: Mapping of terminal colors to X11 names. - @type ANSI_COLORS: dictionary - - @ivar text_buffer: Widget's text buffer. - @type text_buffer: Gtk.TextBuffer - @ivar color_pat: Regex of terminal color pattern - @type color_pat: _sre.SRE_Pattern - @ivar mark: Scroll mark for automatic scrolling on input. - @type mark: Gtk.TextMark - @ivar line_start: Start of command line mark. - @type line_start: Gtk.TextMark - ''' - - ANSI_COLORS = {'0;30': 'Black', '0;31': 'Red', - '0;32': 'Green', '0;33': 'Brown', - '0;34': 'Blue', '0;35': 'Purple', - '0;36': 'Cyan', '0;37': 'LightGray', - '1;30': 'DarkGray', '1;31': 'DarkRed', - '1;32': 'SeaGreen', '1;33': 'Yellow', - '1;34': 'LightBlue', '1;35': 'MediumPurple', - '1;36': 'LightCyan', '1;37': 'White'} - - def __init__(self): - ''' - Initialize console view - ''' - GObject.GObject.__init__(self) - self.override_font(Pango.FontDescription('Mono')) - self.set_cursor_visible(True) - self.text_buffer = self.get_buffer() - self.mark = self.text_buffer.create_mark( - 'scroll_mark', - self.text_buffer.get_end_iter(), - False) - for code, color in self.ANSI_COLORS.items(): - self.text_buffer.create_tag(code, - foreground=color, - weight=700) - self.text_buffer.create_tag('0') - self.text_buffer.create_tag('notouch', editable=False) - self.color_pat = re.compile(r'\x01?\x1b[(.*?)m\x02?') - - self.style_dict = { - Token.Prompt: '0;32', - Token.PromptNum: '1;32', - Token.OutPrompt: '0;31', - Token.OutPromptNum: '1;31', - } - - self.line_start = \ - self.text_buffer.create_mark('line_start', - self.text_buffer.get_end_iter(), - True) - self.connect('key-press-event', self.onKeyPress) - - def write(self, text, editable=False): - if isinstance(text, str): - GLib.idle_add(self._write, text, editable) - else: - GLib.idle_add(self._write5, text, editable) - - def _write5(self, text, editable=False): - ''' - Write given text to buffer - - @param text: Text to append. - @type text: list of (token: string) - @param editable: If true, added text is editable. - @type editable: boolean - ''' - start_mark = self.text_buffer.create_mark( - None, self.text_buffer.get_end_iter(), True) - - for token, segment in text: - tag = self.style_dict[token] - self.text_buffer.insert_with_tags_by_name( - self.text_buffer.get_end_iter(), segment, tag) - if not editable: - self.text_buffer.apply_tag_by_name( - 'notouch', - self.text_buffer.get_iter_at_mark(start_mark), - self.text_buffer.get_end_iter()) - self.text_buffer.delete_mark(start_mark) - self.scroll_mark_onscreen(self.mark) - - def _write(self, text, editable=False): - ''' - Write given text to buffer - - @param text: Text to append. - @type text: string - @param editable: If true, added text is editable. - @type editable: boolean - ''' - if isinstance(text, list): - self._write5(text, editable) - return - segments = self.color_pat.split(text) - segment = segments.pop(0) - start_mark = self.text_buffer.create_mark( - None, self.text_buffer.get_end_iter(), True) - self.text_buffer.insert(self.text_buffer.get_end_iter(), segment) - - if segments: - ansi_tags = self.color_pat.findall(text) - for tag in ansi_tags: - i = segments.index(tag) - self.text_buffer.insert_with_tags_by_name( - self.text_buffer.get_end_iter(), segments[i + 1], str(tag)) - segments.pop(i) - if not editable: - self.text_buffer.apply_tag_by_name( - 'notouch', - self.text_buffer.get_iter_at_mark(start_mark), - self.text_buffer.get_end_iter()) - - self.text_buffer.delete_mark(start_mark) - self.scroll_mark_onscreen(self.mark) - - def showPrompt(self, prompt): - GLib.idle_add(self._showPrompt, prompt) - - def _showPrompt(self, prompt): - ''' - Print prompt at start of line - - @param prompt: Prompt to print. - @type prompt: string - ''' - self._write(prompt) - self.text_buffer.move_mark(self.line_start, - self.text_buffer.get_end_iter()) - - def changeLine(self, text): - GLib.idle_add(self._changeLine, text) - - def _changeLine(self, text): - ''' - Replace currently entered command line with given text - - @param text: Text to use as replacement. - @type text: string - ''' - iter_ = self.text_buffer.get_iter_at_mark(self.line_start) - iter_.forward_to_line_end() - self.text_buffer.delete( - self.text_buffer.get_iter_at_mark(self.line_start), iter_) - self._write(text, True) - - def getCurrentLine(self): - ''' - Get text in current command line - - @return: Text of current command line. - @rtype: string - ''' - rv = self.text_buffer.get_slice( - self.text_buffer.get_iter_at_mark(self.line_start), - self.text_buffer.get_end_iter(), False) - return rv - - def showReturned(self, text): - GLib.idle_add(self._showReturned, text) - - def _showReturned(self, text): - ''' - Show returned text from last command and print new prompt - - @param text: Text to show. - @type text: string - ''' - iter_ = self.text_buffer.get_iter_at_mark(self.line_start) - iter_.forward_to_line_end() - self.text_buffer.apply_tag_by_name( - 'notouch', - self.text_buffer.get_iter_at_mark(self.line_start), - iter_) - self._write('\n' + text) - if text: - self._write('\n') - self._showPrompt(self.prompt) - self.text_buffer.move_mark( - self.line_start, self.text_buffer.get_end_iter()) - self.text_buffer.place_cursor(self.text_buffer.get_end_iter()) - - if self.IP.rl_do_indent: - indentation = self.IP.input_splitter.indent_spaces * ' ' - self.text_buffer.insert_at_cursor(indentation) - - def onKeyPress(self, _widget, event): - ''' - Key press callback used for correcting behavior for console-like - interfaces. For example 'home' should go to prompt, not to beginning of - line - - @param widget: Widget that key press occurred in. - @type widget: Gtk.Widget - @param event: Event object - @type event: Gdk.Event - - @return: Return True if event should not trickle. - @rtype: boolean - ''' - insert_mark = self.text_buffer.get_insert() - insert_iter = self.text_buffer.get_iter_at_mark(insert_mark) - selection_mark = self.text_buffer.get_selection_bound() - selection_iter = self.text_buffer.get_iter_at_mark(selection_mark) - start_iter = self.text_buffer.get_iter_at_mark(self.line_start) - if event.keyval == Gdk.KEY_Home: - if event.get_state() == 0: - self.text_buffer.place_cursor(start_iter) - return True - if event.get_state() == Gdk.ModifierType.SHIFT_MASK: - self.text_buffer.move_mark(insert_mark, start_iter) - return True - - if event.keyval == Gdk.KEY_Left: - insert_iter.backward_cursor_position() - if not insert_iter.editable(True): - return True - elif not event.string: - pass - elif (start_iter.compare(insert_iter) <= 0 and - start_iter.compare(selection_iter) <= 0): - pass - elif (start_iter.compare(insert_iter) > 0 and - start_iter.compare(selection_iter) > 0): - self.text_buffer.place_cursor(start_iter) - elif insert_iter.compare(selection_iter) < 0: - self.text_buffer.move_mark(insert_mark, start_iter) - elif insert_iter.compare(selection_iter) > 0: - self.text_buffer.move_mark(selection_mark, start_iter) - - return self.onKeyPressExtend(event) - - def onKeyPressExtend(self, event): - ''' - For some reason we can't extend onKeyPress directly (bug #500900) - ''' - - -class IPythonView(ConsoleView, IterableIPShell): - ''' - Sub-class of both modified IPython shell and L{ConsoleView} this makes - a GTK IPython console. - ''' - def __init__(self): - ''' - Initialize. Redirect I/O to console - ''' - ConsoleView.__init__(self) - self.cout = StringIO() - IterableIPShell.__init__(self, cout=self.cout, cerr=self.cout, - input_func=self.raw_input) - - displayhook = MyPromptDisplayHook(shell=self.IP, view=self) - self.IP.displayhook = displayhook - self.IP.display_trap = DisplayTrap(hook=displayhook) - - self.interrupt = False - self.execute() - self.prompt = self.generatePrompt(False) - self.cout.truncate(0) - self.showPrompt(self.prompt) - - def prompt_for_code(self): - # IPython 5.0 calls prompt_for_code instead of raw_input - return self.raw_input(self) - - def raw_input(self, _prompt=''): - ''' - Custom raw_input() replacement. Gets current line from console buffer - - @param prompt: Prompt to print. Here for compatibility as replacement. - @type prompt: string - - @return: The current command line text. - @rtype: string - ''' - if self.interrupt: - self.interrupt = False - raise KeyboardInterrupt - return self.getCurrentLine() - - def onKeyPressExtend(self, event): - ''' - Key press callback with plenty of shell goodness, like history, - autocompletions, etc - - @param widget: Widget that key press occurred in. - @type widget: Gtk.Widget - @param event: Event object. - @type event: Gdk.Event - - @return: True if event should not trickle. - @rtype: boolean - ''' - if (event.get_state() & Gdk.ModifierType.CONTROL_MASK and - event.keyval == 99): - self.interrupt = True - self._processLine() - return True - if event.keyval == Gdk.KEY_Return: - self._processLine() - return True - if event.keyval == Gdk.KEY_Up: - self.changeLine(self.historyBack()) - return True - if event.keyval == Gdk.KEY_Down: - self.changeLine(self.historyForward()) - return True - if event.keyval == Gdk.KEY_Tab: - if not self.getCurrentLine().strip(): - return False - completed, possibilities = self.complete(self.getCurrentLine()) - if len(possibilities) > 1: - slice_ = self.getCurrentLine() - self.write('\n') - for symbol in possibilities: - self.write(symbol + '\n') - self.showPrompt(self.prompt) - self.changeLine(completed or slice_) - return True - - def _processLine(self): - ''' - Process current command line - ''' - self.history_pos = 0 - self.execute() - rv = self.cout.getvalue() - if rv: - rv = rv.strip('\n') - self.showReturned(rv) - self.cout.truncate(0) - self.cout.seek(0)
===================================== gajim/gtk/application.py ===================================== @@ -165,13 +165,6 @@ def __init__(self): GLib.OptionArg.NONE, _('Show all warnings'))
- self.add_main_option( - 'ipython', - ord('i'), - GLib.OptionFlags.NONE, - GLib.OptionArg.NONE, - _('Open IPython shell')) - self.add_main_option( 'gdebug', 0, @@ -345,7 +338,6 @@ def _command_line(self, options = command_line.get_options_dict()
remote_commands = [ - ('ipython', None), ('start-chat', GLib.Variant('s', '')), ('show', None) ] @@ -417,7 +409,6 @@ def _connect_app_actions(self) -> None: ('join-support-chat', self._on_join_support_chat), ('about', self._on_about_action), ('faq', self._on_faq_action), - ('ipython', self._on_ipython_action), ('start-chat', self._on_new_chat_action), ('accounts', self._on_accounts_action), ('add-contact', self._on_add_contact_action), @@ -742,18 +733,6 @@ def _on_mark_as_read_action(self,
app.window.mark_as_read(params.account, params.jid)
- @staticmethod - def _on_ipython_action(_action: Gio.SimpleAction, - _param: Optional[GLib.Variant]) -> None: - ''' - Show/hide the ipython window - ''' - win = cast(Gtk.Window, app.ipython_window) - if win and win.is_visible(): - win.present() - else: - app.interface.create_ipython_window() - @staticmethod def _on_open_mail_action(_action: Gio.SimpleAction, param: GLib.Variant) -> None:
===================================== gajim/gtk/avatar.py ===================================== @@ -52,8 +52,8 @@ log = logging.getLogger('gajim.gui.avatar')
-AvatarCacheT = dict[JID, dict[tuple[int, int, Optional[str]], - cairo.ImageSurface]] +AvatarCacheT = dict[Union[JID, str], dict[tuple[int, int, Optional[str]], + cairo.ImageSurface]]
def generate_avatar(letters: str, @@ -102,7 +102,7 @@ def generate_avatar(letters: str, context.set_operator(cairo.Operator.OVER) context.show_text(letters)
- return context.get_target() + return context.get_target() # pyright: ignore
@lru_cache(maxsize=None) @@ -132,7 +132,8 @@ def make_workspace_avatar(letter: str,
def add_status_to_avatar(surface: cairo.ImageSurface, - show: str) -> cairo.ImageSurface: + show: str + ) -> cairo.ImageSurface:
width = surface.get_width() height = surface.get_height() @@ -179,11 +180,15 @@ def add_status_to_avatar(surface: cairo.ImageSurface, context.set_line_width(clip_radius / 4) context.stroke()
- return context.get_target() + return context.get_target() # pyright: ignore
@lru_cache(maxsize=128) -def get_show_circle(show, size: int, scale: int) -> cairo.ImageSurface: +def get_show_circle(show: Union[str, types.PresenceShowT], + size: int, + scale: int + ) -> cairo.ImageSurface: + if not isinstance(show, str): show = show.value
@@ -212,7 +217,7 @@ def get_show_circle(show, size: int, scale: int) -> cairo.ImageSurface: context.set_line_width(size / 10) context.stroke()
- return context.get_target() + return context.get_target() # pyright: ignore
def fit(surface: cairo.ImageSurface, size: int) -> cairo.ImageSurface: @@ -235,7 +240,7 @@ def fit(surface: cairo.ImageSurface, size: int) -> cairo.ImageSurface: context.scale(factor, factor) context.set_source_surface(square_surface, 0, 0) context.paint() - return context.get_target() + return context.get_target() # pyright: ignore
def square(surface: cairo.ImageSurface, size: int) -> cairo.ImageSurface: @@ -259,7 +264,7 @@ def square(surface: cairo.ImageSurface, size: int) -> cairo.ImageSurface:
context.set_source_surface(surface, x_pos, y_pos) context.paint() - return context.get_target() + return context.get_target() # pyright: ignore
def clip(surface: cairo.ImageSurface, mode: str) -> cairo.ImageSurface: @@ -289,7 +294,7 @@ def clip_circle(surface: cairo.ImageSurface) -> cairo.ImageSurface: context.clip() context.paint()
- return context.get_target() + return context.get_target() # pyright: ignore
def round_corners(surface: cairo.ImageSurface) -> cairo.ImageSurface: @@ -320,7 +325,7 @@ def round_corners(surface: cairo.ImageSurface) -> cairo.ImageSurface:
context.paint()
- return context.get_target() + return context.get_target() # pyright: ignore
def convert_to_greyscale(surface: cairo.ImageSurface) -> cairo.ImageSurface: @@ -333,14 +338,14 @@ def convert_to_greyscale(surface: cairo.ImageSurface) -> cairo.ImageSurface: context.set_source_rgba(1, 1, 1, 0.5) context.rectangle(0, 0, surface.get_width(), surface.get_height()) context.fill() - return context.get_target() + return context.get_target() # pyright: ignore
class AvatarStorage(metaclass=Singleton): def __init__(self): self._cache: AvatarCacheT = defaultdict(dict)
- def invalidate_cache(self, jid: JID) -> None: + def invalidate_cache(self, jid: Union[JID, str]) -> None: self._cache.pop(jid, None)
def get_pixbuf(self, @@ -373,6 +378,7 @@ def get_surface(self,
if transport_icon is not None: surface = load_icon_surface(transport_icon, size, scale) + assert surface is not None if show is not None: surface = add_status_to_avatar(surface, show) self._cache[jid][(size, scale, show)] = surface @@ -412,11 +418,12 @@ def get_muc_surface(self,
if transport_icon is not None: surface = load_icon_surface(transport_icon, size, scale) - self._cache[jid][(size, scale)] = surface + assert surface is not None + self._cache[jid][(size, scale, None)] = surface return surface
if not default: - surface = self._cache[jid].get((size, scale)) + surface = self._cache[jid].get((size, scale, None)) if surface is not None: return surface
@@ -425,7 +432,7 @@ def get_muc_surface(self, surface = self.surface_from_filename(avatar_sha, size, scale) if surface is not None: surface = clip(surface, style) - self._cache[jid][(size, scale)] = surface + self._cache[jid][(size, scale, None)] = surface return surface
# avatar_sha set, but image is missing @@ -436,7 +443,7 @@ def get_muc_surface(self, name = get_groupchat_name(con, jid) letter = self._generate_letter(name) surface = generate_default_avatar(letter, str(jid), size, scale, style) - self._cache[jid][(size, scale)] = surface + self._cache[jid][(size, scale, None)] = surface return surface
def get_workspace_surface(self, @@ -444,7 +451,7 @@ def get_workspace_surface(self, size: int, scale: int) -> Optional[cairo.ImageSurface]:
- surface = self._cache[workspace_id].get((size, scale)) + surface = self._cache[workspace_id].get((size, scale, None)) if surface is not None: return surface
@@ -465,7 +472,7 @@ def get_workspace_surface(self, letter = name[:1].upper() surface = make_workspace_avatar( letter, rgba_to_float(rgba), size, scale) - self._cache[workspace_id][(size, scale)] = surface + self._cache[workspace_id][(size, scale, None)] = surface return surface
@staticmethod @@ -483,6 +490,7 @@ def _load_for_publish(path: str) -> Optional[tuple[bool, bytes]]: height, GdkPixbuf.InterpType.BILINEAR)
+ assert pixbuf is not None return pixbuf.save_to_bufferv('png', [], [])
@staticmethod
===================================== gajim/gtk/avatar_selector.py ===================================== @@ -178,7 +178,9 @@ def _scale_for_publish(pixbuf: GdkPixbuf.Pixbuf return scaled_pixbuf, width, height return pixbuf, width, height
- def get_avatar_surface(self) -> Optional[tuple[cairo.Surface, int, int]]: + def get_avatar_surface(self) -> Optional[tuple[cairo.ImageSurface, + int, + int]]: pixbuf = self._crop_area.get_pixbuf() if pixbuf is None: return None
===================================== gajim/gtk/chat_banner.py ===================================== @@ -236,12 +236,12 @@ def _update_avatar(self) -> None: scale = app.window.get_scale_factor() assert self._contact surface = self._contact.get_avatar(AvatarSize.CHAT, scale) - assert isinstance(surface, cairo.Surface) + assert isinstance(surface, cairo.ImageSurface) self._ui.avatar_image.set_from_surface(surface)
def _update_content(self) -> None: - if self._client is None or self._contact is None: - return + assert self._contact is not None + assert self._client is not None
name = self._contact.name
===================================== gajim/gtk/chat_list.py ===================================== @@ -26,6 +26,7 @@ from gi.repository import Gio from gi.repository import Gdk from gi.repository import GLib +from gi.repository import GObject from gi.repository import Gtk import cairo
@@ -47,6 +48,7 @@ from gajim.common.helpers import AdditionalDataDict from gajim.common.preview_helpers import filename_from_uri from gajim.common.preview_helpers import guess_simple_file_type +from gajim.common.setting_values import OpenChatsSettingT from gajim.common.types import ChatContactT from gajim.common.types import OneOnOneContactT from gajim.common.modules.contacts import BareContact @@ -65,6 +67,13 @@
class ChatList(Gtk.ListBox, EventHelper): + + __gsignals__ = { + 'chat-order-changed': (GObject.SignalFlags.RUN_LAST, + None, + ()), + } + def __init__(self, workspace_id: str) -> None: Gtk.ListBox.__init__(self) EventHelper.__init__(self) @@ -94,12 +103,19 @@ def __init__(self, workspace_id: str) -> None: entries, Gdk.DragAction.MOVE)
+ self._drag_row: Optional[ChatRow] = None + self._row_before: Optional[ChatRow] = None + self._row_after: Optional[ChatRow] = None + self._chat_order: list[ChatRow] = [] + self.register_events([ ('account-enabled', ged.GUI2, self._on_account_changed), ('account-disabled', ged.GUI2, self._on_account_changed), ('bookmarks-received', ged.GUI1, self._on_bookmarks_received), ])
+ self.connect('drag-motion', self._on_drag_motion) + self.connect('drag-data-received', self._on_drag_data_received) self.connect('destroy', self._on_destroy)
self._timer_id = GLib.timeout_add_seconds(60, self._update_timer) @@ -146,6 +162,103 @@ def emit_unread_changed(self) -> None: self._workspace_id, count)
+ def _get_row_before(self, row: ChatRow) -> Optional[ChatRow]: + row = cast(ChatRow, self.get_row_at_index(row.get_index() - 1)) + return row + + def _get_row_after(self, row: ChatRow) -> Optional[ChatRow]: + row = cast(ChatRow, self.get_row_at_index(row.get_index() + 1)) + return row + + def _get_last_row(self) -> ChatRow: + row = cast(ChatRow, self.get_row_at_index(len(self.get_children()))) + return row + + def set_drag_row(self, row: ChatRow) -> None: + self._drag_row = row + + def _on_drag_motion(self, + _widget: Gtk.Widget, + _drag_context: Gdk.DragContext, + _x_coord: int, + y_coord: int, + _time: int + ) -> bool: + + row = cast(ChatRow, self.get_row_at_y(y_coord)) + if row: + alloc = row.get_allocation() + hover_row_y = alloc.y + hover_row_height = alloc.height + + if y_coord < hover_row_y + hover_row_height / 2: + self._row_before = self._get_row_before(row) + self._row_after = row + else: + self._row_before = row + self._row_after = self._get_row_after(row) + else: + self._row_before = self._get_last_row() + self._row_after = None + + if self._drag_row in (self._row_before, self._row_after): + return False + + return True + + def _on_drag_data_received(self, + _widget: Gtk.Widget, + _drag_context: Gdk.DragContext, + _x_coord: int, + y_coord: int, + selection_data: Gtk.SelectionData, + _info: int, + _time: int + ) -> None: + + item_type = selection_data.get_data_type().name() + if item_type == 'CHAT_LIST_ITEM': + self._change_pinned_order() + else: + log.debug('Unknown item type dropped') + + def _change_pinned_order(self) -> None: + assert self._drag_row is not None + + if not self._drag_row.is_pinned: + log.debug('Dragged row is not pinned') + return + + if self._drag_row in (self._row_before, self._row_after): + log.debug('Dragged row dropped on self') + return + + if self._row_after is None: + log.debug('Dragged row dropped in empty space') + return + + self._chat_order.remove(self._drag_row) + + if self._row_before is None: + self._chat_order.insert(0, self._drag_row) + else: + if self._row_before.position < self._drag_row.position: + offset = 1 + else: + offset = 0 + self._chat_order.insert( + self._row_before.position + offset, self._drag_row) + + for row in self._chat_order: + row.position = self._chat_order.index(row) + + self._row_before = None + self._row_after = None + self._drag_row = None + + self.emit('chat-order-changed') + self.invalidate_sort() + def _on_destroy(self, _widget: Gtk.Widget) -> None: GLib.source_remove(self._timer_id)
@@ -198,9 +311,11 @@ def _sort_func(self, row1: ChatRow, row2: ChatRow) -> int: log.debug('Mouseover active, don’t sort rows') return 0
- # Don’t sort pinned rows themselves + # Sort pinned rows according to stored order if row1.is_pinned and row2.is_pinned: - return 0 + if row1.position > row2.position: + return 1 + return -1
# Sort pinned rows to top if row1.is_pinned > row2.is_pinned: @@ -250,14 +365,30 @@ def get_chat_type(self, account: str, jid: JID) -> Optional[str]: return row.type return None
- def add_chat(self, account: str, jid: JID, type_: str, - pinned: bool = False) -> None: - if self._chats.get((account, jid)) is not None: + def add_chat(self, + account: str, + jid: JID, + type_: str, + pinned: bool, + position: int + ) -> None: + + key = (account, jid) + if self._chats.get(key) is not None: # Chat is already in the List return
- row = ChatRow(self._workspace_id, account, jid, type_, pinned) - self._chats[(account, jid)] = row + row = ChatRow(self._workspace_id, + account, + jid, + type_, + pinned, + position) + + self._chats[key] = row + if position != -1: + self._chat_order.insert(position, row) + self.add(row)
def select_chat(self, account: str, jid: JID) -> None: @@ -333,11 +464,27 @@ def select_chat_number(self, number: int) -> None: def toggle_chat_pinned(self, account: str, jid: JID) -> None: row = self._chats[(account, jid)] row.toggle_pinned() + + self._chat_order.clear() + chat_rows = cast(list[ChatRow], self.get_children()) + for row in chat_rows: + if row.is_pinned: + row.position = chat_rows.index(row) + self._chat_order.append(row) + else: + row.position = -1 + self.invalidate_sort()
- def remove_chat(self, account: str, jid: JID, - emit_unread: bool = True) -> None: + def remove_chat(self, + account: str, + jid: JID, + emit_unread: bool = True + ) -> None: + row = self._chats.pop((account, jid)) + if row.position != -1: + self._chat_order.remove(row) self.remove(row) row.destroy() if emit_unread: @@ -359,10 +506,15 @@ def get_selected_chat(self) -> Optional[ChatRow]: def contains_chat(self, account: str, jid: JID) -> bool: return self._chats.get((account, jid)) is not None
- def get_open_chats(self) -> list[tuple[str, JID, str, bool]]: - open_chats: list[tuple[str, JID, str, bool]] = [] - for key, value in self._chats.items(): - open_chats.append(key + (value.type, value.is_pinned)) + def get_open_chats(self) -> OpenChatsSettingT: + open_chats: OpenChatsSettingT = [] + for key, row in self._chats.items(): + account, jid = key + open_chats.append({'account': account, + 'jid': jid, + 'type': row.type, + 'pinned': row.is_pinned, + 'position': row.position}) return open_chats
def update_time(self) -> None: @@ -536,14 +688,22 @@ def _on_bookmarks_received(self, _event: events.BookmarksReceived) -> None:
class ChatRow(Gtk.ListBoxRow): - def __init__(self, workspace_id: str, account: str, jid: JID, type_: str, - pinned: bool) -> None: + def __init__(self, + workspace_id: str, + account: str, + jid: JID, + type_: str, + pinned: bool, + position: int + ) -> None: + Gtk.ListBoxRow.__init__(self)
self.account = account self.jid = jid self.workspace_id = workspace_id self.type = type_ + self._position = position
self.active_label = ActiveHeader() self.conversations_label = ConversationsHeader() @@ -705,6 +865,14 @@ def header(self, type_: Optional[RowHeaderType]) -> None: def is_pinned(self) -> bool: return self._pinned
+ @property + def position(self) -> int: + return self._position + + @position.setter + def position(self, position: int) -> None: + self._position = position + def _on_button_press(self, _widget: Gtk.Widget, event: Gdk.EventButton @@ -735,6 +903,10 @@ def _on_drag_begin(self, widget: Gtk.Widget, drag_context: Gdk.DragContext ) -> None: + + chat_list = cast(ChatList, self.get_parent()) + chat_list.set_drag_row(self) + # Use rendered ChatListRow as drag icon alloc = self.get_allocation() surface = cairo.ImageSurface(
===================================== gajim/gtk/chat_list_stack.py ===================================== @@ -14,7 +14,6 @@
from __future__ import annotations
-from typing import Dict from typing import Optional from typing import cast
@@ -66,7 +65,7 @@ def __init__(self, self.set_vexpand(True) self.set_vhomogeneous(False)
- self._chat_lists: Dict[str, ChatList] = {} + self._chat_lists: dict[str, ChatList] = {}
self._last_visible_child_name: str = 'default'
@@ -153,6 +152,7 @@ def _on_search_changed(self, search_entry: Gtk.SearchEntry) -> None: def add_chat_list(self, workspace_id: str) -> ChatList: chat_list = ChatList(workspace_id) chat_list.connect('row-selected', self._on_row_selected) + chat_list.connect('chat-order-changed', self._on_chat_order_changed)
self._chat_lists[workspace_id] = chat_list self.add_named(chat_list, workspace_id) @@ -161,8 +161,10 @@ def add_chat_list(self, workspace_id: str) -> ChatList: def remove_chat_list(self, workspace_id: str) -> None: chat_list = self._chat_lists[workspace_id] self.remove(chat_list) - for account, jid, _type, _pinned in chat_list.get_open_chats(): - self.remove_chat(workspace_id, account, jid) + for open_chat in chat_list.get_open_chats(): + self.remove_chat(workspace_id, + open_chat['account'], + open_chat['jid'])
self._chat_lists.pop(workspace_id) chat_list.destroy() @@ -177,6 +179,12 @@ def _on_row_selected(self,
self.emit('chat-selected', row.workspace_id, row.account, row.jid)
+ def _on_chat_order_changed(self, + chat_list: ChatList + ) -> None: + + self.store_open_chats(chat_list.workspace_id) + def show_chat_list(self, workspace_id: str) -> None: cur_workspace_id = self.get_visible_child_name() if cur_workspace_id == workspace_id: @@ -187,12 +195,19 @@ def show_chat_list(self, workspace_id: str) -> None:
self.set_visible_child_name(workspace_id)
- def add_chat(self, workspace_id: str, account: str, jid: JID, type_: str, - pinned: bool = False) -> None: + def add_chat(self, + workspace_id: str, + account: str, + jid: JID, + type_: str, + pinned: bool, + position: int + ) -> None: + chat_list = self._chat_lists.get(workspace_id) if chat_list is None: chat_list = self.add_chat_list(workspace_id) - chat_list.add_chat(account, jid, type_, pinned) + chat_list.add_chat(account, jid, type_, pinned, position)
def select_chat(self, account: str, jid: JID) -> None: chat_list = self.find_chat(account, jid) @@ -235,7 +250,8 @@ def _move_chat_to_workspace(self, current_chatlist.remove_chat(params.account, params.jid)
new_chatlist = self.get_chatlist(workspace_id) - new_chatlist.add_chat(params.account, params.jid, type_) + new_chatlist.add_chat(params.account, params.jid, type_, False, -1) + self.store_open_chats(current_chatlist.workspace_id) self.store_open_chats(workspace_id)
@@ -251,31 +267,38 @@ def remove_chat(self, workspace_id: str, account: str, jid: JID) -> None: chat_list = self._chat_lists[workspace_id] type_ = chat_list.get_chat_type(account, jid)
- def _leave(is_checked: bool) -> None: - if is_checked: + def _leave(not_ask_again: bool) -> None: + if not_ask_again: app.settings.set('confirm_close_muc', False) + _remove()
+ def _remove() -> None: chat_list.remove_chat(account, jid, emit_unread=False) self.store_open_chats(workspace_id) self.emit('chat-removed', account, jid, type_)
- if type_ == 'groupchat' and app.settings.get('confirm_close_muc'): - client = app.get_client(account) - contact = client.get_module('Contacts').get_group_chat_contact(jid) - ConfirmationCheckDialog( - _('Leave Group Chat'), - _('Are you sure you want to leave this group chat?'), - _('If you close this chat, you will leave ' - '"%s".') % contact.name, - _('_Do not ask me again'), - [DialogButton.make('Cancel'), - DialogButton.make('Accept', - text=_('_Leave'), - callback=_leave)], - transient_for=app.window).show() + if type_ != 'groupchat' or not app.settings.get('confirm_close_muc'): + _remove() + return + + client = app.get_client(account) + contact = client.get_module('Contacts').get_group_chat_contact(jid) + + if contact.is_not_joined: + _remove() return
- _leave(True) + ConfirmationCheckDialog( + _('Leave Group Chat'), + _('Are you sure you want to leave this group chat?'), + _('If you close this chat, you will leave ' + '"%s".') % contact.name, + _('_Do not ask me again'), + [DialogButton.make('Cancel'), + DialogButton.make('Accept', + text=_('_Leave'), + callback=_leave)], + transient_for=app.window).show()
def remove_chats_for_account(self, account: str) -> None: for workspace_id, chat_list in self._chat_lists.items():
===================================== gajim/gtk/chat_page.py ===================================== @@ -221,6 +221,7 @@ def add_chat_for_workspace(self, jid: JID, type_: str, pinned: bool = False, + position: int = -1, select: bool = False) -> None:
client = app.get_client(account) @@ -240,7 +241,7 @@ def add_chat_for_workspace(self, return
self._chat_list_stack.add_chat(workspace_id, account, jid, type_, - pinned) + pinned, position)
if self._startup_finished: if select: @@ -252,15 +253,17 @@ def load_workspace_chats(self, workspace_id: str) -> None: 'open_chats')
active_accounts = app.settings.get_active_accounts() - for account, jid, type_, pinned in open_chats: + for open_chat in open_chats: + account = open_chat['account'] if account not in active_accounts: continue
self.add_chat_for_workspace(workspace_id, account, - jid, - type_, - pinned=pinned) + open_chat['jid'], + open_chat['type'], + pinned=open_chat['pinned'], + position=open_chat['position'])
def is_chat_active(self, account: str, jid: JID) -> bool: return self._chat_list_stack.is_chat_active(account, jid)
===================================== gajim/gtk/chat_stack.py ===================================== @@ -34,6 +34,7 @@ from gajim.common import events from gajim.common import helpers from gajim.common import preview_helpers +from gajim.common.commands import ChatCommands from gajim.common.i18n import _ from gajim.common.const import CallType from gajim.common.modules.contacts import BareContact @@ -76,10 +77,10 @@ def __init__(self): self._chat_banner = ChatBanner() self._chat_control = ChatControl() self._message_action_box = MessageActionsBox() - self._message_action_box.connect('command-error', - self._on_command_error) - self._message_action_box.connect('command-not-found', - self._on_command_not_found) + + app.commands.connect('command-error', self._on_command_signal) + app.commands.connect('command-not-found', self._on_command_signal) + app.commands.connect('command-result', self._on_command_signal)
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) box.add(self._chat_banner) @@ -741,18 +742,14 @@ def clear(self) -> None: self._message_action_box.clear() self._chat_control.clear()
- def _on_command_error(self, - message_actions_box: MessageActionsBox, - error: str) -> None: - - self._chat_control.add_command_output(error, True) - - def _on_command_not_found(self, - message_actions_box: MessageActionsBox, - error: str - ) -> None: + def _on_command_signal(self, + chat_commands: ChatCommands, + signal_name: str, + text: str + ) -> None:
- self._chat_control.add_command_output(error, True) + is_error = signal_name != 'command-result' + self._chat_control.add_command_output(text, is_error)
class ChatPlaceholderBox(Gtk.Box):
===================================== gajim/gtk/const.py ===================================== @@ -171,7 +171,6 @@ def __str__(self): ('file-transfer', None), ('forget-groupchat', 'a{sv}'), ('groupchat-join', 'as'), - ('ipython', None), ('join-support-chat', None), ('manage-proxies', None), ('open-link', 'as'),
===================================== gajim/gtk/main.py ===================================== @@ -305,7 +305,7 @@ def _add_actions(self) -> None: actions = [ ('add-workspace', 's', self._add_workspace), ('edit-workspace', 's', self._edit_workspace), - ('remove-workspace', None, self._remove_workspace), + ('remove-workspace', 's', self._remove_workspace), ('activate-workspace', 's', self._activate_workspace), ('add-chat', 'a{sv}', self._add_chat), ('add-group-chat', 'as', self._add_group_chat), @@ -616,9 +616,12 @@ def _edit_workspace(self,
def _remove_workspace(self, _action: Gio.SimpleAction, - _param: Optional[GLib.Variant]) -> None: + param: GLib.Variant) -> None: + + workspace_id = param.get_string() or None + if workspace_id is None: + workspace_id = self.get_active_workspace()
- workspace_id = self.get_active_workspace() if workspace_id is not None: self.remove_workspace(workspace_id)
===================================== gajim/gtk/menus.py ===================================== @@ -574,6 +574,15 @@ def get_format_menu() -> GajimMenu: return GajimMenu.from_list(menuitems)
+def get_workspace_menu(workspace_id: str) -> GajimMenu: + menuitems: MenuItemListT = [ + (_('Edit…'), 'win.edit-workspace', f'"{workspace_id}"'), + (_('Remove'), 'win.remove-workspace', f'"{workspace_id}"'), + ] + + return GajimMenu.from_list(menuitems) + + def escape_mnemonic(label: Optional[str]) -> Optional[str]: if label is None: return None
===================================== gajim/gtk/message_actions_box.py ===================================== @@ -37,9 +37,7 @@ from gajim.common import app from gajim.common import events from gajim.common import ged -from gajim.common.commands import ArgumentParserError -from gajim.common.commands import CommandError -from gajim.common.commands import CommandNotFoundError +from gajim.common.commands import CommandFailed from gajim.common.i18n import _ from gajim.common.client import Client from gajim.common.const import SimpleClientState @@ -67,12 +65,6 @@
class MessageActionsBox(Gtk.Grid, ged.EventHelper): - - __gsignals__ = { - 'command-error': (GObject.SignalFlags.RUN_FIRST, None, (str,)), - 'command-not-found': (GObject.SignalFlags.RUN_FIRST, None, (str,)) - } - def __init__(self) -> None: Gtk.Grid.__init__(self) ged.EventHelper.__init__(self) @@ -544,10 +536,8 @@ def _on_msg_textview_key_press_event(self, not message.startswith('/me ')): try: app.commands.parse(self._contact.type_string, message) - except (ArgumentParserError, CommandError) as error: - self.emit('command-error', error) - except CommandNotFoundError as error: - self.emit('command-not-found', error) + except CommandFailed: + pass else: self.msg_textview.clear() return True
===================================== gajim/gtk/profile.py ===================================== @@ -94,7 +94,7 @@ def __init__(self, account: str) -> None: self._add_actions()
self._avatar_selector: Optional[AvatarSelector] = None - self._current_avatar: Optional[cairo.Surface] = None + self._current_avatar: Optional[cairo.ImageSurface] = None self._current_vcard: Optional[VCard] = None self._avatar_nick_public: Optional[bool] = None
===================================== gajim/gtk/util.py ===================================== @@ -106,7 +106,7 @@ def load_icon_surface( size: int = 16, scale: Optional[int] = None, flags: Gtk.IconLookupFlags = Gtk.IconLookupFlags.FORCE_SIZE -) -> Optional[cairo.Surface]: +) -> Optional[cairo.ImageSurface]:
icon_info = load_icon_info(icon_name, size, scale, flags) if icon_info is None:
===================================== gajim/gtk/workspace_side_bar.py ===================================== @@ -15,7 +15,6 @@ from __future__ import annotations
from typing import Any -from typing import List from typing import Optional from typing import cast
@@ -23,8 +22,6 @@ import pickle
from gi.repository import Gdk -from gi.repository import Gio -from gi.repository import GLib from gi.repository import Gtk from nbxmpp.protocol import JID
@@ -34,6 +31,7 @@
from .chat_list_stack import ChatListStack from .chat_page import ChatPage +from .menus import get_workspace_menu from .structs import ChatListEntryParam from .util import open_window
@@ -323,7 +321,7 @@ def _popup_menu(self, _widget: Gtk.Widget, event: Gdk.EventButton) -> None: if event.button != 3: # right click return
- menu = self._get_workspace_menu() + menu = get_workspace_menu(self.workspace_id)
rectangle = Gdk.Rectangle() rectangle.x = int(event.x) @@ -336,20 +334,6 @@ def _popup_menu(self, _widget: Gtk.Widget, event: Gdk.EventButton) -> None: popover.set_pointing_to(rectangle) popover.popup()
- def _get_workspace_menu(self) -> Gio.Menu: - menu_items: List[Any] = [ - ('edit-workspace', _('Edit…')), - ] - menu = Gio.Menu() - for item in menu_items: - action, label = item - action = f'win.{action}' - menuitem = Gio.MenuItem.new(label, action) - variant = GLib.Variant('s', self.workspace_id) - menuitem.set_action_and_target_value(action, variant) - menu.append_item(menuitem) - return menu - def update_avatar(self) -> None: self._image.update()
===================================== gajim/gui_interface.py ===================================== @@ -38,13 +38,11 @@ from typing import Optional from typing import Union
-import os import sys import time import logging from functools import partial from threading import Thread -from importlib.util import find_spec
from gi.repository import Gtk from gi.repository import GLib @@ -678,47 +676,6 @@ def save_avatar(self, data: bytes) -> Optional[str]: def avatar_exists(self, filename: str) -> bool: return app.app.avatar_storage.get_avatar_path(filename) is not None
- @staticmethod - def create_ipython_window() -> None: - # Check if IPython is installed - ipython = find_spec('IPython') - is_installed = ipython is not None - if not is_installed: - # Abort early to avoid tracebacks - print('IPython is not installed') - return - try: - from gajim.dev.ipython_view import IPythonView - except ImportError: - print('ipython_view not found') - return - from gi.repository import Pango - - if os.name == 'nt': - font = 'Lucida Console 9' - else: - font = 'Luxi Mono 10' - - window = Gtk.Window() - window.set_title(_('Gajim: IPython Console')) - window.set_size_request(750, 550) - window.set_resizable(True) - sw = Gtk.ScrolledWindow() - sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) - view = IPythonView() - view.override_font(Pango.FontDescription(font)) - view.set_wrap_mode(Gtk.WrapMode.CHAR) - sw.add(view) - window.add(sw) - window.show_all() - - def _on_delete(win, _event): - win.hide() - return True - window.connect('delete_event', _on_delete) - view.updateNamespace({'gajim': app}) - app.ipython_window = window - def run(self, _application: Gtk.Application) -> None: # Creating plugin manager from gajim import plugins
===================================== pyrightconfig.json ===================================== @@ -15,15 +15,13 @@ ".ci/*", "scripts/*", "win/*", - "gajim/gajim.py", - "gajim/gajim_remote.py", - "gajim/common/dbus/*", "gajim/common/application.py", "gajim/common/call_manager.py", "gajim/common/cert_store.py", "gajim/common/commands.py", "gajim/common/configpaths.py", "gajim/common/const.py", + "gajim/common/dbus/*", "gajim/common/events.py", "gajim/common/exceptions.py", "gajim/common/filetransfer.py", @@ -34,15 +32,16 @@ "gajim/common/modules/chat_markers.py", "gajim/common/modules/pep.py", "gajim/common/modules/register.py", - "gajim/common/modules/vcard_temp.py", "gajim/common/modules/vcard4.py", + "gajim/common/modules/vcard_temp.py", "gajim/common/passwords.py", "gajim/common/regex.py", "gajim/common/setting_values.py", "gajim/common/sound.py", "gajim/common/storage/", "gajim/common/task_manager.py", - "gajim/gtk/conversation", + "gajim/gajim.py", + "gajim/gajim_remote.py", "gajim/gtk/about.py", "gajim/gtk/account_page.py", "gajim/gtk/account_side_bar.py", @@ -51,6 +50,7 @@ "gajim/gtk/app_side_bar.py", "gajim/gtk/application.py", "gajim/gtk/apply_button_box.py", + "gajim/gtk/avatar.py", "gajim/gtk/avatar_selector.py", "gajim/gtk/blocking.py", "gajim/gtk/bookmarks.py", @@ -62,8 +62,8 @@ "gajim/gtk/chat_banner.py", "gajim/gtk/chat_filter.py", "gajim/gtk/chat_function_page.py", - "gajim/gtk/chat_list_stack.py", "gajim/gtk/chat_list.py", + "gajim/gtk/chat_list_stack.py", "gajim/gtk/chat_page.py", "gajim/gtk/chat_stack.py", "gajim/gtk/const.py", @@ -71,6 +71,7 @@ "gajim/gtk/contact_settings.py", "gajim/gtk/contacts_flowbox.py", "gajim/gtk/control.py", + "gajim/gtk/conversation/*", "gajim/gtk/dialogs.py", "gajim/gtk/emoji_data_gtk.py", "gajim/gtk/exception.py", @@ -83,8 +84,8 @@ "gajim/gtk/groupchat_invitation.py", "gajim/gtk/groupchat_inviter.py", "gajim/gtk/groupchat_join.py", - "gajim/gtk/groupchat_nick_completion.py", "gajim/gtk/groupchat_nick.py", + "gajim/gtk/groupchat_nick_completion.py", "gajim/gtk/groupchat_outcasts.py", "gajim/gtk/groupchat_roster.py", "gajim/gtk/groupchat_settings.py", @@ -92,20 +93,20 @@ "gajim/gtk/gstreamer.py", "gajim/gtk/history_export.py", "gajim/gtk/history_sync.py", - "gajim/gtk/main_stack.py", "gajim/gtk/main.py", + "gajim/gtk/main_stack.py", "gajim/gtk/mam_preferences.py", "gajim/gtk/manage_sounds.py", "gajim/gtk/menus.py", "gajim/gtk/message_actions_box.py", "gajim/gtk/message_input.py", - "gajim/gtk/notification_manager.py", "gajim/gtk/notification.py", + "gajim/gtk/notification_manager.py", "gajim/gtk/password_dialog.py", "gajim/gtk/pep_config.py", "gajim/gtk/plugins.py", - "gajim/gtk/preview_audio.py", "gajim/gtk/preview.py", + "gajim/gtk/preview_audio.py", "gajim/gtk/proxies.py", "gajim/gtk/remove_account.py", "gajim/gtk/resource_selector.py", @@ -123,6 +124,7 @@ "gajim/gtk/util.py", "gajim/gtk/video_preview.py", "gajim/gtk/workspace_dialog.py", + "gajim/gtk/workspace_side_bar.py", "gajim/gtk/xml_console.py", "gajim/plugins/gajimplugin.py", "gajim/plugins/helpers.py",
View it on GitLab: https://dev.gajim.org/gajim/gajim/-/compare/c321404fc5d66713613754367a09553e...